From e6062fe9e4353a93db164b88e6f1fdc1c555e9a2 Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Wed, 22 Jul 2026 08:05:11 +0200 Subject: [PATCH] fix(webui): enforce full-result DataGrid queries --- docs/UI_UX_DECISION_LEDGER.md | 6 +++ webui/src/components/table/DataGrid.tsx | 71 +++++++++++++++++++++---- webui/src/index.ts | 2 +- webui/tests/data-grid-actions.test.tsx | 42 ++++++++++++++- webui/tsconfig.component-tests.json | 1 + 5 files changed, 109 insertions(+), 13 deletions(-) diff --git a/docs/UI_UX_DECISION_LEDGER.md b/docs/UI_UX_DECISION_LEDGER.md index a3f9269..b663777 100644 --- a/docs/UI_UX_DECISION_LEDGER.md +++ b/docs/UI_UX_DECISION_LEDGER.md @@ -233,6 +233,12 @@ instead of reproducing their behavior. `disabledReason` for row state; omit an action only when that action does not belong to the table. `minimumSlots` reserves trailing positions for an empty row. `DataGridEmptyAction` does this for the standard add/move/remove layout. +- A paginated `DataGrid` has exactly one query owner. Client mode receives the + complete logical row set and applies filtering and sorting before slicing a + page. Server mode receives only the loaded page, requires `onQueryChange`, + and the backend applies every emitted filter/sort before pagination while + returning `totalRows` for the filtered result. Server list filters declare + their complete option domain instead of deriving it from the loaded page. - Feedback and confirmation use `Dialog`, `ConfirmDialog`, or `DismissibleAlert`. They never fall back to `window.alert`. diff --git a/webui/src/components/table/DataGrid.tsx b/webui/src/components/table/DataGrid.tsx index 954acb1..586faf0 100644 --- a/webui/src/components/table/DataGrid.tsx +++ b/webui/src/components/table/DataGrid.tsx @@ -30,18 +30,36 @@ export type DataGridQueryState = { filters: Record; }; -export type DataGridPagination = { - /** Client mode slices the filtered rows; server mode treats rows as the loaded page. */ - mode?: "client" | "server"; +type DataGridPaginationBase = { page: number; pageSize: number; - totalRows?: number; pageSizeOptions?: number[]; onPageChange: (page: number) => void; onPageSizeChange?: (pageSize: number) => void; disabled?: boolean; }; +export type DataGridClientPagination = DataGridPaginationBase & { + /** + * Client mode filters and sorts before slicing. `rows` must therefore contain + * the complete logical result set, never an already paginated backend page. + */ + mode?: "client"; + totalRows?: never; +}; + +export type DataGridServerPagination = DataGridPaginationBase & { + /** + * Server mode treats `rows` as the current backend page. The query callback + * must apply every emitted sort/filter to the backend before pagination. + */ + mode: "server"; + /** Total rows after the backend has applied the current filters. */ + totalRows: number; +}; + +export type DataGridPagination = DataGridClientPagination | DataGridServerPagination; + type TypedFilterOperator = "contains" | "eq" | "gt" | "gte" | "lt" | "lte" | "before" | "after"; export type DataGridColumn = { @@ -78,7 +96,7 @@ type DataGridState = { bufferWidth?: number; }; -type DataGridProps = { +type DataGridBaseProps = { id: string; rows: T[]; columns: DataGridColumn[]; @@ -98,11 +116,18 @@ type DataGridProps = { storageKey?: string; initialFilters?: Record; initialSort?: {columnId: string;direction: DataGridSortDirection;}; - pagination?: DataGridPagination; - /** In server pagination mode, sorting/filtering is emitted instead of applied locally. */ - onQueryChange?: (query: DataGridQueryState) => void; }; +/** + * Query ownership is deliberately explicit: client grids receive all rows and + * DataGrid queries them locally; server grids receive one page and must handle + * the complete query through `onQueryChange`. + */ +export type DataGridProps = DataGridBaseProps & ( + | {pagination?: DataGridClientPagination;onQueryChange?: never;} + | {pagination: DataGridServerPagination;onQueryChange: (query: DataGridQueryState) => void;} +); + export type DataGridRowActionsProps = { disabled?: boolean; removeDisabled?: boolean; @@ -213,14 +238,21 @@ export default function DataGrid({ const headerCellRefs = useRef>({}); const filterButtonRefs = useRef>({}); const filterPopoverRef = useRef(null); + const serverQueryMode = pagination?.mode === "server"; const onQueryChangeRef = useRef(onQueryChange); + const serverPaginationRef = useRef(serverQueryMode ? pagination : null); + const lastServerQueryRef = useRef(null); const lastLayoutSignatureRef = useRef(null); const lastContainerWidthRef = useRef(undefined); const [measuredWidths, setMeasuredWidths] = useState>({}); - const serverQueryMode = pagination?.mode === "server"; useEffect(() => {onQueryChangeRef.current = onQueryChange;}, [onQueryChange]); + useEffect(() => { + serverPaginationRef.current = serverQueryMode ? pagination as DataGridServerPagination : null; + if (!serverQueryMode) lastServerQueryRef.current = null; + }, [pagination, serverQueryMode]); + useEffect(() => { setState((current) => sanitizePersistedColumnState(columns, current, effectiveResizeBehavior)); }, [columns, effectiveResizeBehavior]); @@ -460,10 +492,17 @@ export default function DataGrid({ useEffect(() => { if (!serverQueryMode) return; - onQueryChangeRef.current?.({ + const query = { sort: state.sort ?? null, filters: { ...(state.filters ?? {}) } - }); + }; + const previousQuery = lastServerQueryRef.current; + lastServerQueryRef.current = query; + const serverPagination = serverPaginationRef.current; + if (previousQuery && !dataGridQueriesEqual(previousQuery, query) && serverPagination && serverPagination.page !== 1) { + serverPagination.onPageChange(1); + } + onQueryChangeRef.current?.(query); }, [serverQueryMode, state.sort, state.filters]); const filterTypes = useMemo(() => { @@ -1668,6 +1707,16 @@ function compareValues(a: unknown, b: unknown): number { return stringifyCell(a).localeCompare(stringifyCell(b), undefined, { numeric: true, sensitivity: "base" }); } +function dataGridQueriesEqual(left: DataGridQueryState, right: DataGridQueryState): boolean { + if ((left.sort?.columnId ?? "") !== (right.sort?.columnId ?? "")) return false; + if ((left.sort?.direction ?? "") !== (right.sort?.direction ?? "")) return false; + const keys = new Set([...Object.keys(left.filters), ...Object.keys(right.filters)]); + for (const key of keys) { + if ((left.filters[key] ?? "") !== (right.filters[key] ?? "")) return false; + } + return true; +} + function stringifyCell(value: unknown): string { if (value === null || value === undefined) return ""; if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") return String(value); diff --git a/webui/src/index.ts b/webui/src/index.ts index b6f1378..0e90af7 100644 --- a/webui/src/index.ts +++ b/webui/src/index.ts @@ -114,7 +114,7 @@ export type { MailServerConnectionTestResult, MailServerCredentialSettings, Mail export { default as FieldLabel } from "./components/help/FieldLabel"; export { default as InlineHelp } from "./components/help/InlineHelp"; export { default as DataGrid, DataGridEmptyAction, DataGridPaginationBar, DataGridRowActions } from "./components/table/DataGrid"; -export type { DataGridColumn, DataGridListOption, DataGridPagination, DataGridPaginationBarProps, DataGridQueryState, DataGridSortDirection } from "./components/table/DataGrid"; +export type { DataGridClientPagination, DataGridColumn, DataGridListOption, DataGridPagination, DataGridPaginationBarProps, DataGridProps, DataGridQueryState, DataGridServerPagination, DataGridSortDirection } from "./components/table/DataGrid"; export { default as TableActionGroup } from "./components/table/TableActionGroup"; export type { TableActionDefinition, TableActionGroupProps } from "./components/table/TableActionGroup"; diff --git a/webui/tests/data-grid-actions.test.tsx b/webui/tests/data-grid-actions.test.tsx index 32343f6..d643ca0 100644 --- a/webui/tests/data-grid-actions.test.tsx +++ b/webui/tests/data-grid-actions.test.tsx @@ -4,7 +4,7 @@ function assertEqual(actual: T, expected: T, message: string): void { import { renderToStaticMarkup } from "react-dom/server"; import Button from "../src/components/Button"; -import { DataGridEmptyAction, DataGridRowActions } from "../src/components/table/DataGrid"; +import DataGrid, { DataGridEmptyAction, DataGridRowActions, type DataGridColumn } from "../src/components/table/DataGrid"; import TableActionGroup, { runTableAction } from "../src/components/table/TableActionGroup"; function noop() {} @@ -77,3 +77,43 @@ const reasonedButton = renderToStaticMarkup(