import React, { ReactElement, ReactNode, useMemo, useState } from 'react'; import { useTable, useSortBy, useRowSelect, useFilters, useExpanded, Column, Row, Filters, IdType, SortingRule, Hooks, HeaderGroup, } from 'react-table'; import css from '../../utils/css'; import StyledTable, { StyledTableWrapper, StyledTHead, StyledTBody, StyledTD, StyledPaginationWrapper, StyledItemsPerPageSelect, StyledEmptyTableContent, } from './StyledTable'; import TableTH from './TableTH'; import Spinner from '../Spinner'; import Pagination from '../Pagination'; import useRowSelectionUI from './useRowSelectionUI'; import useRowExpansionUI from './useRowExpansionUI'; import { flatMap, fromUndefinedable, getOrElse, map } from '../../fp/Option'; import { always, equal, invokeWith, noop, pipe, isDefined, } from '../../fp/function'; import { find, reject } from '../../fp/Array'; import { CommonProps } from '../common'; import { useDeprecation, useResizeObserver } from '../../utils/hooks'; import useColumnLefts from './useColumnLefts'; export interface TableProps> extends CommonProps { /** * Array of table columns. Some notes when you provide this prop: * * Must be memoized. * * Column must be an object of: * * Cell?: a React component to render the cell at column. Checkout this example http://hero-design.surge.sh/components/table#example-cell-customisation for further information. * * Filter?: @deprecated - a React component to render the column filter. * * Header?: a string or React component to render the column header. * * accessor?: a string as the path of the data element's property. * * align?: one of 'left' | 'right' to handle the text alignment. * * disableSortBy?: a boolean to enable/disable column sorting (default is false) * * displayAtBreakpoint?: one of 'sm' | 'md' | 'lg' | 'xl' to handle the column rendering depending on media query breakpoints. * * width?: a string to specify an explicit width of the column. */ columns: Column[]; /** * Array of D (D is the generic type of data element you pass in). */ data: D[]; /** * Controlled expanded rows state, expandedRows is an object of: * - key: row index. * - value: boolean. True means the row at index is expanded. */ expandedRows?: Record, boolean>; /** * Whether the table has expanded rows. When expansion is available, its data shape must be an object of: * - expandedRowRenderer: a render function '(rowData: D) => ReactElement' to render the expanded row. * - rowExpandable: a predicate function '(rowData: D) => boolean' to decide a row is expandable or not. */ expansion?: { expandedRowRenderer: (rowData: D) => ReactElement; rowExpandable: (rowData: D) => boolean; }; /** * Controlled filters value, Filters is an array of: * - id: column id, this matches with accessor configuration. * - value: filter value */ filters?: Filters; /** * Whether the table's first column(s) sticks to the left when scroll horizontally on small screen size devices. */ horizontallySticky?: boolean; /** * Controlled items per page, itemsPerPages is an object of: * - options: array of [Select](http://hero-design.surge.sh/components/select#select-1) options. * - value: selected items per page value. */ itemsPerPage?: { options: { text: string; value: string | number; }[]; value?: string | number; }; /** * Loading state of Table, which will render a spinner in the center of table. */ loading?: boolean; /** * Content to render when there is no data in the table. */ noData?: ReactNode; /** * Callback invoked when any row is expanded or collapsed. expandedRows is an object of: * - key: row index. * - value: boolean. True means the row at index is expanded. */ onExpandedRowsChange?: (expandedRows: Record, boolean>) => void; /** * Callback invoked when filters are changed. Filters is an array of: * - id: column id, this matches with accessor configuration. * - value: filter value */ onFiltersChange?: (filters: Filters) => void; /** * Callback invoked when items per page selection is changed. */ onItemsPerPageChange?: (itemsPerPage: string | number) => void; /** * Callback invoked when a page in pagination is changed. */ onPaginationChange?: (page: number) => void; /** * Callback invoked when any row is selected or deselected. selectedRows is an object of: * - key: row index. * - value: boolean. True means the row at index is selected. */ onSelectedRowsChange?: (selectedRows: Record, boolean>) => void; /** * Callback invoked when sorting is changed. sortBy is an array of: * - id: column id, this matches with accessor configuration. * - desc: boolean, true means sorting descendingly. */ onSortByChange?: (sortBy: SortingRule[]) => void; /** * Whether the table has pagination. When pagination is ON, its data shape must be an object of: * - current: a number which is 1-based indexing to indicate the current selected page. * - total: a number indicates the total of pages. */ pagination?: { current: number; total: number; }; /** * An object for rows configuration, it must be memoized. */ rows?: { generateClassName?: (row: Row) => string | undefined; }; /** * Whether the table is allowed using checkbox to select table rows. */ selectable?: boolean; /** * Controlled selected rows state, selectedRows is an object of: * - key: row index. * - value: boolean. True means the row at index is selected. */ selectedRows?: Record, boolean>; /** * Controlled sortBy state, sortBy is an array of: * - id: column id, this matches with accessor configuration. * - desc: boolean, true means sorting descendingly. */ sortBy?: SortingRule[]; /** * Whether the table's header sticks to the top. */ sticky?: boolean; } export const hasFixedLayout = >( columns: Column[] ) => { const headerWithWidth = columns.find(header => Object.keys(header).includes('width') ); return headerWithWidth !== undefined; }; const emptyHook = >(_hooks: Hooks): void => undefined; type TableActionType = | 'toggleRowSelected' | 'toggleAllRowsSelected' | 'toggleSortBy' | 'setFilter' | 'toggleRowExpanded'; function Table>({ columns, data, loading, sticky = false, horizontallySticky = false, selectable = false, selectedRows, onSelectedRowsChange, expansion, expandedRows, onExpandedRowsChange, pagination, rows: rowsConfig, onPaginationChange, itemsPerPage, onItemsPerPageChange, filters, onFiltersChange, sortBy, onSortByChange, id, className, style, sx = {}, 'data-test-id': dataTestId, noData, }: TableProps): ReactElement { useDeprecation( 'Filter property of a column is deprecated. It will be removed in the next major release of Hero-design!', columns.find(c => c.Filter !== undefined) !== undefined ); const useControlledState = React.useCallback( state => ({ ...state, filters: pipe( fromUndefinedable(filters), getOrElse(() => state.filters) ), expanded: pipe( fromUndefinedable(expandedRows), getOrElse(() => state.expanded) ), sortBy: pipe( fromUndefinedable(sortBy), getOrElse(() => state.sortBy) ), selectedRowIds: pipe( fromUndefinedable(selectedRows), getOrElse(() => state.selectedRowIds) ), }), [filters, expandedRows, sortBy, selectedRows] ); const { getTableProps, getTableBodyProps, headerGroups, rows, prepareRow, } = useTable( { columns, data, manualFilters: true, initialState: { filters, expanded: pipe( fromUndefinedable(expandedRows), getOrElse(() => ({} as Record, boolean>)) ), sortBy: pipe( fromUndefinedable(sortBy), getOrElse(() => []) ), selectedRowIds: pipe( fromUndefinedable(selectedRows), getOrElse(() => ({} as Record, boolean>)) ), }, stateReducer: (newState, action) => { switch (action.type as TableActionType) { case 'toggleRowSelected': case 'toggleAllRowsSelected': pipe( fromUndefinedable(onSelectedRowsChange), getOrElse(() => noop), invokeWith(newState.selectedRowIds) ); break; case 'toggleSortBy': pipe( fromUndefinedable(onSortByChange), getOrElse(() => noop), invokeWith(newState.sortBy) ); break; case 'setFilter': pipe( fromUndefinedable(onFiltersChange), getOrElse(() => noop), invokeWith(newState.filters) ); break; case 'toggleRowExpanded': pipe( fromUndefinedable(onExpandedRowsChange), getOrElse(() => noop), invokeWith(newState.expanded) ); break; default: break; } return newState; }, useControlledState, customRowExpandable: expansion !== undefined ? expansion.rowExpandable : always(false), }, ...pipe( [ filters !== undefined && onFiltersChange !== undefined ? useFilters : emptyHook, useSortBy, expansion !== undefined ? useExpanded : emptyHook, expansion !== undefined ? useRowExpansionUI : emptyHook, selectable === true ? useRowSelect : emptyHook, selectable === true ? useRowSelectionUI : emptyHook, ], reject(equal(emptyHook)) ) ); const tableProps = getTableProps(); const bodyProps = getTableBodyProps(); // We are using headerGroups[0] here because currently we don't support multiple header groups const [columnLefts, columnRefs] = useColumnLefts( headerGroups[0]?.headers.length ); // Column divider divides the sticky and scrollable parts of the table. // We want to stick expansion/selection column and the first column after expansion/selection column. // Since expansion/selection column does NOT have accessor, the first column with accessor will be the divider. const columnDividerIndex = headerGroups[0] !== undefined ? headerGroups[0].headers.findIndex(col => col.accessor !== undefined) : 0; const [horizontallyScrollable, setHorizontallyScrollable] = useState(false); const [ tableWrapperElement, setTableWrapperElement, ] = useState(null); useResizeObserver(({ clientWidth, scrollWidth }) => { setHorizontallyScrollable(clientWidth < scrollWidth); }, tableWrapperElement); const isHorizontallySticky = useMemo( () => (columnIndex: number) => horizontallySticky && horizontallyScrollable && columnIndex <= columnDividerIndex, [horizontallySticky, columnDividerIndex, horizontallyScrollable] ); const shouldShowDivider = useMemo( () => (columnIndex: number) => horizontallySticky && horizontallyScrollable && columnIndex === columnDividerIndex, [horizontallySticky, columnDividerIndex, horizontallyScrollable] ); return ( {headerGroups.map((headerGroup: HeaderGroup) => { const headerGroupProps = headerGroup.getHeaderGroupProps(); return ( {headerGroup.headers.map((column, columnIndex) => { const headerProps = column.getHeaderProps( column.getSortByToggleProps() ); const hasFilterInput = pipe( fromUndefinedable(filters), flatMap(find(filter => filter.id === column.id)), map( ({ value }) => isDefined(value) && !(typeof value === 'string' && value === '') ), getOrElse(() => false) ); return ( void; }): ReactNode => column.render('Filter', props)} hasFilterInput={hasFilterInput} data-test-id={`table__${headerProps.key}`} align={column.align} themeWidth={column.width} themeHorizontallySticky={isHorizontallySticky( columnIndex )} themeLeft={columnLefts[columnIndex]} themeShowDivider={shouldShowDivider(columnIndex)} ref={columnRefs[columnIndex]} > {column.render('Header')} ); })} ); })} {rows.map((row: Row) => { prepareRow(row); const rowProps = row.getRowProps(); const rowClassName = rowsConfig !== undefined && rowsConfig.generateClassName !== undefined ? rowsConfig.generateClassName(row) : undefined; return ( {row.cells.map((cell, cellIndex) => { const cellProps = cell.getCellProps(); return ( {cell.render('Cell')} ); })} {expansion !== undefined && row.isExpanded === true && expansion.expandedRowRenderer(row.original)} ); })} {rows.length === 0 && noData !== undefined && ( {noData} )} {pipe( fromUndefinedable(pagination), map(paginationValue => ( {pipe( fromUndefinedable(itemsPerPage), map(ipp => ( { if ( val !== undefined && onItemsPerPageChange !== undefined ) { onItemsPerPageChange(val); } }} options={ipp.options} value={ipp.value} optionMenuStyle={{ width: 'auto' }} /> )), getOrElse(() => null) )} )), getOrElse(() => null) )} ); } export default Table;