/* eslint-disable react-hooks/exhaustive-deps */ import React, { useEffect, useState, ReactNode } from 'react'; import { GenesysDevIcon, GenesysDevIcons } from 'genesys-dev-icons'; import moment from 'moment'; import DxTextbox from '../dxtextbox/DxTextbox'; import DxToggle from '../dxtoggle/DxToggle'; import CopyButton from '../copybutton/CopyButton'; import { BaseComponentProps, DataTableRow } from '..'; import './DataTable.scss'; interface IProps extends BaseComponentProps { rows: DataTableRow[]; headerRow?: DataTableRow; className?: string; indentation?: number; sortable?: boolean; filterable?: boolean; } interface ColumnFilterCollection { [colId: string]: ColumnFilter; } interface ColumnFilter { colId: number; dataType: 'string' | 'number' | 'datetime'; filter: any; filterModifier?: FilterModifierGtLt; } type FilterModifierGtLt = 'greaterthan' | 'lessthan'; interface ColumnSort { colId?: number; sort: 'none' | 'ascending' | 'descending'; } interface ColumnTypeCollection { [colId: string]: 'string' | 'number' | 'date'; } interface RawColumnTypeCollection { [colId: string]: RawColumnTypeCount; } interface RawColumnTypeCount { colId: number; number: number; date: number; string: number; } const TABLE_CLASS_REGEX = /(?:^|\s)table(?:$|\s)/i; export default function DataTable(props: IProps) { // filterRows filters the input rows using the configured filters const filterRows = (): DataTableRow[] => { // Return raw data if we don't have info to filter if (!columnTypes || Object.keys(columnTypes).length === 0 || !filters || Object.keys(filters).length === 0) return parsedRows; // Filter source rows let newRows = [] as DataTableRow[]; let anyValidFilters = false; parsedRows.forEach((row) => { let filterMatch: boolean | undefined; Object.keys(filters) .map((i) => { let ii = parseInt(i); return ii; }) // .map(parseInt) .forEach((colId) => { const filter = filters[colId]; if (!filter || filter.filter === '' || filter.filter === undefined) return; switch (filter.dataType) { case 'datetime': { const m = filter.filter as moment.Moment | undefined; const value = row.cells[colId].parsedContent as Date | undefined; if (filterMatch === false || !moment.isMoment(m) || !m.isValid() || !value) return; const datePoint = m.toDate(); filterMatch = filter.filterModifier === 'greaterthan' ? value > datePoint : value < datePoint; break; } case 'number': { if (filter.filter === '' || filter.filter === undefined || !filter.filterModifier || filterMatch === false) return; if (filter.filterModifier === 'greaterthan' && (row.cells[colId].parsedContent as number) >= filter.filter) filterMatch = true; else if (filter.filterModifier === 'lessthan' && (row.cells[colId].parsedContent as number) <= filter.filter) filterMatch = true; else if (filter.filterModifier !== undefined) filterMatch = false; // Didn't hit a valid filter, take no action if (filterMatch === undefined) return; break; } case 'string': default: { if (filter.filter === '' || filterMatch === false) return; filterMatch = (row.cells[colId].parsedContent as string).includes(filter.filter); } } anyValidFilters = true; }); if (filterMatch === true) newRows.push(row); }); return anyValidFilters ? newRows : parsedRows; }; // sortRows sorts the filtered rows using the configured sorting const sortRows = (): DataTableRow[] => { // Abort if we can't sort if (!colsort || colsort.colId === undefined || filteredRows.length < 2 || !filteredRows[0].cells[colsort.colId]) return filteredRows; // Unsort rows if (colsort.sort === 'none') { return filteredRows; } // Sort rows const i = colsort.colId; const isAscending = colsort.sort === 'ascending'; return [...filteredRows].sort((a, b) => { if ((a.cells[i].parsedContent as number) < (b.cells[i].parsedContent as number)) return isAscending ? -1 : 1; if ((a.cells[i].parsedContent as number) > (b.cells[i].parsedContent as number)) return isAscending ? 1 : -1; return 0; }); }; const [parsedRows, setParsedRows] = useState([] as DataTableRow[]); // Filtered set of rows (first pass) const [filteredRows, setFilteredRows] = useState([] as DataTableRow[]); // Sorted set of rows (second pass) const [sortedRows, setSortedRows] = useState([] as DataTableRow[]); // Rows to display in the table (third pass, paginated) const [rows, setRows] = useState([] as DataTableRow[]); const [filters, setFilters] = useState({} as ColumnFilterCollection); const [colsort, setColsort] = useState({ sort: 'none' } as ColumnSort); const [columnTypes, setColumnTypes] = useState({} as ColumnTypeCollection); const [isFilterOpen, setIsFilterOpen] = useState(false); // Reinit when rows change useEffect(() => { // Infer column types if (props.rows.length > 0 && props.rows[0].cells.length > 0) { // Seed columns const cellTypeData = {} as RawColumnTypeCollection; props.rows[0].cells.forEach((cell, i) => (cellTypeData[i] = { colId: i, number: 0, date: 0, string: 0 })); // Iterate rows and cells to infer and count types props.rows.forEach((row) => { row.cells.forEach((cell, i) => { if (!cell || !cell.content || !cellTypeData[i]) return; // Check number first (moment parses numbers as dates successfully) // Passing a string to isNaN uses built-in type coersion logic that's different than Number.parseFloat() if (!isNaN(cell.content as any) && !isNaN(parseFloat(cell.content))) { cellTypeData[i].number++; cell.parsedContent = parseFloat(cell.content); return; } // Check date if (moment(cell.content, 'M/D/YYYY', true).isValid() || moment(cell.content, 'M-D-YYYY', true).isValid()) { cellTypeData[i].date++; cell.parsedContent = Date.parse(cell.content); return; } // Default: string cellTypeData[i].string++; cell.parsedContent = cell.content.toLowerCase(); }); }); // Assign column types const newColumnTypes = {} as ColumnTypeCollection; for (let i = 0; i < props.rows[0].cells.length; i++) { const maxCount = Math.max(cellTypeData[i].date, cellTypeData[i].number, cellTypeData[i].string); if (cellTypeData[i].date === maxCount) newColumnTypes[i] = 'date'; else if (cellTypeData[i].number === maxCount) newColumnTypes[i] = 'number'; else newColumnTypes[i] = 'string'; } setColumnTypes(newColumnTypes); setParsedRows(props.rows); } else { setRows(props.rows); setParsedRows(props.rows); } }, [props.rows]); // Filter changed useEffect(() => { const r = filterRows(); setFilteredRows(r); }, [filters, columnTypes, parsedRows]); // Sort or filtered rows (source) changed useEffect(() => { const r = sortRows(); setSortedRows(r); }, [colsort, filteredRows]); // sorted rows (source) changed useEffect(() => { setRows([...sortedRows]); }, [sortedRows]); // Consolidation props to identify conditions for rendering const isSortable = props.sortable || props.className?.includes('sortable') || props.className?.includes('sort-and-filter'); const isFilterable = props.filterable || props.className?.includes('filterable') || props.className?.includes('sort-and-filter'); // getSortCaret returns the FontAwesome glyph name to use for the column sort indicator based on the current sort configuration const getSortCaret = (columnId: number): GenesysDevIcons => { if (colsort.colId !== columnId || colsort.sort === 'none') return GenesysDevIcons.AppSort; return colsort.sort === 'descending' ? GenesysDevIcons.AppSortDown : GenesysDevIcons.AppSortUp; }; // The filterChanged functions are raised when the user updates a filter column const stringFilterChanged = (colId: string, filterValue: string) => { const newFilters = { ...filters }; newFilters[colId] = { colId: parseInt(colId), dataType: 'string', filter: filterValue.toLowerCase() }; setFilters(newFilters); }; const numberFilterChanged = (colId: string, filterValue: string) => { const newFilters = { ...filters }; const i = parseFloat(filterValue); newFilters[colId] = { colId: parseInt(colId), dataType: 'number', filter: isNaN(i) ? undefined : i, filterModifier: 'lessthan' }; if (filters[colId]) newFilters[colId].filterModifier = filters[colId].filterModifier; setFilters(newFilters); }; const numberFilterModifierChanged = (colId: string, filterModifier: FilterModifierGtLt) => { const newFilters = { ...filters }; if (!newFilters[colId]) newFilters[colId] = { colId: parseInt(colId), dataType: 'number', filter: undefined }; newFilters[colId].filterModifier = filterModifier; setFilters(newFilters); }; const dateFilterChanged = (colId: string, filterValue: string) => { const newFilters = { ...filters }; newFilters[colId] = { colId: parseInt(colId), dataType: 'datetime', filter: moment(filterValue), filterModifier: 'lessthan' }; if (filters[colId]) newFilters[colId].filterModifier = filters[colId].filterModifier; setFilters(newFilters); }; const dateFilterModifierChanged = (colId: string, filterModifier: FilterModifierGtLt) => { const newFilters = { ...filters }; if (!newFilters[colId]) newFilters[colId] = { colId: parseInt(colId), dataType: 'datetime', filter: undefined }; newFilters[colId].filterModifier = filterModifier; setFilters(newFilters); }; // sortChanged is raised when the user clicks a sortable column header const sortChanged = (columnId: string) => { const colId = parseInt(columnId); const newSort = { ...colsort }; newSort.colId = colId; // Unset column on invalid id if (colId < 0 || (rows[0] && colId >= rows[0].cells.length)) newSort.colId = undefined; // Update sort order if (newSort.colId !== colsort.colId) { // New sorts are always descending first newSort.sort = 'ascending'; } else { // Rotate sort order switch (newSort.sort) { case 'ascending': { newSort.sort = 'descending'; break; } case 'descending': { newSort.sort = 'none'; break; } default: { newSort.sort = 'ascending'; } } } setColsort(newSort); }; /***** Setup complete, build the component *****/ // Build column headers let columnHeaders; if (props.headerRow) { columnHeaders = ( {props.headerRow.cells.map((cell, i) => ( sortChanged(i.toString()) : undefined} >
{cell.renderedContent || cell.content} {filters[i] && filters[i].filter !== '' && filters[i].filter !== undefined ? ( ) : ( '' )} {isSortable ? : null}
))} ); } // Build filter row let filterRow; if (isFilterable && Object.keys(columnTypes).length > 0) { filterRow = ( {Object.keys(columnTypes).map((colId, i) => { const columnType = columnTypes[colId]; switch (columnType) { case 'date': { return (
dateFilterChanged(colId, value)} initialValue={moment.isMoment(filters[i]?.filter) ? filters[i]?.filter.format('YYYY-MM-DD') : undefined} /> dateFilterModifierChanged(colId, value === false ? 'lessthan' : 'greaterthan')} />
); } case 'number': { return (
numberFilterChanged(colId, value)} placeholder={props.headerRow?.cells[i]?.content} initialValue={filters[i]?.filter} /> numberFilterModifierChanged(colId, value === false ? 'lessthan' : 'greaterthan')} />
); } case 'string': default: { return ( stringFilterChanged(colId, value)} clearButton={true} initialValue={filters[i]?.filter} /> ); } } })}
); } // Build optional table header let thead; if (columnHeaders || filterRow) { thead = ( {columnHeaders} {isFilterOpen ? filterRow : undefined} ); } // Make sure classes always has "table"; sometimes it will be provided, sometimes not let tableClassName = props.className || ''; if (tableClassName?.match(TABLE_CLASS_REGEX) === null) { tableClassName = 'table ' + tableClassName.trim(); } return (
setIsFilterOpen(!isFilterOpen)} />
{thead} {rows.map((row, i) => { const rowClass: string = row.className?.trim() || ''; return ( {row.cells.map((cell, ii) => ( ))} ); })}
{cell?.content ? (
{cell.renderedContent || cell.content} {cell.copyButton ? : undefined}
) : null}
); }