UNPKG

@carbon/react

Version:

React components for the Carbon Design System

721 lines (692 loc) 22.5 kB
/** * Copyright IBM Corp. 2016, 2023 * * This source code is licensed under the Apache-2.0 license found in the * LICENSE file in the root directory of this source tree. */ 'use strict'; var PropTypes = require('prop-types'); var React = require('react'); var isEqual = require('react-fast-compare'); var getDerivedStateFromProps = require('./state/getDerivedStateFromProps.js'); var sorting = require('./state/sorting.js'); var cells = require('./tools/cells.js'); var denormalize = require('./tools/denormalize.js'); var events = require('../../tools/events.js'); var filter = require('./tools/filter.js'); var setupGetInstanceId = require('../../tools/setupGetInstanceId.js'); var Table = require('./Table.js'); var TableActionList = require('./TableActionList.js'); var TableBatchAction = require('./TableBatchAction.js'); var TableBatchActions = require('./TableBatchActions.js'); var TableBody = require('./TableBody.js'); var TableCell = require('./TableCell.js'); var TableContainer = require('./TableContainer.js'); var TableDecoratorRow = require('./TableDecoratorRow.js'); var TableExpandHeader = require('./TableExpandHeader.js'); var TableExpandRow = require('./TableExpandRow.js'); var TableExpandedRow = require('./TableExpandedRow.js'); var TableHead = require('./TableHead.js'); var TableHeader = require('./TableHeader.js'); var TableRow = require('./TableRow.js'); var TableSelectAll = require('./TableSelectAll.js'); var TableSelectRow = require('./TableSelectRow.js'); var TableSlugRow = require('./TableSlugRow.js'); var TableToolbar = require('./TableToolbar.js'); var TableToolbarAction = require('./TableToolbarAction.js'); var TableToolbarContent = require('./TableToolbarContent.js'); var TableToolbarSearch = require('./TableToolbarSearch.js'); var TableToolbarMenu = require('./TableToolbarMenu.js'); const getInstanceId = setupGetInstanceId.setupGetInstanceId(); const translationKeys = { expandRow: 'carbon.table.row.expand', collapseRow: 'carbon.table.row.collapse', expandAll: 'carbon.table.all.expand', collapseAll: 'carbon.table.all.collapse', selectAll: 'carbon.table.all.select', unselectAll: 'carbon.table.all.unselect', selectRow: 'carbon.table.row.select', unselectRow: 'carbon.table.row.unselect' }; // TODO: All code comments in this file should be revisited for accuracy and // clarity. /** * Message ids that will be passed to translateWithId(). */ const defaultTranslations = { [translationKeys.expandAll]: 'Expand all rows', [translationKeys.collapseAll]: 'Collapse all rows', [translationKeys.expandRow]: 'Expand current row', [translationKeys.collapseRow]: 'Collapse current row', [translationKeys.selectAll]: 'Select all rows', [translationKeys.unselectAll]: 'Unselect all rows', [translationKeys.selectRow]: 'Select row', [translationKeys.unselectRow]: 'Unselect row' }; const translateWithId = id => defaultTranslations[id]; /** * Data Tables are used to represent a collection of resources, displaying a * subset of their fields in columns, or headers. We prioritize direct updates * to the state of what we're rendering, so internally we end up normalizing the * given data and then denormalizing it when rendering. * * As a result, each part of the DataTable is accessible through look-up by id, * and updating the state of the single entity will cascade updates to the * consumer. */ const DataTable = props => { const { children, filterRows = filter.defaultFilterRows, headers, render, translateWithId: t = translateWithId, size, isSortable: isSortableProp, useZebraStyles, useStaticWidth, stickyHeader, overflowMenuOnHover, experimentalAutoAlign, radio, rows } = props; const instanceId = React.useMemo(() => getInstanceId(), []); const [state, setState] = React.useState(() => ({ ...getDerivedStateFromProps.default(props, {}), isExpandedAll: false // Start with collapsed state, treat `undefined` as neutral state })); React.useEffect(() => { const nextRowIds = rows.map(row => row.id); const nextHeaders = headers.map(header => header.key); const hasRowIdsChanged = !isEqual(nextRowIds, state.rowIds); const currentHeaders = Object.keys(state.cellsById).reduce((acc, cellId) => { const headerKey = cellId.split(':')[1]; if (headerKey && !acc.includes(headerKey)) { acc.push(headerKey); } return acc; }, []); const hasHeadersChanged = !isEqual(nextHeaders, currentHeaders); const currentRows = state.rowIds.map(id => { const row = state.rowsById[id]; return { // TODO: Investigate whether it be okay to just return `row`. id: row.id, disabled: row.disabled, isExpanded: row.isExpanded, isSelected: row.isSelected }; }); const hasRowsChanged = !isEqual(rows, currentRows); if (hasRowIdsChanged || hasHeadersChanged || hasRowsChanged) { setState(prev => getDerivedStateFromProps.default(props, prev)); } }, [headers, rows]); /** * Get the props associated with the given header. Mostly used for adding in * sorting behavior. */ const getHeaderProps = ({ header, onClick, isSortable = isSortableProp, ...rest }) => { const { sortDirection, sortHeaderKey } = state; return { ...rest, key: header.key, sortDirection, isSortable, isSortHeader: sortHeaderKey === header.key, slug: header.slug, decorator: header.decorator, onClick: event => { const nextSortState = sorting.getNextSortState(props, state, { key: header.key }); setState(prev => ({ ...prev, ...nextSortState })); onClick && handleOnHeaderClick(onClick, { sortHeaderKey: header.key, sortDirection: nextSortState.sortDirection })(event); } }; }; /** * Get the props associated with the given expand header. */ const getExpandHeaderProps = ({ onClick, onExpand, ...rest } = {}) => { const { isExpandedAll, rowIds, rowsById } = state; const isExpanded = isExpandedAll || rowIds.every(id => rowsById[id].isExpanded); const translationKey = isExpanded ? translationKeys.collapseAll : translationKeys.expandAll; return { ...rest, 'aria-label': t(translationKey), // Provide a string of all the expanded row id's, separated by a space. 'aria-controls': rowIds.map(id => `expanded-row-${id}`).join(' '), isExpanded, // Compose the event handlers so we don't overwrite a consumer's `onClick` // handler onExpand: events.composeEventHandlers([handleOnExpandAll, onExpand, // TODO: Avoid passing `false` to this function. onClick && handleOnExpandHeaderClick(onClick, { isExpanded })]) }; }; /** * Decorate consumer's `onClick` event handler with sort parameters */ const handleOnHeaderClick = (onClick, sortParams) => { return event => onClick(event, sortParams); }; /** * Decorate consumer's `onClick` event handler with expand parameters */ const handleOnExpandHeaderClick = (onClick, expandParams) => { return event => onClick(event, expandParams); }; /** * Get the props associated with the given row. Mostly used for expansion. */ const getRowProps = ({ row, onClick, ...rest }) => { const translationKey = row.isExpanded ? translationKeys.collapseRow : translationKeys.expandRow; return { ...rest, key: row.id, onClick, // Compose the event handlers so we don't overwrite a consumer's `onClick` // handler onExpand: events.composeEventHandlers([handleOnExpandRow(row.id), onClick]), isExpanded: row.isExpanded, 'aria-label': t(translationKey), 'aria-controls': `expanded-row-${row.id}`, isSelected: row.isSelected, disabled: row.disabled }; }; /** * Get the props associated with an expanded row */ const getExpandedRowProps = ({ row, ...rest }) => { return { ...rest, id: `expanded-row-${row.id}` }; }; /** * Gets the props associated with selection for a header or a row, where * applicable. Most often used to indicate selection status of the table or * for a specific row. */ const getSelectionProps = ({ onClick, row, ...rest } = {}) => { // If we're given a row, return the selection state values for that row if (row) { const translationKey = row.isSelected ? translationKeys.unselectRow : translationKeys.selectRow; return { ...rest, checked: row.isSelected, onSelect: events.composeEventHandlers([handleOnSelectRow(row.id), onClick]), id: `${getTablePrefix()}__select-row-${row.id}`, name: `select-row-${instanceId}`, 'aria-label': t(translationKey), disabled: row.disabled, radio }; } // Otherwise, we're working on `TableSelectAll` which handles toggling the // selection state of all rows. const rowCount = state.rowIds.length; const selectedRowCount = getSelectedRows().length; const checked = rowCount > 0 && selectedRowCount === rowCount; const indeterminate = rowCount > 0 && selectedRowCount > 0 && selectedRowCount !== rowCount; const translationKey = checked || indeterminate ? translationKeys.unselectAll : translationKeys.selectAll; return { ...rest, 'aria-label': t(translationKey), checked, id: `${getTablePrefix()}__select-all`, indeterminate, name: `select-all-${instanceId}`, onSelect: events.composeEventHandlers([handleSelectAll, onClick]) }; }; const getToolbarProps = props => { const isSmall = size === 'xs' || size === 'sm'; return { ...props, size: isSmall ? 'sm' : undefined }; }; const getBatchActionProps = props => { const { shouldShowBatchActions } = state; const totalSelected = getSelectedRows().length; return { onSelectAll: undefined, totalCount: state.rowIds.length || 0, ...props, shouldShowBatchActions: shouldShowBatchActions && totalSelected > 0, totalSelected, onCancel: handleOnCancel }; }; const getTableProps = () => { return { useZebraStyles, size: size ?? 'lg', isSortable: isSortableProp, useStaticWidth, stickyHeader, overflowMenuOnHover: overflowMenuOnHover ?? false, experimentalAutoAlign }; }; const getTableContainerProps = () => { return { stickyHeader, useStaticWidth }; }; // TODO: `getHeaderProps` and `getRowProps` return `key` props. Would it be // beneficial for this function to also return a `key` prop? /** * Get the props associated with the given table cell. */ const getCellProps = ({ cell: { hasAILabelHeader, hasDecoratorHeader }, ...rest }) => { return { ...rest, hasAILabelHeader, hasDecoratorHeader }; }; /** * Helper utility to get all the currently selected rows * * @returns the array of rowIds that are currently selected */ const getSelectedRows = () => state.rowIds.filter(id => { const row = state.rowsById[id]; return row.isSelected && !row.disabled; }); /** * Helper utility to get all of the available rows after applying the filter * * @returns the array of rowIds that are currently included through the filter */ const getFilteredRowIds = () => { const filteredRowIds = typeof state.filterInputValue === 'string' ? filterRows({ rowIds: state.rowIds, headers: headers, cellsById: state.cellsById, inputValue: state.filterInputValue, getCellId: cells.getCellId }) : state.rowIds; // TODO: Use strict equality check. if (filteredRowIds.length == 0) { return []; } return filteredRowIds; }; /** * Helper for getting the table prefix for elements that require an * `id` attribute that is unique. */ const getTablePrefix = () => `data-table-${instanceId}`; /** * Helper for toggling all selected items in a state. Does not call * setState, so use it when setting state. * * @returns object to put into this.setState (use spread operator) */ const setAllSelectedState = (initialState, isSelected, filteredRowIds) => { const { rowIds } = initialState; // TODO: Use strict inequality check. const isFiltered = rowIds.length != filteredRowIds.length; return { // TODO: Should the `reduce` be typed with `<Record<string, // DataTableRow<ColTypes>>>`? rowsById: rowIds.reduce((acc, id) => { const row = { ...initialState.rowsById[id] }; if (!row.disabled && (!isFiltered || filteredRowIds.includes(id))) { row.isSelected = isSelected; } acc[id] = row; // Local mutation for performance with large tables return acc; }, {}) }; }; /** * Handler for the `onCancel` event to hide the batch action bar and * deselect all selected rows */ const handleOnCancel = () => { setState(prev => { return { ...prev, shouldShowBatchActions: false, ...setAllSelectedState(prev, false, getFilteredRowIds()) }; }); }; /** * Handler for toggling the selection state of all rows in the database */ const handleSelectAll = () => { setState(prev => { const filteredRowIds = getFilteredRowIds(); const { rowsById } = prev; const isSelected = !(Object.values(rowsById).filter(row => row.isSelected && !row.disabled).length > 0); return { ...prev, shouldShowBatchActions: isSelected, ...setAllSelectedState(prev, isSelected, filteredRowIds) }; }); }; /** * Handler for toggling the selection state of a given row. */ const handleOnSelectRow = rowId => () => { setState(prev => { const row = prev.rowsById[rowId]; if (radio) { // TODO: // 1. Should the `reduce` be typed with `<Record<string, // DataTableRow<ColTypes>>>`? // 2. Add better parameter names. Use `acc` and `row`. // // deselect all radio buttons const rowsById = Object.entries(prev.rowsById).reduce((p, c) => { const [key, val] = c; val.isSelected = false; p[key] = val; return p; }, {}); return { ...prev, shouldShowBatchActions: false, rowsById: { ...rowsById, [rowId]: { ...row, isSelected: !row.isSelected } } }; } const selectedRows = prev.rowIds.filter(id => prev.rowsById[id].isSelected).length; // Predict the length of the selected rows after this change occurs const selectedRowsCount = !row.isSelected ? selectedRows + 1 : selectedRows - 1; return { ...prev, // Basic assumption here is that we want to show the batch action bar if // the row is being selected. If it's being unselected, then see if we // have a non-zero number of selected rows that batch actions could // still apply to shouldShowBatchActions: !row.isSelected || selectedRowsCount > 0, rowsById: { ...prev.rowsById, [rowId]: { ...row, isSelected: !row.isSelected } } }; }); }; /** * Handler for toggling the expansion state of a given row. */ const handleOnExpandRow = rowId => () => { setState(prev => { const row = prev.rowsById[rowId]; const { isExpandedAll } = prev; return { ...prev, isExpandedAll: row.isExpanded ? false : isExpandedAll, rowsById: { ...prev.rowsById, [rowId]: { ...row, isExpanded: !row.isExpanded } } }; }); }; /** * Handler for changing the expansion state of all rows. */ const handleOnExpandAll = () => { setState(prev => { const { rowIds, isExpandedAll } = prev; return { ...prev, isExpandedAll: !isExpandedAll, // TODO: Add generic to `reduce`. rowsById: rowIds.reduce((acc, id) => ({ ...acc, [id]: { ...prev.rowsById[id], isExpanded: !isExpandedAll } }), {}) }; }); }; /** * Handler for transitioning to the next sort state of the table * * @param headerKey - The field for the header that we are sorting by. */ const handleSortBy = headerKey => () => { setState(prev => sorting.getNextSortState(props, prev, { key: headerKey })); }; /** * Event handler for transitioning input value state changes for the table * filter component. */ const handleOnInputValueChange = (event, defaultValue) => { if (event.target) { setState(prev => ({ ...prev, filterInputValue: event.target.value })); } if (defaultValue) { setState(prev => ({ ...prev, filterInputValue: defaultValue })); } }; // TODO: Could getFilteredRowIds be used here? const filteredRowIds = typeof state.filterInputValue === 'string' ? filterRows({ rowIds: state.rowIds, headers, cellsById: state.cellsById, inputValue: state.filterInputValue, getCellId: cells.getCellId }) : state.rowIds; const renderProps = { // Data derived from state rows: denormalize.default(filteredRowIds, state.rowsById, state.cellsById), headers: headers, selectedRows: denormalize.default(getSelectedRows(), state.rowsById, state.cellsById), // Prop accessors/getters getHeaderProps, getExpandHeaderProps, getRowProps, getExpandedRowProps, getSelectionProps, getToolbarProps, getBatchActionProps, getTableProps, getTableContainerProps, getCellProps, // Custom event handlers onInputChange: handleOnInputValueChange, // Expose internal state change actions sortBy: headerKey => handleSortBy(headerKey)(), selectAll: handleSelectAll, selectRow: rowId => handleOnSelectRow(rowId)(), expandRow: rowId => handleOnExpandRow(rowId)(), expandAll: handleOnExpandAll, radio: radio }; if (typeof render !== 'undefined') { return render(renderProps); } if (typeof children !== 'undefined') { return children(renderProps); } return null; }; DataTable.translationKeys = Object.values(translationKeys); DataTable.Table = Table.Table; DataTable.TableActionList = TableActionList.default; DataTable.TableBatchAction = TableBatchAction.default; DataTable.TableBatchActions = TableBatchActions.default; DataTable.TableBody = TableBody.default; DataTable.TableCell = TableCell.default; DataTable.TableContainer = TableContainer.default; DataTable.TableDecoratorRow = TableDecoratorRow.default; DataTable.TableExpandHeader = TableExpandHeader.default; DataTable.TableExpandRow = TableExpandRow.default; DataTable.TableExpandedRow = TableExpandedRow.default; DataTable.TableHead = TableHead.default; DataTable.TableHeader = TableHeader.default; DataTable.TableRow = TableRow.default; DataTable.TableSelectAll = TableSelectAll.default; DataTable.TableSelectRow = TableSelectRow.default; DataTable.TableSlugRow = TableSlugRow.default; DataTable.TableToolbar = TableToolbar.default; DataTable.TableToolbarAction = TableToolbarAction.default; DataTable.TableToolbarContent = TableToolbarContent.default; DataTable.TableToolbarSearch = TableToolbarSearch.default; DataTable.TableToolbarMenu = TableToolbarMenu.default; DataTable.propTypes = { /** * Experimental property. Allows table to align cell contents to the top if there is text wrapping in the content. Might have performance issues, intended for smaller tables */ experimentalAutoAlign: PropTypes.bool, /** * Optional hook to manually control filtering of the rows from the * TableToolbarSearch component */ filterRows: PropTypes.func, /** * The `headers` prop represents the order in which the headers should * appear in the table. We expect an array of objects to be passed in, where * `key` is the name of the key in a row object, and `header` is the name of * the header. */ headers: PropTypes.arrayOf(PropTypes.shape({ key: PropTypes.string.isRequired, header: PropTypes.node.isRequired })).isRequired, /** * Specify whether the table should be able to be sorted by its headers */ isSortable: PropTypes.bool, /** * Provide a string for the current locale */ locale: PropTypes.string, /** * Specify whether the overflow menu (if it exists) should be shown always, or only on hover */ overflowMenuOnHover: PropTypes.bool, /** * Specify whether the control should be a radio button or inline checkbox */ radio: PropTypes.bool, /** * The `rows` prop is where you provide us with a list of all the rows that * you want to render in the table. The only hard requirement is that this * is an array of objects, and that each object has a unique `id` field * available on it. */ rows: PropTypes.arrayOf(PropTypes.shape({ id: PropTypes.string.isRequired, disabled: PropTypes.bool, isSelected: PropTypes.bool, isExpanded: PropTypes.bool })).isRequired, /** * Change the row height of table. Currently supports `xs`, `sm`, `md`, `lg`, and `xl`. */ size: PropTypes.oneOf(['xs', 'sm', 'md', 'lg', 'xl']), /** * Optional hook to manually control sorting of the rows. */ sortRow: PropTypes.func, /** * Specify whether the header should be sticky. * Still experimental: may not work with every combination of table props */ stickyHeader: PropTypes.bool, /** * Optional method that takes in a message id and returns an * internationalized string. See `DataTable.translationKeys` for all * available message ids. */ translateWithId: PropTypes.func, /** * `false` If true, will use a width of 'auto' instead of 100% */ useStaticWidth: PropTypes.bool, /** * `true` to add useZebraStyles striping. */ useZebraStyles: PropTypes.bool }; exports.DataTable = DataTable;