fix(webui): enforce full-result DataGrid queries
This commit is contained in:
@@ -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`.
|
||||
|
||||
|
||||
@@ -30,18 +30,36 @@ export type DataGridQueryState = {
|
||||
filters: Record<string, string>;
|
||||
};
|
||||
|
||||
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<T> = {
|
||||
@@ -78,7 +96,7 @@ type DataGridState = {
|
||||
bufferWidth?: number;
|
||||
};
|
||||
|
||||
type DataGridProps<T> = {
|
||||
type DataGridBaseProps<T> = {
|
||||
id: string;
|
||||
rows: T[];
|
||||
columns: DataGridColumn<T>[];
|
||||
@@ -98,11 +116,18 @@ type DataGridProps<T> = {
|
||||
storageKey?: string;
|
||||
initialFilters?: Record<string, string | string[]>;
|
||||
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 = {
|
||||
disabled?: boolean;
|
||||
removeDisabled?: boolean;
|
||||
@@ -213,14 +238,21 @@ export default function DataGrid<T>({
|
||||
const headerCellRefs = useRef<Record<string, HTMLDivElement | null>>({});
|
||||
const filterButtonRefs = useRef<Record<string, HTMLButtonElement | null>>({});
|
||||
const filterPopoverRef = useRef<HTMLDivElement | null>(null);
|
||||
const serverQueryMode = pagination?.mode === "server";
|
||||
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 lastContainerWidthRef = useRef<number | undefined>(undefined);
|
||||
const [measuredWidths, setMeasuredWidths] = useState<Record<string, number>>({});
|
||||
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<T>({
|
||||
|
||||
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);
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ function assertEqual<T>(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(<Button disabledReason="Permission d
|
||||
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("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");
|
||||
|
||||
@@ -45,6 +45,7 @@
|
||||
"src/components/IconButton.tsx",
|
||||
"src/components/admin/AdminIconButton.tsx",
|
||||
"src/components/ToggleSwitch.tsx",
|
||||
"src/components/table/DataGrid.tsx",
|
||||
"src/components/table/TableActionGroup.tsx"
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user