import React, { useState, useEffect, useMemo, useRef, useContext } from 'react';
import { useSelector, useDispatch, ReactReduxContext } from 'react-redux';
import { useAppContext } from '../../context';
import PropTypes, { element } from 'prop-types';
import reduce from 'lodash/reduce';
import { connect } from 'react-redux';
import classNames from 'classnames';
import isEqual from 'lodash/isEqual';
import sum from 'lodash/sum';
import ReactList from 'react-list';
import findLastIndex from 'lodash/findLastIndex';
/* !- React Actions */
import { setValues, unsetValues } from '../../form/actions';
/* !- Constants */
import { FORM_PREFIX, SHORTCUTS_NAME } from '../constants';
/* !- React Elements */
import Sortable from '../../dragAndDrop/sortable';
/* !- Types */
type HookFormatType = {
value: string | number,
record: {},
helper: {},
}
type HookType =
{
title: string,
format?: (a: HookFormatType) => JSX.Element | string,
sort?: (a: {}, b: {}) => boolean,
status?: number,
width?: string,
}
const defaultProps =
{
id: '',
showHeader: true,
selectable: false,
expandSelect: false,
multipleSelect: true,
checkboxSelect: false,
sortable: false,
hook: {},
helper: {},
orderDirection: '',
orderColumn: '',
noResults:
No Results.
,
rowElement: ({ children, onClick, data, className }) =>
{children}
,
onClickCell()
{},
onDoubleClickCell()
{},
onContextClickCell()
{},
onChangeOrder()
{},
className: 'grid column',
headClassName: 'thead',
bodyClassName: 'tbody',
style: {},
height: '',
freezeHeader: false,
infinity: false,
};
type PropTypes = Partial &
{
id: string,
/**
* Content of the Grid
* @example
[
{ id: 1, name: 'Megan J. Cushman', gender: 1, visits: '2017-07-23' },
...,
];
*/
data: { [index: string]: number | string }[],
/**
* Determine visible column,
* Change column title and format the columns value
* @example
* {
* name: 'Name',
* visits:
* {
* title: 'Visits',
* format: ({ value }) => new Date(value).toLocaleDateString(),
* status: 1,
* width: '50%',
* }
* }
*
* // ->
* @type {string}
* title: change rawData key to custom column title.
*
* @type {function}
* @param {string} column record key
* tooltip: create help badge to title, onClick call this function
*
* @type {function}
* @param {object} object {
* column: current record key
* columnHook: current column hook props
* helper: table helpers
* record: current row = data.record
* value: current row and column value = record key value
* data: current visible data = paginated data
* index: record index within data
* }
* @return {string}
* format: change the record value
*
* @type {number}
* status: determine the visibility of columns
*
* @type {string}
* width: cell relative width in percent Eg: '50%'
*/
hook: { [index: string]: string | HookType }
/**
* Serve data for hook
*
* @example
* {
* userId: { 1: 'John', 2: 'Doe', ... },
* }
* or
* {
* userId: [
* { id: 1, title: 'John' },
* { id: 2, title: 'Doe' },
* ]
* }
*/
helper: {},
/**
* Column title indicator, which shows ordered column
* Order direction will show in this column title
*/
orderColumn: string | void,
/**
* Column title indicator, which shows order direction
*/
orderDirection: 'asc' | 'desc' | '',
/**
* This text appear when data props is empty
*/
noResults: string | JSX.Element,
/**
* Custom grid row componet
* @example
* const Row = ({ data, columns }) => (
*
* {columns.map(column => | {data[column]} | )}
*
* );
*
* Row.propTypes =
* {
* data: PropTypes.objectOf(PropTypes.oneOfType([
* PropTypes.string,
* PropTypes.number,
* ])).isRequired,
* columns: PropTypes.arrayOf(PropTypes.string),
* };
*
* Row.defaultProps =
* {
* columns: [],
* };
*
*
*/
rowElement: React.FC<{ data?: [], columns?: [], onClickCell?: [], onClick?: [], dispatch?: void }>,
/**
* OnClickCell handler
* @param {int} rowIndex number of row
* @param {int} colIndex number of cell
* @example
console.log(rowIndex, colIndex)}
/>
*/
onClickCell: (record: {}, index: number) => void,
/**
* OnChangeOrder handler
* @param {string} columnId
* @example
console.log(columnId)}
/>
*/
onChangeOrder: (columnId: string) => void,
/**
* Determine visiblity of table's header
*/
showHeader: boolean,
/**
* Enable select one item from grid
*/
selectable: boolean,
/**
* Enable select more than one item from grid
*/
multipleSelect: boolean,
/**
* When select a new record it is automatically expand the selection list
*/
expandSelect: boolean,
checkboxSelect: boolean,
sortable: boolean,
className: string,
headClassName: string,
bodyClassName: string,
/**
* Classic style
*/
style: {},
height: string,
/**
* Always visible header, you must set height
*/
freezeHeader: boolean,
/**
* Enable infinity scroll (ReactList)
* UITableView Inspired
* https://github.com/coderiety/react-list
*/
infinity: boolean,
};
/**
* Grid Component
*
* @example
* import Grid from '@1studio/ui/grid/grid'
* const gridData = [
* { id: 1, name: 'Megan J. Cushman', gender: 1, visits: '2017-07-23' },
* { id: 2, name: 'Taylor R. Fallin', gender: 2, visits: '2017-07-22' },
* { id: 3, name: 'Jose C. Rosado', gender: 1, visits: '2017-07-20' },
* { id: 4, name: 'Sammy C. Brandt', gender: 1, visits: '2017-07-10' },
* ];
*
*
*
* @example
* const gridData = [
* { id: 1, name: 'Megan J. Cushman', gender: 1, visits: '2017-07-23' },
* { id: 2, name: 'Taylor R. Fallin', gender: 2, visits: '2017-07-22' },
* { id: 3, name: 'Jose C. Rosado', gender: 1, visits: '2017-07-20' },
* { id: 4, name: 'Sammy C. Brandt', gender: 1, visits: '2017-07-10' },
* ];
* const gridSettings =
* {
* hook:
* {
* name: 'Name',
* visits:
* {
* title: 'Visits',
* format: ({ value }) => new Date(value).toLocaleDateString(),
* },
* gender:
* {
* title: 'Gender',
* format: ({ value, config }) => config.gender[value],
* },
* },
* helper:
* {
* gender: { 1: 'male', 2: 'female' },
* },
* order:
* {
* column: 'name',
* order: 'desc'
* }
* };
*
*/
export const Grid = ({
id,
data,
showHeader,
selectable,
expandSelect,
multipleSelect,
checkboxSelect,
sortable,
onSort,
hook,
helper,
orderDirection,
orderColumn,
noResults,
rowElement,
onClickCell,
onDoubleClickCell,
onContextClickCell,
onChangeOrder,
className,
headClassName,
bodyClassName,
style,
height,
freezeHeader,
infinity,
}: PropTypes) =>
{
const {
addShortcuts,
removeShortcuts,
addListener,
removeListener,
} = useAppContext();
const { store } = useContext(ReactReduxContext);
const dispatch = useDispatch();
const element = useRef(null);
const elementBody = useRef(null);
const prevSelectedItemId = useRef(null);
const [focus, setFocus] = useState(true);
useEffect(
() =>
{
if (focus)
{
addShortcutsListeners();
}
else
{
removeShortcuts(SHORTCUTS_NAME);
}
},
[focus],
);
/**
* Grid props which store in redux Form with this key
* @type {string}
*/
let gridId = id;
/**
* Grid props which store in redux Form with this key
* @type {string}
*/
let formId = FORM_PREFIX + gridId;
/**
* Rows of table is clickable
* @type {Boolean}
*/
let isClickRows = false;
// componentWillMount => constructor
useMemo(
() =>
{
//@todo context
//gridId = id || context.grid || '';
isClickRows =
selectable || onClickCell.toString() !== defaultProps.onClickCell.toString();
},
[],
);
// componentDidMount, componentWillUnmount
useEffect(
() =>
{
// componentDidMount
if (addListener)
{
addListener('click', onFocusListener);
}
// componentWillUnmount
return () =>
{
if (removeListener)
{
removeListener(onFocusListener);
removeShortcuts(SHORTCUTS_NAME);
}
};
},
[],
);
useSelector(
(state) =>
{
const gridSelectedItemIds = state.form[formId];
if (gridSelectedItemIds && elementBody.current)
{
const nextSelectedItemId = gridSelectedItemIds[gridSelectedItemIds.length - 1];
if (prevSelectedItemId.current !== nextSelectedItemId)
{
prevSelectedItemId.current = nextSelectedItemId;
const nextSelectedItemIndex = getData()
.findIndex(({ id }) => id === nextSelectedItemId);
const itemHeight = infinity ?
elementBody.current.children[0].children[0].children[0].children[0].offsetHeight :
elementBody.current.children[0].offsetHeight;
const bodyHeight = element.current.offsetHeight;
const itemScrollTop = itemHeight * nextSelectedItemIndex;
if (nextSelectedItemIndex === -1)
{
element.current.scrollTop = 0;
}
else if
(
// out bottom
(element.current.scrollTop + bodyHeight < itemScrollTop + itemHeight)
// out top
|| (element.current.scrollTop > itemScrollTop)
)
{
element.current.scrollTop = itemScrollTop - (bodyHeight / 2) + (itemHeight / 2);
}
}
}
// return false;
}
);
/**
* Handling the grid is on focus
*/
const onFocusListener = (event, focus) =>
{
if (!elementBody.current)
{
return;
}
const isFocus = elementBody.current.contains(event.target);
setFocus(isFocus);
}
/**
* Handling KeyDown Arrow Up and down
* @param {integer} direction +1 or -1
* @return {function} handler
*/
const onKeyArrowHandler = direction => event =>
{
event.preventDefault();
const state = store.getState();
const gridData = getData();
const activeRecords = state.form[formId];
let nextActiveRecord;
if (!activeRecords || activeRecords.length === 0)
{
nextActiveRecord = gridData[0];
}
else
{
const lastRecordIndex =
gridData.findIndex(({ id }) => id === activeRecords[activeRecords.length - 1]);
const nextRecordIndex = lastRecordIndex + direction;
if (nextRecordIndex < 0 || nextRecordIndex >= gridData.length)
{
return false;
}
nextActiveRecord = gridData[nextRecordIndex];
}
setActiveRecords(nextActiveRecord);
return true;
};
const onKeySelectAllHandler = (event) =>
{
event.preventDefault();
event.stopPropagation();
const gridData = getData();
if (gridData)
{
setActiveRecords(gridData);
}
}
const setActiveRecords = (records) =>
{
const recordsArray = Array.isArray(records) ? records : [records];
dispatch(setValues({
id: formId,
value: recordsArray.map(({ id }) => id),
}));
}
const getData = () =>
{
return store.getState().grid?.[gridId]?.data || data;
}
/**
* Figure out which columns are displayed and show only those
* @return {array} array of columns
* @example
* // => [id, title, status]
*/
const getColumns = () =>
{
if (hook && Object.keys(hook).length > 0)
{
return reduce(
hook,
(result, value, index) =>
{
if (
typeof value === 'string' ||
value.status !== 0
)
{
return [...result, index];
}
return result;
},
[],
);
}
if (data.length)
{
return Object.keys(data[0]);
}
return [];
};
/**
* Add necessary keyboard shortcuts
*/
const addShortcutsListeners = () =>
{
if (addShortcuts)
{
addShortcuts(
[
{
keyCode: 'ArrowUp',
handler: onKeyArrowHandler(-1),
description: 'Grid Arrow Up',
},
// {
// keyCode: 'ArrowLeft',
// handler: onKeyArrowHandler(-1),
// description: 'Grid Arrow Left',
// },
{
keyCode: 'ArrowDown',
handler: onKeyArrowHandler(+1),
description: 'Grid Arrow Down',
},
// {
// keyCode: 'ArrowRight',
// handler: onKeyArrowHandler(+1),
// description: 'Grid Arrow Right',
// },
{
keyCode: 'CTRL+A',
handler: onKeySelectAllHandler,
description: 'Grid Select All',
},
{
keyCode: 'META+A',
handler: onKeySelectAllHandler,
description: 'Grid Select All',
},
],
SHORTCUTS_NAME,
);
}
}
/* !- Elements */
/**
* Render the Table Header Order direction indicator
* @private
* @return {ReactElement} SVG icon
*/
const renderOrderArrow = () =>
{
const direction = orderDirection;
if (direction === 'asc')
{
return ;
}
else if (direction === 'desc')
{
return ;
}
return null;
};
const getColumnsWidthByHook = (hook) =>
Object.keys(hook)
.map(id => parseInt((hook[id].width || '').replace('%', '')) || 0)
.filter(width => width > 0);
const getRestColumnWidthByHook = (hook) =>
{
const colWidths = getColumnsWidthByHook(hook);
return Math.floor((100 - sum(colWidths)) / (Object.keys(hook).length - colWidths.length))
}
/**
* Render the title row of table
* @private
* @return {ReactElement} TableRow dom node
*/
const renderHeaders = () =>
{
const getRestColumnWidth = getRestColumnWidthByHook(hook);
const nodeTableHeaderColumns = getColumns().map((column) =>
{
let title = column;
let columnHook = {};
if (Object.keys(hook).length > 0)
{
columnHook = hook[column] || {};
if (typeof columnHook.title !== 'undefined')
{
title = columnHook.title;
// if (typeof columnHook.tooltip === 'function')
// {
// title = (
//
// {
// columnHook.tooltip(event.currentTarget.dataset.id);
// event.stopPropagation();
// }}
// />
// }
// value="Category"
// />
// );
// }
}
else
{
title = columnHook;
}
}
return (
onChangeOrder(column)}
style={{
width: (typeof columnHook.width !== 'undefined') ?
columnHook.width : `${getRestColumnWidth}%`,
}}
className={orderColumn === column ? 'active' : ''}
>
{title}
{orderColumn === column && renderOrderArrow()}
);
});
// insert checkbox first row
if (selectable && checkboxSelect)
{
// onClick header checkbox
const onClickHeaderCheckboxHandler = () =>
{
const state = store.getState();
const form = state.form[formId] || [];
if (form.length)
{
dispatch(unsetValues({ id: formId }));
}
else
{
const gridData = getData();
dispatch(setValues({
id: formId,
value: gridData.map(({ id }) => id),
}));
}
};
const Checkbox = connect(
({ grid, form }) =>
{
const formLength = form[formId] ? form[formId].length : 0;
const gridLength = grid[gridId] ? grid[gridId].data.length : 0;
return ({
status: formLength && Math.floor((formLength + gridLength) / gridLength),
});
},
)(({ status }) => (
));
nodeTableHeaderColumns.unshift();
}
return (
{ nodeTableHeaderColumns }
);
};
const renderCell = (record, index, column) =>
{
let value = record[column];
const columnHook = hook[column] || {};
const getRestColumnWidth = getRestColumnWidthByHook(hook);
// format value of field
if (typeof columnHook.format === 'function')
{
value = columnHook.format({
value,
helper,
record,
column,
columnHook,
index,
data,
last: index === (data.length - 1),
});
}
return (
onClickCell(record, column, event)}
onDoubleClick={event => onDoubleClickCell(record, column, event)}
onContextMenu={event => onContextClickCell(record, column, event)}
style={{
textAlign: (typeof columnHook.align !== 'undefined') ?
columnHook.align : 'center',
width: (typeof columnHook.width !== 'undefined') ?
columnHook.width : `${getRestColumnWidth}%`,
cursor: isClickRows ? 'pointer' : 'default',
}}
>
{value}
);
}
const renderRow = (record, index, columns) =>
{
/**
* All cell of row
* (all field of record)
* @type {ReactElement}
*/
const nodeTableRowColumns = columns.map(column => renderCell(record, index, column));
// insert checkbox first row
if (selectable && checkboxSelect)
{
nodeTableRowColumns.unshift();
}
/**
* Handling Grid selectable function
* @param {Function} selectable if the grid is selectable
* @return {Function} [description]
*/
const onClickTableRowHandler = (!selectable) ? undefined : (event) =>
{
const prevSelection = store.getState().form[formId] || [];
let nextSelection = [record.id];
/**
* IF multipleSelect = expand or reduce width new value (reduce if it is exist yet)
* ELSE always contain the selected value
*/
const isExpandable =
multipleSelect && (expandSelect || (event.ctrlKey || event.metaKey) || event.shiftKey);
if (isExpandable)
{
const isNewItem = prevSelection.indexOf(record.id) === -1;
if (event.shiftKey)
{
const grid = store.getState().grid[gridId];
const gridData = grid ? grid.data : data;
const index = gridData.findIndex(({ id }) => id === record.id);
const min = Math.min(
gridData.findIndex(({ id }) => prevSelection.indexOf(id) !== -1),
index,
);
const max = isNewItem ?
Math.max(
findLastIndex(gridData, ({ id }) => prevSelection.indexOf(id) !== -1),
index,
)
: index;
nextSelection = gridData.slice(min, max + 1).map(({ id }) => id)
// if (isNewItem)
// {
// data.some(({ id }) =>
// {
// // this item selected yet
// if (prevSelection.indexOf(id) !== -1)
// {
// nextSelection = [];
// }
// else
// {
// nextSelection.push(id);
// }
//
// return (record.id === id);
// });
//
// nextSelection = prevSelection.concat(nextSelection);
// }
// else
// {
// // if the selected item found
// let found = false;
//
// data.some(({ id }) =>
// {
// // end of iterate, current item selected yet
// if (found && prevSelection.indexOf(id) === -1)
// {
// return true;
// }
// // current item selected yet.
// else if (found && prevSelection.indexOf(id) !== -1)
// {
// nextSelection.push(id);
// }
// // found clicked item
// else if (!found && record.id === id)
// {
// nextSelection = [];
// found = true;
// }
//
// return false;
// });
//
// nextSelection = prevSelection.filter(x => nextSelection.indexOf(x) === -1);
// }
}
else
{
nextSelection = isNewItem ? prevSelection.concat(nextSelection) : prevSelection.filter(i => i !== record.id);
}
}
if (!isEqual(prevSelection, nextSelection))
{
dispatch(setValues({
id: formId,
value: nextSelection,
}));
}
};
// if the grid selectable use connected rowElement componet
return React.createElement(
selectable ?
connect(
({ form }) =>
{
const isActive = (form[formId] || []).indexOf(record.id) !== -1;
return {
className: isActive ? 'active' : '',
};
},
)(rowElement)
:
rowElement,
{
key: record.id,
data: record,
columns,
onClickCell,
onClick: onClickTableRowHandler,
dispatch: dispatch,
},
nodeTableRowColumns,
);
}
/**
* Render the rows of table
* @private
* @return {ReactElement} Table Row dom node
*/
const renderRows = () =>
{
if (Array.isArray(data) && data.length)
{
const columns = getColumns();
let nodeTableRows;
if (infinity)
{
nodeTableRows = (
renderRow(data[index], index, columns)}
type="uniform"
/>
);
}
else
{
nodeTableRows = data.map((record, index) => renderRow(record, index, columns));
}
const bodyClasses = classNames({
[bodyClassName]: bodyClassName,
'scroll-y': freezeHeader,
infinity,
});
return React.createElement(
sortable ? Sortable : 'div',
{
className: bodyClasses,
ref: elementBody,
onChange: onSort,
},
nodeTableRows,
);
}
return (
{ noResults }
);
};
const gridClassName = classNames({
[className]: true,
column: freezeHeader,
scroll: !!height,
'not-focus': !focus,
});
return (
{ showHeader === true && renderHeaders() }
{ renderRows() }
);
}
Grid.defaultProps = defaultProps;
export default Grid;
|