src/components/widgets/AdvancedTable/AdvancedTable.jsx
import React, { Component } from 'react';
import { compose, pure } from 'recompose';
import ReactDom from 'react-dom';
import PropTypes from 'prop-types';
import Table from 'rc-table';
import AdvancedTableExpandIcon from './AdvancedTableExpandIcon';
import AdvancedTableExpandedRenderer from './AdvancedTableExpandedRenderer';
import { HotKeys } from 'react-hotkeys';
import cx from 'classnames';
import propsResolver from '../../../utils/propsResolver';
import _, {
find,
some,
isEqual,
isEmpty,
map,
forOwn,
every,
flattenDeep,
isArray,
findIndex,
values,
eq,
get,
forEach,
reduce,
includes,
has,
isNumber,
} from 'lodash';
import AdvancedTableRow from './AdvancedTableRow';
import AdvancedTableHeaderCell from './AdvancedTableHeaderCell';
import AdvancedTableEmptyText from './AdvancedTableEmptyText';
import CheckboxN2O from '../../controls/Checkbox/CheckboxN2O';
import AdvancedTableCell from './AdvancedTableCell';
import AdvancedTableHeaderRow from './AdvancedTableHeaderRow';
import withAdvancedTableRef from './withAdvancedTableRef';
export const getIndex = (data, selectedId) => {
const index = _.findIndex(data, model => model.id == selectedId);
return index >= 0 ? index : 0;
};
const KEY_CODES = {
DOWN: 'down',
UP: 'up',
SPACE: 'space',
};
/**
* Компонент Таблица
* @reactProps {boolean} hasFocus - флаг наличия фокуса
* @reactProps {string} className - класс таблицы
* @reactProps {Array.<Object>} columns - настройки колонок
* @reactProps {Array.<Object>} data - данные
* @reactProps {function} onRow - функция прокидывания дополнительных параметров в компонент строки
* @reactProps {Object} components - компоненты обертки
* @reactProps {Node} emptyText - компонент пустых данных
* @reactProps {object} hotKeys - настройка hot keys
* @reactProps {any} expandedComponent - кастомный компонент подстроки
*/
class AdvancedTable extends Component {
constructor(props) {
super(props);
this.state = {
focusIndex: props.autoFocus
? props.data && props.data[props.selectedId]
? get(props.data[props.selectedId], 'id')
: get(props.data[0], 'id')
: props.hasFocus
? 0
: 1,
selectIndex: props.hasSelect
? getIndex(props.data, props.selectedId)
: -1,
data: props.data || [],
expandedRowKeys: [],
expandRowByClick: false,
selection: {},
selectAll: false,
columns: this.mapColumns(props.columns),
checkedAll: false,
checked: props.data ? this.mapChecked(props.data) : {},
};
this.rows = {};
this._dataStorage = [];
this.components = {
header: {
row: AdvancedTableHeaderRow,
cell: AdvancedTableHeaderCell,
...get(props.components, 'header', {}),
},
body: {
row: AdvancedTableRow,
cell: AdvancedTableCell,
...get(props.components, 'body', {}),
},
};
this.setRowRef = this.setRowRef.bind(this);
this.getRowProps = this.getRowProps.bind(this);
this.handleKeyDown = this.handleKeyDown.bind(this);
this.handleExpandedRowsChange = this.handleExpandedRowsChange.bind(this);
this.mapColumns = this.mapColumns.bind(this);
this.checkAll = this.checkAll.bind(this);
this.handleChangeChecked = this.handleChangeChecked.bind(this);
this.handleFilter = this.handleFilter.bind(this);
this.handleEdit = this.handleEdit.bind(this);
this.setSelectionRef = this.setSelectionRef.bind(this);
this.getModelsFromData = this.getModelsFromData.bind(this);
this.setTableRef = this.setTableRef.bind(this);
this.openAllRows = this.openAllRows.bind(this);
this.closeAllRows = this.closeAllRows.bind(this);
this.renderIcon = this.renderIcon.bind(this);
this.renderExpandedRow = this.renderExpandedRow.bind(this);
this.getScroll = this.getScroll.bind(this);
}
componentDidMount() {
const { rowClick } = this.props;
const {
isAnyTableFocused,
isActive,
focusIndex,
selectIndex,
data,
autoFocus,
} = this.state;
if (!isAnyTableFocused && isActive && !rowClick && autoFocus) {
this.setSelectAndFocus(
get(data[selectIndex], 'id'),
get(data[focusIndex], 'id')
);
}
}
componentDidUpdate(prevProps, prevState) {
const {
hasSelect,
data,
isAnyTableFocused,
isActive,
selectedId,
autoFocus,
} = this.props;
if (hasSelect && !isEmpty(data) && !isEqual(data, prevProps.data)) {
const id = selectedId || data[0].id;
if (isAnyTableFocused && !isActive) {
this.setNewSelectIndex(id);
} else if (autoFocus) {
this.setSelectAndFocus(id, id);
}
}
if (!isEqual(prevProps, this.props)) {
let state = {};
if (this.props.data && !isEqual(prevProps.data, this.props.data)) {
const checked = this.mapChecked(this.props.data);
state = {
data: isArray(data) ? data : [this.props.data],
checked,
};
this._dataStorage = this.getModelsFromData(this.props.data);
}
if (!isEqual(prevProps.columns, this.props.columns)) {
state = {
...state,
columns: this.mapColumns(this.props.columns),
};
}
if (!isEqual(prevProps.selectedId, selectedId)) {
this.setNewSelectIndex(selectedId);
}
this.setState({ ...state });
}
}
mapChecked(data) {
const checked = {};
map(data, item => {
checked[item.id] = false;
});
return checked;
}
getModelsFromData(data) {
let dataStorage = [];
const getChildren = children => {
return map(children, model => {
let array = [...children];
if (model.children) {
array = [...array, getChildren(model.children)];
}
return array;
});
};
map(data, item => {
if (item.children) {
const children = getChildren(item.children);
dataStorage.push(...flattenDeep(children));
}
dataStorage.push(item);
});
return dataStorage;
}
setTableRef(el) {
this.table = el;
}
setSelectionRef(el) {
this.selectAllCheckbox = el;
}
setRowRef(ref, id) {
if (ref && ref !== this.rows[id]) {
this.rows[id] = ref;
}
}
handleKeyDown(e, keyName) {
const { data, children, hasFocus, hasSelect, autoFocus } = this.props;
const { focusIndex } = this.state;
const modelIndex = findIndex(data, i => i.id === focusIndex);
if (eq(keyName, KEY_CODES.UP) || eq(keyName, KEY_CODES.DOWN)) {
if (!React.Children.count(children) && hasFocus) {
const newFocusIndex = eq(keyName, KEY_CODES.UP)
? modelIndex - 1
: modelIndex + 1;
if (newFocusIndex >= data.length || newFocusIndex < 0) return false;
const nextData = data[newFocusIndex];
if (hasSelect && autoFocus) {
this.setSelectAndFocus(nextData.id, nextData.id);
this.props.onResolve(nextData);
} else {
this.setNewFocusIndex(nextData.id);
}
}
} else if (eq(keyName, KEY_CODES.SPACE)) {
if (hasSelect && !autoFocus) {
this.props.onResolve(data[modelIndex]);
this.setNewSelectIndex(focusIndex);
}
}
}
handleFilter(filter) {
const { onFilter } = this.props;
onFilter && onFilter(filter);
}
handleRowClick(id, index, needReturn, noResolve) {
const {
hasFocus,
hasSelect,
rowClick,
onRowClickAction,
onResolve,
isActive,
} = this.props;
if (!noResolve && rowClick) {
!hasSelect && onResolve(_.find(this._dataStorage, { id }));
onRowClickAction();
}
if (isActive === needReturn) return;
if (hasSelect && !noResolve) {
onResolve(_.find(this._dataStorage, { id }));
}
if (!noResolve && hasSelect && hasFocus && !rowClick) {
this.setSelectAndFocus(id, id);
} else if (hasFocus) {
this.setNewFocusIndex(id);
} else if (hasSelect && !rowClick) {
this.setNewSelectIndex(id);
}
}
setNewFocusIndex(index) {
this.setState({ focusIndex: index }, () => this.focusActiveRow());
}
setNewSelectIndex(index) {
this.setState({ selectIndex: index });
}
setSelectAndFocus(selectIndex, focusIndex) {
this.setState({ selectIndex, focusIndex }, () => {
this.focusActiveRow();
});
}
focusActiveRow() {
this.rows[this.state.focusIndex] &&
this.rows[this.state.focusIndex].focus();
}
openAllRows() {
const { data } = this.props;
const keys = [];
const getKeys = array => {
return map(array, item => {
keys.push(item.id);
if (item.children) {
getKeys(item.children);
}
});
};
getKeys(data);
this.setState({
expandedRowKeys: keys,
});
}
closeAllRows() {
this.setState({
expandedRowKeys: [],
});
}
handleExpandedRowsChange(rows) {
this.setState({
expandedRowKeys: rows,
});
}
checkAll(event) {
const { onSetSelection } = this.props;
const checked = !event.target.checked;
const newChecked = {};
onSetSelection(checked ? _.toArray(this.props.data) : []);
forOwn(this.state.checked, (v, k) => {
newChecked[k] = checked;
});
this.setState(() => ({
checkedAll: checked,
checked: newChecked,
}));
}
handleChangeChecked(event, index) {
const selectAllCheckbox = ReactDom.findDOMNode(
this.selectAllCheckbox
).querySelector('input');
const { onSetSelection, data } = this.props;
const checked = !event.target.checked;
let checkedAll = this.state.checkedAll;
let multi = [];
const checkedState = {
...this.state.checked,
[index]: checked,
};
const isSomeOneChecked = some(checkedState, checked => checked);
const isAllChecked = every(checkedState, checked => checked);
if (isAllChecked) {
checkedAll = true;
}
selectAllCheckbox.indeterminate = isSomeOneChecked && !isAllChecked;
forOwn(checkedState, (v, k) => {
if (v) {
const item = find(data, i => i.id.toString() === k.toString());
multi.push(item);
}
});
onSetSelection(multi);
this.setState(() => ({
checked: checkedState,
checkedAll,
}));
}
handleResize(index) {
return (e, { size }) => {
this.setState(({ columns }) => {
const nextColumns = [...columns];
nextColumns[index] = {
...nextColumns[index],
width: size.width,
};
return { columns: nextColumns };
});
};
}
handleEdit(value, index, id) {
const { onEdit } = this.props;
let data = this.state.data;
data[index][id] = value;
this.setState({
data,
});
onEdit(value, index, id);
}
getRowProps(model, index) {
const { rowClick, rowClass } = this.props;
return {
index,
rowClick,
isRowActive: model.id === this.state.selectIndex,
rowClass: rowClass && propsResolver(rowClass, model),
model,
setRef: this.setRowRef,
onClick: () => this.handleRowClick(model.id, model.id, false),
onFocus: () => this.handleRowClick(model.id, model.id, true, true),
};
}
createSelectionColumn() {
const isSomeFixed = some(this.state.columns, c => c.fixed);
return {
title: (
<div className="n2o-advanced-table-selection-item">
<CheckboxN2O
ref={this.setSelectionRef}
inline={true}
checked={this.state.checkedAll}
onChange={this.checkAll}
/>
</div>
),
dataIndex: 'row-selection',
key: 'row-selection',
className: 'n2o-advanced-table-selection-container',
width: 30,
fixed: isSomeFixed && 'left',
render: (value, model) => (
<CheckboxN2O
inline={true}
checked={this.state.checked[model.id]}
onChange={event => this.handleChangeChecked(event, model.id)}
/>
),
};
}
getRowKey(row) {
return row.key;
}
renderIcon({ record, expanded, onExpand }) {
const { expandedFieldId, expandedComponent } = this.props;
return (
<AdvancedTableExpandIcon
record={record}
expanded={expanded}
onExpand={onExpand}
expandedFieldId={expandedFieldId}
expandedComponent={expandedComponent}
/>
);
}
renderExpandedRow() {
const { expandable, expandedComponent, expandedFieldId } = this.props;
return (
expandable &&
(expandedComponent
? (record, index, indent) =>
React.createElement(expandedComponent, {
record,
index,
indent,
expandedFieldId,
})
: (record, index, indent) => (
<AdvancedTableExpandedRenderer
record={record}
index={index}
indent={indent}
expandedFieldId={expandedFieldId}
/>
))
);
}
mapColumns(columns = []) {
const { rowSelection, filters } = this.props;
let newColumns = columns;
newColumns = map(newColumns, (col, columnIndex) => ({
...col,
onHeaderCell: column => ({
...column,
onFilter: this.handleFilter,
onResize: this.handleResize(columnIndex),
filters,
}),
onCell: record => ({
record,
editable: col.editable && record.editable,
hasSpan: col.hasSpan,
}),
}));
if (rowSelection) {
newColumns = [this.createSelectionColumn(), ...newColumns];
}
return newColumns;
}
getScroll() {
if (!some(this.state.columns, col => has(col, 'fixed')))
return this.props.scroll;
const { scroll, columns } = this.props;
const calcXScroll = () => {
const getWidth = (
separator,
startValue,
defaultWidth,
tryParse = false
) =>
reduce(
columns,
(result, value) => {
if (value.width) {
return includes(value.width, separator) ||
(tryParse && isNumber(value.width))
? result + parseInt(value.width)
: result;
} else {
return result + defaultWidth;
}
},
startValue
);
const pxWidth = getWidth('px', 5, 100, true);
const percentWidth = getWidth('%', 0, 0);
return percentWidth !== 0
? `calc(${percentWidth}%${pxWidth > 5 ? ` + ${pxWidth}px` : ''})`
: pxWidth;
};
return {
...scroll,
x: calcXScroll(),
};
}
render() {
const {
hasFocus,
className,
expandable,
onExpand,
tableSize,
useFixedHeader,
bordered,
isActive,
onFocus,
rowSelection,
} = this.props;
return (
<HotKeys
keyMap={{ events: values(KEY_CODES) }}
handlers={{ events: this.handleKeyDown }}
>
<div onFocus={!isActive ? onFocus : undefined}>
<Table
ref={this.setTableRef}
prefixCls={'n2o-advanced-table'}
className={cx('n2o-table table table-hover', className, {
'has-focus': hasFocus,
[`table-${tableSize}`]: tableSize,
'table-bordered': bordered,
})}
columns={this.state.columns}
data={this.state.data}
onRow={this.getRowProps}
components={this.components}
rowKey={this.getRowKey}
expandIcon={this.renderIcon}
expandIconAsCell={rowSelection && expandable}
expandedRowRender={this.renderExpandedRow()}
expandedRowKeys={this.state.expandedRowKeys}
onExpandedRowsChange={this.handleExpandedRowsChange}
onExpand={onExpand}
useFixedHeader={useFixedHeader}
indentSize={20}
emptyText={AdvancedTableEmptyText}
scroll={this.getScroll()}
/>
</div>
</HotKeys>
);
}
}
AdvancedTable.propTypes = {
hasFocus: PropTypes.bool,
className: PropTypes.string,
columns: PropTypes.arrayOf(PropTypes.object),
data: PropTypes.arrayOf(PropTypes.object),
onRow: PropTypes.func,
components: PropTypes.object,
emptyText: PropTypes.node,
hotKeys: PropTypes.object,
bordered: PropTypes.bool,
rowSelection: PropTypes.bool,
expandable: PropTypes.bool,
expandedFieldId: PropTypes.string,
expandedComponent: PropTypes.any,
autoFocus: PropTypes.bool,
};
AdvancedTable.defaultProps = {
expandedFieldId: 'expandedContent',
data: [],
bordered: false,
tableSize: 'sm',
rowSelection: false,
expandable: false,
onFocus: () => {},
onSetSelection: () => {},
autoFocus: false,
};
export default compose(
pure,
withAdvancedTableRef
)(AdvancedTable);