fix(webui): enforce full-result DataGrid queries
This commit is contained in:
@@ -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";
|
||||
|
||||
|
||||
Reference in New Issue
Block a user