From 7470afd89b21810d6a1b67144a2434d25e70517b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 14:11:41 +0000 Subject: [PATCH 1/2] fix(plugin-list,plugin-grid): a grouped list view under a toolbar search groups on the server, with the searched counts ListView no longer takes a grouped grid's fetch back while a toolbar search is active: the header query takes the term since @objectstack/spec 17.5.0, so the grid that groups on the server is handed the term as its `search` prop and the view's searchable fields on its node, and puts them on both of its queries. ObjectGrid reads a host's `search` whenever one is passed, and drops its held group row query when it leaves server grouping, so turning grouping back on never asks, or paints, the query it held before. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0122Knsowci76D2rBWReCzzZ --- .../listViewGroupedSearch-11021.test.tsx | 302 ++++++++++++++++++ packages/plugin-grid/src/ObjectGrid.tsx | 52 ++- .../serverGroupedSearch-11021.test.tsx | 78 ++++- packages/plugin-list/src/ListView.tsx | 31 +- ...istView.groupedGridOwnsFetch-7189.test.tsx | 32 +- ...ew.groupingNeedsHeaderQuery-10881.test.tsx | 15 +- 6 files changed, 469 insertions(+), 41 deletions(-) create mode 100644 packages/app-shell/src/__tests__/listViewGroupedSearch-11021.test.tsx diff --git a/packages/app-shell/src/__tests__/listViewGroupedSearch-11021.test.tsx b/packages/app-shell/src/__tests__/listViewGroupedSearch-11021.test.tsx new file mode 100644 index 0000000000..59647081e4 --- /dev/null +++ b/packages/app-shell/src/__tests__/listViewGroupedSearch-11021.test.tsx @@ -0,0 +1,302 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectui#11021 — a grouped LIST VIEW under a toolbar search groups on the + * server, and its group counts are the searched rows' counts. + * + * objectui#7189 ruling A: "the set of groups and every number in a group + * header ... are properties of the query, not of the fetched page". Since + * `@objectstack/spec` 17.5.0 the group header query takes ADR-0061 + * `search` / `searchFields`, and the grid sends the term on the header query + * and on every group's row query as one pair. What reaches the console's + * grouped list view is the other half: `ListView` has to hand its toolbar term + * to the grid that groups on the server, instead of grouping one fetched + * window of searched rows in the browser (whose counts are page slices once + * the matches outnumber the window). + * + * ## Why this file lives in `app-shell` + * + * The claim is about two components at once — the toolbar term is + * `ListView`'s, the queries are `ObjectGrid`'s — so both are mounted for real. + * `app-shell` already depends on `plugin-list` and `plugin-grid`, so this adds + * no dependency edge (the same reason `displayPageSizeFromSpec-9853.test.tsx` + * lives here). `plugin-list`'s own handoff pin, over a stub grid, is + * `ListView.groupedGridOwnsFetch-7189.test.tsx`; the grid's own reading of the + * handed term is `serverGroupedSearch-11021.test.tsx` in `plugin-grid`. + * + * ## What the double answers + * + * `queryGroupHeaders` and `find` apply ONE matcher to the whole store: the + * compiled `where` / `$filter`, then `search` / `$search` over `searchFields` + * / `$searchFields`. The term `#1` matches numbers 1 and 10-19 of each unit: + * 34 rows, more than the view's page of 20, and none of the Depot's one row. + */ +import React from 'react'; +import { describe, it, expect, vi, afterEach, beforeAll } from 'vitest'; +import { render, cleanup, fireEvent, screen, waitFor, within } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { ListView } from '@object-ui/plugin-list'; +// Registers `object-grid`, the renderer `ListView` mounts through the registry. +import '@object-ui/plugin-grid'; +import { registerAllFields } from '@object-ui/fields'; +import { ActionProvider, SchemaRendererProvider } from '@object-ui/react'; + +beforeAll(() => { + registerAllFields(); +}); + +const OBJECT = 'duly_task'; +const OBJECT_FIELDS = { + id: { type: 'text', label: 'Id' }, + subject: { type: 'text', label: 'Subject' }, + business_unit: { type: 'text', label: 'Business Unit' }, +}; + +/** 186 rows over five units sized 86/61/31/7/1, as objectui#7189's fixture. */ +const UNITS: Array<[string, number]> = [ + ['Northgate Operations', 86], + ['Northgate Plant', 61], + ['Northgate Quality', 31], + ['Riverside Plant', 7], + ['Riverside Depot', 1], +]; +const UNSEARCHED: Record = Object.fromEntries(UNITS); + +interface Row { id: string; subject: string; business_unit: string } +const ROWS: Row[] = UNITS.flatMap(([unit, n]) => + Array.from({ length: n }, (_, i) => ({ id: `${unit}-${i}`, subject: `${unit} #${i}`, business_unit: unit })), +); + +const TERM = '#1'; +const SEARCH_FIELDS = ['subject']; +/** Numbers 1 and 10-19 of each unit: the Depot's one row (#0) has none. */ +const SEARCHED: Record = { + 'Northgate Operations': 11, + 'Northgate Plant': 11, + 'Northgate Quality': 11, + 'Riverside Plant': 1, +}; +/** The view's page: fewer rows than the term matches. */ +const PAGE_SIZE = 20; + +/** The `FilterCondition` subset the compiled queries use: `$and`, `$eq`, `$null`, equality. */ +function matchesWhere(row: Record, cond: unknown): boolean { + if (cond === undefined || cond === null) return true; + return Object.entries(cond as Record).every(([key, value]) => { + if (key === '$and') return (value as unknown[]).every((c) => matchesWhere(row, c)); + if (value && typeof value === 'object' && !Array.isArray(value)) { + const op = value as Record; + if ('$eq' in op) return row[key] === op.$eq; + if ('$null' in op) return op.$null ? row[key] == null : row[key] != null; + throw new Error(`double: unsupported operator ${JSON.stringify(op)}`); + } + return row[key] === value; + }); +} + +/** ADR-0061 as the double serves it: a substring over the searched fields. */ +function matchesSearch(row: Record, term: unknown, fields: unknown): boolean { + if (term === undefined) return true; + if (typeof term !== 'string' || term === '') throw new Error(`double: a search that is not a term: ${JSON.stringify(term)}`); + const over = Array.isArray(fields) ? (fields as string[]) : ['subject', 'business_unit']; + return over.some((f) => String(row[f] ?? '').toLowerCase().includes(term.toLowerCase())); +} + +type HeaderQuery = { where?: unknown; groupBy?: string[]; search?: unknown; searchFields?: unknown }; + +/** A data source serving BOTH compiled queries, and the list's own window, with one matcher. */ +const makeServerDataSource = () => ({ + queryGroupHeaders: vi.fn(async (_object: string, query: HeaderQuery) => { + const groupBy = query.groupBy ?? []; + const buckets = new Map(); + for (const row of ROWS) { + if (!matchesWhere(row as any, query.where) || !matchesSearch(row as any, query.search, query.searchFields)) continue; + const key = JSON.stringify(groupBy.map((f) => (row as any)[f] ?? null)); + buckets.set(key, (buckets.get(key) ?? 0) + 1); + } + return [...buckets.entries()].map(([key, count]) => { + const values = JSON.parse(key) as unknown[]; + return { ...Object.fromEntries(groupBy.map((f, i) => [f, values[i]])), count }; + }); + }), + find: vi.fn(async (_object: string, params: Record) => { + const matching = ROWS.filter((r) => + matchesWhere(r as any, params.$filter) && matchesSearch(r as any, params.$search, params.$searchFields)); + const skip = (params.$skip as number | undefined) ?? 0; + const top = (params.$top as number | undefined) ?? matching.length; + return { data: matching.slice(skip, skip + top), total: matching.length }; + }), + findOne: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + getObjectSchema: vi.fn(async () => ({ name: OBJECT, fields: OBJECT_FIELDS })), +}); +type ServerDataSource = ReturnType; + +const GROUPING = { fields: [{ field: 'business_unit', order: 'asc', collapsed: false }] }; + +const listSchema = (grouped: boolean) => ({ + type: 'list-view', + objectName: OBJECT, + columns: ['subject'], + searchableFields: SEARCH_FIELDS, + pagination: { pageSize: PAGE_SIZE }, + ...(grouped ? { grouping: GROUPING } : {}), +}) as any; + +const view = (ds: ServerDataSource, grouped: boolean) => ( + + + + + +); + +const groupRows = () => [...document.querySelectorAll('[data-testid^="group-row-"]')]; +const headerOf = (el: Element) => ({ + label: el.querySelector('.group-label')?.textContent ?? '', + count: el.querySelector('.group-count')?.textContent ?? '', +}); +const headerCounts = () => Object.fromEntries(groupRows().map((r) => { const h = headerOf(r); return [h.label, Number(h.count)]; })); +const groupRowEl = (label: string) => groupRows().find((r) => headerOf(r).label === label)!; +const subjectsIn = (el: Element, unit: string) => + within(el as HTMLElement).queryAllByText(new RegExp(`^${unit} #\\d+$`)).map((n) => n.textContent); + +/** The grid's row page queries: each one carries a group key under `$and`. */ +const groupRowFinds = (ds: ServerDataSource, from = 0) => + ds.find.mock.calls.slice(from).map(([, p]) => p).filter((p) => Array.isArray((p.$filter as { $and?: unknown } | undefined)?.$and)); + +/** Type into the list's toolbar Search box (the popover's input). */ +function typeInToolbarSearch(value: string) { + if (!screen.queryByPlaceholderText(/search/i)) fireEvent.click(screen.getByTestId('search-icon-button')); + fireEvent.change(screen.getByPlaceholderText(/search/i), { target: { value } }); +} + +/** Every header set the grid paints, sampled on each DOM mutation. */ +function recordPaintedHeaders() { + const painted: Array> = []; + const observer = new MutationObserver(() => { + const counts = headerCounts(); + if (Object.keys(counts).length > 0) painted.push(counts); + }); + observer.observe(document.body, { childList: true, subtree: true, characterData: true }); + return { painted, stop: () => observer.disconnect() }; +} + +afterEach(() => cleanup()); + +describe('a grouped list view under a toolbar search groups on the server (objectui#11021)', () => { + it('the fixture: the term matches more rows than the view\'s page', () => { + const matches = Object.values(SEARCHED).reduce((a, b) => a + b, 0); + expect(matches).toBeGreaterThan(PAGE_SIZE); + }); + + it('SEARCHED: only the groups holding matches, each counting all its matches, over exactly those rows', async () => { + const ds = makeServerDataSource(); + render(view(ds, true)); + await waitFor(() => expect(headerCounts()).toEqual(UNSEARCHED)); + + typeInToolbarSearch(TERM); + + await waitFor(() => expect(headerCounts()).toEqual(SEARCHED)); + // The Depot's one row does not match, so it is not a group at all. + expect(groupRows().map((r) => headerOf(r).label)).not.toContain('Riverside Depot'); + // Each group draws the rows its header counted. + for (const [unit, count] of Object.entries(SEARCHED)) { + await waitFor(() => expect(subjectsIn(groupRowEl(unit), unit)).toHaveLength(count)); + } + + // One pair on both of the grid's queries. + const [, headerQuery] = ds.queryGroupHeaders.mock.calls.at(-1)!; + expect(headerQuery.search).toBe(TERM); + expect(headerQuery.searchFields).toEqual(SEARCH_FIELDS); + const rowFinds = groupRowFinds(ds).slice(-Object.keys(SEARCHED).length); + expect(rowFinds).toHaveLength(Object.keys(SEARCHED).length); + for (const params of rowFinds) { + expect(params.$search).toBe(TERM); + expect(params.$searchFields).toEqual(SEARCH_FIELDS); + } + }); + + it('UNSEARCHED: the five whole units, and no search key on either of the grid\'s queries', async () => { + const ds = makeServerDataSource(); + render(view(ds, true)); + + await waitFor(() => expect(headerCounts()).toEqual(UNSEARCHED)); + for (const [, query] of ds.queryGroupHeaders.mock.calls) { + expect(query).not.toHaveProperty('search'); + expect(query).not.toHaveProperty('searchFields'); + } + await waitFor(() => expect(groupRowFinds(ds).length).toBeGreaterThanOrEqual(UNITS.length)); + for (const params of groupRowFinds(ds)) { + expect(params).not.toHaveProperty('$search'); + expect(params).not.toHaveProperty('$searchFields'); + } + }); + + it('clearing the toolbar search restores the unsearched groups, and drops the term from both queries', async () => { + const ds = makeServerDataSource(); + render(view(ds, true)); + typeInToolbarSearch(TERM); + await waitFor(() => expect(headerCounts()).toEqual(SEARCHED)); + for (const [unit, count] of Object.entries(SEARCHED)) { + await waitFor(() => expect(subjectsIn(groupRowEl(unit), unit)).toHaveLength(count)); + } + + const findsBefore = ds.find.mock.calls.length; + typeInToolbarSearch(''); + + await waitFor(() => expect(headerCounts()).toEqual(UNSEARCHED)); + const [, lastHeaderQuery] = ds.queryGroupHeaders.mock.calls.at(-1)!; + expect(lastHeaderQuery).not.toHaveProperty('search'); + expect(lastHeaderQuery).not.toHaveProperty('searchFields'); + // Every unit's latest row page is asked without the term. + await waitFor(() => expect(subjectsIn(groupRowEl('Riverside Depot'), 'Riverside Depot')).toHaveLength(1)); + for (const [unit] of UNITS) { + const pages = groupRowFinds(ds, findsBefore).filter((p) => + JSON.stringify((p.$filter as { $and: unknown[] }).$and).includes(JSON.stringify({ business_unit: { $eq: unit } }))); + expect(pages.length).toBeGreaterThan(0); + expect(pages.at(-1)).not.toHaveProperty('$search'); + expect(pages.at(-1)).not.toHaveProperty('$searchFields'); + } + }); + + it('turning grouping back on after the term was cleared asks and paints no stale searched groups', async () => { + const ds = makeServerDataSource(); + const { rerender } = render(view(ds, true)); + typeInToolbarSearch(TERM); + await waitFor(() => expect(headerCounts()).toEqual(SEARCHED)); + + // Grouping off: a flat list. The term is cleared while nothing is grouped. + rerender(view(ds, false)); + await waitFor(() => expect(groupRows()).toHaveLength(0)); + typeInToolbarSearch(''); + await waitFor(() => expect(ds.find.mock.calls.at(-1)?.[1]).not.toHaveProperty('$search')); + + // Grouping back on. + const headerCallsBefore = ds.queryGroupHeaders.mock.calls.length; + const recorder = recordPaintedHeaders(); + rerender(view(ds, true)); + await waitFor(() => expect(headerCounts()).toEqual(UNSEARCHED)); + recorder.stop(); + + // No header query after the re-entry carries the cleared term… + const reentryHeaderQueries = ds.queryGroupHeaders.mock.calls.slice(headerCallsBefore).map(([, q]) => q); + expect(reentryHeaderQueries.length).toBeGreaterThan(0); + for (const query of reentryHeaderQueries) { + expect(query).not.toHaveProperty('search'); + } + // …and no searched group set is painted on the way to the unsearched one. + expect(recorder.painted.length).toBeGreaterThan(0); + for (const counts of recorder.painted) { + expect(counts).not.toEqual(SEARCHED); + } + }); +}); diff --git a/packages/plugin-grid/src/ObjectGrid.tsx b/packages/plugin-grid/src/ObjectGrid.tsx index e24be5d574..d71db9b0ca 100644 --- a/packages/plugin-grid/src/ObjectGrid.tsx +++ b/packages/plugin-grid/src/ObjectGrid.tsx @@ -523,6 +523,11 @@ export interface ObjectGridColumnState { * `Partial<...>` wrapper is deliberate and is the only shape change: the whole * mode is opt-in, and `DataTableSchema['data']` is required because a table * always has rows, while a grid that was given no `data` fetches its own. + * + * `search` is read outside this mode too (objectui#11021): a host that passes + * it owns the search term even where the grid fetches for itself, and the + * grid's own queries carry it. `ListView` hands its toolbar term to a grid + * that groups on the server that way, with no rows. */ export interface ObjectGridExternalPaginationProps extends Partial< @@ -2020,6 +2025,14 @@ export const ObjectGrid: React.FC = ({ // A term is asked of one collection. Pointing the grid at another object // makes it a question about rows it was never typed for. useEffect(() => { setSearchTerm(''); }, [objectName]); + // [objectui#11021] Whose term is it? A host that passes `search` owns it — + // under host-driven paging, and also when this grid fetches for itself: + // `ListView` fetches for a flat grid, but hands a grid that groups on the + // server no rows, only its toolbar term, which this grid's own queries then + // carry (both group queries, or its own window). Absent that prop, the term + // is the one typed into this grid's box. + const hostOwnsSearch = externalManualPagination || hostSearch !== undefined; + const querySearchTerm = hostOwnsSearch ? (hostSearch ?? '') : searchTerm; // --- Inline data effect (synchronous, no fetch needed) --- useEffect(() => { @@ -2223,6 +2236,16 @@ export const ObjectGrid: React.FC = ({ // filter — resolved by the load effect below exactly as the flat fetch // resolves it, and handed to each group's row page. `null` until resolved. const [groupRowQuery, setGroupRowQuery] = useState | null>(null); + // [objectui#11021] Leaving server grouping drops the held query. Only the + // load effect below writes it, and only while grouping on the server, so a + // grid that went flat and came back asked its first header query with the + // query it held when it last grouped — a cleared search term, or an old + // filter, sort or projection — and painted that answer before the fresh + // one. `null` holds both group queries until the query as it stands now is + // resolved. + useEffect(() => { + if (!serverGroupedFetch) setGroupRowQuery(null); + }, [serverGroupedFetch]); // objectui#10035 — the refresh input this grid had none of, so a host could // show it a write only by remounting it (AGENTS.md #8's corollary: refresh @@ -2635,7 +2658,7 @@ export const ObjectGrid: React.FC = ({ // when the view declared `searchableFields` — it can narrow that // server-resolved set, never widen it — which is exactly what the // ListView toolbar sends. - const trimmedSearch = searchTerm.trim(); + const trimmedSearch = querySearchTerm.trim(); if (trimmedSearch) { params.$search = trimmedSearch; if (schema.searchableFields && schema.searchableFields.length > 0) { @@ -2779,7 +2802,7 @@ export const ObjectGrid: React.FC = ({ // `groupingNeedsHeaderQuery` (objectui#10881): it flips when the object's // types land and show the only grouping key masked, and the flat fetch it // withheld must then go out. - }, [objectName, schemaFields, schemaColumns, schemaFilter, schemaFilterRefusal, schemaDefaultFilters, schemaSort, headerSort, searchTerm, schemaPagination, schemaPageSize, serverPage, fetchWindow, dataSource, hasInlineData, dataConfig, refreshKey, perms.isLoaded, groupingProjectionKey, invalidationNonce, serverGroupedFetch, groupingNeedsHeaderQuery]); + }, [objectName, schemaFields, schemaColumns, schemaFilter, schemaFilterRefusal, schemaDefaultFilters, schemaSort, headerSort, querySearchTerm, schemaPagination, schemaPageSize, serverPage, fetchWindow, dataSource, hasInlineData, dataConfig, refreshKey, perms.isLoaded, groupingProjectionKey, invalidationNonce, serverGroupedFetch, groupingNeedsHeaderQuery]); // The same reset, for the path the loader above never runs on (objectui#4501 // clause 2). "All N matching are selected" is a claim about ONE query, so it @@ -2807,7 +2830,7 @@ export const ObjectGrid: React.FC = ({ // become 2, and "page 5" of that is nothing at all. React.useEffect(() => { setServerPage(1); - }, [objectName, schemaFilter, schemaSort, headerSort, searchTerm]); + }, [objectName, schemaFilter, schemaSort, headerSort, querySearchTerm]); // --- NavigationConfig support --- // Must be called before any early returns to satisfy React hooks rules @@ -4563,8 +4586,12 @@ export const ObjectGrid: React.FC = ({ // question, filter axis: when `data` is one window, a `.filter()` over it // narrows the fifty rows on screen while the rest of the collection never // participates — and unlike a mis-scoped sort, the count it produces reads as - // a statement about the whole list. Grouped/inline grids hold every row they - // display, so their box keeps filtering client-side, where it is honest. + // a statement about the whole list. A flat grid over inline rows holds every + // row it displays, so its box keeps filtering client-side, where it is + // honest. A grouped grid draws no box (each group's table is + // `searchable: false`); one that groups on the server sends its term — typed + // while flat, or a host's `search` — on its group header query and on every + // group's row query (objectui#11021), so its groups are the searched ones. const manualSearchOn = manualPaginationOn; /** @@ -5196,14 +5223,13 @@ export const ObjectGrid: React.FC = ({ // The search term, in whichever server mode applies. When a parent owns the // rows it owns the term too (ListView already does, from its own toolbar — - // which is why it passes `showSearch: false` and no box is rendered here); - // when we own the fetch, the box writes `searchTerm` and the effect above - // turns it into `$search`. A parent that drives the rows but offers no - // `onSearchChange` gets NO box rather than one scoped to its window. - const manualSearch = externalManualPagination - ? (hostSearch ?? '') - : searchTerm; - const manualOnSearchChange = externalManualPagination + // which is why it passes `showSearch: false` and no box is rendered here), + // and so does a parent that hands down `search` (`hostOwnsSearch`); else the + // box writes `searchTerm`. Either way the effect above turns the term into + // `$search` when this grid fetches. A parent that owns the term but offers + // no `onSearchChange` gets NO box rather than one that cannot change it. + const manualSearch = querySearchTerm; + const manualOnSearchChange = hostOwnsSearch ? hostOnSearchChange : setSearchTerm; diff --git a/packages/plugin-grid/src/__tests__/serverGroupedSearch-11021.test.tsx b/packages/plugin-grid/src/__tests__/serverGroupedSearch-11021.test.tsx index 9631a4921b..dd8935d9e9 100644 --- a/packages/plugin-grid/src/__tests__/serverGroupedSearch-11021.test.tsx +++ b/packages/plugin-grid/src/__tests__/serverGroupedSearch-11021.test.tsx @@ -20,10 +20,13 @@ * ## How a term reaches a server-grouped grid here * * A server-grouped grid draws no search box of its own; its one box is the - * flat table's. So each test types the term while the grid is flat and then - * turns grouping on, the way a host that rewrites `grouping` at runtime does. - * The term stays the grid's while grouped; clearing it takes turning grouping - * off again. + * flat table's. So a term reaches it two ways, and both are pinned: + * + * - typed while the grid is flat, and grouping turned on after, the way a + * host that rewrites `grouping` at runtime does. The term stays the grid's + * while grouped; clearing it takes turning grouping off again; + * - handed down by a host that owns the term, as the `search` prop — how + * `ListView` hands its toolbar term to the grid that groups for it. * * ## What the double answers * @@ -140,7 +143,7 @@ type ServerDataSource = ReturnType; const GROUPING = { fields: [{ field: 'business_unit' }] }; -const grid = (ds: ServerDataSource, grouped: boolean) => ( +const grid = (ds: ServerDataSource, grouped: boolean, hostSearch?: string) => ( ( ...(grouped ? { grouping: GROUPING } : {}), } as any} dataSource={ds as any} + {...(hostSearch !== undefined ? { search: hostSearch } : {})} /> ); @@ -172,6 +176,17 @@ const subjectsIn = (el: Element, unit: string) => const groupRowFinds = (ds: ServerDataSource, from = 0) => ds.find.mock.calls.slice(from).map(([, p]) => p).filter((p) => Array.isArray((p.$filter as { $and?: unknown } | undefined)?.$and)); +/** Every header set the grid paints, sampled on each DOM mutation. */ +function recordPaintedHeaders() { + const painted: Array> = []; + const observer = new MutationObserver(() => { + const counts = headerCounts(); + if (Object.keys(counts).length > 0) painted.push(counts); + }); + observer.observe(document.body, { childList: true, subtree: true, characterData: true }); + return { painted, stop: () => observer.disconnect() }; +} + /** Type the term into the flat grid's box, then turn grouping on. */ async function searchThenGroup(ds: ServerDataSource) { const view = render(grid(ds, false)); @@ -242,17 +257,26 @@ describe('a grouped grid under a search groups the searched rows (objectui#11021 await vi.waitFor(() => expect(ds.find.mock.calls.at(-1)?.[1]).not.toHaveProperty('$search')); const findsBefore = ds.find.mock.calls.length; + const headerCallsBefore = ds.queryGroupHeaders.mock.calls.length; + const recorder = recordPaintedHeaders(); view.rerender(grid(ds, true)); - // Read the SETTLED answer. On re-entry the grid may first ask with the row - // query it held when it last grouped, then with the one it resolves now; - // the headers on screen are the last query's answer. await vi.waitFor(() => expect(headerCounts()).toEqual(UNSEARCHED)); await vi.waitFor(() => expect(subjectsIn(groupRowEl('Northgate Operations'), 'Northgate Operations')).toHaveLength(86)); - const [, lastHeaderQuery] = ds.queryGroupHeaders.mock.calls.at(-1)!; - expect(lastHeaderQuery).toEqual(compileListViewGroupQuery({ grouping: GROUPING, columns: [] }, { depth: 1 })); - expect(lastHeaderQuery).not.toHaveProperty('search'); - expect(lastHeaderQuery).not.toHaveProperty('searchFields'); + recorder.stop(); + // Re-entering grouping asks with the query the grid resolves NOW, never + // with the row query it held when it last grouped: no header query after + // the re-entry carries the cleared term, and no searched group set is + // painted on the way to the unsearched one. + const reentryHeaderQueries = ds.queryGroupHeaders.mock.calls.slice(headerCallsBefore).map(([, q]) => q); + expect(reentryHeaderQueries.length).toBeGreaterThan(0); + for (const query of reentryHeaderQueries) { + expect(query).toEqual(compileListViewGroupQuery({ grouping: GROUPING, columns: [] }, { depth: 1 })); + } + expect(recorder.painted.length).toBeGreaterThan(0); + for (const counts of recorder.painted) { + expect(counts).not.toEqual(SEARCHED); + } // Every unit's latest row page is asked without the term. for (const [unit] of UNITS) { const pages = groupRowFinds(ds, findsBefore).filter((p) => @@ -262,4 +286,34 @@ describe('a grouped grid under a search groups the searched rows (objectui#11021 expect(pages.at(-1)).not.toHaveProperty('$searchFields'); } }); + + it('a host that owns the term hands it down as `search`: both queries carry it, and an empty one restores the groups', async () => { + const ds = makeServerDataSource(); + const view = render(grid(ds, true, TERM)); + + await vi.waitFor(() => expect(headerCounts()).toEqual(SEARCHED)); + const [, headerQuery] = ds.queryGroupHeaders.mock.calls.at(-1)!; + expect(headerQuery).toEqual({ + ...compileListViewGroupQuery({ grouping: GROUPING, columns: [] }, { depth: 1 }), + search: TERM, + searchFields: SEARCH_FIELDS, + }); + for (const [unit, count] of Object.entries(SEARCHED)) { + await vi.waitFor(() => expect(subjectsIn(groupRowEl(unit), unit)).toHaveLength(count)); + } + for (const params of groupRowFinds(ds)) { + expect(params.$search).toBe(TERM); + expect(params.$searchFields).toEqual(SEARCH_FIELDS); + } + + // The host clears its term: the grid's own box plays no part. + const findsBefore = ds.find.mock.calls.length; + view.rerender(grid(ds, true, '')); + await vi.waitFor(() => expect(headerCounts()).toEqual(UNSEARCHED)); + expect(ds.queryGroupHeaders.mock.calls.at(-1)![1]).not.toHaveProperty('search'); + await vi.waitFor(() => expect(groupRowFinds(ds, findsBefore).length).toBeGreaterThanOrEqual(UNITS.length)); + for (const params of groupRowFinds(ds, findsBefore)) { + expect(params).not.toHaveProperty('$search'); + } + }); }); diff --git a/packages/plugin-list/src/ListView.tsx b/packages/plugin-list/src/ListView.tsx index 5789e6b507..b70479daf3 100644 --- a/packages/plugin-list/src/ListView.tsx +++ b/packages/plugin-list/src/ListView.tsx @@ -2150,11 +2150,12 @@ export const ListView = React.forwardRef(({ * for its groups and pages each group's rows itself, with the SAME effective * filter this component would have sent (see `selfQueryFilter` below). * - * ⛔ Not while a toolbar search is active. `$search` has no counterpart on - * the header query the platform answers — its aggregate branch composes - * `where` / `groupBy` / `aggregations` / `having` and nothing else — so - * searched group counts would not be the searched rows' counts. The - * searched view keeps grouping this component's window, as before. + * A toolbar search included (objectui#11021). The header query takes + * ADR-0061 `search` / `searchFields` beside `where`, and the grid sends the + * term on it and on every group's row query as one pair, so the groups and + * their counts are the searched rows'. The grid is handed this component's + * term as `search`, and the view's `searchableFields` on its node — the pair + * this component's own fetch sends as `$search` / `$searchFields`. */ const gridOwnsGroupedFetch = currentView === 'grid' && @@ -2162,8 +2163,7 @@ export const ListView = React.forwardRef(({ !Array.isArray(schema.data) && (schema.data as any)?.provider !== 'value' && !!schema.objectName && - typeof dataSource?.queryGroupHeaders === 'function' && - !searchTerm; + typeof dataSource?.queryGroupHeaders === 'function'; /** * Is a GROUPED grid refused here? (objectui#10881, maintainer ruling F) @@ -3328,7 +3328,16 @@ export const ListView = React.forwardRef(({ ...(groupingConfig ? { grouping: groupingConfig } : {}), // objectui#7189 — a grid grouping on the server runs its own query, // so it carries the effective filter. See `gridOwnsGroupedFetch`. - ...(gridOwnsGroupedFetch ? { filter: selfQueryFilter } : {}), + // objectui#11021 — and the fields the toolbar term may match, when + // the view declares them; the term itself is a host prop (below). + ...(gridOwnsGroupedFetch + ? { + filter: selfQueryFilter, + ...(schema.searchableFields && schema.searchableFields.length > 0 + ? { searchableFields: schema.searchableFields } + : {}), + } + : {}), ...(rowColorConfig ? { rowColor: rowColorConfig } : {}), ...(schema.rowActions ? { rowActions: schema.rowActions } : {}), /** @@ -5192,6 +5201,12 @@ export const ListView = React.forwardRef(({ // rows would turn back into page-scoped grouping (objectui#7189). ? {} : { data })} + {...(gridOwnsGroupedFetch + // objectui#11021 — the grid that groups on the server queries for + // itself, so it is handed the toolbar term to query with (an + // empty one too: the term is this component's, never the grid's). + ? { search: searchTerm } + : {})} {...(viewComponentSchema.type === 'object-grid' && objectDef?.fields // objectui#10657 — the grid is handed the rows this component // fetched, so they paint before the grid's own read of the diff --git a/packages/plugin-list/src/__tests__/ListView.groupedGridOwnsFetch-7189.test.tsx b/packages/plugin-list/src/__tests__/ListView.groupedGridOwnsFetch-7189.test.tsx index 114cee36b6..2a1ad4fbee 100644 --- a/packages/plugin-list/src/__tests__/ListView.groupedGridOwnsFetch-7189.test.tsx +++ b/packages/plugin-list/src/__tests__/ListView.groupedGridOwnsFetch-7189.test.tsx @@ -90,10 +90,10 @@ afterAll(() => { beforeEach(() => { lastGridProps = null; }); afterEach(() => { cleanup(); lastGridProps = null; }); -const renderList = (ds: any, schema: ListViewSchema) => +const renderList = (ds: any, schema: ListViewSchema, props: Record = {}) => render( - + , ); @@ -113,6 +113,25 @@ describe('ListView → grouped grid: the grid owns its fetch when the server can expect(lastGridProps.schema.grouping.fields.map((f: any) => f.field)).toEqual(['business_unit']); }); + // objectui#11021 — a toolbar search no longer takes the grid's fetch back: + // the header query takes the term, so the grid is handed the term (and the + // view's searchable fields) to put on both of its queries, and still no rows. + it('under a toolbar search, hands the grouped grid NO rows, and the term with the searchable fields', async () => { + const ds = makeDataSource(true); + renderList(ds, listSchema({ searchableFields: ['subject'] }), { initialSearchTerm: 'Task 1' }); + await waitFor(() => expect(ds.find).toHaveBeenCalled()); + await waitFor(() => expect(screen.getByTestId('grid-stub')).toBeInTheDocument()); + + const listParams = ds.find.mock.calls[ds.find.mock.calls.length - 1][1]; + expect(listParams.$search).toBe('Task 1'); + expect(lastGridProps.data, 'a searched window would turn the grid back into page-scoped grouping').toBeUndefined(); + // The term the grid queries with is the one THIS component queried with, + // and so are the fields it may match. + expect(lastGridProps.search).toBe(listParams.$search); + expect(lastGridProps.schema.searchableFields).toEqual(listParams.$searchFields); + expect(lastGridProps.schema.filter).toEqual(listParams.$filter); + }); + it('draws no record-count bar over a grid that drew something else', async () => { const ds = makeDataSource(true); renderList(ds, listSchema()); @@ -138,4 +157,13 @@ describe('ListView → grouped grid: the grid owns its fetch when the server can await waitFor(() => expect(Array.isArray(lastGridProps?.data) && lastGridProps.data.length > 0).toBe(true)); expect(ds.queryGroupHeaders).not.toHaveBeenCalled(); }); + + it('CONTROL — an UNGROUPED grid under a toolbar search is handed its searched window, and no term to query with', async () => { + const ds = makeDataSource(true); + renderList(ds, listSchema({ grouping: undefined, searchableFields: ['subject'] }), { initialSearchTerm: 'Task 1' }); + await waitFor(() => expect(Array.isArray(lastGridProps?.data) && lastGridProps.data.length > 0).toBe(true)); + expect(ds.find.mock.calls[ds.find.mock.calls.length - 1][1].$search).toBe('Task 1'); + expect(lastGridProps.search).toBeUndefined(); + expect(lastGridProps.schema.searchableFields).toBeUndefined(); + }); }); diff --git a/packages/plugin-list/src/__tests__/ListView.groupingNeedsHeaderQuery-10881.test.tsx b/packages/plugin-list/src/__tests__/ListView.groupingNeedsHeaderQuery-10881.test.tsx index f81e1a0e0c..27b28cc13d 100644 --- a/packages/plugin-list/src/__tests__/ListView.groupingNeedsHeaderQuery-10881.test.tsx +++ b/packages/plugin-list/src/__tests__/ListView.groupingNeedsHeaderQuery-10881.test.tsx @@ -151,11 +151,11 @@ describe('ListView refuses a grouped grid over a data source with no header quer }); // Round 2 (the grid's second refusal, a host-DECLARED window): does ListView - // ever declare one? The one shape in which it hands a GROUPED grid a window - // is a source that answers the header query while a toolbar search is - // active (objectstack#20358). Measured there: no host paging, because - // `paginate` is off while grouped — the grid takes those rows as handed. - it('a grouped grid handed a searched window is handed no host paging', async () => { + // ever declare one? Over a source that answers the header query, a grouped + // grid is handed no window at all — a toolbar search included, since the + // header query takes the term (objectui#11021; it used to be handed the + // searched window). So there is no window to declare, and no host paging. + it('a grouped grid under a toolbar search is handed no window and no host paging', async () => { const ds = { ...makeFindOnlyDataSource(), queryGroupHeaders: vi.fn(async () => []) }; render( @@ -164,8 +164,11 @@ describe('ListView refuses a grouped grid over a data source with no header quer ); await waitFor(() => expect(ds.find).toHaveBeenCalled()); - await waitFor(() => expect(Array.isArray(lastGridProps?.data) && lastGridProps.data.length > 0).toBe(true)); + await waitFor(() => expect(screen.getByTestId('grid-stub')).toBeInTheDocument()); + await settle(); + expect(ds.find.mock.calls.at(-1)[1].$search, 'the searched state is reached').toBe('Task'); expect(lastGridProps.schema.grouping.fields.map((f: { field: string }) => f.field)).toEqual(['business_unit']); + expect(lastGridProps.data).toBeUndefined(); expect(lastGridProps.manualPagination).toBeUndefined(); expect(lastGridProps.rowCount).toBeUndefined(); expect(refusal()).toBeNull(); From 620c07cd762138a2649be6884f25192750978b55 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 14:13:30 +0000 Subject: [PATCH 2/2] chore(plugin-grid,plugin-list): raise the spec floor, add the changeset, and correct the prose this change made false plugin-grid declares @objectstack/spec ^17.5.0, the release whose group header query takes a search (the lockfile specifier moves; the resolved version does not). The plugin-list and plugin-grid READMEs, the plugin-grid docs page, a useGroupedData comment, three plugin-list test comments and the grid half's pending changeset said ListView keeps grouping its own window under a toolbar search; they now say what the code does. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0122Knsowci76D2rBWReCzzZ --- .../11021-grouped-grid-search-both-queries.md | 3 --- .changeset/11021-listview-grouped-search.md | 27 +++++++++++++++++++ content/docs/plugins/plugin-grid.mdx | 9 ++++--- packages/plugin-grid/README.md | 9 ++++--- packages/plugin-grid/package.json | 4 +-- packages/plugin-grid/src/useGroupedData.ts | 6 ++--- packages/plugin-list/README.md | 6 +++-- .../ListView.expandFls-7215.test.tsx | 4 +-- .../ListView.groupingProjection-7179.test.tsx | 4 +-- .../ListView.speculativeFls-7216.test.tsx | 4 +-- pnpm-lock.yaml | 2 +- 11 files changed, 50 insertions(+), 28 deletions(-) create mode 100644 .changeset/11021-listview-grouped-search.md diff --git a/.changeset/11021-grouped-grid-search-both-queries.md b/.changeset/11021-grouped-grid-search-both-queries.md index 6308fcf82a..4f3ff34ca1 100644 --- a/.changeset/11021-grouped-grid-search-both-queries.md +++ b/.changeset/11021-grouped-grid-search-both-queries.md @@ -20,6 +20,3 @@ matching rows, each header counts its matching rows, the rows under it are those rows, and clearing the term restores the unsearched groups. The ObjectStack adapter already posts the header query verbatim, so it needs no change. - -A `ListView` still keeps grouping its own window while its toolbar search is -active: it does not hand the term to the grid. diff --git a/.changeset/11021-listview-grouped-search.md b/.changeset/11021-listview-grouped-search.md new file mode 100644 index 0000000000..f8409905f6 --- /dev/null +++ b/.changeset/11021-listview-grouped-search.md @@ -0,0 +1,27 @@ +--- +'@object-ui/plugin-list': patch +'@object-ui/plugin-grid': patch +--- + +fix(plugin-list,plugin-grid): a grouped list view under a toolbar search groups on the server, and each group counts all its matching rows + +A grouped list view whose data source answers the group header query hands +the grid its own fetch, so the groups and their counts come from the server. +A toolbar search used to take that back: the list view fetched one page of +searched rows and the grid grouped that page in the browser, so once the +matches outnumbered the page a group's count was the page's slice of it, and +a group whose matches all fell past the page was missing. + +The list view now hands the grid its toolbar term (the grid's `search` prop) +and the view's `searchableFields`, and the grid sends them on the group header +query and on every group's row query. A searched grouped list view shows only +the groups that hold matching rows, each counting all its matches, and +clearing the search restores the unsearched groups. + +`ObjectGrid` now reads a host's `search` whenever one is passed, not only +under host-driven paging: the host then owns the term, and the grid's own +queries carry it. Turning grouping back on no longer asks for, or briefly +shows, the groups of the query the grid held when it last grouped (a cleared +search, or an old filter or sort). `@object-ui/plugin-grid` now declares +`@objectstack/spec` `^17.5.0`, the release whose group header query takes a +search. diff --git a/content/docs/plugins/plugin-grid.mdx b/content/docs/plugins/plugin-grid.mdx index 46d4156da5..ff83408940 100644 --- a/content/docs/plugins/plugin-grid.mdx +++ b/content/docs/plugins/plugin-grid.mdx @@ -426,10 +426,11 @@ makes the same refusal before it mounts such a grid. To group, implement A **search** term the grid queries with goes on the group header query and on every group's row query, as one pair (`search` / `searchFields`, which the header query declares beside `where`): the headers count the -searched rows, and the rows under them are those rows (objectui#11021). A -`ListView` does not hand its toolbar search to the grid: over a data source -that answers the header query it keeps grouping its own window while a search -term is active, so those group counts are the window's, not the query's. +searched rows, and the rows under them are those rows (objectui#11021). The +term is the one typed into the grid's box, or the one a host hands down as the +`search` prop: a host that passes `search` owns the term even where the grid +fetches for itself. That is how a `ListView` hands its toolbar search to the +grid that groups for it, with the view's `searchableFields` on the grid's node. ### ObjectQL Integration diff --git a/packages/plugin-grid/README.md b/packages/plugin-grid/README.md index c6223ceaa2..06533706b0 100644 --- a/packages/plugin-grid/README.md +++ b/packages/plugin-grid/README.md @@ -544,10 +544,11 @@ makes the same refusal before it mounts such a grid. To group, implement A **search** term the grid queries with goes on the group header query and on every group's row query, as one pair (`search` / `searchFields`, which the header query declares beside `where`): the headers count the -searched rows, and the rows under them are those rows (objectui#11021). A -`ListView` does not hand its toolbar search to the grid: over a data source -that answers the header query it keeps grouping its own window while a search -term is active, so those group counts are the window's, not the query's. +searched rows, and the rows under them are those rows (objectui#11021). The +term is the one typed into the grid's box, or the one a host hands down as the +`search` prop: a host that passes `search` owns the term even where the grid +fetches for itself. That is how a `ListView` hands its toolbar search to the +grid that groups for it, with the view's `searchableFields` on the grid's node. ## Integration with Data Sources diff --git a/packages/plugin-grid/package.json b/packages/plugin-grid/package.json index 99b3fa48e3..147e97b578 100644 --- a/packages/plugin-grid/package.json +++ b/packages/plugin-grid/package.json @@ -31,7 +31,7 @@ "@object-ui/plugin-detail": "workspace:*", "@object-ui/react": "workspace:*", "@object-ui/types": "workspace:*", - "@objectstack/spec": "^17.4.0", + "@objectstack/spec": "^17.5.0", "@tanstack/react-virtual": "^3.14.9", "exceljs": "^4.4.0", "lucide-react": "^1.43.0" @@ -44,7 +44,7 @@ "@object-ui/data-objectstack": "workspace:*", "@object-ui/sdui-parser": "workspace:*", "@object-ui/test-support": "workspace:*", - "@objectstack/spec": "^17.4.0", + "@objectstack/spec": "^17.5.0", "@tailwindcss/postcss": "^4.3.3", "@vitejs/plugin-react": "^6.0.5", "msw": "^2.15.0", diff --git a/packages/plugin-grid/src/useGroupedData.ts b/packages/plugin-grid/src/useGroupedData.ts index b0c0cddb1e..97fa747793 100644 --- a/packages/plugin-grid/src/useGroupedData.ts +++ b/packages/plugin-grid/src/useGroupedData.ts @@ -380,9 +380,9 @@ function sameGroupKey(a: unknown, b: unknown): boolean { * query it refuses grouping instead (objectui#10881, ruling F). A host * that DECLARES the rows one page of more (`manualPagination`, * `onPageChange` and a larger `rowCount`) is refused too; one that hands in a window without - * saying so gets that window grouped — `ListView` does while a toolbar - * search is active, since the header query carries no search - * (objectstack#20358). + * saying so gets that window grouped. `ListView` hands a grouped grid no + * window over a source that answers the header query, a toolbar search + * included: it hands the term instead (objectui#11021). * * @param config - GroupingConfig from the grid schema (optional) * @param data - flat data rows (grouped only when `server` is absent) diff --git a/packages/plugin-list/README.md b/packages/plugin-list/README.md index 712a50c0ab..6cca39c965 100644 --- a/packages/plugin-list/README.md +++ b/packages/plugin-list/README.md @@ -114,8 +114,10 @@ A grouped **grid** is grouped on the server: over a data source that answers the group header query (`dataSource.queryGroupHeaders`), `ListView` hands the grid its own fetch, and the group set, every group count and each group's rows come from the query (see *Grouping is server-side* in the -`@object-ui/plugin-grid` README). While a toolbar search is active it hands the -grid its window instead, since the header query carries no search. +`@object-ui/plugin-grid` README). A toolbar search is handed to that grid too, +with the view's `searchableFields`: the grid puts the term on the group header +query and on every group's row query, so only the groups holding matches are +shown and each count is its matching rows (objectui#11021). Over a data source that declares no `queryGroupHeaders`, `ListView` does not fetch a window and group it — every count would be a page slice, and groups diff --git a/packages/plugin-list/src/__tests__/ListView.expandFls-7215.test.tsx b/packages/plugin-list/src/__tests__/ListView.expandFls-7215.test.tsx index 310360a6ba..5cb391d947 100644 --- a/packages/plugin-list/src/__tests__/ListView.expandFls-7215.test.tsx +++ b/packages/plugin-list/src/__tests__/ListView.expandFls-7215.test.tsx @@ -92,9 +92,7 @@ const makeDataSource = () => // objectui#10881: over a data source that declares no `queryGroupHeaders` // a grouped grid view is refused before this component fetches anything. // Declaring it keeps the window fetch the grouping pins read the - // projection from: the rows a grouped grid is handed while a toolbar - // search is active, and what the filter chip counts and the client export - // read. + // projection from: what the filter chip counts and the client export read. queryGroupHeaders: vi.fn(async () => []), findOne: vi.fn(), create: vi.fn(), diff --git a/packages/plugin-list/src/__tests__/ListView.groupingProjection-7179.test.tsx b/packages/plugin-list/src/__tests__/ListView.groupingProjection-7179.test.tsx index c59ac37200..55cd69cf88 100644 --- a/packages/plugin-list/src/__tests__/ListView.groupingProjection-7179.test.tsx +++ b/packages/plugin-list/src/__tests__/ListView.groupingProjection-7179.test.tsx @@ -62,9 +62,7 @@ const makeDataSource = () => // objectui#10881: over a data source that declares no `queryGroupHeaders` // a grouped grid view is refused before this component fetches anything. // Declaring it keeps the window fetch the grouping pins read the - // projection from: the rows a grouped grid is handed while a toolbar - // search is active, and what the filter chip counts and the client export - // read. + // projection from: what the filter chip counts and the client export read. queryGroupHeaders: vi.fn(async () => []), findOne: vi.fn(), create: vi.fn(), diff --git a/packages/plugin-list/src/__tests__/ListView.speculativeFls-7216.test.tsx b/packages/plugin-list/src/__tests__/ListView.speculativeFls-7216.test.tsx index 02b4669157..55e511acf3 100644 --- a/packages/plugin-list/src/__tests__/ListView.speculativeFls-7216.test.tsx +++ b/packages/plugin-list/src/__tests__/ListView.speculativeFls-7216.test.tsx @@ -119,9 +119,7 @@ const makeDataSource = () => // objectui#10881: over a data source that declares no `queryGroupHeaders` // a grouped grid view is refused before this component fetches anything. // Declaring it keeps the window fetch the grouping pins read the - // projection from: the rows a grouped grid is handed while a toolbar - // search is active, and what the filter chip counts and the client export - // read. + // projection from: what the filter chip counts and the client export read. queryGroupHeaders: vi.fn(async () => []), findOne: vi.fn(), create: vi.fn(), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c5a5b03755..008b26701e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2126,7 +2126,7 @@ importers: specifier: workspace:* version: link:../types '@objectstack/spec': - specifier: ^17.4.0 + specifier: ^17.5.0 version: 17.5.0(ai@7.0.65(zod@4.6.5)) '@tanstack/react-virtual': specifier: ^3.14.9