import React, { ReactNode, useEffect, useRef, useState } from 'react'; import { default as ReactDataTable } from 'react-data-table-component'; import { FaCaretDown } from 'react-icons/fa'; import { Column, ColumnFormat, ConditionalRowStyle } from '../SearchUI/types'; import { getColumnsFromKeys, initColumns } from '../../../utils/table'; import { Paginator } from '../Paginator'; import { ColumnsMenu } from './ColumnsMenu'; import './DataTable.css'; import classNames from 'classnames'; import { Markdown } from '../Markdown'; export interface DataTableProps { /** * The ID used to identify this component in Dash callbacks */ id?: string; /** * Dash-assigned callback that should be called whenever any of the * properties change */ setProps?: (value: any) => any; /** * Class name(s) to append to the component's default class. * By default, the .box and .p-0 classes are applied to the table, but these will be * overridden if a className is supplied. */ className?: string; /** * Array of data objects to display in the table */ data: any[]; /** * An array of column definition objects to control what is rendered in the table. * See `Column` documentation for specifics on how to construct `Column` objects. */ columns?: Column[]; /** * Optionally include a field to sort by on initial load * Must be a valid field and included in your list of columns */ sortField?: string; /** * If including a sortField, set whether it should ascend by default * True for ascending, False for descending */ sortAscending?: boolean; /** * Optionally include a secondary sort field. If the sortField ever becomes the same as * the secondarySortField, the secondary field is removed. * Must be a valid field and included in your list of columns. */ secondarySortField?: string; /** * If including a secondarySortField, set whether it should ascend by default. * True for ascending, False for descending. */ secondarySortAscending?: boolean; /** * List of conditions for styling rows based on a property (selector) and a value. * Note that this prop currently only supports checking for * value equivalence (i.e. row[selector] === value). * See `ConditionalRowStyle` documentation for how to construct `ConditionalRowStyle` conditions. */ conditionalRowStyles?: ConditionalRowStyle[]; /** * Optionally include/exclude checkboxes next to rows for selecting */ selectableRows?: boolean; /** * Property to maintain the state of selected rows so that * they are accessible via Dash callback */ selectedRows?: any[]; /** * Combine with selectableRows prop to only allow one row to be selected at a time. */ singleSelectableRows?: boolean; /** * Set to true to show a header with total number of rows and a columns selector */ hasHeader?: boolean; /** * Optional class name to apply to the table header */ headerClassName?: string; /** * A noun in singular form to describe what a result represents (e.g. "material"). * This is displayed in the table header. */ resultLabel?: string; /** * Plural form of the result label. If none supplied, it will automatically be the result label plus an "s" */ resultLabelPlural?: string; /** * Set to true to paginate the table records */ pagination?: boolean; /** * If true, an expanded component will be used for pagination (same as in `SearchUI`). * If false, a compact version will be used. */ paginationIsExpanded?: boolean; /** * Content to display below the table but inside the table's box wrapper. * Accepts markdown. */ footer?: ReactNode; /** * This is a temporary solution to allow SearchUI's to render in Storybook. * There is an issue with the dynamic column header components that causes * Storybook to crash. Rendering column headers as plain strings fixes the problem. * Note that this will disable column tooltips and unit labels. */ disableRichColumnHeaders?: boolean; } /** * Component for rendering data in a table. * Uses react-data-table-component under the hood. */ export const DataTable: React.FC = ({ className = 'box p-0', resultLabel = 'record', resultLabelPlural = resultLabel + 's', headerClassName = 'title is-6', ...otherProps }) => { const props = { className, resultLabel, resultLabelPlural, headerClassName, ...otherProps }; const columnDefs = props.columns || getColumnsFromKeys(props.data[0]); /** * If table rows are set to be clickable, a radio button column will be added to the column defs, * an isClicked property will be added to each record, and an id will be added to each record to * keep track of which row was clicked. */ if (props.singleSelectableRows) { columnDefs.unshift({ selector: '_isSelected', title: '', formatType: ColumnFormat.RADIO, width: '48px', excludeFromColumnsSelector: true, onChange: handleClickedRow }); } const [columns, setColumns] = useState(() => { return initColumns(columnDefs, props.disableRichColumnHeaders); }); const [tableColumns, setTableColumns] = useState(columns); /** * Add _isSelected property to track which rows are selected. * Add _index property so that rows can be uniquely identified for selection logic. * The _index value is simply the row's index in the full unsorted data array. */ const [data, setData] = useState(() => { if (props.selectableRows) { return props.data.map((d, i) => { d._isSelected = false; d._index = i; return d; }); } else { return props.data; } }); const [toggleClearRows, setToggleClearRows] = useState(false); const tableRef = useRef(null); const hasMultiSelectableRows = props.selectableRows && !props.singleSelectableRows; const showPagination = props.pagination && data.length > 10; /** * In order for the table to allow for data to be set from outside the component (i.e. via dash callback), * row selection needs to be handled in an explicit data variable. Otherwise, all rows would become deselected * once the data hook effect is triggered. This happens because that hook forces a re-render and react-data-table * would refresh its internal row state. */ /** * Event triggered when a row checkbox is clicked. * This only pertains to multi selectable rows. * This event is triggered by react-data-table-component's onSelectedRowsChange and therefore * has access to rowState which is managed internally by react-data-table-component. */ const handleSelectedRowsChange = (rowState) => { if (rowState.selectedRows != props.selectedRows) { const newData = data.map((d) => { const selected = rowState.selectedRows.find((r) => r._index === d._index); if (selected) { d._isSelected = true; selected._isSelected = true; } else { d._isSelected = false; } return { ...d }; }); setData(newData); if (props.setProps) props.setProps({ ...props, data: [...newData], selectedRows: rowState.selectedRows }); } }; /** * Event triggered when any part of a row is clicked. * Define this function using a function declaration so that * it gets hoisted and can be used in the _isSelected column def. */ function handleClickedRow(row) { const newData = data.map((d) => { if (props.singleSelectableRows) { d._isSelected = d._index === row._index ? true : false; } else if (d._index === row._index) { d._isSelected = !d._isSelected; } return { ...d }; }); const newSelectedRows = newData.filter((d) => d._isSelected); setData(newData); if (props.setProps) props.setProps({ ...props, data: [...newData], selectedRows: newSelectedRows }); } const CustomPaginator = ({ rowsPerPage, rowCount, onChangePage, onChangeRowsPerPage, currentPage, isTop = false }) => ( ); const conditionalRowStyles: any[] | undefined = props.conditionalRowStyles?.map((c) => { c.when = (row) => row[c.selector] === c.value; return c; }); /** * Update data if changed from outside component (e.g. via dash callback) */ useEffect(() => { setData(props.data); }, [props.data]); /** * Update columns if changed from outside component (e.g. via dash callback) */ useEffect(() => { setColumns(initColumns(columnDefs, props.disableRichColumnHeaders)); }, [props.columns]); /** * tableColumns should include only the columns that are viewable in the table (i.e. not hidden) */ useEffect(() => { setTableColumns(columns.filter((c) => !c.hidden)); }, [columns]); return (
{props.hasHeader && (
{data.length} {data.length === 1 ? props.resultLabel : props.resultLabelPlural}
)}
} defaultSortField={props.sortField} defaultSortAsc={props.sortAscending} customStyles={{ rows: { style: { minHeight: '4em' } } }} onRowClicked={props.selectableRows ? handleClickedRow : undefined} conditionalRowStyles={conditionalRowStyles} selectableRows={hasMultiSelectableRows} onSelectedRowsChange={handleSelectedRowsChange} selectableRowSelected={(row) => { return row._isSelected; }} clearSelectedRows={toggleClearRows} />
{props.footer && (
{props.footer}
)}
); };