nexxtway/react-rainbow

View on GitHub
src/components/Table/index.js

Summary

Maintainability
F
3 days
Test Coverage
/* eslint-disable react/sort-comp */
/* eslint-disable no-console */
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { ThemeProvider } from 'styled-components';
import Body from './body';
import Head from './head';
import { getNextSortDirection } from './helpers/sort';
import { getColumns, isNotSameColumns } from './helpers/columns';
import { getUpdatedColumns, getResizedColumns, getTableWidth } from './helpers/resizer';
import { getRows, getIndexes } from './helpers/rows';
import {
    getUpdatedRowsWhenSelect,
    getUpdatedRowsWhenDeselect,
    getUpdatedRowsWhenSelectAll,
    getUpdatedRowsWhenDeselectAll,
    getBulkSelectionState,
    getRowsWithInitalSelectedRows,
    isValidMaxRowSelection,
    getSelectedRowKeys,
    getSelectedRowKeysFromSelectedRows,
} from './helpers/selector';
import { normalizeData } from './helpers/data';
import ResizeSensor from '../../libs/ResizeSensor';
import debounce from '../../libs/debounce';
import { uniqueId } from '../../libs/utils';
import EmptyIcon from './body/icons/empty';
import StyledContainer from './styled/container';
import StyledScrollableX from './styled/scrollableX';
import StyledScrollableY from './styled/scrollableY';
import StyledTable from './styled/table';
import StyledTableBody from './styled/tableBody';
import StyledThead from './styled/thead';

/**
 * A table lists a collection of data that makes sense when displays them in rows and columns.
 * The data contained in a table is easier to read due to the format, so it can be useful to sort,
 * search, and filter your data.
 * @category DataView
 */
export default class Table extends Component {
    constructor(props) {
        super(props);
        const {
            children,
            showCheckboxColumn,
            keyField,
            data,
            showRowNumberColumn,
            rowNumberOffset,
            maxRowSelection,
            minColumnWidth,
            maxColumnWidth,
            selectedRows,
            variant,
        } = props;

        this.state = {
            columns: getColumns({
                children,
                showCheckboxColumn,
                showRowNumberColumn,
                rowNumberOffset,
                minColumnWidth,
                maxColumnWidth,
                variant,
            }),
            tableWidth: undefined,
            rows: getRows({
                keyField,
                rows: normalizeData(data),
                maxRowSelection: maxRowSelection && Number(maxRowSelection),
                selectedRowsKeys: {},
            }),
            bulkSelection: 'none',
        };
        const { rows } = this.state;
        this.indexes = getIndexes(rows);
        this.selectedRowsKeys = getSelectedRowKeysFromSelectedRows(selectedRows, this.indexes);

        this.tableId = uniqueId('table');
        this.tableContainerRef = React.createRef();
        this.resizeTarget = React.createRef();
        this.handleSort = this.handleSort.bind(this);
        this.handleResize = this.handleResize.bind(this);
        this.updateColumnsAndTableWidth = this.updateColumnsAndTableWidth.bind(this);
        this.handleSelectRow = this.handleSelectRow.bind(this);
        this.handleDeselectRow = this.handleDeselectRow.bind(this);
        this.handleSelectAllRows = this.handleSelectAllRows.bind(this);
        this.handleDeselectAllRows = this.handleDeselectAllRows.bind(this);

        this.scrollableY = React.createRef();
    }

    componentDidMount() {
        this.widthObserver = new ResizeSensor(
            this.resizeTarget.current,
            debounce(this.updateColumnsAndTableWidth, 200),
        );
        this.updateRows();
        this.updateColumnsAndTableWidth();
    }

    componentDidUpdate(prevProps) {
        const {
            children: prevChildren,
            showCheckboxColumn: prevShowCheckboxColumn,
            maxRowSelection: prevMaxRowSelection,
            selectedRows: prevSelectedRows,
            data: prevData,
            minColumnWidth: prevMinColumnWidth,
            maxColumnWidth: prevMaxColumnWidth,
        } = prevProps;
        const {
            children,
            showCheckboxColumn,
            showRowNumberColumn,
            rowNumberOffset,
            maxRowSelection,
            selectedRows,
            data,
            keyField,
            minColumnWidth,
            maxColumnWidth,
            onRowSelection,
            variant,
        } = this.props;
        const prevColumns = getColumns({
            children: prevChildren,
            showCheckboxColumn: prevShowCheckboxColumn,
            showRowNumberColumn,
            rowNumberOffset,
            minColumnWidth: prevMinColumnWidth,
            maxColumnWidth: prevMaxColumnWidth,
            variant,
        });
        const currentColumns = getColumns({
            children,
            showCheckboxColumn,
            showRowNumberColumn,
            rowNumberOffset,
            minColumnWidth,
            maxColumnWidth,
            variant,
        });
        const isNotSameMaxRowSelection = prevMaxRowSelection !== maxRowSelection;
        const isNotSameData = data !== prevData;
        if (isNotSameMaxRowSelection || isNotSameData) {
            this.updateRows();
        }
        if (isNotSameColumns(prevColumns, currentColumns)) {
            this.updateColumnsAndTableWidth(currentColumns);
        }
        const isNotSameSelectedRows = prevSelectedRows !== selectedRows;
        if (isNotSameSelectedRows) {
            const selectedRowsKeysLength = Object.keys(this.selectedRowsKeys).length;
            if (selectedRowsKeysLength !== selectedRows.length) {
                this.selectedRowsKeys = getSelectedRowKeysFromSelectedRows(
                    selectedRows,
                    this.indexes,
                );
                const updatedRows = getRows({
                    keyField,
                    rows: normalizeData(data),
                    maxRowSelection,
                    selectedRowsKeys: this.selectedRowsKeys,
                });
                onRowSelection(this.getSelectedRows(updatedRows));
                this.updateRows();
            }
        }
    }

    componentWillUnmount() {
        this.widthObserver.detach();
    }

    getTableWidthFromDom() {
        const containerElement = this.tableContainerRef.current;
        if (containerElement) {
            return containerElement.offsetWidth;
        }
        return 0;
    }

    getSelectedRows(rows) {
        const { data } = this.props;
        return normalizeData(data).filter((item, index) => rows[index].isSelected);
    }

    getMaxRowSelection() {
        const { maxRowSelection, data } = this.props;
        const rowsLength = normalizeData(data).length;
        const maxRowSelectionNumber = Number(maxRowSelection);

        if (!isValidMaxRowSelection(maxRowSelection, rowsLength)) {
            return rowsLength;
        }
        return maxRowSelectionNumber;
    }

    /**
     * It will scroll to the top of the Y scrollable container.
     * @public
     */
    scrollTop() {
        this.scrollableY.current.scrollTop = 0;
    }

    updateRows() {
        const { keyField, selectedRows, onRowSelection, data } = this.props;
        const maxRowSelection = this.getMaxRowSelection();
        const newRows = getRows({
            keyField,
            rows: normalizeData(data),
            maxRowSelection,
            selectedRowsKeys: this.selectedRowsKeys,
        });
        this.indexes = getIndexes(newRows);
        const selectedRowsKeysLength = Object.keys(this.selectedRowsKeys).length;
        const currentSelectedRows = this.getSelectedRows(newRows);
        const isNotSameSelectedRowsWithNewData =
            selectedRowsKeysLength !== currentSelectedRows.length;

        if (isNotSameSelectedRowsWithNewData) {
            onRowSelection(currentSelectedRows);
            this.selectedRowsKeys = getSelectedRowKeys(currentSelectedRows, keyField);
        }

        this.setState({
            rows: getRowsWithInitalSelectedRows({
                rows: newRows,
                selectedRows,
                maxRowSelection,
                indexes: this.indexes,
                selectedRowsKeys: this.selectedRowsKeys,
            }),
            bulkSelection: getBulkSelectionState({
                maxRowSelection,
                selectedRowsKeys: this.selectedRowsKeys,
            }),
        });
    }

    updateColumnsAndTableWidth(newColumns) {
        const { columns } = this.state;
        const { minColumnWidth, maxColumnWidth } = this.props;
        const domTableWidth = this.getTableWidthFromDom();
        const minColWidth = Number(minColumnWidth) || 50;
        const maxColWidth = Number(maxColumnWidth) || Infinity;
        const updatedColumns = getUpdatedColumns({
            columns: newColumns || columns,
            domTableWidth,
            minColumnWidth: minColWidth,
            maxColumnWidth: maxColWidth,
        });
        this.setState({
            columns: updatedColumns,
        });
        if (this.hasFlexibleColumns()) {
            this.setState({
                tableWidth: getTableWidth(updatedColumns),
            });
        }
    }

    handleSelectAllRows() {
        const { onRowSelection } = this.props;
        const { rows } = this.state;
        const maxRowSelection = this.getMaxRowSelection();

        this.selectedRowsKeys = {};
        const updatedRows = getUpdatedRowsWhenSelectAll({
            rows,
            maxRowSelection,
            selectedRowsKeys: this.selectedRowsKeys,
        });
        const bulkSelection = getBulkSelectionState({
            maxRowSelection,
            selectedRowsKeys: this.selectedRowsKeys,
        });
        this.setState({
            rows: updatedRows,
            bulkSelection,
        });
        onRowSelection(this.getSelectedRows(updatedRows));
    }

    handleDeselectAllRows() {
        const { onRowSelection } = this.props;
        const { rows } = this.state;

        this.selectedRowsKeys = {};
        const updatedRows = getUpdatedRowsWhenDeselectAll(rows);
        const bulkSelection = getBulkSelectionState({
            maxRowSelection: this.getMaxRowSelection(),
            selectedRowsKeys: this.selectedRowsKeys,
        });
        this.setState({
            rows: updatedRows,
            bulkSelection,
        });
        onRowSelection(this.getSelectedRows(updatedRows));
    }

    handleSelectRow(event, isMultiple, rowKeyValue) {
        const { onRowSelection } = this.props;
        const { indexes } = this;
        const { rows } = this.state;
        const maxRowSelection = this.getMaxRowSelection();

        if (maxRowSelection > 1) {
            const updatedRows = getUpdatedRowsWhenSelect({
                maxRowSelection,
                rows,
                indexes,
                isMultiple,
                rowKeyValue,
                lastSelectedRowKey: this.lastSelectedRowKey,
                selectedRowsKeys: this.selectedRowsKeys,
            });
            const bulkSelection = getBulkSelectionState({
                maxRowSelection,
                selectedRowsKeys: this.selectedRowsKeys,
            });
            this.setState({
                rows: updatedRows,
                bulkSelection,
            });
            onRowSelection(this.getSelectedRows(updatedRows));
        } else {
            this.selectedRowsKeys = {};
            this.selectedRowsKeys[rowKeyValue] = true;
            const updatedRows = getUpdatedRowsWhenSelect({
                maxRowSelection,
                rows,
                rowKeyValue,
                selectedRowsKeys: this.selectedRowsKeys,
            });
            this.setState({
                rows: updatedRows,
            });
            onRowSelection(this.getSelectedRows(updatedRows));
        }

        this.lastSelectedRowKey = rowKeyValue;
    }

    handleDeselectRow(event, isMultiple, rowKeyValue) {
        const { onRowSelection } = this.props;
        const { indexes } = this;
        const { rows } = this.state;
        const maxRowSelection = this.getMaxRowSelection();

        const updatedRows = getUpdatedRowsWhenDeselect({
            maxRowSelection,
            rows,
            indexes,
            isMultiple,
            rowKeyValue,
            lastSelectedRowKey: this.lastSelectedRowKey,
            selectedRowsKeys: this.selectedRowsKeys,
        });
        const bulkSelection = getBulkSelectionState({
            maxRowSelection,
            selectedRowsKeys: this.selectedRowsKeys,
        });
        this.setState({
            rows: updatedRows,
            bulkSelection,
        });
        this.lastSelectedRowKey = rowKeyValue;
        onRowSelection(this.getSelectedRows(updatedRows));
    }

    hasFlexibleColumns() {
        const { columns } = this.state;
        return columns.some(column => column.isResized !== true);
    }

    handleResize(widthDelta, colIndex) {
        const { columns, tableWidth } = this.state;
        if (widthDelta !== 0) {
            this.setState({
                columns: getResizedColumns({ columns, colIndex, widthDelta }),
                tableWidth: tableWidth + widthDelta,
            });
        }
    }

    handleSort(event, field, sortDirection) {
        const { onSort, sortedBy } = this.props;
        const nextSortDirection = getNextSortDirection(field, sortedBy, sortDirection);
        onSort(event, field, nextSortDirection);
    }

    render() {
        const {
            id,
            data,
            sortedBy,
            sortDirection,
            defaultSortDirection,
            resizeColumnDisabled,
            rowNumberOffset,
            minColumnWidth,
            maxColumnWidth,
            style,
            className,
            isLoading,
            emptyIcon,
            emptyTitle,
            emptyDescription,
            keyField,
            hideTableHeader,
            variant,
        } = this.props;
        const { columns, tableWidth, rows, bulkSelection } = this.state;
        const tableStyles = {
            width: tableWidth,
        };
        const maxRowSelection = this.getMaxRowSelection();
        const minColWidth = Number(minColumnWidth) || 50;
        const maxColWidth = Number(maxColumnWidth) || 5000;

        const isEmpty = data.length === 0;
        const theme = { variant, hideTableHeader, isEmpty };

        if (keyField && typeof keyField === 'string') {
            return (
                <StyledContainer id={id} className={className} style={style}>
                    <div ref={this.resizeTarget} />
                    <ThemeProvider theme={theme}>
                        <StyledContainer>
                            <StyledScrollableX ref={this.tableContainerRef}>
                                <StyledScrollableY
                                    isEmpty={isEmpty}
                                    isLoading={isLoading}
                                    ref={this.scrollableY}
                                    style={tableStyles}
                                >
                                    <StyledTable style={tableStyles}>
                                        <StyledThead>
                                            <tr>
                                                <Head
                                                    columns={columns}
                                                    sortedBy={sortedBy}
                                                    sortDirection={sortDirection}
                                                    defaultSortDirection={defaultSortDirection}
                                                    resizeColumnDisabled={resizeColumnDisabled}
                                                    minColumnWidth={minColWidth}
                                                    maxColumnWidth={maxColWidth}
                                                    onSort={this.handleSort}
                                                    onResize={this.handleResize}
                                                    onSelectAllRows={this.handleSelectAllRows}
                                                    onDeselectAllRows={this.handleDeselectAllRows}
                                                    tableId={this.tableId}
                                                    maxRowSelection={maxRowSelection}
                                                    bulkSelection={bulkSelection}
                                                />
                                            </tr>
                                        </StyledThead>
                                        <StyledTableBody rowNumberOffset={rowNumberOffset}>
                                            <Body
                                                data={normalizeData(data)}
                                                columns={columns}
                                                rows={rows}
                                                tableId={this.tableId}
                                                isLoading={isLoading}
                                                emptyIcon={emptyIcon}
                                                emptyTitle={emptyTitle}
                                                emptyDescription={emptyDescription}
                                                onSelectRow={this.handleSelectRow}
                                                onDeselectRow={this.handleDeselectRow}
                                            />
                                        </StyledTableBody>
                                    </StyledTable>
                                </StyledScrollableY>
                            </StyledScrollableX>
                        </StyledContainer>
                    </ThemeProvider>
                </StyledContainer>
            );
        }
        console.error('The "keyField" is a required prop of the Table component.');
        return null;
    }
}

Table.propTypes = {
    /** An array containing the objects(rows) to be displayed. */
    data: PropTypes.arrayOf(Object),
    /** The column fieldName that controls the sorting order.
     * Sorts the data using the onsort event handler. */
    sortedBy: PropTypes.string,
    /**
     * Specifies the sorting direction. Valid options are 'asc' or 'desc'.
     */
    sortDirection: PropTypes.oneOf(['asc', 'desc']),
    /** Specifies the default sorting direction on an unsorted column.
     * Valid options include 'asc' and 'desc'.
     * The default is 'asc' for sorting in ascending order. */
    defaultSortDirection: PropTypes.oneOf(['asc', 'desc']),
    /** Action triggered when a column is sorted.
     * Receives the event object, field and sortDirection. */
    onSort: PropTypes.func,
    /** Specifies whether column resizing is disabled. The default is false. */
    resizeColumnDisabled: PropTypes.bool,
    /** The minimum width for all columns. The default value is 50px. */
    minColumnWidth: PropTypes.oneOfType([PropTypes.number, PropTypes.string]),
    /** The maximum width for all columns. The default value is Infinity. */
    maxColumnWidth: PropTypes.oneOfType([PropTypes.number, PropTypes.string]),
    /** Shows or hide the checkbox column for row selection. To show set
     * showCheckboxColumn to true. The default value is false. */
    showCheckboxColumn: PropTypes.bool,
    /** Shows or hides the row number column. Set to true to show the row number column. The default is false. */
    showRowNumberColumn: PropTypes.bool,
    /** Determines where to start counting the row number. The default is 0. */
    rowNumberOffset: PropTypes.number,
    /** The action triggered when a row is selected. Receives the selectedRows array. */
    onRowSelection: PropTypes.func,
    /** The maximum number of rows that can be selected. When the value is
     * 1 the selection is made by radio buttons, otherwise with checkboxes. */
    maxRowSelection: PropTypes.oneOfType([PropTypes.number, PropTypes.string]),
    /** An array with a list of keyField values of the selected rows. */
    selectedRows: PropTypes.array,
    /** It is required to associate each row with a unique ID. Must be one of the data key.
     * If it is not passed the component will not render.
     */
    keyField: PropTypes.string.isRequired,
    /** Specifies whether data is being loaded. The default is false. */
    isLoading: PropTypes.bool,
    /** The icon that appears in the message of the Table when is empty.
     * If not passed a fallback icon will be shown. */
    emptyIcon: PropTypes.node,
    /** The title that appears in the message of the Table when is empty.
     *  If not passed a fallback title will be shown.
     */
    emptyTitle: PropTypes.oneOfType([PropTypes.string, PropTypes.node]),
    /** The description that appears in the message of the Table when is empty.
     *  If not passed a fallback description will be shown.
     */
    emptyDescription: PropTypes.oneOfType([PropTypes.string, PropTypes.node]),
    /** The variant changes the appearance of the button. Accepted variants include default and listview. */
    variant: PropTypes.oneOf(['default', 'listview']),
    /** The id of the outer element. */
    id: PropTypes.string,
    /** Shows or hides the header column. The default is false */
    hideTableHeader: PropTypes.bool,
    /** A CSS class for the outer element, in addition to the component's base classes. */
    className: PropTypes.string,
    /** An object with custom style applied for the outer element. */
    style: PropTypes.object,
    /**
     * This prop that should not be visible in the documentation.
     * @ignore
     */
    children: PropTypes.node,
};

Table.defaultProps = {
    data: [],
    sortedBy: undefined,
    sortDirection: undefined,
    defaultSortDirection: 'asc',
    onSort: () => {},
    resizeColumnDisabled: false,
    minColumnWidth: 50,
    maxColumnWidth: Infinity,
    showCheckboxColumn: false,
    showRowNumberColumn: false,
    rowNumberOffset: 0,
    onRowSelection: () => {},
    maxRowSelection: undefined,
    selectedRows: [],
    isLoading: false,
    emptyIcon: <EmptyIcon />,
    emptyTitle: 'It’s empty here',
    emptyDescription: 'Our robots did not find any match...',
    variant: 'default',
    id: undefined,
    hideTableHeader: false,
    className: undefined,
    style: undefined,
    children: undefined,
};