From eacda69486c98dc4f2b5e14c7a975f29029922eb Mon Sep 17 00:00:00 2001 From: woksin Date: Tue, 29 Sep 2026 13:20:53 +0200 Subject: [PATCH 1/2] Extract DataTable row reading, filter matching and row processing valueAtPath, the filter matchers and processRows become their own modules with direct specs, so the filter-and-sort pipeline #178 step 2 bypasses for server mode can be reasoned about on its own (#372) --- Source/DataTables/ProcessedRow.ts | 10 ++ Source/DataTables/RowProcessingState.ts | 20 ++++ Source/DataTables/classNames.ts | 10 ++ .../when_matching_filter_entries.ts | 84 +++++++++++++++ .../for_processRows/when_processing_rows.ts | 72 +++++++++++++ .../for_valueAtPath/when_reading_a_path.ts | 35 ++++++ Source/DataTables/matchesFilterEntry.ts | 101 ++++++++++++++++++ Source/DataTables/processRows.ts | 64 +++++++++++ Source/DataTables/valueAtPath.ts | 30 ++++++ 9 files changed, 426 insertions(+) create mode 100644 Source/DataTables/ProcessedRow.ts create mode 100644 Source/DataTables/RowProcessingState.ts create mode 100644 Source/DataTables/classNames.ts create mode 100644 Source/DataTables/for_matchesFilterEntry/when_matching_filter_entries.ts create mode 100644 Source/DataTables/for_processRows/when_processing_rows.ts create mode 100644 Source/DataTables/for_valueAtPath/when_reading_a_path.ts create mode 100644 Source/DataTables/matchesFilterEntry.ts create mode 100644 Source/DataTables/processRows.ts create mode 100644 Source/DataTables/valueAtPath.ts 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/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/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; +}; From c377f04ea7101cdc62c365453728358bbd1b1ed7 Mon Sep 17 00:00:00 2001 From: woksin Date: Tue, 29 Sep 2026 13:20:53 +0200 Subject: [PATCH 2/2] Split DataTableCore into state, selection and rendering modules DataTableCore keeps its props contract and composes useDataTableState, useDataTableMessages, useRowSelection, the search box, header cell, body and row. Internal components take the part attributes as parts, so the public pt inventory is unchanged, and the parts manifest lists their files as DataTableCore sources. Rendered markup is byte-identical to before across 29 prop combinations, server and client, and the API surface snapshot is unchanged (#372) --- Source/DataTables/DataTableBody.tsx | 116 +++ Source/DataTables/DataTableCore.tsx | 797 +++--------------- Source/DataTables/DataTableHeaderCell.tsx | 148 ++++ .../DataTables/DataTableMessageOverrides.ts | 15 + Source/DataTables/DataTableMessages.ts | 15 + Source/DataTables/DataTableParts.ts | 48 ++ Source/DataTables/DataTableRow.tsx | 117 +++ Source/DataTables/DataTableRowClickEvent.ts | 10 + Source/DataTables/DataTableSearch.tsx | 43 + Source/DataTables/DataTableState.ts | 27 + Source/DataTables/DataTableStateProps.ts | 23 + Source/DataTables/RowSelection.ts | 22 + Source/DataTables/RowSelectionSource.ts | 18 + Source/DataTables/useDataTableMessages.ts | 42 + Source/DataTables/useDataTableState.ts | 53 ++ Source/DataTables/useRowSelection.ts | 118 +++ Source/scripts/generate-parts.mjs | 8 +- 17 files changed, 957 insertions(+), 663 deletions(-) create mode 100644 Source/DataTables/DataTableBody.tsx create mode 100644 Source/DataTables/DataTableHeaderCell.tsx create mode 100644 Source/DataTables/DataTableMessageOverrides.ts create mode 100644 Source/DataTables/DataTableMessages.ts create mode 100644 Source/DataTables/DataTableParts.ts create mode 100644 Source/DataTables/DataTableRow.tsx create mode 100644 Source/DataTables/DataTableRowClickEvent.ts create mode 100644 Source/DataTables/DataTableSearch.tsx create mode 100644 Source/DataTables/DataTableState.ts create mode 100644 Source/DataTables/DataTableStateProps.ts create mode 100644 Source/DataTables/RowSelection.ts create mode 100644 Source/DataTables/RowSelectionSource.ts create mode 100644 Source/DataTables/useDataTableMessages.ts create mode 100644 Source/DataTables/useDataTableState.ts create mode 100644 Source/DataTables/useRowSelection.ts 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/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/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/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',