diff --git a/package-lock.json b/package-lock.json index e3654d001..f82c02061 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2263,7 +2263,6 @@ "@deephaven/react-hooks": "^1.21.1", "@deephaven/utils": "^1.10.0", "buffer": "^6.0.3", - "fast-deep-equal": "^3.1.3", "lodash": "^4.17.21", "memoize-one": "^5.1.1", "memoizee": "^0.4.15", @@ -24585,6 +24584,7 @@ "@fortawesome/react-fontawesome": "^0.2.0", "@internationalized/date": "^3.5.5", "classnames": "^2.5.1", + "fast-deep-equal": "^3.1.3", "fast-json-patch": "^3.1.1", "json-rpc-2.0": "^1.6.0", "memoizee": "^0.4.17", diff --git a/plugins/ui/src/deephaven/ui/components/table.py b/plugins/ui/src/deephaven/ui/components/table.py index f07a32c0a..51c8623de 100644 --- a/plugins/ui/src/deephaven/ui/components/table.py +++ b/plugins/ui/src/deephaven/ui/components/table.py @@ -286,11 +286,21 @@ class table(Element): The callback is invoked with the selected rows with data from the columns in `always_fetch_columns`. always_fetch_columns: The columns to always fetch from the server regardless of if they are in the viewport. If True, all columns will always be fetched. This may make tables with many columns slow. - quick_filters: The quick filters to apply to the table. Dictionary of column name to filter value. - sorts: The sorts to apply to the table. - These are UI-controlled sorts (similar to reverse) rather than engine-transformed table data. - User changes to the sort state are persisted and restored on reload. + quick_filters: The initial quick filters to apply to the table. User changes + are retained and persisted when the table is reloaded. Dictionary of + column name to filter value. + controlled_quick_filters: The quick filters to update programmatically. + Whenever this value changes, it is re-applied to the table and replaces + any quick filters the user changed in the UI. Cannot be used with + `quick_filters`. Dictionary of column name to filter value. + sorts: The initial sorts to apply to the table. User changes are retained and + persisted when the table is reloaded. These are UI-controlled sorts + (similar to reverse) rather than engine-transformed table data. Accepts a column name, TableSort, or list containing column names and TableSort instances. + controlled_sorts: The sorts to update programmatically. Whenever this value + changes, it is re-applied to the table and replaces any sorts the user + changed in the UI. Cannot be used with `sorts`. Accepts the same values + as `sorts`. show_quick_filters: Whether to show the quick filter bar by default. aggregations: An aggregation or list of aggregations to apply to the table. These will be shown as a floating row at the bottom of the table by default. aggregations_position: The position to show the aggregations. One of "top" or "bottom". "bottom" by default. @@ -376,7 +386,9 @@ def __init__( on_selection_change: SelectionChangeCallback | None = None, always_fetch_columns: ColumnName | list[ColumnName] | bool | None = None, quick_filters: dict[ColumnName, QuickFilterExpression] | None = None, + controlled_quick_filters: dict[ColumnName, QuickFilterExpression] | None = None, sorts: TableSortLike | list[TableSortLike] | None = None, + controlled_sorts: TableSortLike | list[TableSortLike] | None = None, show_quick_filters: bool = False, aggregations: TableAgg | list[TableAgg] | None = None, aggregations_position: Literal["top", "bottom"] | None = None, @@ -444,9 +456,20 @@ def __init__( if format_ is not None: _validate_table_format(format_, table) + if quick_filters is not None and controlled_quick_filters is not None: + raise ValueError( + "ui.table quick_filters and controlled_quick_filters cannot both be set" + ) + + if sorts is not None and controlled_sorts is not None: + raise ValueError("ui.table sorts and controlled_sorts cannot both be set") + if sorts is not None: props["sorts"] = _normalize_table_sorts(sorts) + if controlled_sorts is not None: + props["controlled_sorts"] = _normalize_table_sorts(controlled_sorts) + props["table"] = resolve(table) if isinstance(table, str) else table del props["self"] self._props = props diff --git a/plugins/ui/src/js/src/elements/UITable/UITable.tsx b/plugins/ui/src/js/src/elements/UITable/UITable.tsx index 6c4930fef..7a27688c1 100644 --- a/plugins/ui/src/js/src/elements/UITable/UITable.tsx +++ b/plugins/ui/src/js/src/elements/UITable/UITable.tsx @@ -77,6 +77,20 @@ const ALWAYS_FETCH_COLUMN_LIMIT = 500; const EMPTY_OBJECT = Object.freeze({}); +/** + * Returns a reference to `value` that only changes when its JSON contents + * change, so an equal-but-new object from an unrelated re-render doesn't + * look like a real prop change to IrisGrid. + */ +function useStableValue(value: T): T { + const json = JSON.stringify(value); + const ref = useRef({ json, value }); + if (ref.current.json !== json) { + ref.current = { json, value }; + } + return ref.current.value; +} + /** * Hook to throw an error during a render cycle so it is caught by the error boundary. * Useful to throw from async or callbacks that occur outside the render cycle. @@ -166,6 +180,32 @@ function useUITableModel({ return model; } +/** + * Hydrate the `quick_filters` dict from the server into the quick filter map + * IrisGrid expects. Returns undefined if the filters or grid are not ready. + */ +function hydrateUITableQuickFilters( + quickFilters: Record | undefined, + model: UITableModel | undefined, + columns: readonly DhType.Column[], + utils: IrisGridUtils | null +): ReturnType | undefined { + if (quickFilters === undefined || utils == null || model == null) { + return undefined; + } + log.debug('Hydrating filters', quickFilters); + + const dehydratedQuickFilters: DehydratedQuickFilter[] = []; + Object.entries(quickFilters).forEach(([columnName, filter]) => { + const columnIndex = model.getColumnIndexByName(columnName); + if (columnIndex !== undefined) { + dehydratedQuickFilters.push([columnIndex, { text: filter }]); + } + }); + + return utils.hydrateQuickFilters(columns, dehydratedQuickFilters); +} + export function UITable({ format_: formatProp = EMPTY_ARRAY as unknown as FormattingRule[], onCellPress, @@ -176,7 +216,9 @@ export function UITable({ onRowDoublePress, onSelectionChange, quickFilters, + controlledQuickFilters, sorts, + controlledSorts, aggregations, aggregationsPosition = 'bottom', alwaysFetchColumns: alwaysFetchColumnsProp, @@ -379,17 +421,47 @@ export function UITable({ [memoizedStateFn, model, setDehydratedState] ); - // Initial sorts are captured once at mount so later re-renders never push - // a new `sorts` reference into IrisGrid (which would call updateSorts and - // clobber the user's interactive sort changes). + // Capture the user-owned `sorts`/`quickFilters` once on mount. These provide + // the initial values only when there is no persisted client state; after + // that, the user's own changes take over. const initialSortsRef = useRef(sorts); + const initialQuickFiltersRef = useRef(quickFilters); + + // Stabilize the raw controlled values so an unrelated re-render can't hand + // us a new-but-equal-content object/array (see `useStableValue` above). + const stableControlledSorts = useStableValue(controlledSorts); + const stableControlledQuickFilters = useStableValue(controlledQuickFilters); + + // The controlled values are live IrisGrid props. They are deliberately named + // separately so changing the existing user-owned props remains non-breaking. + const hydratedControlledSorts = useMemo(() => { + if ( + stableControlledSorts === undefined || + utils == null || + columns.length === 0 + ) { + return undefined; + } + log.debug('Hydrating controlled sorts', stableControlledSorts); + return utils.hydrateSort(columns, stableControlledSorts); + }, [stableControlledSorts, utils, columns]); + + const hydratedControlledQuickFilters = useMemo( + () => + hydrateUITableQuickFilters( + stableControlledQuickFilters, + model, + columns, + utils + ), + [stableControlledQuickFilters, model, columns, utils] + ); - // Lock the initial hydrated state to a stable value the first time model+utils - // are available. Recomputing it would change the `sorts` (and other) prop - // identities and cause IrisGrid to overwrite user changes on every re-render. - const lockedInitialHydratedStateRef = useRef< - Partial | undefined - >(undefined); + // Lock the initial state once the model is ready. Recomputing it would pass + // new prop identities into IrisGrid and overwrite interactive changes. + const initialHydratedStateRef = useRef | undefined>( + undefined + ); const initialHydratedStateComputedRef = useRef(false); if ( !initialHydratedStateComputedRef.current && @@ -405,40 +477,34 @@ export function UITable({ } : undefined; const initialSorts = initialSortsRef.current; - const seededSorts = - persisted == null && initialSorts !== undefined && columns !== undefined + const initialQuickFilters = initialQuickFiltersRef.current; + const hydratedInitialSorts = + initialSorts !== undefined && columns.length > 0 ? utils.hydrateSort(columns, initialSorts) : undefined; + const hydratedInitialQuickFilters = hydrateUITableQuickFilters( + initialQuickFilters, + model, + columns, + utils + ); if (persisted != null) { - lockedInitialHydratedStateRef.current = persisted; - } else if (seededSorts !== undefined) { - lockedInitialHydratedStateRef.current = { sorts: seededSorts }; - } - } - const initialHydratedState = lockedInitialHydratedStateRef.current; - - const hydratedQuickFilters = useMemo(() => { - if ( - quickFilters !== undefined && - utils && - model !== undefined && - columns !== undefined + initialHydratedStateRef.current = persisted; + } else if ( + hydratedInitialSorts !== undefined || + hydratedInitialQuickFilters !== undefined ) { - log.debug('Hydrating filters', quickFilters); - - const dehydratedQuickFilters: DehydratedQuickFilter[] = []; - - Object.entries(quickFilters).forEach(([columnName, filter]) => { - const columnIndex = model.getColumnIndexByName(columnName); - if (columnIndex !== undefined) { - dehydratedQuickFilters.push([columnIndex, { text: filter }]); - } - }); - - return utils.hydrateQuickFilters(columns, dehydratedQuickFilters); + initialHydratedStateRef.current = { + ...(hydratedInitialSorts !== undefined + ? { sorts: hydratedInitialSorts } + : {}), + ...(hydratedInitialQuickFilters !== undefined + ? { quickFilters: hydratedInitialQuickFilters } + : {}), + }; } - return undefined; - }, [quickFilters, model, columns, utils]); + } + const initialHydratedState = initialHydratedStateRef.current; // Get any format values that match column names // Assume the format value is derived from the column @@ -561,7 +627,8 @@ export function UITable({ mouseHandlers, alwaysFetchColumns, showSearchBar, - quickFilters: hydratedQuickFilters, + sorts: hydratedControlledSorts, + quickFilters: hydratedControlledQuickFilters, isFilterBarShown: showQuickFilters, reverse, density, @@ -610,7 +677,8 @@ export function UITable({ alwaysFetchColumns, showSearchBar, showQuickFilters, - hydratedQuickFilters, + hydratedControlledSorts, + hydratedControlledQuickFilters, reverse, density, settings, diff --git a/plugins/ui/src/js/src/elements/UITable/UITableUtils.ts b/plugins/ui/src/js/src/elements/UITable/UITableUtils.ts index d8a3c61bd..64aee4f1d 100644 --- a/plugins/ui/src/js/src/elements/UITable/UITableUtils.ts +++ b/plugins/ui/src/js/src/elements/UITable/UITableUtils.ts @@ -100,7 +100,9 @@ export type UITableProps = StyleProps & { onSelectionChange?: (selectedRows: RowDataMap[]) => void; alwaysFetchColumns?: string | string[] | boolean; quickFilters?: Record; + controlledQuickFilters?: Record; sorts?: DehydratedSort[]; + controlledSorts?: DehydratedSort[]; aggregations?: UIAggregation | UIAggregation[]; aggregationsPosition?: 'top' | 'bottom'; showSearch: boolean; diff --git a/plugins/ui/test/deephaven/ui/test_ui_table.py b/plugins/ui/test/deephaven/ui/test_ui_table.py index ce460428a..5d7e48abe 100644 --- a/plugins/ui/test/deephaven/ui/test_ui_table.py +++ b/plugins/ui/test/deephaven/ui/test_ui_table.py @@ -127,6 +127,42 @@ def test_quick_filters(self): }, ) + def test_controlled_quick_filters(self): + import deephaven.ui as ui + + t = ui.table(self.source, controlled_quick_filters={"X": "X > 1"}) + + self.expect_render( + t, + { + "controlledQuickFilters": {"X": "X > 1"}, + }, + ) + + def test_quick_filter_modes_are_exclusive(self): + import deephaven.ui as ui + + self.assertRaises( + ValueError, + lambda: ui.table( + self.source, + quick_filters={"X": "X > 1"}, + controlled_quick_filters={"Y": "Y < 2"}, + ), + ) + + t = ui.table( + self.source, + controlled_quick_filters={"X": "X > 1", "Y": "Y < 2"}, + ) + + self.expect_render( + t, + { + "controlledQuickFilters": {"X": "X > 1", "Y": "Y < 2"}, + }, + ) + def test_show_quick_filters(self): import deephaven.ui as ui @@ -263,6 +299,62 @@ def test_sorts_list(self): }, ) + def test_controlled_sorts(self): + import deephaven.ui as ui + + t = ui.table(self.source, controlled_sorts="X") + + self.expect_render( + t, + { + "controlledSorts": [ + { + "column": "X", + "direction": "ASC", + "isAbs": False, + } + ] + }, + ) + + def test_sort_modes_are_exclusive(self): + import deephaven.ui as ui + + self.assertRaises( + ValueError, + lambda: ui.table( + self.source, + sorts="X", + controlled_sorts="Y", + ), + ) + + t = ui.table( + self.source, + controlled_sorts=[ + "X", + ui.TableSort(column="Y", direction="DESC", is_abs=True), + ], + ) + + self.expect_render( + t, + { + "controlledSorts": [ + { + "column": "X", + "direction": "ASC", + "isAbs": False, + }, + { + "column": "Y", + "direction": "DESC", + "isAbs": True, + }, + ] + }, + ) + def test_sorts_invalid_direction(self): import deephaven.ui as ui diff --git a/tests/app.d/ui_table.py b/tests/app.d/ui_table.py index a931e352b..246038a71 100644 --- a/tests/app.d/ui_table.py +++ b/tests/app.d/ui_table.py @@ -450,6 +450,44 @@ def t_selection_component(): sorts=ui.TableSort(column="Name", direction="DESC", is_abs=True), ) + +@ui.component +def t_controlled_component(): + # Explicit controlled props re-apply server changes to the grid. + sort_direction, set_sort_direction = ui.use_state("ASC") + sym, set_sym = ui.use_state("CAT") + return [ + ui.button( + "Update sort and filter", + on_press=lambda _: (set_sort_direction("DESC"), set_sym("DOG")), + ), + ui.table( + _stocks, + controlled_sorts=ui.TableSort(column="Size", direction=sort_direction), + controlled_quick_filters={"Sym": sym}, + show_quick_filters=True, + ), + ] + + +t_controlled = t_controlled_component() + +# The existing user-owned props set the initial state. Their changes are +# persisted and restored on reload. Uses an explicit table so `Sym` is the first +# column, giving the e2e test a deterministic column position to click. +_persist_table = new_table( + [ + string_col("Sym", ["CAT", "DOG", "BEAR", "FISH", "CAT", "DOG", "BEAR"]), + int_col("Size", [10, 20, 30, 40, 50, 60, 70]), + ] +) +t_default = ui.table( + _persist_table, + sorts=ui.TableSort(column="Size", direction="ASC"), + quick_filters={"Sym": "CAT"}, + show_quick_filters=True, +) + from deephaven import agg _rollup_source = empty_table(100).update( diff --git a/tests/ui_table.spec.ts b/tests/ui_table.spec.ts index c66f5f056..8a069d5a7 100644 --- a/tests/ui_table.spec.ts +++ b/tests/ui_table.spec.ts @@ -4,6 +4,9 @@ import { openPanel, gotoPage, clickGridRow, + clickGridColumnHeader, + setGridQuickFilter, + waitForLoad, waitForGridRender, } from './utils'; @@ -106,3 +109,58 @@ test('UI table with tree table', async ({ page }) => { const locator = page.locator(SELECTORS.WIDGET_LOADER_ELEMENT_VISIBLE); await expect(locator.locator('.iris-grid')).toBeVisible(); }); + +// DH-22976: Explicit controlled props re-apply when their values change +// programmatically. The quick-filter change exercises IrisGrid's +// `updateQuickFilters` path. +test('UI table sorts and filters update programmatically', async ({ page }) => { + await gotoPage(page, ''); + await openPanel( + page, + 't_controlled', + SELECTORS.WIDGET_LOADER_ELEMENT_VISIBLE + ); + + const locator = page.locator(SELECTORS.WIDGET_LOADER_ELEMENT_VISIBLE); + await expect(locator.locator('.iris-grid')).toBeVisible(); + await expect(locator).toHaveScreenshot(); + + await locator.getByRole('button', { name: 'Update sort and filter' }).click(); + await waitForLoad(page); + await expect(locator).toHaveScreenshot(); +}); + +// DH-22976: Existing user-owned sorts and quick filters persist after refresh. +test('UI table user sorts and filters persist after refresh', async ({ + page, +}) => { + await gotoPage(page, ''); + await openPanel(page, 't_default', SELECTORS.WIDGET_LOADER_ELEMENT_VISIBLE); + + const locator = page.locator(SELECTORS.WIDGET_LOADER_ELEMENT_VISIBLE); + const grid = locator.locator('.iris-grid'); + await expect(grid).toBeVisible(); + + // User changes the sort by clicking a column header and sets a quick filter. + await clickGridColumnHeader(grid, 50); + await waitForLoad(page); + await setGridQuickFilter(grid, 50, 'DOG'); + await waitForLoad(page); + await expect(locator).toHaveScreenshot(); + + // Disable "Close Panels on Disconnect" so the layout is persisted on refresh. + await page + .getByRole('button', { name: 'More Actions...', exact: true }) + .click(); + await page + .getByRole('button', { name: 'Close Panels on Disconnect', exact: true }) + .click(); + // Wait for the debounced setting to save before refreshing. + await page.waitForTimeout(2000); + + await page.reload(); + await waitForLoad(page); + + // The user's sort and quick filter are restored from the persisted layout. + await expect(locator).toHaveScreenshot(); +}); diff --git a/tests/ui_table.spec.ts-snapshots/UI-table-sorts-and-filters-update-programmatically-1-chromium-linux.png b/tests/ui_table.spec.ts-snapshots/UI-table-sorts-and-filters-update-programmatically-1-chromium-linux.png new file mode 100644 index 000000000..e2c26dbb8 Binary files /dev/null and b/tests/ui_table.spec.ts-snapshots/UI-table-sorts-and-filters-update-programmatically-1-chromium-linux.png differ diff --git a/tests/ui_table.spec.ts-snapshots/UI-table-sorts-and-filters-update-programmatically-1-firefox-linux.png b/tests/ui_table.spec.ts-snapshots/UI-table-sorts-and-filters-update-programmatically-1-firefox-linux.png new file mode 100644 index 000000000..b80a056a7 Binary files /dev/null and b/tests/ui_table.spec.ts-snapshots/UI-table-sorts-and-filters-update-programmatically-1-firefox-linux.png differ diff --git a/tests/ui_table.spec.ts-snapshots/UI-table-sorts-and-filters-update-programmatically-1-webkit-linux.png b/tests/ui_table.spec.ts-snapshots/UI-table-sorts-and-filters-update-programmatically-1-webkit-linux.png new file mode 100644 index 000000000..463190280 Binary files /dev/null and b/tests/ui_table.spec.ts-snapshots/UI-table-sorts-and-filters-update-programmatically-1-webkit-linux.png differ diff --git a/tests/ui_table.spec.ts-snapshots/UI-table-sorts-and-filters-update-programmatically-2-chromium-linux.png b/tests/ui_table.spec.ts-snapshots/UI-table-sorts-and-filters-update-programmatically-2-chromium-linux.png new file mode 100644 index 000000000..d070f4fae Binary files /dev/null and b/tests/ui_table.spec.ts-snapshots/UI-table-sorts-and-filters-update-programmatically-2-chromium-linux.png differ diff --git a/tests/ui_table.spec.ts-snapshots/UI-table-sorts-and-filters-update-programmatically-2-firefox-linux.png b/tests/ui_table.spec.ts-snapshots/UI-table-sorts-and-filters-update-programmatically-2-firefox-linux.png new file mode 100644 index 000000000..77ba3bd43 Binary files /dev/null and b/tests/ui_table.spec.ts-snapshots/UI-table-sorts-and-filters-update-programmatically-2-firefox-linux.png differ diff --git a/tests/ui_table.spec.ts-snapshots/UI-table-sorts-and-filters-update-programmatically-2-webkit-linux.png b/tests/ui_table.spec.ts-snapshots/UI-table-sorts-and-filters-update-programmatically-2-webkit-linux.png new file mode 100644 index 000000000..ef3c9c0a8 Binary files /dev/null and b/tests/ui_table.spec.ts-snapshots/UI-table-sorts-and-filters-update-programmatically-2-webkit-linux.png differ diff --git a/tests/ui_table.spec.ts-snapshots/UI-table-user-sorts-and-filters-persist-after-refresh-1-chromium-linux.png b/tests/ui_table.spec.ts-snapshots/UI-table-user-sorts-and-filters-persist-after-refresh-1-chromium-linux.png new file mode 100644 index 000000000..2ca9a0c53 Binary files /dev/null and b/tests/ui_table.spec.ts-snapshots/UI-table-user-sorts-and-filters-persist-after-refresh-1-chromium-linux.png differ diff --git a/tests/ui_table.spec.ts-snapshots/UI-table-user-sorts-and-filters-persist-after-refresh-1-firefox-linux.png b/tests/ui_table.spec.ts-snapshots/UI-table-user-sorts-and-filters-persist-after-refresh-1-firefox-linux.png new file mode 100644 index 000000000..7a5ba3fbd Binary files /dev/null and b/tests/ui_table.spec.ts-snapshots/UI-table-user-sorts-and-filters-persist-after-refresh-1-firefox-linux.png differ diff --git a/tests/ui_table.spec.ts-snapshots/UI-table-user-sorts-and-filters-persist-after-refresh-1-webkit-linux.png b/tests/ui_table.spec.ts-snapshots/UI-table-user-sorts-and-filters-persist-after-refresh-1-webkit-linux.png new file mode 100644 index 000000000..eeafb67a9 Binary files /dev/null and b/tests/ui_table.spec.ts-snapshots/UI-table-user-sorts-and-filters-persist-after-refresh-1-webkit-linux.png differ diff --git a/tests/ui_table.spec.ts-snapshots/UI-table-user-sorts-and-filters-persist-after-refresh-2-chromium-linux.png b/tests/ui_table.spec.ts-snapshots/UI-table-user-sorts-and-filters-persist-after-refresh-2-chromium-linux.png new file mode 100644 index 000000000..612a7d1f9 Binary files /dev/null and b/tests/ui_table.spec.ts-snapshots/UI-table-user-sorts-and-filters-persist-after-refresh-2-chromium-linux.png differ diff --git a/tests/ui_table.spec.ts-snapshots/UI-table-user-sorts-and-filters-persist-after-refresh-2-firefox-linux.png b/tests/ui_table.spec.ts-snapshots/UI-table-user-sorts-and-filters-persist-after-refresh-2-firefox-linux.png new file mode 100644 index 000000000..3b1f32029 Binary files /dev/null and b/tests/ui_table.spec.ts-snapshots/UI-table-user-sorts-and-filters-persist-after-refresh-2-firefox-linux.png differ diff --git a/tests/ui_table.spec.ts-snapshots/UI-table-user-sorts-and-filters-persist-after-refresh-2-webkit-linux.png b/tests/ui_table.spec.ts-snapshots/UI-table-user-sorts-and-filters-persist-after-refresh-2-webkit-linux.png new file mode 100644 index 000000000..21226fba9 Binary files /dev/null and b/tests/ui_table.spec.ts-snapshots/UI-table-user-sorts-and-filters-persist-after-refresh-2-webkit-linux.png differ diff --git a/tests/utils.ts b/tests/utils.ts index c6b3b6fbd..8b411602c 100644 --- a/tests/utils.ts +++ b/tests/utils.ts @@ -200,6 +200,46 @@ export async function clickGridRow( }); } +/** + * Clicks a column header in the grid to sort by that column. + * Repeated clicks cycle the sort direction (ascending, descending, none). + * @param gridContainer The Playwright Locator of the grid container + * @param x The horizontal pixel offset of the column header to click + */ +export async function clickGridColumnHeader( + gridContainer: Locator, + x: number +): Promise { + // Coordinates are relative to the grid canvas wrapper, not the outer + // `.iris-grid` element which also contains toolbars above the grid. + await gridContainer.locator('.grid-wrapper').click({ + position: { x, y: COLUMN_HEADER_HEIGHT / 2 }, + }); +} + +/** + * Types a quick filter into the quick filter bar for a column and applies it. + * Assumes the quick filter bar is shown (e.g. `show_quick_filters=True`). + * @param gridContainer The Playwright Locator of the grid container + * @param x The horizontal pixel offset of the column's filter cell to click + * @param text The filter expression to type + */ +export async function setGridQuickFilter( + gridContainer: Locator, + x: number, + text: string +): Promise { + // The quick filter bar renders directly below the column headers. Coordinates + // are relative to the grid canvas wrapper. Clicking the filter cell focuses + // it, then the value is typed directly via the keyboard (the grid renders its + // cell input on a canvas overlay, so we type rather than fill an element). + await gridContainer.locator('.grid-wrapper').click({ + position: { x, y: COLUMN_HEADER_HEIGHT + ROW_HEIGHT / 2 }, + }); + await gridContainer.page().keyboard.type(text); + await gridContainer.page().keyboard.press('Enter'); +} + /** * Waits for a grid to actually render content before continuing. *