diff --git a/Source/DataTables/DataTableBody.tsx b/Source/DataTables/DataTableBody.tsx new file mode 100644 index 00000000..840817a2 --- /dev/null +++ b/Source/DataTables/DataTableBody.tsx @@ -0,0 +1,116 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import type { ReactNode } from 'react'; +import { classNames } from './classNames'; +import type { DataTableMessages } from './DataTableMessages'; +import type { DataTableParts } from './DataTableParts'; +import { DataTableStatus } from './DataTableStatus'; + +/** Props for {@link DataTableBody}. */ +export interface DataTableBodyProps { + status: DataTableStatus; + /** Whether the table was given any rows, before filtering. */ + hasLoadedRows: boolean; + /** How many rows are left to render after filtering. */ + rowCount: number; + columnCount: number; + /** The table's part attributes. */ + parts?: DataTableParts; + messages: DataTableMessages; + emptyMessage: ReactNode; + /** Renders the data rows; called only when there are rows to show. */ + renderRows: () => ReactNode; +} + +/** The table body: a failure, loading or empty message row, or the data rows. */ +export const DataTableBody = ({ + status, + hasLoadedRows, + rowCount, + columnCount, + parts: pt, + messages, + emptyMessage, + renderRows, +}: DataTableBodyProps) => ( + + {status === DataTableStatus.Failed || status === DataTableStatus.Unauthorized ? ( + + +
+ {status === DataTableStatus.Failed + ? messages.resolvedFailureMessage + : messages.resolvedUnauthorizedMessage} +
+ + + ) : status === DataTableStatus.Loading && !hasLoadedRows ? ( + + +
{messages.resolvedLoadingMessage}
+ + + ) : rowCount === 0 ? ( + + + {emptyMessage} + + + ) : ( + renderRows() + )} + +); diff --git a/Source/DataTables/DataTableCore.tsx b/Source/DataTables/DataTableCore.tsx index ba5bac61..62f063d0 100644 --- a/Source/DataTables/DataTableCore.tsx +++ b/Source/DataTables/DataTableCore.tsx @@ -1,83 +1,39 @@ // Copyright (c) Cratis. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. -import React, { - useEffect, - useId, - useMemo, - useRef, - type CSSProperties, - type HTMLAttributes, - type ReactNode, - type TableHTMLAttributes, - type TdHTMLAttributes, - type ThHTMLAttributes, -} from 'react'; -import { useCratisComponentsConfig } from '../Common/CratisComponentsProvider'; +import React, { useId, useMemo, type CSSProperties, type ReactNode } from 'react'; import { useCratisIcon } from '../configuration/useCratisIcon'; import type { ColumnProps } from './Column'; -import { ColumnFilterMenu } from './ColumnFilterMenu'; +import { classNames } from './classNames'; +import type { DataTableFilterMeta } from './DataTableFilterMeta'; +import { DataTableHeaderCell } from './DataTableHeaderCell'; +import type { DataTableParts } from './DataTableParts'; +import type { DataTableRowClickEvent } from './DataTableRowClickEvent'; +import { DataTableBody } from './DataTableBody'; +import { DataTableRow } from './DataTableRow'; +import { DataTableSearch } from './DataTableSearch'; +import { DataTableRowProcessing } from './DataTableRowProcessing'; import type { DataTableSelectionChangeEvent } from './DataTableSelectionChangeEvent'; -import { - DataTableFilterMatchMode, - type DataTableFilterConstraint, - type DataTableFilterEntry, - type DataTableFilterMeta, -} from './DataTableFilterMeta'; -import { resolveDataTableFilterMatcher } from './DataTableFilterMatcherRegistry'; -import { DataTableStatus } from './DataTableStatus'; import type { DataTableSort } from './DataTableSort'; -import { DataTableSortDirection } from './DataTableSortDirection'; -import { DataTableRowProcessing } from './DataTableRowProcessing'; -import { useControllableState } from './useControllableState'; +import { DataTableStatus } from './DataTableStatus'; +import { processRows } from './processRows'; +import { useDataTableMessages } from './useDataTableMessages'; +import { useDataTableState } from './useDataTableState'; +import { useRowSelection } from './useRowSelection'; -/* eslint-disable @typescript-eslint/no-explicit-any */ +export type { DataTableParts } from './DataTableParts'; +export type { DataTableRowClickEvent } from './DataTableRowClickEvent'; -/** Event emitted when a DataTable row is activated. */ -export interface DataTableRowClickEvent { - /** Activated row data. */ - data: TData; - /** Loaded-page row index. */ - index: number; -} +/* eslint-disable @typescript-eslint/no-explicit-any */ -/** Stable Cratis-owned parts for styling a {@link DataTableCore}. */ -export interface DataTableParts { - /** Outer table composition. */ - root?: HTMLAttributes; - /** Loaded-page search wrapper. */ - search?: HTMLAttributes; - /** Loaded-page search input. */ - searchInput?: React.InputHTMLAttributes; - /** Scroll container. */ - tableContainer?: HTMLAttributes; - /** Semantic table element. */ - table?: TableHTMLAttributes; - /** Table head. */ - head?: HTMLAttributes; - /** Header row. */ - headerRow?: HTMLAttributes; - /** Header cell. */ - headerCell?: ThHTMLAttributes; - /** Table body. */ - body?: HTMLAttributes; - /** Data row. */ - row?: HTMLAttributes; - /** Data cell. */ - cell?: TdHTMLAttributes; - /** Empty-state row. */ - emptyRow?: HTMLAttributes; - /** Empty-state cell. */ - emptyCell?: TdHTMLAttributes; - /** Loading-state row. */ - loadingRow?: HTMLAttributes; - /** Loading-state cell. */ - loadingCell?: TdHTMLAttributes; - /** Failed or unauthorized row. */ - failureRow?: HTMLAttributes; - /** Failed or unauthorized cell. */ - failureCell?: TdHTMLAttributes; -} +const useColumns = (children: React.ReactNode): React.ReactElement>[] => + useMemo( + () => + React.Children.toArray(children).filter( + React.isValidElement, + ) as React.ReactElement>[], + [children], + ); /** Props for the semantic loaded-page DataTable renderer. */ export interface DataTableCoreProps { @@ -165,139 +121,6 @@ export interface DataTableCoreProps { unstyled?: boolean; } -const useColumns = (children: ReactNode): React.ReactElement>[] => - useMemo( - () => - React.Children.toArray(children).filter( - React.isValidElement, - ) as React.ReactElement>[], - [children], - ); - -type CellValue = - string | number | boolean | bigint | symbol | Date | object | null | undefined; - -const asCellValue = (value: unknown): CellValue => - typeof value === 'function' ? String(value) : (value as CellValue); - -const valueAtPath = ( - row: Record, - path: string | undefined, -): CellValue => { - if (!path) return undefined; - let current: CellValue = row; - for (const segment of path.split('.')) { - if (current === null || typeof current !== 'object') return undefined; - const record = current as Record; - if (!Object.hasOwn(record, segment)) return undefined; - current = asCellValue(record[segment]); - } - return current; -}; - -const renderCellContent = ( - column: ColumnProps, - row: Record, -): ReactNode => { - if (column.body) return column.body(row); - const value = valueAtPath(row, column.field); - return value == null ? '' : String(value); -}; - -const dateNumber = (value: unknown) => { - // A cloned Date is normalized in place so caller-owned Date instances (row - // values and filter constraints alike) are never mutated by comparison. - const date = - value instanceof Date ? new Date(value.getTime()) : new Date(String(value)); - return Number.isNaN(date.getTime()) ? undefined : date.setHours(0, 0, 0, 0); -}; - -const firstConstraint = ( - entry: DataTableFilterEntry | undefined, -): DataTableFilterConstraint | undefined => - entry && 'constraints' in entry ? entry.constraints[0] : entry; - -const builtInMatches = ( - value: unknown, - constraint: DataTableFilterConstraint, -): boolean => { - const filter = constraint.value; - const mode = constraint.matchMode ?? DataTableFilterMatchMode.Contains; - if (filter === null || filter === undefined || filter === '') return true; - - const valueText = String(value ?? '').toLocaleLowerCase(); - const filterText = String(filter).toLocaleLowerCase(); - const valueNumber = typeof value === 'number' ? value : Number(value); - const filterNumber = typeof filter === 'number' ? filter : Number(filter); - - switch (mode) { - case DataTableFilterMatchMode.StartsWith: - return valueText.startsWith(filterText); - case DataTableFilterMatchMode.Contains: - return valueText.includes(filterText); - case DataTableFilterMatchMode.NotContains: - return !valueText.includes(filterText); - case DataTableFilterMatchMode.EndsWith: - return valueText.endsWith(filterText); - case DataTableFilterMatchMode.Equals: - return Object.is(value, filter) || valueText === filterText; - case DataTableFilterMatchMode.NotEquals: - return !(Object.is(value, filter) || valueText === filterText); - case DataTableFilterMatchMode.In: - return Array.isArray(filter) && filter.some((item) => Object.is(item, value)); - case DataTableFilterMatchMode.Between: - return ( - Array.isArray(filter) && - filter.length >= 2 && - valueNumber >= Number(filter[0]) && - valueNumber <= Number(filter[1]) - ); - case DataTableFilterMatchMode.LessThan: - return valueNumber < filterNumber; - case DataTableFilterMatchMode.LessThanOrEqual: - return valueNumber <= filterNumber; - case DataTableFilterMatchMode.GreaterThan: - return valueNumber > filterNumber; - case DataTableFilterMatchMode.GreaterThanOrEqual: - return valueNumber >= filterNumber; - case DataTableFilterMatchMode.DateIs: - return dateNumber(value) === dateNumber(filter); - case DataTableFilterMatchMode.DateIsNot: - return dateNumber(value) !== dateNumber(filter); - case DataTableFilterMatchMode.DateBefore: - return (dateNumber(value) ?? Infinity) < (dateNumber(filter) ?? -Infinity); - case DataTableFilterMatchMode.DateAfter: - return (dateNumber(value) ?? -Infinity) > (dateNumber(filter) ?? Infinity); - default: - return resolveDataTableFilterMatcher(String(mode))?.(value, filter) ?? false; - } -}; - -const matchesFilterEntry = (value: unknown, entry: DataTableFilterEntry) => { - if (!('constraints' in entry)) return builtInMatches(value, entry); - if (entry.constraints.length === 0) return true; - - const matches = entry.constraints.map((constraint) => - builtInMatches(value, constraint), - ); - return entry.operator?.toLowerCase() === 'or' - ? matches.some(Boolean) - : matches.every(Boolean); -}; - -const compareValues = (left: unknown, right: unknown): number => { - if (typeof left === 'number' && typeof right === 'number') return left - right; - if (left instanceof Date && right instanceof Date) - return left.getTime() - right.getTime(); - return String(left ?? '').localeCompare(String(right ?? ''), undefined, { - numeric: true, - sensitivity: 'base', - }); -}; - -const classNames = (...values: Array) => - values.filter(Boolean).join(' '); - /** A semantic, renderer-independent data table over one already-loaded page. */ export const DataTableCore = ({ data, @@ -334,72 +157,67 @@ export const DataTableCore = ({ style, pt, }: DataTableCoreProps) => { - const { messages } = useCratisComponentsConfig(); - const dataTableMessages = messages?.dataTable; - const resolvedSelectionAriaLabel = - selectionAriaLabel ?? dataTableMessages?.selectRow ?? 'Select row'; - const resolvedSelectAllAriaLabel = - selectAllAriaLabel ?? dataTableMessages?.selectAllRows ?? 'Select all rows'; - const resolvedGlobalSearchPlaceholder = - globalSearchPlaceholder ?? dataTableMessages?.search ?? 'Search…'; - const resolvedGlobalSearchAriaLabel = - globalSearchAriaLabel ?? dataTableMessages?.searchAriaLabel ?? 'Search table'; - const resolvedLoadingMessage = loadingMessage ?? dataTableMessages?.loading ?? 'Loading…'; - const resolvedFailureMessage = - failureMessage ?? dataTableMessages?.failed ?? 'Could not load data.'; - const resolvedUnauthorizedMessage = - unauthorizedMessage ?? dataTableMessages?.unauthorized ?? - 'You are not authorized to view this data.'; + const messages = useDataTableMessages({ + selectionAriaLabel, + selectAllAriaLabel, + globalSearchPlaceholder, + globalSearchAriaLabel, + loadingMessage, + failureMessage, + unauthorizedMessage, + }); + const { + resolvedSelectionAriaLabel, + resolvedSelectAllAriaLabel, + resolvedGlobalSearchPlaceholder, + resolvedGlobalSearchAriaLabel, + } = messages; const isBusy = status === DataTableStatus.Loading && data.length > 0; const icon = useCratisIcon(); const sortAscendingIcon = icon('sortAscending', '▲'); const sortDescendingIcon = icon('sortDescending', '▼'); const columns = useColumns(children); const selectionGroupName = useId(); - const [filters, setFilters] = useControllableState(filtersProp, defaultFilters ?? {}, onFilter); - const [globalFilter, setGlobalFilter] = useControllableState(globalFilterProp, '', onGlobalFilterChange); - const [sort, setSort] = useControllableState(sortProp, null, onSortChange); - - const filteredRows = useMemo(() => { - if (rowProcessing === DataTableRowProcessing.None) { - return data.map((row, loadedIndex) => ({ row, loadedIndex })); - } - const term = globalFilter.trim().toLocaleLowerCase(); - const rows = data - .map((row, loadedIndex) => ({ row, loadedIndex })) - .filter(({ row }) => { - const rowValues = row as Record; - const matchesColumns = Object.entries(filters).every(([field, entry]) => - matchesFilterEntry(valueAtPath(rowValues, field), entry), - ); - if (!matchesColumns) return false; - if (!term || !globalFilterFields?.length) return true; - return globalFilterFields.some((field) => - String(valueAtPath(rowValues, field) ?? '') - .toLocaleLowerCase() - .includes(term), - ); - }); - - if (!sort) return rows; - return [...rows].sort((left, right) => { - const comparison = compareValues( - valueAtPath(left.row as Record, sort.field), - valueAtPath(right.row as Record, sort.field), - ); - return sort.direction === 'ascending' ? comparison : -comparison; + const { filters, updateFilter, globalFilter, setGlobalFilter, sort, setSort } = + useDataTableState({ + defaultFilters, + filters: filtersProp, + onFilter, + globalFilter: globalFilterProp, + onGlobalFilterChange, + sort: sortProp, + onSortChange, }); - }, [data, filters, globalFilter, globalFilterFields, sort, rowProcessing]); - const updateFilter = ( - field: string, - constraint: DataTableFilterConstraint | undefined, - ) => { - const next = { ...filters }; - if (constraint) next[field] = constraint; - else delete next[field]; - setFilters(next); - }; + const filteredRows = useMemo( + () => + processRows(data, { + filters, + globalFilter, + globalFilterFields, + sort, + rowProcessing, + }), + [data, filters, globalFilter, globalFilterFields, sort, rowProcessing], + ); + const { + dataKeyIdentity, + isSelectedRow, + isRowSelected, + toggleRowSelection, + allFilteredRowsSelected, + selectAllRef, + toggleSelectAll, + } = useRowSelection({ + data, + visibleRows: filteredRows.map(({ row }) => row), + dataKey, + selection, + selectedItems, + onSelectedItemsChange, + }); + const isInteractive = + Boolean(onRowClick) || selectionMode === 'single' || selectionMode === 'multiple'; const activateRow = ( row: TData, @@ -415,94 +233,6 @@ export const DataTableCore = ({ } }; - const dataKeyIdentity = (row: TData) => - String(valueAtPath(row as Record, dataKey!)); - const dataKeyCounts = useMemo(() => { - const counts = new Map(); - if (!dataKey) return counts; - for (const row of data) { - const identity = String(valueAtPath(row as Record, dataKey)); - counts.set(identity, (counts.get(identity) ?? 0) + 1); - } - return counts; - }, [data, dataKey]); - const firstLoadedIndexByDataKey = useMemo(() => { - const indices = new Map(); - if (!dataKey) return indices; - data.forEach((row, loadedIndex) => { - const identity = String(valueAtPath(row as Record, dataKey)); - if (!indices.has(identity)) indices.set(identity, loadedIndex); - }); - return indices; - }, [data, dataKey]); - const selectedLoadedIndex = selection ? data.indexOf(selection) : -1; - const selectedDataKey = selection && dataKey ? dataKeyIdentity(selection) : undefined; - const isSelectedRow = (row: TData, loadedIndex: number) => { - if (!selection) return false; - if (!dataKey) return loadedIndex === selectedLoadedIndex; - - const identity = dataKeyIdentity(row); - if (identity !== selectedDataKey) return false; - if ((dataKeyCounts.get(identity) ?? 0) <= 1) return true; - if (selectedLoadedIndex >= 0) return loadedIndex === selectedLoadedIndex; - return loadedIndex === firstLoadedIndexByDataKey.get(identity); - }; - - // Membership is by dataKey when there is one, and by object identity otherwise. A table whose - // rows are replaced wholesale on every refresh - which is every observable query - keeps its - // selection only in the first case, which is why dataKey matters here as much as it does for - // single selection. - const selectedItemsList = useMemo(() => selectedItems ?? [], [selectedItems]); - const isRowSelected = (row: TData) => - dataKey - ? selectedItemsList.some( - (selected) => dataKeyIdentity(selected) === dataKeyIdentity(row), - ) - : selectedItemsList.includes(row); - - const toggleRowSelection = (row: TData) => { - const next = isRowSelected(row) - ? selectedItemsList.filter((selected) => - dataKey - ? dataKeyIdentity(selected) !== dataKeyIdentity(row) - : selected !== row, - ) - : [...selectedItemsList, row]; - onSelectedItemsChange?.(next); - }; - - // Select-all means the rows the user can currently see. A filtered table that silently selected - // rows hidden behind the filter would act on more than it showed, which is the whole hazard of a - // bulk action. - const visibleRows = filteredRows.map(({ row }) => row); - const isVisibleRow = (row: TData) => - dataKey - ? visibleRows.some((visible) => dataKeyIdentity(visible) === dataKeyIdentity(row)) - : visibleRows.includes(row); - const selectedVisibleCount = visibleRows.filter((row) => isRowSelected(row)).length; - const allFilteredRowsSelected = - visibleRows.length > 0 && selectedVisibleCount === visibleRows.length; - const someFilteredRowsSelected = - selectedVisibleCount > 0 && !allFilteredRowsSelected; - const selectAllRef = useRef(null); - useEffect(() => { - if (selectAllRef.current) { - selectAllRef.current.indeterminate = someFilteredRowsSelected; - } - }, [someFilteredRowsSelected]); - - const toggleSelectAll = () => { - if (allFilteredRowsSelected) { - onSelectedItemsChange?.( - selectedItemsList.filter((selected) => !isVisibleRow(selected)), - ); - return; - } - const additions = visibleRows.filter((row) => !isRowSelected(row)); - onSelectedItemsChange?.([...selectedItemsList, ...additions]); - }; - - return (
({ data-busy={isBusy || undefined} > {!!globalFilterFields?.length && ( -
- setGlobalFilter(event.target.value)} - /> -
+ )}
({ )} data-cratis-part='header-row' > - {columns.map((column, index) => { - const field = - column.props.filterField ?? column.props.field; - const ariaSort = - sort && sort.field === column.props.field - ? sort.direction - : undefined; - return ( - -
- {column.props.selectionMode === - 'multiple' ? ( - - ) : ( - column.props.selectionMode && ( - - {resolvedSelectionAriaLabel} - - ) - )} - {column.props.sortable && - column.props.field ? ( - - ) : ( - column.props.header - )} - {column.props.filter && field && ( - - updateFilter(field, constraint) - } - onClear={() => - updateFilter(field, undefined) - } - /> - )} -
- - ); - })} + {columns.map((column, index) => ( + + ))} - - {status === DataTableStatus.Failed || - status === DataTableStatus.Unauthorized ? ( - - -
- {status === DataTableStatus.Failed - ? resolvedFailureMessage - : resolvedUnauthorizedMessage} -
- - - ) : status === DataTableStatus.Loading && data.length === 0 ? ( - - -
{resolvedLoadingMessage}
- - - ) : filteredRows.length === 0 ? ( - - - {emptyMessage} - - - ) : ( + 0} + rowCount={filteredRows.length} + columnCount={columns.length} + parts={pt} + messages={messages} + emptyMessage={emptyMessage} + renderRows={() => filteredRows.map(({ row, loadedIndex }) => { const identity = dataKey ? dataKeyIdentity(row) : 'loaded-row'; - const rowKey = `${identity}::${loadedIndex}`; const isSelected = selectionMode === 'single' && isSelectedRow(row, loadedIndex); - const isInteractive = - Boolean(onRowClick) || - selectionMode === 'single' || - selectionMode === 'multiple'; return ( - + key={`${identity}::${loadedIndex}`} + row={row} + columns={columns} + parts={pt} + selectionMode={selectionMode} + isSelected={isSelected} + isInteractive={isInteractive} + isRowSelected={isRowSelected(row)} + rowClassName={rowClassName} + resolvedSelectionAriaLabel={ + resolvedSelectionAriaLabel } - className={classNames( - 'cratis-datatable__row', - pt?.row?.className, - rowClassName?.(row), - )} - data-cratis-part='row' - data-selected={isSelected || undefined} - data-interactive={isInteractive || undefined} - onClick={(event) => + selectionGroupName={selectionGroupName} + onActivate={(event) => activateRow(row, loadedIndex, event) } - onKeyDown={(event) => { - if ( - event.target !== event.currentTarget || - (event.key !== 'Enter' && - event.key !== ' ') - ) { - return; - } - event.preventDefault(); - activateRow(row, loadedIndex, event); - }} - > - {columns.map((column, columnIndex) => ( - - {column.props.selectionMode === - 'multiple' ? ( - - event.stopPropagation() - } - onChange={() => - toggleRowSelection(row) - } - /> - ) : column.props.selectionMode ? ( - - ) : ( - renderCellContent( - column.props, - row as Record, - ) - )} - - ))} - + onToggleSelection={() => toggleRowSelection(row)} + /> ); }) - )} - + } + />
diff --git a/Source/DataTables/DataTableHeaderCell.tsx b/Source/DataTables/DataTableHeaderCell.tsx new file mode 100644 index 00000000..d22ef243 --- /dev/null +++ b/Source/DataTables/DataTableHeaderCell.tsx @@ -0,0 +1,148 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import type { ReactElement, ReactNode, RefObject } from 'react'; +import type { ColumnProps } from './Column'; +import { ColumnFilterMenu } from './ColumnFilterMenu'; +import { classNames } from './classNames'; +import type { + DataTableFilterConstraint, + DataTableFilterMeta, +} from './DataTableFilterMeta'; +import type { DataTableParts } from './DataTableParts'; +import type { DataTableSort } from './DataTableSort'; +import { DataTableSortDirection } from './DataTableSortDirection'; +import { firstConstraint } from './matchesFilterEntry'; + +/* eslint-disable @typescript-eslint/no-explicit-any */ + +/** Props for {@link DataTableHeaderCell}. */ +export interface DataTableHeaderCellProps { + column: ReactElement>; + /** The table's part attributes. */ + parts?: DataTableParts; + sort: DataTableSort | null; + onSort: (sort: DataTableSort) => void; + filters: DataTableFilterMeta; + updateFilter: ( + field: string, + constraint: DataTableFilterConstraint | undefined, + ) => void; + resolvedSelectionAriaLabel: string; + resolvedSelectAllAriaLabel: string; + allFilteredRowsSelected: boolean; + selectAllRef: RefObject; + toggleSelectAll: () => void; + sortAscendingIcon: ReactNode; + sortDescendingIcon: ReactNode; +} + +/** One column header: its label, sort control, filter menu, and select-all or selection label. */ +export const DataTableHeaderCell = ({ + column, + parts: pt, + sort, + onSort, + filters, + updateFilter, + resolvedSelectionAriaLabel, + resolvedSelectAllAriaLabel, + allFilteredRowsSelected, + selectAllRef, + toggleSelectAll, + sortAscendingIcon, + sortDescendingIcon, +}: DataTableHeaderCellProps) => { + const field = column.props.filterField ?? column.props.field; + const ariaSort = + sort && sort.field === column.props.field ? sort.direction : undefined; + return ( + +
+ {column.props.selectionMode === 'multiple' ? ( + + ) : ( + column.props.selectionMode && ( + + {resolvedSelectionAriaLabel} + + ) + )} + {column.props.sortable && column.props.field ? ( + + ) : ( + column.props.header + )} + {column.props.filter && field && ( + updateFilter(field, constraint)} + onClear={() => updateFilter(field, undefined)} + /> + )} +
+ + ); +}; diff --git a/Source/DataTables/DataTableMessageOverrides.ts b/Source/DataTables/DataTableMessageOverrides.ts new file mode 100644 index 00000000..1cf3861d --- /dev/null +++ b/Source/DataTables/DataTableMessageOverrides.ts @@ -0,0 +1,15 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import type { ReactNode } from 'react'; + +/** Table messages passed as props, each overriding the provider's message. */ +export interface DataTableMessageOverrides { + selectionAriaLabel?: string; + selectAllAriaLabel?: string; + globalSearchPlaceholder?: string; + globalSearchAriaLabel?: string; + loadingMessage?: ReactNode; + failureMessage?: ReactNode; + unauthorizedMessage?: ReactNode; +} diff --git a/Source/DataTables/DataTableMessages.ts b/Source/DataTables/DataTableMessages.ts new file mode 100644 index 00000000..b5e1fc09 --- /dev/null +++ b/Source/DataTables/DataTableMessages.ts @@ -0,0 +1,15 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import type { ReactNode } from 'react'; + +/** The table messages to show, after props and provider messages are applied. */ +export interface DataTableMessages { + resolvedSelectionAriaLabel: string; + resolvedSelectAllAriaLabel: string; + resolvedGlobalSearchPlaceholder: string; + resolvedGlobalSearchAriaLabel: string; + resolvedLoadingMessage: ReactNode; + resolvedFailureMessage: ReactNode; + resolvedUnauthorizedMessage: ReactNode; +} diff --git a/Source/DataTables/DataTableParts.ts b/Source/DataTables/DataTableParts.ts new file mode 100644 index 00000000..68ec8c02 --- /dev/null +++ b/Source/DataTables/DataTableParts.ts @@ -0,0 +1,48 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import type React from 'react'; +import type { + HTMLAttributes, + TableHTMLAttributes, + TdHTMLAttributes, + ThHTMLAttributes, +} from 'react'; + +/** Stable Cratis-owned parts for styling a `DataTableCore`. */ +export interface DataTableParts { + /** Outer table composition. */ + root?: HTMLAttributes; + /** Loaded-page search wrapper. */ + search?: HTMLAttributes; + /** Loaded-page search input. */ + searchInput?: React.InputHTMLAttributes; + /** Scroll container. */ + tableContainer?: HTMLAttributes; + /** Semantic table element. */ + table?: TableHTMLAttributes; + /** Table head. */ + head?: HTMLAttributes; + /** Header row. */ + headerRow?: HTMLAttributes; + /** Header cell. */ + headerCell?: ThHTMLAttributes; + /** Table body. */ + body?: HTMLAttributes; + /** Data row. */ + row?: HTMLAttributes; + /** Data cell. */ + cell?: TdHTMLAttributes; + /** Empty-state row. */ + emptyRow?: HTMLAttributes; + /** Empty-state cell. */ + emptyCell?: TdHTMLAttributes; + /** Loading-state row. */ + loadingRow?: HTMLAttributes; + /** Loading-state cell. */ + loadingCell?: TdHTMLAttributes; + /** Failed or unauthorized row. */ + failureRow?: HTMLAttributes; + /** Failed or unauthorized cell. */ + failureCell?: TdHTMLAttributes; +} diff --git a/Source/DataTables/DataTableRow.tsx b/Source/DataTables/DataTableRow.tsx new file mode 100644 index 00000000..183c39e0 --- /dev/null +++ b/Source/DataTables/DataTableRow.tsx @@ -0,0 +1,117 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import type { ReactElement, ReactNode, SyntheticEvent } from 'react'; +import type { ColumnProps } from './Column'; +import { classNames } from './classNames'; +import type { DataTableParts } from './DataTableParts'; +import { valueAtPath } from './valueAtPath'; + +/* eslint-disable @typescript-eslint/no-explicit-any */ + +const renderCellContent = ( + column: ColumnProps, + row: Record, +): ReactNode => { + if (column.body) return column.body(row); + const value = valueAtPath(row, column.field); + return value == null ? '' : String(value); +}; + +/** Props for {@link DataTableRow}. */ +export interface DataTableRowProps { + row: TData; + columns: ReactElement>[]; + /** The table's part attributes. */ + parts?: DataTableParts; + selectionMode?: 'single' | 'multiple'; + isSelected: boolean; + isInteractive: boolean; + isRowSelected: boolean; + rowClassName?: (rowData: TData) => string; + resolvedSelectionAriaLabel: string; + selectionGroupName: string; + onActivate: (event: SyntheticEvent) => void; + onToggleSelection: () => void; +} + +/** One data row, activated by click, Enter or Space. */ +export const DataTableRow = ({ + row, + columns, + parts: pt, + selectionMode, + isSelected, + isInteractive, + isRowSelected, + rowClassName, + resolvedSelectionAriaLabel, + selectionGroupName, + onActivate, + onToggleSelection, +}: DataTableRowProps) => ( + onActivate(event)} + onKeyDown={(event) => { + if ( + event.target !== event.currentTarget || + (event.key !== 'Enter' && event.key !== ' ') + ) { + return; + } + event.preventDefault(); + onActivate(event); + }} + > + {columns.map((column, columnIndex) => ( + + {column.props.selectionMode === 'multiple' ? ( + event.stopPropagation()} + onChange={() => onToggleSelection()} + /> + ) : column.props.selectionMode ? ( + + ) : ( + renderCellContent(column.props, row as Record) + )} + + ))} + +); diff --git a/Source/DataTables/DataTableRowClickEvent.ts b/Source/DataTables/DataTableRowClickEvent.ts new file mode 100644 index 00000000..afb01bf6 --- /dev/null +++ b/Source/DataTables/DataTableRowClickEvent.ts @@ -0,0 +1,10 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +/** Event emitted when a DataTable row is activated. */ +export interface DataTableRowClickEvent { + /** Activated row data. */ + data: TData; + /** Loaded-page row index. */ + index: number; +} diff --git a/Source/DataTables/DataTableSearch.tsx b/Source/DataTables/DataTableSearch.tsx new file mode 100644 index 00000000..688c2efb --- /dev/null +++ b/Source/DataTables/DataTableSearch.tsx @@ -0,0 +1,43 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { classNames } from './classNames'; +import type { DataTableParts } from './DataTableParts'; + +/** Props for {@link DataTableSearch}. */ +export interface DataTableSearchProps { + /** The table's part attributes. */ + parts?: DataTableParts; + value: string; + placeholder: string; + ariaLabel: string; + onChange: (value: string) => void; +} + +/** The search box above a table that has search fields. */ +export const DataTableSearch = ({ + parts: pt, + value, + placeholder, + ariaLabel, + onChange, +}: DataTableSearchProps) => ( +
+ onChange(event.target.value)} + /> +
+); diff --git a/Source/DataTables/DataTableState.ts b/Source/DataTables/DataTableState.ts new file mode 100644 index 00000000..9d9e2bc6 --- /dev/null +++ b/Source/DataTables/DataTableState.ts @@ -0,0 +1,27 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import type { + DataTableFilterConstraint, + DataTableFilterMeta, +} from './DataTableFilterMeta'; +import type { DataTableSort } from './DataTableSort'; + +/** A table's current filter, search and sort state, with its setters. */ +export interface DataTableState { + /** Applied per-field filters. */ + filters: DataTableFilterMeta; + /** Sets or removes the filter for one field. */ + updateFilter: ( + field: string, + constraint: DataTableFilterConstraint | undefined, + ) => void; + /** Search text. */ + globalFilter: string; + /** Sets the search text. */ + setGlobalFilter: (globalFilter: string) => void; + /** Applied sort, or null. */ + sort: DataTableSort | null; + /** Sets the sort. */ + setSort: (sort: DataTableSort | null) => void; +} diff --git a/Source/DataTables/DataTableStateProps.ts b/Source/DataTables/DataTableStateProps.ts new file mode 100644 index 00000000..744a04a0 --- /dev/null +++ b/Source/DataTables/DataTableStateProps.ts @@ -0,0 +1,23 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import type { DataTableFilterMeta } from './DataTableFilterMeta'; +import type { DataTableSort } from './DataTableSort'; + +/** The props that control a table's filter, search and sort state. */ +export interface DataTableStateProps { + /** Initial per-field filters, used while the filters are not controlled. */ + defaultFilters?: DataTableFilterMeta; + /** Controlled per-field filters. */ + filters?: DataTableFilterMeta; + /** Receives every filter change. */ + onFilter?: (filters: DataTableFilterMeta) => void; + /** Controlled search text. */ + globalFilter?: string; + /** Receives every search text change. */ + onGlobalFilterChange?: (globalFilter: string) => void; + /** Controlled sort; null means not sorted. */ + sort?: DataTableSort | null; + /** Receives every sort change. */ + onSortChange?: (sort: DataTableSort | null) => void; +} diff --git a/Source/DataTables/ProcessedRow.ts b/Source/DataTables/ProcessedRow.ts new file mode 100644 index 00000000..1fcb773e --- /dev/null +++ b/Source/DataTables/ProcessedRow.ts @@ -0,0 +1,10 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +/** A row the table renders, with its index on the loaded page. */ +export interface ProcessedRow { + /** The row. */ + row: TData; + /** Its index on the loaded page, which stays stable through filtering and sorting. */ + loadedIndex: number; +} diff --git a/Source/DataTables/RowProcessingState.ts b/Source/DataTables/RowProcessingState.ts new file mode 100644 index 00000000..95148bbe --- /dev/null +++ b/Source/DataTables/RowProcessingState.ts @@ -0,0 +1,20 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import type { DataTableFilterMeta } from './DataTableFilterMeta'; +import type { DataTableRowProcessing } from './DataTableRowProcessing'; +import type { DataTableSort } from './DataTableSort'; + +/** The filter, search and sort state a table applies to its loaded rows. */ +export interface RowProcessingState { + /** Applied per-field filters. */ + filters: DataTableFilterMeta; + /** Search text. */ + globalFilter: string; + /** Fields the search text matches against. */ + globalFilterFields?: string[]; + /** Applied sort, or null. */ + sort: DataTableSort | null; + /** Whether the table processes rows at all. */ + rowProcessing: DataTableRowProcessing; +} diff --git a/Source/DataTables/RowSelection.ts b/Source/DataTables/RowSelection.ts new file mode 100644 index 00000000..590f9644 --- /dev/null +++ b/Source/DataTables/RowSelection.ts @@ -0,0 +1,22 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import type { RefObject } from 'react'; + +/** Row selection state and actions for a `DataTableCore`-style table. */ +export interface RowSelection { + /** The text identity of a row by its data key. */ + dataKeyIdentity: (row: TData) => string; + /** Whether a row is the single selected row. */ + isSelectedRow: (row: TData, loadedIndex: number) => boolean; + /** Whether a row is among the multiple selected rows. */ + isRowSelected: (row: TData) => boolean; + /** Adds a row to, or removes it from, the multiple selection. */ + toggleRowSelection: (row: TData) => void; + /** Whether every visible row is selected. */ + allFilteredRowsSelected: boolean; + /** The select-all checkbox, whose indeterminate state the hook keeps. */ + selectAllRef: RefObject; + /** Selects every visible row, or clears them when all are selected. */ + toggleSelectAll: () => void; +} diff --git a/Source/DataTables/RowSelectionSource.ts b/Source/DataTables/RowSelectionSource.ts new file mode 100644 index 00000000..7cc27494 --- /dev/null +++ b/Source/DataTables/RowSelectionSource.ts @@ -0,0 +1,18 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +/** What `useRowSelection` selects from. */ +export interface RowSelectionSource { + /** The loaded rows. */ + data: TData[]; + /** The rows currently visible after filtering. */ + visibleRows: TData[]; + /** The row property used as identity. */ + dataKey?: string; + /** The single selected row. */ + selection?: TData | null; + /** The multiple selected rows. */ + selectedItems?: TData[]; + /** Receives the next multiple selection. */ + onSelectedItemsChange?: (items: TData[]) => void; +} diff --git a/Source/DataTables/classNames.ts b/Source/DataTables/classNames.ts new file mode 100644 index 00000000..195469d3 --- /dev/null +++ b/Source/DataTables/classNames.ts @@ -0,0 +1,10 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +/** + * Joins the class names that are set. + * @param values Class names, some possibly undefined. + * @returns The space-separated class names. + */ +export const classNames = (...values: Array) => + values.filter(Boolean).join(' '); diff --git a/Source/DataTables/for_matchesFilterEntry/when_matching_filter_entries.ts b/Source/DataTables/for_matchesFilterEntry/when_matching_filter_entries.ts new file mode 100644 index 00000000..eca1e676 --- /dev/null +++ b/Source/DataTables/for_matchesFilterEntry/when_matching_filter_entries.ts @@ -0,0 +1,84 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { DataTableFilterMatchMode } from '../DataTableFilterMeta'; +import { firstConstraint, matchesFilterEntry } from '../matchesFilterEntry'; + +describe('when matching a constraint with no filter value', () => { + it('should match every value', () => { + matchesFilterEntry('anything', { value: '', matchMode: DataTableFilterMatchMode.Equals }).should.be.true; + }); +}); + +describe('when matching text without a match mode', () => { + it('should match values that contain the text, ignoring case', () => { + matchesFilterEntry('Example Project', { value: 'project' }).should.be.true; + matchesFilterEntry('Example Project', { value: 'sample' }).should.be.false; + }); +}); + +describe('when matching constraints combined with or', () => { + const entry = { + operator: 'or', + constraints: [ + { value: 'Ex', matchMode: DataTableFilterMatchMode.StartsWith }, + { value: 'User', matchMode: DataTableFilterMatchMode.EndsWith }, + ], + }; + it('should match when either constraint matches', () => { + matchesFilterEntry('Sample User', entry).should.be.true; + matchesFilterEntry('Demo Assistant', entry).should.be.false; + }); +}); + +describe('when matching constraints combined with and', () => { + const entry = { + operator: 'and', + constraints: [ + { value: 'Ex', matchMode: DataTableFilterMatchMode.StartsWith }, + { value: 'Project', matchMode: DataTableFilterMatchMode.EndsWith }, + ], + }; + it('should match only when every constraint matches', () => { + matchesFilterEntry('Example Project', entry).should.be.true; + matchesFilterEntry('Example User', entry).should.be.false; + }); +}); + +describe('when matching an entry with no constraints', () => { + it('should match every value', () => { + matchesFilterEntry('anything', { operator: 'and', constraints: [] }).should.be.true; + }); +}); + +describe('when matching a number between two bounds', () => { + it('should include both bounds', () => { + const entry = { value: [2, 4], matchMode: DataTableFilterMatchMode.Between }; + [1, 2, 4, 5].map((value) => matchesFilterEntry(value, entry)).should.deep.equal([false, true, true, false]); + }); +}); + +describe('when matching dates', () => { + const filter = new Date(2024, 4, 17, 8, 0, 0, 0); + const filterTime = filter.getTime(); + const value = new Date(2024, 4, 17, 20, 0, 0, 0); + const valueTime = value.getTime(); + const matched = matchesFilterEntry(value, { value: filter, matchMode: DataTableFilterMatchMode.DateIs }); + + it('should compare by day', () => { + matched.should.be.true; + }); + it('should not change the caller-owned dates', () => { + filter.getTime().should.equal(filterTime); + value.getTime().should.equal(valueTime); + }); +}); + +describe('when taking the first constraint of an entry', () => { + it('should return the constraint itself or the first of several', () => { + const constraint = { value: 'a' }; + firstConstraint(constraint)!.should.equal(constraint); + firstConstraint({ operator: 'or', constraints: [constraint, { value: 'b' }] })!.should.equal(constraint); + (firstConstraint(undefined) === undefined).should.be.true; + }); +}); diff --git a/Source/DataTables/for_processRows/when_processing_rows.ts b/Source/DataTables/for_processRows/when_processing_rows.ts new file mode 100644 index 00000000..2427e060 --- /dev/null +++ b/Source/DataTables/for_processRows/when_processing_rows.ts @@ -0,0 +1,72 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { DataTableFilterMatchMode } from '../DataTableFilterMeta'; +import { DataTableRowProcessing } from '../DataTableRowProcessing'; +import { DataTableSortDirection } from '../DataTableSortDirection'; +import { processRows } from '../processRows'; +import type { RowProcessingState } from '../RowProcessingState'; + +const rows = [ + { name: 'Charlie', score: 10, team: { name: 'Blue' } }, + { name: 'Alpha', score: 9, team: { name: 'Red' } }, + { name: 'Bravo', score: 100, team: { name: 'Blue' } }, +]; +const state = (overrides: Partial): RowProcessingState => ({ + filters: {}, + globalFilter: '', + sort: null, + rowProcessing: DataTableRowProcessing.Loaded, + ...overrides, +}); +const names = (processed: ReturnType>) => processed.map(({ row }) => row.name); + +describe('when processing rows with no state', () => { + it('should keep the loaded order and indexes', () => { + processRows(rows, state({})).map(({ loadedIndex }) => loadedIndex).should.deep.equal([0, 1, 2]); + }); +}); + +describe('when processing rows with a column filter and search text', () => { + const processed = processRows(rows, state({ + filters: { 'team.name': { value: 'Blue', matchMode: DataTableFilterMatchMode.Equals } }, + globalFilter: ' BRA ', + globalFilterFields: ['name'], + })); + it('should keep only rows matching both', () => { + names(processed).should.deep.equal(['Bravo']); + }); + it('should keep the row\'s loaded index', () => { + processed[0].loadedIndex.should.equal(2); + }); +}); + +describe('when processing rows with search text but no search fields', () => { + it('should ignore the search text', () => { + names(processRows(rows, state({ globalFilter: 'zzz' }))).should.deep.equal(['Charlie', 'Alpha', 'Bravo']); + }); +}); + +describe('when processing rows sorted by a number', () => { + it('should sort numerically rather than as text', () => { + names(processRows(rows, state({ sort: { field: 'score', direction: DataTableSortDirection.Ascending } }))) + .should.deep.equal(['Alpha', 'Charlie', 'Bravo']); + }); +}); + +describe('when processing rows sorted descending by a nested field', () => { + it('should reverse the order and keep ties in loaded order', () => { + names(processRows(rows, state({ sort: { field: 'team.name', direction: DataTableSortDirection.Descending } }))) + .should.deep.equal(['Alpha', 'Charlie', 'Bravo']); + }); +}); + +describe('when processing rows with row processing off', () => { + it('should return the rows as given', () => { + names(processRows(rows, state({ + rowProcessing: DataTableRowProcessing.None, + filters: { name: { value: 'zzz' } }, + sort: { field: 'name', direction: DataTableSortDirection.Ascending }, + }))).should.deep.equal(['Charlie', 'Alpha', 'Bravo']); + }); +}); diff --git a/Source/DataTables/for_valueAtPath/when_reading_a_path.ts b/Source/DataTables/for_valueAtPath/when_reading_a_path.ts new file mode 100644 index 00000000..a22c12d0 --- /dev/null +++ b/Source/DataTables/for_valueAtPath/when_reading_a_path.ts @@ -0,0 +1,35 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { valueAtPath } from '../valueAtPath'; + +describe('when reading a nested path', () => { + it('should return the nested value', () => { + (valueAtPath({ address: { city: 'Example City' } }, 'address.city') as string).should.equal('Example City'); + }); +}); + +describe('when reading a path with a missing segment', () => { + it('should return undefined', () => { + (valueAtPath({ address: null }, 'address.city') === undefined).should.be.true; + }); +}); + +describe('when reading a path through an inherited property', () => { + it('should not read properties the row does not own', () => { + (valueAtPath({}, 'constructor') === undefined).should.be.true; + }); +}); + +describe('when reading a path to a function', () => { + it('should return the function as text', () => { + const value = valueAtPath({ describe: () => 'value' }, 'describe'); + (typeof value).should.equal('string'); + }); +}); + +describe('when reading without a path', () => { + it('should return undefined', () => { + (valueAtPath({ name: 'Sample User' }, undefined) === undefined).should.be.true; + }); +}); diff --git a/Source/DataTables/matchesFilterEntry.ts b/Source/DataTables/matchesFilterEntry.ts new file mode 100644 index 00000000..83cbda34 --- /dev/null +++ b/Source/DataTables/matchesFilterEntry.ts @@ -0,0 +1,101 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { + DataTableFilterMatchMode, + type DataTableFilterConstraint, + type DataTableFilterEntry, +} from './DataTableFilterMeta'; +import { resolveDataTableFilterMatcher } from './DataTableFilterMatcherRegistry'; + +const dateNumber = (value: unknown) => { + // A cloned Date is normalized in place so caller-owned Date instances (row + // values and filter constraints alike) are never mutated by comparison. + const date = + value instanceof Date ? new Date(value.getTime()) : new Date(String(value)); + return Number.isNaN(date.getTime()) ? undefined : date.setHours(0, 0, 0, 0); +}; + +/** + * The first constraint of a filter entry, which is what the column filter menu edits. + * @param entry The filter entry, if any. + * @returns Its first constraint, or undefined. + */ +export const firstConstraint = ( + entry: DataTableFilterEntry | undefined, +): DataTableFilterConstraint | undefined => + entry && 'constraints' in entry ? entry.constraints[0] : entry; + +const builtInMatches = ( + value: unknown, + constraint: DataTableFilterConstraint, +): boolean => { + const filter = constraint.value; + const mode = constraint.matchMode ?? DataTableFilterMatchMode.Contains; + if (filter === null || filter === undefined || filter === '') return true; + + const valueText = String(value ?? '').toLocaleLowerCase(); + const filterText = String(filter).toLocaleLowerCase(); + const valueNumber = typeof value === 'number' ? value : Number(value); + const filterNumber = typeof filter === 'number' ? filter : Number(filter); + + switch (mode) { + case DataTableFilterMatchMode.StartsWith: + return valueText.startsWith(filterText); + case DataTableFilterMatchMode.Contains: + return valueText.includes(filterText); + case DataTableFilterMatchMode.NotContains: + return !valueText.includes(filterText); + case DataTableFilterMatchMode.EndsWith: + return valueText.endsWith(filterText); + case DataTableFilterMatchMode.Equals: + return Object.is(value, filter) || valueText === filterText; + case DataTableFilterMatchMode.NotEquals: + return !(Object.is(value, filter) || valueText === filterText); + case DataTableFilterMatchMode.In: + return Array.isArray(filter) && filter.some((item) => Object.is(item, value)); + case DataTableFilterMatchMode.Between: + return ( + Array.isArray(filter) && + filter.length >= 2 && + valueNumber >= Number(filter[0]) && + valueNumber <= Number(filter[1]) + ); + case DataTableFilterMatchMode.LessThan: + return valueNumber < filterNumber; + case DataTableFilterMatchMode.LessThanOrEqual: + return valueNumber <= filterNumber; + case DataTableFilterMatchMode.GreaterThan: + return valueNumber > filterNumber; + case DataTableFilterMatchMode.GreaterThanOrEqual: + return valueNumber >= filterNumber; + case DataTableFilterMatchMode.DateIs: + return dateNumber(value) === dateNumber(filter); + case DataTableFilterMatchMode.DateIsNot: + return dateNumber(value) !== dateNumber(filter); + case DataTableFilterMatchMode.DateBefore: + return (dateNumber(value) ?? Infinity) < (dateNumber(filter) ?? -Infinity); + case DataTableFilterMatchMode.DateAfter: + return (dateNumber(value) ?? -Infinity) > (dateNumber(filter) ?? Infinity); + default: + return resolveDataTableFilterMatcher(String(mode))?.(value, filter) ?? false; + } +}; + +/** + * Whether a value satisfies a filter entry, combining its constraints with the entry's operator. + * @param value The row value. + * @param entry The filter entry. + * @returns True when the value matches. + */ +export const matchesFilterEntry = (value: unknown, entry: DataTableFilterEntry) => { + if (!('constraints' in entry)) return builtInMatches(value, entry); + if (entry.constraints.length === 0) return true; + + const matches = entry.constraints.map((constraint) => + builtInMatches(value, constraint), + ); + return entry.operator?.toLowerCase() === 'or' + ? matches.some(Boolean) + : matches.every(Boolean); +}; diff --git a/Source/DataTables/processRows.ts b/Source/DataTables/processRows.ts new file mode 100644 index 00000000..3c9c03ea --- /dev/null +++ b/Source/DataTables/processRows.ts @@ -0,0 +1,64 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { DataTableRowProcessing } from './DataTableRowProcessing'; +import { matchesFilterEntry } from './matchesFilterEntry'; +import type { ProcessedRow } from './ProcessedRow'; +import type { RowProcessingState } from './RowProcessingState'; +import { valueAtPath } from './valueAtPath'; + +const compareValues = (left: unknown, right: unknown): number => { + if (typeof left === 'number' && typeof right === 'number') return left - right; + if (left instanceof Date && right instanceof Date) + return left.getTime() - right.getTime(); + return String(left ?? '').localeCompare(String(right ?? ''), undefined, { + numeric: true, + sensitivity: 'base', + }); +}; + +/** + * Filters and sorts loaded rows by the table's state, or keeps them as given when row processing is off. + * @param data The loaded rows. + * @param state The filter, search and sort state. + * @returns The rows to render, each with its loaded-page index. + */ +export const processRows = ( + data: TData[], + { + filters, + globalFilter, + globalFilterFields, + sort, + rowProcessing, + }: RowProcessingState, +): ProcessedRow[] => { + if (rowProcessing === DataTableRowProcessing.None) { + return data.map((row, loadedIndex) => ({ row, loadedIndex })); + } + const term = globalFilter.trim().toLocaleLowerCase(); + const rows = data + .map((row, loadedIndex) => ({ row, loadedIndex })) + .filter(({ row }) => { + const rowValues = row as Record; + const matchesColumns = Object.entries(filters).every(([field, entry]) => + matchesFilterEntry(valueAtPath(rowValues, field), entry), + ); + if (!matchesColumns) return false; + if (!term || !globalFilterFields?.length) return true; + return globalFilterFields.some((field) => + String(valueAtPath(rowValues, field) ?? '') + .toLocaleLowerCase() + .includes(term), + ); + }); + + if (!sort) return rows; + return [...rows].sort((left, right) => { + const comparison = compareValues( + valueAtPath(left.row as Record, sort.field), + valueAtPath(right.row as Record, sort.field), + ); + return sort.direction === 'ascending' ? comparison : -comparison; + }); +}; diff --git a/Source/DataTables/useDataTableMessages.ts b/Source/DataTables/useDataTableMessages.ts new file mode 100644 index 00000000..8eeef242 --- /dev/null +++ b/Source/DataTables/useDataTableMessages.ts @@ -0,0 +1,42 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { useCratisComponentsConfig } from '../Common/CratisComponentsProvider'; +import type { DataTableMessageOverrides } from './DataTableMessageOverrides'; +import type { DataTableMessages } from './DataTableMessages'; + +/** + * Resolves each table message from its prop, then the provider's `dataTable` messages, then the default. + * @param overrides The messages passed as props. + * @returns The messages to show. + */ +export const useDataTableMessages = ( + overrides: DataTableMessageOverrides, +): DataTableMessages => { + const { messages } = useCratisComponentsConfig(); + const dataTableMessages = messages?.dataTable; + return { + resolvedSelectionAriaLabel: + overrides.selectionAriaLabel ?? dataTableMessages?.selectRow ?? 'Select row', + resolvedSelectAllAriaLabel: + overrides.selectAllAriaLabel ?? + dataTableMessages?.selectAllRows ?? + 'Select all rows', + resolvedGlobalSearchPlaceholder: + overrides.globalSearchPlaceholder ?? dataTableMessages?.search ?? 'Search…', + resolvedGlobalSearchAriaLabel: + overrides.globalSearchAriaLabel ?? + dataTableMessages?.searchAriaLabel ?? + 'Search table', + resolvedLoadingMessage: + overrides.loadingMessage ?? dataTableMessages?.loading ?? 'Loading…', + resolvedFailureMessage: + overrides.failureMessage ?? + dataTableMessages?.failed ?? + 'Could not load data.', + resolvedUnauthorizedMessage: + overrides.unauthorizedMessage ?? + dataTableMessages?.unauthorized ?? + 'You are not authorized to view this data.', + }; +}; diff --git a/Source/DataTables/useDataTableState.ts b/Source/DataTables/useDataTableState.ts new file mode 100644 index 00000000..64fff898 --- /dev/null +++ b/Source/DataTables/useDataTableState.ts @@ -0,0 +1,53 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import type { + DataTableFilterConstraint, + DataTableFilterMeta, +} from './DataTableFilterMeta'; +import type { DataTableSort } from './DataTableSort'; +import type { DataTableState } from './DataTableState'; +import type { DataTableStateProps } from './DataTableStateProps'; +import { useControllableState } from './useControllableState'; + +/** + * The table's filter, search and sort state, each controlled by its prop when set and kept by the + * table otherwise. Every change is reported through its callback in both modes. + * @param props The state props of the table. + * @returns The current state and its setters. + */ +export const useDataTableState = ({ + defaultFilters, + filters: filtersProp, + onFilter, + globalFilter: globalFilterProp, + onGlobalFilterChange, + sort: sortProp, + onSortChange, +}: DataTableStateProps): DataTableState => { + const [filters, setFilters] = useControllableState( + filtersProp, + defaultFilters ?? {}, + onFilter, + ); + const [globalFilter, setGlobalFilter] = useControllableState( + globalFilterProp, + '', + onGlobalFilterChange, + ); + const [sort, setSort] = useControllableState( + sortProp, + null, + onSortChange, + ); + const updateFilter = ( + field: string, + constraint: DataTableFilterConstraint | undefined, + ) => { + const next = { ...filters }; + if (constraint) next[field] = constraint; + else delete next[field]; + setFilters(next); + }; + return { filters, updateFilter, globalFilter, setGlobalFilter, sort, setSort }; +}; diff --git a/Source/DataTables/useRowSelection.ts b/Source/DataTables/useRowSelection.ts new file mode 100644 index 00000000..38dba6ca --- /dev/null +++ b/Source/DataTables/useRowSelection.ts @@ -0,0 +1,118 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { useEffect, useMemo, useRef } from 'react'; +import type { RowSelection } from './RowSelection'; +import type { RowSelectionSource } from './RowSelectionSource'; +import { valueAtPath } from './valueAtPath'; + +/** + * Single and multiple row selection by data key, or by object identity without one. + * @param source The rows and the selection to work with. + * @returns The selection state and actions. + */ +export const useRowSelection = ({ + data, + visibleRows, + dataKey, + selection, + selectedItems, + onSelectedItemsChange, +}: RowSelectionSource): RowSelection => { + const dataKeyIdentity = (row: TData) => + String(valueAtPath(row as Record, dataKey!)); + const dataKeyCounts = useMemo(() => { + const counts = new Map(); + if (!dataKey) return counts; + for (const row of data) { + const identity = String(valueAtPath(row as Record, dataKey)); + counts.set(identity, (counts.get(identity) ?? 0) + 1); + } + return counts; + }, [data, dataKey]); + const firstLoadedIndexByDataKey = useMemo(() => { + const indices = new Map(); + if (!dataKey) return indices; + data.forEach((row, loadedIndex) => { + const identity = String(valueAtPath(row as Record, dataKey)); + if (!indices.has(identity)) indices.set(identity, loadedIndex); + }); + return indices; + }, [data, dataKey]); + const selectedLoadedIndex = selection ? data.indexOf(selection) : -1; + const selectedDataKey = selection && dataKey ? dataKeyIdentity(selection) : undefined; + const isSelectedRow = (row: TData, loadedIndex: number) => { + if (!selection) return false; + if (!dataKey) return loadedIndex === selectedLoadedIndex; + + const identity = dataKeyIdentity(row); + if (identity !== selectedDataKey) return false; + if ((dataKeyCounts.get(identity) ?? 0) <= 1) return true; + if (selectedLoadedIndex >= 0) return loadedIndex === selectedLoadedIndex; + return loadedIndex === firstLoadedIndexByDataKey.get(identity); + }; + + // Membership is by dataKey when there is one, and by object identity otherwise. A table whose + // rows are replaced wholesale on every refresh - which is every observable query - keeps its + // selection only in the first case, which is why dataKey matters here as much as it does for + // single selection. + const selectedItemsList = useMemo(() => selectedItems ?? [], [selectedItems]); + const isRowSelected = (row: TData) => + dataKey + ? selectedItemsList.some( + (selected) => dataKeyIdentity(selected) === dataKeyIdentity(row), + ) + : selectedItemsList.includes(row); + + const toggleRowSelection = (row: TData) => { + const next = isRowSelected(row) + ? selectedItemsList.filter((selected) => + dataKey + ? dataKeyIdentity(selected) !== dataKeyIdentity(row) + : selected !== row, + ) + : [...selectedItemsList, row]; + onSelectedItemsChange?.(next); + }; + + // Select-all means the rows the user can currently see. A filtered table that silently selected + // rows hidden behind the filter would act on more than it showed, which is the whole hazard of a + // bulk action. + const isVisibleRow = (row: TData) => + dataKey + ? visibleRows.some( + (visible) => dataKeyIdentity(visible) === dataKeyIdentity(row), + ) + : visibleRows.includes(row); + const selectedVisibleCount = visibleRows.filter((row) => isRowSelected(row)).length; + const allFilteredRowsSelected = + visibleRows.length > 0 && selectedVisibleCount === visibleRows.length; + const someFilteredRowsSelected = selectedVisibleCount > 0 && !allFilteredRowsSelected; + const selectAllRef = useRef(null); + useEffect(() => { + if (selectAllRef.current) { + selectAllRef.current.indeterminate = someFilteredRowsSelected; + } + }, [someFilteredRowsSelected]); + + const toggleSelectAll = () => { + if (allFilteredRowsSelected) { + onSelectedItemsChange?.( + selectedItemsList.filter((selected) => !isVisibleRow(selected)), + ); + return; + } + const additions = visibleRows.filter((row) => !isRowSelected(row)); + onSelectedItemsChange?.([...selectedItemsList, ...additions]); + }; + + return { + dataKeyIdentity, + isSelectedRow, + isRowSelected, + toggleRowSelection, + allFilteredRowsSelected, + selectAllRef, + toggleSelectAll, + }; +}; diff --git a/Source/DataTables/valueAtPath.ts b/Source/DataTables/valueAtPath.ts new file mode 100644 index 00000000..659d0797 --- /dev/null +++ b/Source/DataTables/valueAtPath.ts @@ -0,0 +1,30 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +/** A value read from a row, with functions turned into their text. */ +export type CellValue = + string | number | boolean | bigint | symbol | Date | object | null | undefined; + +const asCellValue = (value: unknown): CellValue => + typeof value === 'function' ? String(value) : (value as CellValue); + +/** + * Reads a dotted property path from a row, only through the row's own properties. + * @param row The row. + * @param path The dotted path, such as `address.city`. + * @returns The value, or undefined when any segment is missing. + */ +export const valueAtPath = ( + row: Record, + path: string | undefined, +): CellValue => { + if (!path) return undefined; + let current: CellValue = row; + for (const segment of path.split('.')) { + if (current === null || typeof current !== 'object') return undefined; + const record = current as Record; + if (!Object.hasOwn(record, segment)) return undefined; + current = asCellValue(record[segment]); + } + return current; +}; diff --git a/Source/scripts/generate-parts.mjs b/Source/scripts/generate-parts.mjs index 45b12f22..4bb68918 100644 --- a/Source/scripts/generate-parts.mjs +++ b/Source/scripts/generate-parts.mjs @@ -385,7 +385,13 @@ export const partDefinitions = { 'row', 'cell', ], - sources: ['DataTables/DataTableCore.tsx'], + sources: [ + 'DataTables/DataTableCore.tsx', + 'DataTables/DataTableSearch.tsx', + 'DataTables/DataTableHeaderCell.tsx', + 'DataTables/DataTableBody.tsx', + 'DataTables/DataTableRow.tsx', + ], ptKeys: [ 'root', 'search',