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
|
`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`.
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user