fix(webui): enforce full-result DataGrid queries

This commit is contained in:
2026-07-22 08:05:11 +02:00
parent 987ca894ed
commit e6062fe9e4
5 changed files with 109 additions and 13 deletions

View File

@@ -233,6 +233,12 @@ instead of reproducing their behavior.
`disabledReason` for row state; omit an action only when that action does not `disabledReason` for row state; omit an action only when that action does not
belong to the table. `minimumSlots` reserves trailing positions for an empty belong to the table. `minimumSlots` reserves trailing positions for an empty
row. `DataGridEmptyAction` does this for the standard add/move/remove layout. 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 - Feedback and confirmation use `Dialog`, `ConfirmDialog`, or
`DismissibleAlert`. They never fall back to `window.alert`. `DismissibleAlert`. They never fall back to `window.alert`.

View File

@@ -30,18 +30,36 @@ export type DataGridQueryState = {
filters: Record<string, string>; filters: Record<string, string>;
}; };
export type DataGridPagination = { type DataGridPaginationBase = {
/** Client mode slices the filtered rows; server mode treats rows as the loaded page. */
mode?: "client" | "server";
page: number; page: number;
pageSize: number; pageSize: number;
totalRows?: number;
pageSizeOptions?: number[]; pageSizeOptions?: number[];
onPageChange: (page: number) => void; onPageChange: (page: number) => void;
onPageSizeChange?: (pageSize: number) => void; onPageSizeChange?: (pageSize: number) => void;
disabled?: boolean; 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"; type TypedFilterOperator = "contains" | "eq" | "gt" | "gte" | "lt" | "lte" | "before" | "after";
export type DataGridColumn<T> = { export type DataGridColumn<T> = {
@@ -78,7 +96,7 @@ type DataGridState = {
bufferWidth?: number; bufferWidth?: number;
}; };
type DataGridProps<T> = { type DataGridBaseProps<T> = {
id: string; id: string;
rows: T[]; rows: T[];
columns: DataGridColumn<T>[]; columns: DataGridColumn<T>[];
@@ -98,11 +116,18 @@ type DataGridProps<T> = {
storageKey?: string; storageKey?: string;
initialFilters?: Record<string, string | string[]>; initialFilters?: Record<string, string | string[]>;
initialSort?: {columnId: string;direction: DataGridSortDirection;}; 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<T> = DataGridBaseProps<T> & (
| {pagination?: DataGridClientPagination;onQueryChange?: never;}
| {pagination: DataGridServerPagination;onQueryChange: (query: DataGridQueryState) => void;}
);
export type DataGridRowActionsProps = { export type DataGridRowActionsProps = {
disabled?: boolean; disabled?: boolean;
removeDisabled?: boolean; removeDisabled?: boolean;
@@ -213,14 +238,21 @@ export default function DataGrid<T>({
const headerCellRefs = useRef<Record<string, HTMLDivElement | null>>({}); const headerCellRefs = useRef<Record<string, HTMLDivElement | null>>({});
const filterButtonRefs = useRef<Record<string, HTMLButtonElement | null>>({}); const filterButtonRefs = useRef<Record<string, HTMLButtonElement | null>>({});
const filterPopoverRef = useRef<HTMLDivElement | null>(null); const filterPopoverRef = useRef<HTMLDivElement | null>(null);
const serverQueryMode = pagination?.mode === "server";
const onQueryChangeRef = useRef(onQueryChange); const onQueryChangeRef = useRef(onQueryChange);
const serverPaginationRef = useRef<DataGridServerPagination | null>(serverQueryMode ? pagination : null);
const lastServerQueryRef = useRef<DataGridQueryState | null>(null);
const lastLayoutSignatureRef = useRef<string | null>(null); const lastLayoutSignatureRef = useRef<string | null>(null);
const lastContainerWidthRef = useRef<number | undefined>(undefined); const lastContainerWidthRef = useRef<number | undefined>(undefined);
const [measuredWidths, setMeasuredWidths] = useState<Record<string, number>>({}); const [measuredWidths, setMeasuredWidths] = useState<Record<string, number>>({});
const serverQueryMode = pagination?.mode === "server";
useEffect(() => {onQueryChangeRef.current = onQueryChange;}, [onQueryChange]); useEffect(() => {onQueryChangeRef.current = onQueryChange;}, [onQueryChange]);
useEffect(() => {
serverPaginationRef.current = serverQueryMode ? pagination as DataGridServerPagination : null;
if (!serverQueryMode) lastServerQueryRef.current = null;
}, [pagination, serverQueryMode]);
useEffect(() => { useEffect(() => {
setState((current) => sanitizePersistedColumnState(columns, current, effectiveResizeBehavior)); setState((current) => sanitizePersistedColumnState(columns, current, effectiveResizeBehavior));
}, [columns, effectiveResizeBehavior]); }, [columns, effectiveResizeBehavior]);
@@ -460,10 +492,17 @@ export default function DataGrid<T>({
useEffect(() => { useEffect(() => {
if (!serverQueryMode) return; if (!serverQueryMode) return;
onQueryChangeRef.current?.({ const query = {
sort: state.sort ?? null, sort: state.sort ?? null,
filters: { ...(state.filters ?? {}) } 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]); }, [serverQueryMode, state.sort, state.filters]);
const filterTypes = useMemo(() => { 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" }); 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 { function stringifyCell(value: unknown): string {
if (value === null || value === undefined) return ""; if (value === null || value === undefined) return "";
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") return String(value); if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") return String(value);

View File

@@ -114,7 +114,7 @@ export type { MailServerConnectionTestResult, MailServerCredentialSettings, Mail
export { default as FieldLabel } from "./components/help/FieldLabel"; export { default as FieldLabel } from "./components/help/FieldLabel";
export { default as InlineHelp } from "./components/help/InlineHelp"; export { default as InlineHelp } from "./components/help/InlineHelp";
export { default as DataGrid, DataGridEmptyAction, DataGridPaginationBar, DataGridRowActions } from "./components/table/DataGrid"; 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 { default as TableActionGroup } from "./components/table/TableActionGroup";
export type { TableActionDefinition, TableActionGroupProps } from "./components/table/TableActionGroup"; export type { TableActionDefinition, TableActionGroupProps } from "./components/table/TableActionGroup";

View File

@@ -4,7 +4,7 @@ function assertEqual<T>(actual: T, expected: T, message: string): void {
import { renderToStaticMarkup } from "react-dom/server"; import { renderToStaticMarkup } from "react-dom/server";
import Button from "../src/components/Button"; 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"; import TableActionGroup, { runTableAction } from "../src/components/table/TableActionGroup";
function noop() {} function noop() {}
@@ -77,3 +77,43 @@ const reasonedButton = renderToStaticMarkup(<Button disabledReason="Permission d
assertEqual(reasonedButton.includes("disabled=\"\""), true, "a disabled reason disables the central button"); assertEqual(reasonedButton.includes("disabled=\"\""), true, "a disabled reason disables the central button");
assertEqual(reasonedButton.includes("disabled-action-tooltip"), true, "a disabled reason remains keyboard discoverable"); assertEqual(reasonedButton.includes("disabled-action-tooltip"), true, "a disabled reason remains keyboard discoverable");
assertEqual(reasonedButton.includes("disabledReason"), false, "the central-only disabled reason is not passed to the DOM"); assertEqual(reasonedButton.includes("disabledReason"), false, "the central-only disabled reason is not passed to the DOM");
type FilterRow = { id: string; name: string };
const filterRows: FilterRow[] = [
{ id: "first-non-match", name: "Ordinary row" },
{ id: "second-non-match", name: "Another row" },
{ id: "first-match", name: "Target alpha" },
{ id: "third-non-match", name: "Unrelated row" },
{ id: "second-match", name: "Target beta" },
{ id: "third-match", name: "Target gamma" }
];
const filterColumns: DataGridColumn<FilterRow>[] = [
{ id: "name", header: "Name", filterable: true, value: (row) => row.name }
];
const fullResultFilterMarkup = renderToStaticMarkup(
<DataGrid
id="full-result-filter-regression"
rows={filterRows}
columns={filterColumns}
getRowKey={(row) => row.id}
initialFilters={{ name: "target" }}
pagination={{ page: 1, pageSize: 2, onPageChange: noop }}
/>
);
assertEqual(fullResultFilterMarkup.includes("Target alpha"), true, "client filtering finds matches beyond the unfiltered first page");
assertEqual(fullResultFilterMarkup.includes("Target beta"), true, "client filtering happens before the first page is sliced");
assertEqual(fullResultFilterMarkup.includes("Target gamma"), false, "client pagination still limits the filtered page size");
assertEqual(fullResultFilterMarkup.includes("1\u20132"), true, "pagination counts describe the filtered result set");
assertEqual(/of(?:<!-- -->)?\s*(?:<!-- -->)?3/.test(fullResultFilterMarkup), true, "the filtered total includes matches from every source row");
const clearedFilterMarkup = renderToStaticMarkup(
<DataGrid
id="cleared-full-result-filter-regression"
rows={filterRows}
columns={filterColumns}
getRowKey={(row) => row.id}
pagination={{ page: 1, pageSize: 2, onPageChange: noop }}
/>
);
assertEqual(clearedFilterMarkup.includes("Ordinary row"), true, "clearing filters restores the first unfiltered row");
assertEqual(/of(?:<!-- -->)?\s*(?:<!-- -->)?6/.test(clearedFilterMarkup), true, "clearing filters restores the full pagination total");

View File

@@ -45,6 +45,7 @@
"src/components/IconButton.tsx", "src/components/IconButton.tsx",
"src/components/admin/AdminIconButton.tsx", "src/components/admin/AdminIconButton.tsx",
"src/components/ToggleSwitch.tsx", "src/components/ToggleSwitch.tsx",
"src/components/table/DataGrid.tsx",
"src/components/table/TableActionGroup.tsx" "src/components/table/TableActionGroup.tsx"
] ]
} }