diff --git a/docs/UI_UX_DECISION_LEDGER.md b/docs/UI_UX_DECISION_LEDGER.md index b663777..ebce309 100644 --- a/docs/UI_UX_DECISION_LEDGER.md +++ b/docs/UI_UX_DECISION_LEDGER.md @@ -239,6 +239,9 @@ instead of reproducing their behavior. 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. + External filter affordances such as summary-count shortcuts update the + grid's `query` contract; the grid header controls and backend query therefore + always display and execute the same filter state. - 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 586faf0..c28c0c4 100644 --- a/webui/src/components/table/DataGrid.tsx +++ b/webui/src/components/table/DataGrid.tsx @@ -116,6 +116,12 @@ type DataGridBaseProps = { storageKey?: string; initialFilters?: Record; initialSort?: {columnId: string;direction: DataGridSortDirection;}; + /** + * Synchronize a query selected outside the grid, for example by a summary + * count shortcut. Server grids should feed `onQueryChange` back into this + * value so the header controls and backend query remain in agreement. + */ + query?: DataGridQueryState; }; /** @@ -204,6 +210,7 @@ export default function DataGrid({ storageKey, initialFilters = {}, initialSort, + query, pagination, onQueryChange }: DataGridProps) { @@ -230,7 +237,15 @@ export default function DataGrid({ const normalizedInitialFilters = useMemo(() => normalizeInitialFilters(initialFilters), [initialFiltersKey]); const initialSortKey = initialSort ? `${initialSort.columnId}:${initialSort.direction}` : ""; const normalizedInitialSort = useMemo(() => initialSort ? { ...initialSort } : undefined, [initialSortKey]); - const [state, setState] = useState(() => mergeInitialSort(mergeInitialFilters(loadState(localStorageKey), normalizedInitialFilters), normalizedInitialSort)); + const externalQueryKey = query ? JSON.stringify(query) : ""; + const normalizedExternalQuery = useMemo( + () => query ? { sort: query.sort ? { ...query.sort } : null, filters: { ...query.filters } } : undefined, + [externalQueryKey] + ); + const [state, setState] = useState(() => mergeExternalQuery( + mergeInitialSort(mergeInitialFilters(loadState(localStorageKey), normalizedInitialFilters), normalizedInitialSort), + normalizedExternalQuery + )); const [resizeState, setResizeState] = useState(null); const [openFilterColumnId, setOpenFilterColumnId] = useState(null); const [filterPosition, setFilterPosition] = useState(null); @@ -265,6 +280,10 @@ export default function DataGrid({ setState((current) => mergeInitialSort(current, normalizedInitialSort)); }, [normalizedInitialSort]); + useEffect(() => { + setState((current) => mergeExternalQuery(current, normalizedExternalQuery)); + }, [normalizedExternalQuery]); + useEffect(() => { try { window.localStorage.setItem(localStorageKey, JSON.stringify(state)); @@ -1241,6 +1260,20 @@ initialSort?: {columnId: string;direction: DataGridSortDirection;}) return { ...state, sort: initialSort }; } +function mergeExternalQuery(state: DataGridState, query?: DataGridQueryState): DataGridState { + if (!query) return state; + const current = { + sort: state.sort ?? null, + filters: state.filters ?? {} + }; + if (dataGridQueriesEqual(current, query)) return state; + return { + ...state, + sort: query.sort ? { ...query.sort } : null, + filters: { ...query.filters } + }; +} + function formatListFilter(values: string[]): string { return `list:${JSON.stringify([...new Set(values)])}`; } diff --git a/webui/tests/data-grid-actions.test.tsx b/webui/tests/data-grid-actions.test.tsx index d643ca0..b8bc1e2 100644 --- a/webui/tests/data-grid-actions.test.tsx +++ b/webui/tests/data-grid-actions.test.tsx @@ -117,3 +117,15 @@ const clearedFilterMarkup = renderToStaticMarkup( ); 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"); + +const externalShortcutMarkup = renderToStaticMarkup( + row.id} + query={{ sort: null, filters: { name: "target" } }} + /> +); +assertEqual(externalShortcutMarkup.includes("Target alpha"), true, "an external count shortcut synchronizes the grid query"); +assertEqual(externalShortcutMarkup.includes("Ordinary row"), false, "the synchronized query does not disagree with visible rows");