From 601aeaf67ec00a856a4b55b27139468b6e7d72a8 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 20:52:09 +0000 Subject: [PATCH 1/3] test(plugin-view): read the grid filter hand-off through the real ObjectGrid (objectui#11880 item 5) Measures, on today's hand-off, the rung order (named view, then table.filter, then table.defaultFilters) through the real grid and a ValueDataSource that applies $filter, and the three ObjectGrid readers that look at one slot and not the other: the server-streamed export, the page reset on an in-place query change, and the refused-filter state. Claude-Session: https://claude.ai/code/session_01DBZ9bntPZ7VKyQNtJeNsgw Co-authored-by: Claude --- ...bjectView.gridFilterHandoff-11880.test.tsx | 295 ++++++++++++++++++ 1 file changed, 295 insertions(+) create mode 100644 packages/plugin-view/src/__tests__/ObjectView.gridFilterHandoff-11880.test.tsx diff --git a/packages/plugin-view/src/__tests__/ObjectView.gridFilterHandoff-11880.test.tsx b/packages/plugin-view/src/__tests__/ObjectView.gridFilterHandoff-11880.test.tsx new file mode 100644 index 0000000000..abace0b303 --- /dev/null +++ b/packages/plugin-view/src/__tests__/ObjectView.gridFilterHandoff-11880.test.tsx @@ -0,0 +1,295 @@ +/** + * 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#11880 item 5 — the filter `ObjectView` hands the `ObjectGrid` it draws, + * read through the REAL grid and an adapter that APPLIES the `$filter` it receives. + * + * The node this component builds for its own grid carries the authored filter + * rungs across two slots: an active view's filter and the deprecated + * `table.defaultFilters` in `defaultFilters`, and `table.filter` in `filter` + * (written only when no view filter is present). `ObjectGrid` reads `filter` + * first and `defaultFilters` only when `filter` lowers to nothing. + * + * Every reading here is of what the grid SENDS and what it DRAWS, plus the + * three readers inside `ObjectGrid` that look at one slot and not the other: + * the server-streamed export, the page reset on a query change, and the + * refused-filter state. + */ + +import React from 'react'; +import { describe, it, expect, vi, beforeAll, beforeEach, afterEach } from 'vitest'; +import { render, screen, waitFor, fireEvent, cleanup } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { ActionProvider, SchemaRendererProvider } from '@object-ui/react'; +import { ValueDataSource } from '@object-ui/core'; +import type { DataSource, ObjectViewSchema } from '@object-ui/types'; +import { ObjectView } from '../ObjectView'; +import { installExplainDouble } from './explainDouble'; + +vi.mock('@object-ui/plugin-form', async (importOriginal) => ({ + ...(await importOriginal>()), + ObjectForm: () =>
, +})); + +const OBJECT = 'handoff_deal'; + +const ROWS = [ + { id: '1', name: 'Acme', stage: 'open' }, + { id: '2', name: 'Beta', stage: 'won' }, + { id: '3', name: 'Cyan', stage: 'lost' }, + { id: '4', name: 'Dune', stage: 'open' }, +]; +const NAMES = ROWS.map((r) => r.name); + +type Rule = { field: string; operator: string; value: unknown }; +/** One rule per rung, each matching a different set, so the winner is readable. */ +const VIEW: Rule[] = [{ field: 'stage', operator: 'equals', value: 'open' }]; // Acme, Dune +const TABLE: Rule[] = [{ field: 'stage', operator: 'equals', value: 'won' }]; // Beta +const DEFAULTS: Rule[] = [{ field: 'stage', operator: 'equals', value: 'lost' }]; // Cyan + +const VIEW_AST = [['stage', 'equals', 'open']]; +const TABLE_AST = [['stage', 'equals', 'won']]; +const DEFAULTS_AST = [['stage', 'equals', 'lost']]; + +beforeAll(() => { + if (!Element.prototype.scrollIntoView) { + Element.prototype.scrollIntoView = vi.fn() as unknown as Element['scrollIntoView']; + } +}); + +beforeEach(() => { + installExplainDouble(); +}); + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +interface Adapter { + find: ReturnType; + exportDownload: ReturnType; + getObjectSchema: ReturnType; +} + +/** `find` answers through a `ValueDataSource`, so `$filter` and paging are applied. */ +function makeAdapter(rows: Array> = ROWS): Adapter & DataSource { + const source = new ValueDataSource({ items: rows, idField: 'id' }); + return { + find: vi.fn((resource: string, params: Record) => source.find(resource, params as never)), + findOne: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + exportDownload: vi.fn(async () => new Blob(['x'])), + getObjectSchema: vi.fn(async () => ({ + name: OBJECT, + label: 'Deal', + fields: { + id: { type: 'text' }, + name: { type: 'text', label: 'Name' }, + stage: { type: 'text', label: 'Stage' }, + }, + })), + } as unknown as Adapter & DataSource; +} + +interface Authored { + view?: unknown; + table?: unknown; + tableDefaults?: unknown; +} + +/** An `object-view` carrying the authored rungs given, every other member fixed. */ +function viewSchema({ view, table, tableDefaults }: Authored, extraTable: Record = {}): ObjectViewSchema { + const t: Record = { columns: ['name'], exportOptions: { formats: ['csv'] }, ...extraTable }; + if (table !== undefined) t.filter = table; + if (tableDefaults !== undefined) t.defaultFilters = tableDefaults; + const named = view !== undefined + ? { defaultListView: 'v1', listViews: { v1: { label: 'Deals', type: 'grid', columns: ['name'], filter: view } } } + : {}; + return { + type: 'object-view', + objectName: OBJECT, + layout: 'drawer', + table: t, + ...named, + } as unknown as ObjectViewSchema; +} + +function mount(schema: ObjectViewSchema, ds: DataSource) { + return render( + + + + + , + ); +} + +function objectFinds(ds: Adapter) { + return ds.find.mock.calls.filter(([resource]) => resource === OBJECT) as Array<[string, Record]>; +} + +/** Render; return the `$filter` the grid's first query sent and the names it drew. */ +async function draw(authored: Authored): Promise<{ $filter: unknown; drawn: string[] }> { + const ds = makeAdapter(); + mount(viewSchema(authored), ds); + await waitFor(() => expect(objectFinds(ds).length).toBeGreaterThan(0)); + const index = ds.find.mock.calls.findIndex(([resource]) => resource === OBJECT); + const params = objectFinds(ds)[0][1]; + const answer = (await ds.find.mock.results[index].value) as { data: Array<{ name: string }> }; + const expected = answer.data.map((r) => r.name); + if (expected.length > 0) await screen.findByText(expected[0]); + const drawn = NAMES.filter((name) => screen.queryByText(name) !== null); + return { $filter: params.$filter, drawn }; +} + +/** Render, open the export menu, export CSV; return the `filter` the server export was handed. */ +async function exported(authored: Authored): Promise<{ called: boolean; filter: unknown; fetched: unknown }> { + // Only the download hand-off is stubbed: the request the export makes is + // what is read, never the file. + vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:handoff'); + vi.spyOn(URL, 'revokeObjectURL').mockReturnValue(undefined); + vi.spyOn(HTMLAnchorElement.prototype, 'click').mockReturnValue(undefined); + const ds = makeAdapter(); + mount(viewSchema(authored), ds); + await waitFor(() => expect(objectFinds(ds).length).toBeGreaterThan(0)); + const fetched = objectFinds(ds)[0][1].$filter; + fireEvent.click(await screen.findByRole('button', { name: /export/i })); + fireEvent.click(await screen.findByRole('button', { name: /export as csv/i })); + await waitFor(() => expect(ds.exportDownload).toHaveBeenCalled()); + const options = (ds.exportDownload.mock.calls[0] as [string, { filter?: unknown }])[1]; + return { called: true, filter: options.filter, fetched }; +} + +describe('objectui#11880 item 5 — the rung order through the real grid', () => { + it('CONTROL: no rung sends no `$filter` and draws every row', async () => { + const r = await draw({}); + expect(r.$filter).toBeUndefined(); + expect(r.drawn).toEqual(['Acme', 'Beta', 'Cyan', 'Dune']); + }); + + it('rung 1 alone: a named view filter', async () => { + expect(await draw({ view: VIEW })).toEqual({ $filter: VIEW_AST, drawn: ['Acme', 'Dune'] }); + }); + + it('rung 2 alone: `table.filter`', async () => { + expect(await draw({ table: TABLE })).toEqual({ $filter: TABLE_AST, drawn: ['Beta'] }); + }); + + it('rung 3 alone: the authored `table.defaultFilters`', async () => { + expect(await draw({ tableDefaults: DEFAULTS })).toEqual({ $filter: DEFAULTS_AST, drawn: ['Cyan'] }); + }); + + it('rungs 1 + 2: the view wins over `table.filter`', async () => { + expect(await draw({ view: VIEW, table: TABLE })).toEqual({ $filter: VIEW_AST, drawn: ['Acme', 'Dune'] }); + }); + + it('rungs 1 + 3: the view wins over `table.defaultFilters`', async () => { + expect(await draw({ view: VIEW, tableDefaults: DEFAULTS })).toEqual({ $filter: VIEW_AST, drawn: ['Acme', 'Dune'] }); + }); + + it('rungs 2 + 3: `table.filter` wins over `table.defaultFilters`', async () => { + expect(await draw({ table: TABLE, tableDefaults: DEFAULTS })).toEqual({ $filter: TABLE_AST, drawn: ['Beta'] }); + }); + + it('rungs 1 + 2 + 3: the view wins', async () => { + expect(await draw({ view: VIEW, table: TABLE, tableDefaults: DEFAULTS })) + .toEqual({ $filter: VIEW_AST, drawn: ['Acme', 'Dune'] }); + }); + + it('an EMPTY named-view filter is still the view rung: no `$filter`, every row', async () => { + expect(await draw({ view: [], table: TABLE, tableDefaults: DEFAULTS })) + .toEqual({ $filter: undefined, drawn: ['Acme', 'Beta', 'Cyan', 'Dune'] }); + }); + + it('an EMPTY `table.filter` lowers to nothing, so `table.defaultFilters` answers', async () => { + expect(await draw({ table: [], tableDefaults: DEFAULTS })).toEqual({ $filter: DEFAULTS_AST, drawn: ['Cyan'] }); + }); +}); + +describe('objectui#11880 item 5 — the server-streamed export', () => { + it('a named view filter: what the export is handed', async () => { + const r = await exported({ view: VIEW }); + expect(r.fetched).toEqual(VIEW_AST); + expect(r.filter).toBeUndefined(); + }); + + it('`table.defaultFilters` alone: what the export is handed', async () => { + const r = await exported({ tableDefaults: DEFAULTS }); + expect(r.fetched).toEqual(DEFAULTS_AST); + expect(r.filter).toBeUndefined(); + }); + + it('LIT CONTROL: `table.filter` reaches the export', async () => { + const r = await exported({ table: TABLE }); + expect(r.fetched).toEqual(TABLE_AST); + expect(r.filter).toEqual(TABLE_AST); + }); +}); + +describe('objectui#11880 item 5 — the page reset when the filter changes in place', () => { + const MANY = Array.from({ length: 8 }, (_, i) => ({ + id: String(i + 1), + name: `Row ${i + 1}`, + stage: i % 2 === 0 ? 'open' : 'won', + region: i < 4 ? 'east' : 'west', + })); + const EAST: Rule[] = [{ field: 'region', operator: 'equals', value: 'east' }]; + const WEST: Rule[] = [{ field: 'region', operator: 'equals', value: 'west' }]; + + async function skipAfterChange(before: Authored, after: Authored): Promise { + const ds = makeAdapter(MANY); + const page = { pagination: { pageSize: 2 } }; + const { container, rerender } = mount(viewSchema(before, page), ds); + await waitFor(() => expect(objectFinds(ds).length).toBeGreaterThan(0)); + await screen.findAllByText(/^Row /); + const last = container.querySelector('.lucide-chevrons-right')?.closest('button'); + expect(last).toBeTruthy(); + fireEvent.click(last as HTMLButtonElement); + await waitFor(() => expect((objectFinds(ds).at(-1)?.[1].$skip as number) ?? 0).toBeGreaterThan(0)); + const settled = objectFinds(ds).length; + rerender( + + + + + , + ); + await waitFor(() => expect(objectFinds(ds).length).toBeGreaterThan(settled)); + await new Promise((r) => setTimeout(r, 50)); + return objectFinds(ds).at(-1)?.[1].$skip ?? 0; + } + + it('a named view filter changed in place: the page the next query asks for', async () => { + expect(await skipAfterChange({ view: EAST }, { view: WEST })).toBeGreaterThan(0); + }); + + it('`table.defaultFilters` changed in place: the page the next query asks for', async () => { + expect(await skipAfterChange({ tableDefaults: EAST }, { tableDefaults: WEST })).toBeGreaterThan(0); + }); + + it('LIT CONTROL: `table.filter` changed in place returns to page 1', async () => { + expect(await skipAfterChange({ table: EAST }, { table: WEST })).toBe(0); + }); +}); + +describe('objectui#11880 item 5 — a refused named-view filter', () => { + it('names the operator and queries nothing', async () => { + const ds = makeAdapter(); + mount(viewSchema({ view: [{ field: 'stage', operator: 'equals', value: ['a', 'b'] }] }), ds); + const headline = await screen.findByTestId('grid-malformed-filter-subject'); + expect(headline.textContent).toContain('equals'); + await new Promise((r) => setTimeout(r, 50)); + expect(objectFinds(ds)).toHaveLength(0); + expect(document.body.textContent).not.toMatch(/error loading grid/i); + }); +}); From d9f895bd9886b18cf5a7b0900457423c7463732a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 21:04:16 +0000 Subject: [PATCH 2/3] fix(plugin-view): hand ObjectView's grid its whole filter chain in `filter` (objectui#11880 item 5) ObjectView's grid branch wrote an active view's filter and the deprecated table.defaultFilters into the grid node's `defaultFilters`, and table.filter into `filter`. ObjectGrid chose between them for the query, but its server export and its page reset read `filter` only, so on route 2 a view's filter narrowed the rows while the download held every record, and changing it in place kept the old page. The chain is now resolved here and handed over in `filter` alone: the active view's filter, else table.filter unless it lowers to nothing (toFilterNodeSafely, so a refused table.filter still draws its panel), else table.defaultFilters. No `defaultFilters` is written on the node, the key the spec retires on object-grid. The instrument becomes the pin file: the export and page-reset rows take the corrected answers; the thirteen rung readings are unchanged. Four plugin-view pins that asserted `grid.defaultFilters` are re-spelled, the README / plugin-view.mdx sentence about forwarding both slots is rewritten, and the stale gridSchema memo comment (ObjectGrid "raw-assigns the legacy one") is replaced. Claude-Session: https://claude.ai/code/session_01DBZ9bntPZ7VKyQNtJeNsgw Co-authored-by: Claude --- .changeset/11880-grid-filter-handoff.md | 14 ++++ content/docs/plugins/plugin-view.mdx | 15 ++-- packages/plugin-view/README.md | 15 ++-- packages/plugin-view/src/ObjectView.tsx | 70 +++++++++++++------ .../ObjectView.canonicalTableKeys.test.tsx | 54 ++++++++------ ...bjectView.gridFilterHandoff-11880.test.tsx | 62 ++++++++++------ ...View.namedViewContextTokens-10506.test.tsx | 4 +- ...jectView.specShapedNamedView-8254.test.tsx | 7 +- .../ObjectView.tableSlotRelay-10976.test.tsx | 11 +++ 9 files changed, 172 insertions(+), 80 deletions(-) create mode 100644 .changeset/11880-grid-filter-handoff.md diff --git a/.changeset/11880-grid-filter-handoff.md b/.changeset/11880-grid-filter-handoff.md new file mode 100644 index 0000000000..9f4678247c --- /dev/null +++ b/.changeset/11880-grid-filter-handoff.md @@ -0,0 +1,14 @@ +--- +'@object-ui/plugin-view': patch +--- + +An `object-view` grid now exports the filter it shows, and returns to page 1 when that filter changes (objectui#11880, item 5). + +`ObjectView` hands the grid it draws one filter slot, `filter`, carrying the whole chain: the active named view's filter, else `table.filter` unless it lowers to nothing (absent, `[]` or `{}`), else the deprecated `table.defaultFilters`. It no longer writes `defaultFilters` on that grid node, which `@objectstack/spec` retires on `object-grid` (objectstack#11509). + +Two user-visible corrections, both on an `object-view` rendered by the registered renderer (a page node, the Studio's stored-view preview; a host that supplies `renderListView` already behaved this way): + +- **The export carries the view's filter.** A named view's filter and an authored `table.defaultFilters` narrowed the rows on screen, but the server-streamed export (CSV, XLSX, JSON) was handed no filter and downloaded every record of the object. It now downloads the rows the view shows, as it already did for `table.filter`. +- **Changing the filter returns the grid to page 1.** When a named view's filter or `table.defaultFilters` changed while the grid stayed mounted, the next query kept the old page index, which could ask for a page the new result does not have. It now returns to page 1, as `table.filter` already did. + +What did not change: which filter applies (each rung and each pair sends the same query and draws the same rows), and a refused filter still draws the grid's malformed-filter panel and queries nothing. Nothing is added to or removed from any published type, schema or export. diff --git a/content/docs/plugins/plugin-view.mdx b/content/docs/plugins/plugin-view.mdx index e0bf6f1cd1..858dcfb098 100644 --- a/content/docs/plugins/plugin-view.mdx +++ b/content/docs/plugins/plugin-view.mdx @@ -180,11 +180,16 @@ correctly, and silently did nothing. That is fixed — the legacy spellings on the right are simply no longer the ones to reach for. **When a key is written both ways, the canonical spelling wins** — that is -`ObjectGrid`'s own existing resolution (`schema.pagination?.pageSize || -schema.pageSize`; `if (schema.selection?.type) … else if (schema.selectable -!== undefined)`; `schemaFilter !== undefined ? … : schema.defaultFilters`), and `ObjectView` -defers to it by forwarding both slots rather than re-resolving the pair -itself: +`ObjectGrid`'s own existing resolution for the paging pairs +(`schema.pagination?.pageSize || schema.pageSize`; `if +(schema.selection?.type) … else if (schema.selectable !== undefined)`), and +`ObjectView` defers to it by forwarding both slots rather than re-resolving +the pair itself. The filter pair is the exception: `ObjectView` resolves it +and hands the grid the winner in `filter` alone (objectui#11880). `table.filter` +wins unless it lowers to nothing (absent, `[]` or `{}`), and then +`table.defaultFilters` applies. Because the grid then has one filter slot, +its export carries the filter in force and a change to it returns the grid to +page 1, as `table.filter` always did: ```typescript import type { ObjectViewSchema } from '@object-ui/types'; diff --git a/packages/plugin-view/README.md b/packages/plugin-view/README.md index fd7a1dbb78..07e62e6571 100644 --- a/packages/plugin-view/README.md +++ b/packages/plugin-view/README.md @@ -250,11 +250,16 @@ just no longer the one to reach for: **Precedence when a key is written both ways** — `table: { pagination: { pageSize: 10 }, pageSize: 50 }`, say — the canonical spelling wins. That is -`ObjectGrid`'s own existing resolution (`schema.pagination?.pageSize || -schema.pageSize`; `if (schema.selection?.type) … else if (schema.selectable -!== undefined)`; `schemaFilter !== undefined ? … : schema.defaultFilters`), and `ObjectView` -defers to it by forwarding both slots rather than re-resolving the pair -itself: +`ObjectGrid`'s own existing resolution for the paging pairs +(`schema.pagination?.pageSize || schema.pageSize`; `if +(schema.selection?.type) … else if (schema.selectable !== undefined)`), and +`ObjectView` defers to it by forwarding both slots rather than re-resolving +the pair itself. The filter pair is the exception: `ObjectView` resolves it +and hands the grid the winner in `filter` alone (objectui#11880). `table.filter` +wins unless it lowers to nothing (absent, `[]` or `{}`), and then +`table.defaultFilters` applies. Because the grid then has one filter slot, +its export carries the filter in force and a change to it returns the grid to +page 1, as `table.filter` always did: ```typescript import type { ObjectViewSchema } from '@object-ui/types'; diff --git a/packages/plugin-view/src/ObjectView.tsx b/packages/plugin-view/src/ObjectView.tsx index dd6e482fb6..80b144c54a 100644 --- a/packages/plugin-view/src/ObjectView.tsx +++ b/packages/plugin-view/src/ObjectView.tsx @@ -85,6 +85,7 @@ import { convertSortToQueryParams, recordDelete, resolveFilterPlaceholders, + toFilterNodeSafely, type FilterTokenScope, } from '@object-ui/core'; import { SchemaRenderer as ImportedSchemaRenderer, useSettledSchema, notifyDataChanged, useDataInvalidation, useFilterScope } from '@object-ui/react'; @@ -410,6 +411,29 @@ interface AuthoredFilterSegments { tableDefaults: ObjectGridSchema['defaultFilters']; } +/** + * objectui#11880 item 5 — the `table` rung of the filter this component hands + * the grid it draws: `table.filter`, unless it lowers to nothing, else the + * deprecated `table.defaultFilters`. + * + * This is the choice `ObjectGrid` made itself while the two arrived in two + * slots (it read `defaultFilters` only when `filter` lowered to nothing), now + * made here so both reach it in `filter` alone — `ObjectGrid`'s export and + * page reset read that slot only. "Lowers to nothing" is the sink's own + * answer (`toFilterNodeSafely`: absent, `[]`, `{}`, a record of nulls), not a + * truthiness test, so an empty `table.filter` beside a `table.defaultFilters` + * keeps the grid's answer: the legacy rung applies. + * + * A REFUSED `table.filter` is kept, not skipped: it reaches `ObjectGrid`, which + * draws its malformed-filter panel, as it did before. Skipping it would let + * the legacy rung answer a query the author's canonical filter forbids. + */ +function gridTableFilterRung(segments: AuthoredFilterSegments): ObjectGridSchema['filter'] { + const canonical = toFilterNodeSafely(segments.table); + if (!canonical.ok || canonical.node !== undefined) return segments.table; + return segments.tableDefaults; +} + /** * objectui#10506 — resolve every filter placeholder in the authored filter * segments ONCE, through `@object-ui/core`'s shared `resolveFilterPlaceholders`, @@ -2424,14 +2448,20 @@ export const ObjectView: React.FC = ({ // on the floor. An author who wrote the canonical shape the type recommends // got a compile-clean, semantically correct, RUNTIME-INERT view. // - // ObjectGrid reads both spellings of the first three and resolves them + // ObjectGrid reads both spellings of the first two and resolves them // canonical-first (`schema.pagination?.pageSize ?? schema.pageSize`; - // `if (schema.selection?.type) … else if (schema.selectable !== undefined)`; - // `schemaFilter !== undefined ? … : schema.defaultFilters`), so the fix is - // forwarding, not translation — and the precedence is not a free choice - // here: emitting both slots lets ObjectGrid's existing canonical-wins rule - // decide, which is the only answer that keeps the two layers saying the - // same thing. + // `if (schema.selection?.type) … else if (schema.selectable !== undefined)`), + // so for those the fix is forwarding, not translation: emitting both slots + // lets ObjectGrid's existing canonical-wins rule decide. + // + // objectui#11880 item 5: the filter pair is resolved HERE instead, and + // handed over in `filter` alone (see `gridTableFilterRung`). ObjectGrid does + // not read its two filter slots alike: its server export and its page reset + // read only `filter`, so a filter riding `defaultFilters` narrowed the rows + // on screen while the downloaded file held every record, and changing it in + // place kept the old page. `@objectstack/spec` also retires + // `object-grid.defaultFilters` (objectstack#11509, A-narrow), so this + // component no longer writes it at all. // // objectui#5861: the fourth pair is no longer a pair. `defaultSort` was // RETIRED under ADR-0049 — `@objectstack/spec` refuses it by name on @@ -2459,14 +2489,11 @@ export const ObjectView: React.FC = ({ const gridSchema: ObjectGridSchema = useMemo(() => { // The two segments ahead of the `table` one, resolved once. // - // `viewFilter` keeps riding the LEGACY slot it rides today: - // `filter`/`defaultFilters` are not interchangeable downstream — - // ObjectGrid lowers the canonical slot through `toFilterNode` and - // raw-assigns the legacy one — so moving a named-view filter across would - // change the wire shape of a path objectui#5270 does not own. Sort has no - // such asymmetry to preserve: its canonical slot is the only one left - // (the legacy `defaultSort` is retired, objectui#5861), and it is the one - // that can hold more than a single key. + // objectui#11880 item 5: `viewFilter` rides the CANONICAL slot, `filter`, + // as the `table` rung does when no view filter is present. Both slots + // reach the wire through the same `toFilterNode` sink in ObjectGrid + // (objectui#4082), so the query is unchanged; what moves is that the + // export and the page reset, which read `filter` only, now see it. // // objectui#10506: all three filter segments are read RESOLVED (see // `authoredFilters`), and held while their inputs are unchanged — ObjectGrid @@ -2514,14 +2541,11 @@ export const ObjectView: React.FC = ({ ...gridOperations, create: false, // Create is handled by the view's create button }, - defaultFilters: viewFilter || authoredFilters.tableDefaults, - // Canonical `table` keys, at last forwarded. `filter` carries the - // `table` segment ONLY: the view segment resolved above already occupies - // the legacy slot, and ObjectGrid prefers this slot over that one — so - // handing it `table.filter` while a named view is active would let the - // table default outrank the view, inverting the precedence the two - // untouched segments exist to express. - filter: viewFilter ? undefined : authoredFilters.table, + // objectui#11880 item 5 — the whole filter chain in ONE slot: the active + // view's filter (any truthy value, an empty array included, as before), + // else the `table` rung (`gridTableFilterRung`). No `defaultFilters`: + // ObjectGrid's export and page reset read `filter` only. + filter: viewFilter || gridTableFilterRung(authoredFilters), // `sort` carries the WHOLE chain instead — view segments first, then the // `table` one. Same precedence as `mergedSort` and the non-grid fetch // express; what changes is only WHICH slot a view's sort arrives in, and diff --git a/packages/plugin-view/src/__tests__/ObjectView.canonicalTableKeys.test.tsx b/packages/plugin-view/src/__tests__/ObjectView.canonicalTableKeys.test.tsx index 36301ee54b..4c6f824d61 100644 --- a/packages/plugin-view/src/__tests__/ObjectView.canonicalTableKeys.test.tsx +++ b/packages/plugin-view/src/__tests__/ObjectView.canonicalTableKeys.test.tsx @@ -44,16 +44,21 @@ * PRECEDENCE, when an author writes both: the canonical key wins. Where * `ObjectView` itself resolves the pair (the non-grid fetch, and the delegated * `renderListView` schema) that choice is encoded in this file's `||` chains - * and pinned below. On the grid path `ObjectView` forwards BOTH slots and - * `ObjectGrid` arbitrates — it already resolves all four pairs canonical-first + * and pinned below. On the grid path `ObjectView` forwards BOTH slots of the + * pagination and selection pairs and `ObjectGrid` arbitrates canonical-first * (`schema.pagination?.pageSize ?? schema.pageSize`; `if (schema.selection - * ?.type) … else if (schema.selectable !== undefined)`; `schemaFilter !== - * undefined ? … : schema.defaultFilters`; `schemaSort ?? (schema.defaultSort - * ? [schema.defaultSort] : undefined)`). Re-resolving the pair here instead - * would have made the two layers disagree, and synthesising a `pagination` - * object out of a legacy `pageSize` would flip `ObjectGrid`'s + * ?.type) … else if (schema.selectable !== undefined)`). Synthesising a + * `pagination` object out of a legacy `pageSize` would flip `ObjectGrid`'s * `paginationEnabled` (`schema.pagination !== undefined ? true : …`) for every * view that only ever wrote the legacy key. + * + * objectui#11880 item 5: the FILTER pair is the exception on the grid path. + * `ObjectView` resolves it itself and hands the winner in `filter` alone, + * never writing `defaultFilters`: `ObjectGrid`'s export and page reset read + * `filter` only, so a value in the legacy slot narrowed the rows but not the + * download. The precedence is unchanged (the canonical key wins unless it + * lowers to nothing), and `ObjectView.gridFilterHandoff-11880.test.tsx` reads + * it through the real grid. */ import { describe, it, expect, vi, beforeEach } from 'vitest'; @@ -204,9 +209,10 @@ describe('grid path: the legacy spellings still work', () => { expect(forwardedGridSchema({ table: { selectable: true } as any }).selectable).toBe(true); }); - it('still forwards table.defaultFilters', () => { + it('still honours table.defaultFilters, handed over in `filter` (objectui#11880 item 5)', () => { const grid = forwardedGridSchema({ table: { defaultFilters: { status: 'active' } } as any }); - expect(grid.defaultFilters).toEqual({ status: 'active' }); + expect(grid.filter).toEqual({ status: 'active' }); + expect(grid.defaultFilters).toBeUndefined(); }); it('no longer forwards a retired table.defaultSort — and does not rescue it into `sort` (objectui#5861)', () => { @@ -230,17 +236,20 @@ describe('grid path: the legacy spellings still work', () => { }); expect(grid.pagination).toBeUndefined(); expect(grid.selection).toBeUndefined(); - expect(grid.filter).toBeUndefined(); expect(grid.sort).toBeUndefined(); + // objectui#11880 item 5 — the filter pair is resolved here, so the alias + // rides the only filter slot the node carries; nothing is synthesised. + expect(grid.filter).toEqual({ a: 1 }); + expect(grid.defaultFilters).toBeUndefined(); }); }); -describe('grid path: both spellings written — each rides its own slot', () => { - // ObjectView forwards both and ObjectGrid arbitrates canonical-first (see the - // file header for the four expressions). What is pinned here is ObjectView's - // half: the canonical value must arrive in the canonical slot, or the - // downstream rule has nothing to prefer. - it('puts the canonical value in the canonical slot and the alias in the legacy one', () => { +describe('grid path: both spellings written', () => { + // ObjectView forwards both slots of the pagination and selection pairs and + // ObjectGrid arbitrates canonical-first (see the file header). The filter + // pair is resolved here instead (objectui#11880 item 5): the canonical value + // arrives in `filter` and no `defaultFilters` is written. + it('puts each canonical value in its slot; the paging aliases ride theirs, the filter alias is not written', () => { const grid = forwardedGridSchema({ table: { pagination: { pageSize: 25 }, @@ -258,7 +267,7 @@ describe('grid path: both spellings written — each rides its own slot', () => expect(grid.selection).toEqual({ type: 'multiple' }); expect(grid.selectable).toBe(false); expect(grid.filter).toEqual([['stage', '=', 'won']]); - expect(grid.defaultFilters).toEqual({ stage: 'lost' }); + expect(grid.defaultFilters).toBeUndefined(); expect(grid.sort).toEqual([{ field: 'name', order: 'desc' }]); // objectui#5861 — the sort pair has no legacy slot any more: the retired // `defaultSort` is dropped, not forwarded beside the canonical value. @@ -268,10 +277,9 @@ describe('grid path: both spellings written — each rides its own slot', () => describe('grid path: a named view still outranks the table segment', () => { // The FILTER segments ahead of `table` (`listViews` entry, then `activeView`) - // are untouched by objectui#5102 — they keep riding the legacy slot. The - // canonical slot must therefore stay EMPTY while one of them is active: - // ObjectGrid prefers the canonical slot, so a `table.filter` forwarded - // unconditionally would outrank the view the user is looking at. + // ride `filter` since objectui#11880 item 5, the one filter slot the node + // carries, so the view's filter must be the value in it: a `table.filter` + // there would outrank the view the user is looking at. // // The SORT half moved in objectui#5270 — see the sort assertion below for // why, and `ObjectView.namedViewSortArity.test.tsx` for the two consumers @@ -293,8 +301,8 @@ describe('grid path: a named view still outranks the table segment', () => { ...namedView, table: { filter: [['stage', '=', 'lost']] } as any, } as any); - expect(grid.filter).toBeUndefined(); - expect(grid.defaultFilters).toEqual([['stage', '=', 'won']]); + expect(grid.filter).toEqual([['stage', '=', 'won']]); + expect(grid.defaultFilters).toBeUndefined(); }); it('keeps the named view sort in force over a table.sort', () => { diff --git a/packages/plugin-view/src/__tests__/ObjectView.gridFilterHandoff-11880.test.tsx b/packages/plugin-view/src/__tests__/ObjectView.gridFilterHandoff-11880.test.tsx index abace0b303..47bc6f2605 100644 --- a/packages/plugin-view/src/__tests__/ObjectView.gridFilterHandoff-11880.test.tsx +++ b/packages/plugin-view/src/__tests__/ObjectView.gridFilterHandoff-11880.test.tsx @@ -10,16 +10,27 @@ * objectui#11880 item 5 — the filter `ObjectView` hands the `ObjectGrid` it draws, * read through the REAL grid and an adapter that APPLIES the `$filter` it receives. * - * The node this component builds for its own grid carries the authored filter - * rungs across two slots: an active view's filter and the deprecated - * `table.defaultFilters` in `defaultFilters`, and `table.filter` in `filter` - * (written only when no view filter is present). `ObjectGrid` reads `filter` - * first and `defaultFilters` only when `filter` lowers to nothing. + * The node this component builds for its own grid carries the whole authored + * filter chain in ONE slot, `filter`: an active view's filter, else `table.filter` + * unless it lowers to nothing, else the deprecated `table.defaultFilters`. Before + * this card the chain was split across two slots (the view's filter and + * `table.defaultFilters` in `defaultFilters`, `table.filter` in `filter`), and + * `ObjectGrid` read `defaultFilters` only when `filter` lowered to nothing. * - * Every reading here is of what the grid SENDS and what it DRAWS, plus the - * three readers inside `ObjectGrid` that look at one slot and not the other: - * the server-streamed export, the page reset on a query change, and the - * refused-filter state. + * Every reading here is of what the grid SENDS and what it DRAWS: + * + * - the rung order, which the move to one slot does not change (each rung and + * each pair reads the same `$filter` and rows as on the two-slot hand-off); + * - the three readers inside `ObjectGrid` that look at `filter` and not at + * `defaultFilters`: the server-streamed export and the page reset, whose + * answers this card CORRECTS (a filter riding `defaultFilters` narrowed the + * rows but not the downloaded file, and changing it in place kept the old + * page), and the refused-filter state, which it keeps. + * + * The corrected rows were lit by their before-readings: on the two-slot hand-off + * (the objectui#11880 PR records the ablation) the export was handed no filter + * and the next query kept a page index above 0, while every rung row stayed + * green. */ import React from 'react'; @@ -217,19 +228,19 @@ describe('objectui#11880 item 5 — the rung order through the real grid', () => }); describe('objectui#11880 item 5 — the server-streamed export', () => { - it('a named view filter: what the export is handed', async () => { + it('a named view filter reaches the export, as it reaches the rows', async () => { const r = await exported({ view: VIEW }); expect(r.fetched).toEqual(VIEW_AST); - expect(r.filter).toBeUndefined(); + expect(r.filter).toEqual(VIEW_AST); }); - it('`table.defaultFilters` alone: what the export is handed', async () => { + it('`table.defaultFilters` alone reaches the export, as it reaches the rows', async () => { const r = await exported({ tableDefaults: DEFAULTS }); expect(r.fetched).toEqual(DEFAULTS_AST); - expect(r.filter).toBeUndefined(); + expect(r.filter).toEqual(DEFAULTS_AST); }); - it('LIT CONTROL: `table.filter` reaches the export', async () => { + it('CONTROL: `table.filter` reaches the export, as before', async () => { const r = await exported({ table: TABLE }); expect(r.fetched).toEqual(TABLE_AST); expect(r.filter).toEqual(TABLE_AST); @@ -269,21 +280,21 @@ describe('objectui#11880 item 5 — the page reset when the filter changes in pl return objectFinds(ds).at(-1)?.[1].$skip ?? 0; } - it('a named view filter changed in place: the page the next query asks for', async () => { - expect(await skipAfterChange({ view: EAST }, { view: WEST })).toBeGreaterThan(0); + it('a named view filter changed in place returns to page 1', async () => { + expect(await skipAfterChange({ view: EAST }, { view: WEST })).toBe(0); }); - it('`table.defaultFilters` changed in place: the page the next query asks for', async () => { - expect(await skipAfterChange({ tableDefaults: EAST }, { tableDefaults: WEST })).toBeGreaterThan(0); + it('`table.defaultFilters` changed in place returns to page 1', async () => { + expect(await skipAfterChange({ tableDefaults: EAST }, { tableDefaults: WEST })).toBe(0); }); - it('LIT CONTROL: `table.filter` changed in place returns to page 1', async () => { + it('CONTROL: `table.filter` changed in place returns to page 1, as before', async () => { expect(await skipAfterChange({ table: EAST }, { table: WEST })).toBe(0); }); }); -describe('objectui#11880 item 5 — a refused named-view filter', () => { - it('names the operator and queries nothing', async () => { +describe('objectui#11880 item 5 — a refused filter still draws its panel', () => { + it('a refused named-view filter names the operator and queries nothing', async () => { const ds = makeAdapter(); mount(viewSchema({ view: [{ field: 'stage', operator: 'equals', value: ['a', 'b'] }] }), ds); const headline = await screen.findByTestId('grid-malformed-filter-subject'); @@ -292,4 +303,13 @@ describe('objectui#11880 item 5 — a refused named-view filter', () => { expect(objectFinds(ds)).toHaveLength(0); expect(document.body.textContent).not.toMatch(/error loading grid/i); }); + + it('a refused `table.filter` is kept, not skipped for `table.defaultFilters`', async () => { + const ds = makeAdapter(); + mount(viewSchema({ table: [{ field: 'stage', operator: 'equals', value: ['a', 'b'] }], tableDefaults: DEFAULTS }), ds); + const headline = await screen.findByTestId('grid-malformed-filter-subject'); + expect(headline.textContent).toContain('equals'); + await new Promise((r) => setTimeout(r, 50)); + expect(objectFinds(ds)).toHaveLength(0); + }); }); diff --git a/packages/plugin-view/src/__tests__/ObjectView.namedViewContextTokens-10506.test.tsx b/packages/plugin-view/src/__tests__/ObjectView.namedViewContextTokens-10506.test.tsx index 05d75f538d..70bb9482d8 100644 --- a/packages/plugin-view/src/__tests__/ObjectView.namedViewContextTokens-10506.test.tsx +++ b/packages/plugin-view/src/__tests__/ObjectView.namedViewContextTokens-10506.test.tsx @@ -204,7 +204,9 @@ describe('named view / table filter → the `object-grid` schema handed to Objec const ds = makeAdapter(); render(); await waitFor(() => expect(gridSchemas.length).toBeGreaterThan(0)); - expect(gridSchemas.at(-1).defaultFilters).toEqual([{ field: 'owner', operator: 'equals', value: USER }]); + // objectui#11880 item 5: in `filter`, the one filter slot the grid node carries. + expect(gridSchemas.at(-1).filter).toEqual([{ field: 'owner', operator: 'equals', value: USER }]); + expect(gridSchemas.at(-1).defaultFilters).toBeUndefined(); }); it('hands ObjectGrid the resolved canonical table.filter', async () => { diff --git a/packages/plugin-view/src/__tests__/ObjectView.specShapedNamedView-8254.test.tsx b/packages/plugin-view/src/__tests__/ObjectView.specShapedNamedView-8254.test.tsx index 606ea216f4..0c12308f24 100644 --- a/packages/plugin-view/src/__tests__/ObjectView.specShapedNamedView-8254.test.tsx +++ b/packages/plugin-view/src/__tests__/ObjectView.specShapedNamedView-8254.test.tsx @@ -275,8 +275,11 @@ describe('objectui#8254 — `filter` / `sort` / `label` / `data` already honour it('the spec `filter` (rule OBJECTS, not the local tuple dialect) reaches the grid and the delegation unchanged', async () => { const spec = await gridSchemaFor(SPEC_VIEW); const local = await gridSchemaFor(LOCAL_VIEW); - expect(spec.defaultFilters).toEqual(local.defaultFilters); - expect(spec.defaultFilters).toEqual(FILTER); + // objectui#11880 item 5: a named view's filter rides `filter`, the one + // filter slot the grid node carries. + expect(spec.filter).toEqual(local.filter); + expect(spec.filter).toEqual(FILTER); + expect(spec.defaultFilters).toBeUndefined(); expect(delegatedSchemaFor(SPEC_VIEW).filter).toEqual(delegatedSchemaFor(LOCAL_VIEW).filter); }); diff --git a/packages/plugin-view/src/__tests__/ObjectView.tableSlotRelay-10976.test.tsx b/packages/plugin-view/src/__tests__/ObjectView.tableSlotRelay-10976.test.tsx index 631038ffc2..e8d2745bd8 100644 --- a/packages/plugin-view/src/__tests__/ObjectView.tableSlotRelay-10976.test.tsx +++ b/packages/plugin-view/src/__tests__/ObjectView.tableSlotRelay-10976.test.tsx @@ -196,10 +196,21 @@ describe('objectui#10976 — a key written on `table` reaches the grid `ObjectVi it('CONTROL: each by-name key still arrives, and `operations` still merges the view\'s `create: false`', async () => { const s = await gridNode({ table: { ...BY_NAME, operations: { update: false } } }); for (const [key, value] of Object.entries(BY_NAME)) { + // objectui#11880 item 5: `defaultFilters` is still read by name, but the + // filter pair is resolved before the node is built and handed over in + // `filter` alone; its arrival is pinned in the case below. + if (key === 'defaultFilters') continue; expect(s[key], `\`table.${key}\` no longer reaches the grid`).toEqual(value); } + expect(s.defaultFilters).toBeUndefined(); expect(s.operations).toMatchObject({ update: false, create: false }); }); + + it('`table.defaultFilters` still arrives, in `filter`, when `table.filter` is absent (objectui#11880 item 5)', async () => { + const s = await gridNode({ table: { defaultFilters: BY_NAME.defaultFilters } }); + expect(s.filter).toEqual(BY_NAME.defaultFilters); + expect(s.defaultFilters).toBeUndefined(); + }); }); /* ── 3. Precedence — a named view's member wins over `table` ────────────────── */ From c8cf03658a1904cd82d3d21b11ad699f673dc2cb Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 21:42:26 +0000 Subject: [PATCH 3/3] docs(types,plugin-grid): two sentences say what the one-slot hand-off makes true (objectui#11880 item 5) The ObjectGridSchema.defaultFilters TSDoc said ObjectView hands the slot an active named view's filter, and ObjectGrid's legacy-read comment said plugin-view forwards a named view's rule array into it. Since this PR ObjectView writes no defaultFilters on the grid it draws: both rungs reach it in `filter`. Comment and TSDoc only; the mirror key, the legacy read and its readers are unchanged. Claude-Session: https://claude.ai/code/session_01DBZ9bntPZ7VKyQNtJeNsgw Co-authored-by: Claude --- packages/plugin-grid/src/ObjectGrid.tsx | 12 +++++++----- packages/types/src/objectql.ts | 11 ++++++----- 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/packages/plugin-grid/src/ObjectGrid.tsx b/packages/plugin-grid/src/ObjectGrid.tsx index 444faefb7c..847ec24752 100644 --- a/packages/plugin-grid/src/ObjectGrid.tsx +++ b/packages/plugin-grid/src/ObjectGrid.tsx @@ -2632,11 +2632,13 @@ export const ObjectGrid: React.FC = ({ // carries. `defaultFilters` was declared `Record` // (the MongoDB-style shape) until objectui#6152 round 10 — it is the // row's `ViewFilterRule` array since, the shape `filter` takes — and - // `isFilterAST` is false for a plain object; `plugin-view` also - // forwards an active named view's `ViewFilterRule[]` into this slot - // (`ObjectView.tsx`, the `gridSchema` memo), and `isFilterAST` is - // false for an array of rule objects too. Either one answers `400 - // INVALID_FILTER` (measured against a real backend in objectui#3431). + // `isFilterAST` is false for a plain object; a rule array is the + // other shape (`plugin-view`'s `ObjectView` forwarded an active + // named view's `ViewFilterRule[]` into this slot until objectui#11880 + // item 5, and hands its whole filter chain to `filter` since), and + // `isFilterAST` is false for an array of rule objects too. Either one + // answers `400 INVALID_FILTER` (measured against a real backend in + // objectui#3431). // // `toFilterNode` handles both without new logic: objects route // through `convertFiltersToAST`, rule arrays lower element-wise, diff --git a/packages/types/src/objectql.ts b/packages/types/src/objectql.ts index 653667e048..7633904948 100644 --- a/packages/types/src/objectql.ts +++ b/packages/types/src/objectql.ts @@ -1158,11 +1158,12 @@ export interface ObjectGridSchema extends BaseSchema { * `table` slot, while the protocol refused it. Write * `[{ field: 'status', operator: 'equals', value: 'open' }]` instead — or, better, * move it to `filter`. ⚠️ The read did not narrow: the sink still lowers a record - * or an AST array that reaches the slot at runtime (`ObjectView` hands it an - * active named view's `filter`, which {@link NamedListView.filter} types - * `any[]`), which is not a shape an author writes here, so this face does not - * publish it (the objectui#10199 split, as {@link ObjectGridSchema.filter} - * makes it). + * or an AST array that reaches the slot at runtime, which is not a shape an + * author writes here, so this face does not publish it (the objectui#10199 + * split, as {@link ObjectGridSchema.filter} makes it). Since objectui#11880 + * item 5 `ObjectView` no longer writes this slot on the grid it draws: an + * active named view's `filter` and an authored `table.defaultFilters` both + * reach that grid in `filter`, so its export and page reset follow them. * * @deprecated Use filter instead — the same rule array; this key is read only * when `filter` is absent.