From c05efd1a8a0ee3b0aa81c84474d357ace406e5dc Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 07:27:55 +0000 Subject: [PATCH 1/5] fix(plugin-view): route 2 reads a named view's grid members off the named view (objectui#10885) The registered object-view renderer passes no renderListView, so an authored node and the Studio ViewPreview land on the object-grid node ObjectView builds for ObjectGrid. That node now reads the bucket-1 members the protocol declares on both a named view and object-grid (pagination, selection, rowHeight, resizable, searchableFields, conditionalFormatting, rowActions, bulkActions, bulkActionDefs, exportOptions) off the named view first, then the node only where it already read the node (table.pagination, table.selection). A named view's hiddenFields is subtracted from the resolved column projection. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_014mXUNuFomfj24w7s1pZzhN --- ...Preview.routeTwoGridMembers-10885.test.tsx | 117 +++++++++ packages/plugin-view/src/ObjectView.tsx | 71 ++++- ...ew.routeTwoNamedGridMembers-10885.test.tsx | 246 ++++++++++++++++++ 3 files changed, 430 insertions(+), 4 deletions(-) create mode 100644 packages/app-shell/src/views/metadata-admin/previews/ViewPreview.routeTwoGridMembers-10885.test.tsx create mode 100644 packages/plugin-view/src/__tests__/ObjectView.routeTwoNamedGridMembers-10885.test.tsx diff --git a/packages/app-shell/src/views/metadata-admin/previews/ViewPreview.routeTwoGridMembers-10885.test.tsx b/packages/app-shell/src/views/metadata-admin/previews/ViewPreview.routeTwoGridMembers-10885.test.tsx new file mode 100644 index 0000000000..d7a0eca437 --- /dev/null +++ b/packages/app-shell/src/views/metadata-admin/previews/ViewPreview.routeTwoGridMembers-10885.test.tsx @@ -0,0 +1,117 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectui#10885 member 1 — the Studio view preview shows a stored grid view's + * own grid members. + * + * `ViewPreview` injects the stored view body as a named `listViews` entry and + * renders it through `SchemaRenderer`, which resolves the REGISTERED + * `object-view` renderer: `ObjectView` with no `renderListView`, so a grid view + * lands on route 2, the `object-grid` node `ObjectView` hands `ObjectGrid`. + * Before this card that node read `columns`, `filter`, `sort`, `grouping` and + * `rowColor` off the named view and nothing else, so a stored view's + * `rowHeight` and `hiddenFields` were accepted and dropped from the preview. + * + * Everything here is the real stack: the real `ViewPreview`, the real + * `SchemaRenderer` and registry, the registered `object-view` renderer and the + * real `ObjectGrid`. The readings are the rendered DOM. + * + * - `rowHeight` — `ObjectGrid` draws its row-height control only when its + * node carries `rowHeight`, titled with the resolved mode. + * - `hiddenFields` — the protocol's column composition: `columns` projects + * and `hiddenFields` subtracts. The hidden column's header is not drawn. + * - Lit control — `columns` off the SAME named view was already honoured on + * route 2: a field the view does not project is not drawn, and a projected + * one is. + */ + +import * as React from 'react'; +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, screen, waitFor, cleanup } from '@testing-library/react'; +import { SchemaRendererProvider } from '@object-ui/react'; +// Module scope, not a hook: this import IS the `object-view` registration the +// preview's `SchemaRenderer` resolves. +import '@object-ui/plugin-view'; +import { ViewPreview } from './ViewPreview'; + +const objectDef = { + name: 'task', + label: 'Task', + fields: { + name: { name: 'name', type: 'text', label: 'Task Name' }, + amount: { name: 'amount', type: 'number', label: 'Budget Amount' }, + stage: { name: 'stage', type: 'text', label: 'Pipeline Stage' }, + owner: { name: 'owner', type: 'text', label: 'Record Owner' }, + }, +}; + +function createDataSource(): any { + return { + find: vi.fn(async () => ({ + data: [{ id: 't1', name: 'Alpha', amount: 5, stage: 'open', owner: 'ada' }], + total: 1, + })), + findOne: vi.fn(async () => null), + create: vi.fn(async () => ({})), + update: vi.fn(async () => ({})), + delete: vi.fn(async () => ({})), + getObjectSchema: vi.fn(async () => objectDef), + }; +} + +/** + * The host's authenticated fetch. `ObjectGrid` asks the explain engine for its + * per-row edit and delete verdicts through it; a 501 is the engine's + * "not implemented", which the grid reads fail-open. Handed in through the + * provider, as the console does, so no read reaches a real socket. + */ +const apiFetch = vi.fn(async () => new Response(null, { status: 501 })); + +/** Mount the Studio preview of one stored grid view, the way the editor does. */ +function renderPreview(config: Record) { + render( + + + , + ); +} + +/** The grid has drawn its headers from the object schema. */ +async function gridDrawn() { + await waitFor(() => expect(screen.getAllByText('Task Name').length).toBeGreaterThan(0)); +} + +afterEach(() => cleanup()); + +describe('objectui#10885 — the Studio view preview honours a stored grid view\'s grid members', () => { + it('LIT CONTROL: the named view\'s `columns` projects the grid on route 2 already', async () => { + renderPreview({ type: 'grid', columns: ['name', 'stage'] }); + await gridDrawn(); + expect(screen.getAllByText('Pipeline Stage').length).toBeGreaterThan(0); + expect(screen.queryByText('Record Owner')).toBeNull(); + }); + + it('`hiddenFields` subtracts from the projected columns — the hidden header is not drawn', async () => { + renderPreview({ type: 'grid', columns: ['name', 'amount', 'stage'], hiddenFields: ['amount'] }); + await gridDrawn(); + expect(screen.getAllByText('Pipeline Stage').length).toBeGreaterThan(0); + expect(screen.queryByText('Budget Amount')).toBeNull(); + }); + + it('`rowHeight` reaches the grid — its row-height control is drawn in the stored mode', async () => { + renderPreview({ type: 'grid', columns: ['name', 'stage'], rowHeight: 'compact' }); + await gridDrawn(); + expect(screen.getByTitle('Row height: compact')).toBeTruthy(); + }); + + it('CONTROL: a stored view that declares neither draws every projected column and no row-height control', async () => { + renderPreview({ type: 'grid', columns: ['name', 'amount', 'stage'] }); + await gridDrawn(); + expect(screen.getAllByText('Budget Amount').length).toBeGreaterThan(0); + expect(screen.queryByTitle(/^Row height:/)).toBeNull(); + }); +}); diff --git a/packages/plugin-view/src/ObjectView.tsx b/packages/plugin-view/src/ObjectView.tsx index b3891b799c..e54270e5ea 100644 --- a/packages/plugin-view/src/ObjectView.tsx +++ b/packages/plugin-view/src/ObjectView.tsx @@ -244,6 +244,35 @@ function viewColumnFieldNames(columns: unknown): string[] | undefined { return columns.map(columnIdentity).filter((n): n is string => !!n); } +/** + * objectui#10885 — a named view's `hiddenFields`, applied to the column + * projection route 2 hands `ObjectGrid`. + * + * The protocol composes three members of a named list view: `columns` + * projects, `hiddenFields` subtracts, `fieldOrder` orders what survives + * (objectstack#15184 ruling B). `ObjectGrid` has no `hiddenFields` read, and + * `object-grid` declares no such key, so relaying the member by name would put + * a key on the grid node that nothing reads. The subtraction is therefore made + * here, on the resolved projection, the way `ListView` makes it for the host + * delegation (its `effectiveFields`). + * + * - Only a DECLARED projection is narrowed. With no `columns` anywhere the grid + * derives its own defaults, and this returns `undefined` unchanged, as + * `ListView` does. + * - An entry is dropped only when `columnIdentity` names a hidden field. Every + * other entry, and the entry's own shape, is kept. + * - A projection that every entry leaves is kept as the empty projection the + * author wrote; it does not fall back to the grid's defaults. + */ +function withoutHiddenFields(columns: T[] | undefined, hidden: readonly string[] | undefined): T[] | undefined { + if (!Array.isArray(columns) || !Array.isArray(hidden) || hidden.length === 0) return columns; + const drop = new Set(hidden); + return columns.filter((entry) => { + const name = columnIdentity(entry); + return !name || !drop.has(name); + }); +} + /** * One entry of `ObjectViewSchema.listViews` — the protocol's * `ObjectListViewSchema`, by reference (objectui#7928). Derived from the member @@ -2202,6 +2231,8 @@ export const ObjectView: React.FC = ({ // objectui#7928: see the non-grid fetch above. A retired string `sort` on a // named view reaches `ObjectGrid` unchanged, which refuses it out loud. const viewSort = (currentNamedViewConfig?.sort as ObjectGridSchema['sort']) || activeView?.sort; + // objectui#10885 — subtracted from whichever projection wins below. + const hiddenFields = currentNamedViewConfig?.hiddenFields; return { type: 'object-grid', @@ -2214,8 +2245,16 @@ export const ObjectView: React.FC = ({ // folded to identities, and `columns` is `string[] | ListColumn[]` so it // takes the authored value raw. One value, two slots, each given the // shape it declares. - fields: viewColumnFieldNames(currentNamedViewConfig?.columns) || activeView?.columns || schema.table?.fields, - columns: currentNamedViewConfig?.columns || activeView?.columns || schema.table?.columns, + // objectui#10885 — both slots lose the named view's `hiddenFields` + // (see `withoutHiddenFields`). + fields: withoutHiddenFields( + viewColumnFieldNames(currentNamedViewConfig?.columns) || activeView?.columns || schema.table?.fields, + hiddenFields, + ), + columns: withoutHiddenFields( + currentNamedViewConfig?.columns || activeView?.columns || schema.table?.columns, + hiddenFields, + ), operations: { ...operations, create: false, // Create is handled by the view's create button @@ -2233,8 +2272,32 @@ export const ObjectView: React.FC = ({ // express; what changes is only WHICH slot a view's sort arrives in, and // this is the one whose declared arity can hold it. sort: viewSort || schema.table?.sort, - pagination: schema.table?.pagination, - selection: schema.table?.selection, + // ⭐ objectui#10885 — the named view's own GRID MEMBERS, read here the + // way the host delegation below reads them since objectui#10758: the + // named view first, then the node only where this branch already read + // the node (`table.pagination`, `table.selection`). Each is a member the + // protocol declares on a named view AND on `object-grid`, under the same + // name, and `ObjectGrid` reads each one. The Studio's view preview renders + // a stored view through this branch, so before this a stored view's + // `rowHeight`, `pagination` and the rest were accepted and not shown. + // + // NAMED-VIEW SOURCED, like `grouping` / `rowColor` below: ⛔ no + // `activeView` rung (the host `views` path never fed these slots on this + // branch) and ⛔ no node read this branch did not already have. ⛔ No + // alias and no key `object-grid` does not declare: `hiddenFields` is + // applied to the projection above rather than relayed, and `navigation` + // is not relayed because this component passes `ObjectGrid` its own + // `onRowClick`, which the grid's navigation hook obeys first. + pagination: currentNamedViewConfig?.pagination ?? schema.table?.pagination, + selection: currentNamedViewConfig?.selection ?? schema.table?.selection, + rowHeight: currentNamedViewConfig?.rowHeight, + resizable: currentNamedViewConfig?.resizable, + searchableFields: currentNamedViewConfig?.searchableFields, + conditionalFormatting: currentNamedViewConfig?.conditionalFormatting, + rowActions: currentNamedViewConfig?.rowActions, + bulkActions: currentNamedViewConfig?.bulkActions, + bulkActionDefs: currentNamedViewConfig?.bulkActionDefs, + exportOptions: currentNamedViewConfig?.exportOptions, // ⭐ objectui#8980 — the AUTHOR-REACHABLE read point for two of the // seventeen. `ObjectGrid` already reads both (`schema.grouping` in its // group-field memo and its reference collector, `useRowColor(schema.rowColor)`), diff --git a/packages/plugin-view/src/__tests__/ObjectView.routeTwoNamedGridMembers-10885.test.tsx b/packages/plugin-view/src/__tests__/ObjectView.routeTwoNamedGridMembers-10885.test.tsx new file mode 100644 index 0000000000..50cf303814 --- /dev/null +++ b/packages/plugin-view/src/__tests__/ObjectView.routeTwoNamedGridMembers-10885.test.tsx @@ -0,0 +1,246 @@ +/** + * 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#10885 member 1 — route 2 reads a named view's GRID MEMBERS. + * + * `index.tsx` registers `object-view` as `ObjectView` with no `renderListView`, + * so an authored node, and the Studio's `ViewPreview`, take route 2: the + * `object-grid` node `ObjectView` builds (`gridSchema`) and hands `ObjectGrid`. + * PR objectui#10884 (objectui#10758) made the HOST delegation read bucket ① of + * the objectui#7924 ruling off the named view. Route 2 read `columns`, `filter`, + * `sort`, `grouping` and `rowColor` off it and nothing else. + * + * This file mounts the REGISTERED renderer through the real `SchemaRenderer` and + * registry, and reads the node `ObjectGrid` receives. + * + * ## Which bucket-① members route 2 takes, measured rather than listed + * + * A member is taken when the protocol declares it on a named view AND on + * `object-grid`, under the same name, and `ObjectGrid` reads it. The first + * describe below derives both declarations from the installed protocol. Three + * bucket-① members pass the declaration test and are still handled apart: + * + * - `hiddenFields` — declared on the named view, NOT on `object-grid`, and + * `ObjectGrid` has no read of it. It is honoured as the protocol composes it: + * subtracted from the column projection route 2 hands the grid. + * - `navigation` — declared on both, but `ObjectView` hands `ObjectGrid` its own + * `onRowClick`, and the grid's navigation hook obeys that first. Relayed, the + * member would be read by nothing on this route, so it is not relayed. + * + * The rest of bucket ① (`addRecord`, `allowPrinting`, `aria`, `compactToolbar`, + * `description`, `emptyState`, `filterableFields`, `inlineEdit`, `sharing`, + * `showRecordCount`, `userFilters`) is list chrome `ListView` draws. `object-grid` + * does not declare it under the same name, so route 2 has no slot to read it + * into. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, waitFor, cleanup } from '@testing-library/react'; +import { ComponentPropsMap, ObjectListViewSchema as SpecObjectListViewSchema } from '@objectstack/spec/ui'; +import { SchemaRenderer, SchemaRendererProvider } from '@object-ui/react'; +// Module scope, not a hook: this import IS the `object-view` registration. +import '../index'; + +/** Every schema handed to `ObjectGrid` — route 2's sink. */ +const gridSchemas: any[] = []; + +vi.mock('@object-ui/plugin-grid', async (importOriginal) => ({ + ...(await importOriginal>()), + ObjectGrid: ({ schema }: any) => { + gridSchemas.push(schema); + return
; + }, +})); +vi.mock('@object-ui/plugin-form', async (importOriginal) => ({ + ...(await importOriginal>()), + ObjectForm: () =>
, +})); + +const dataSource = (): any => ({ + find: vi.fn().mockResolvedValue({ data: [], total: 0 }), + findOne: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + getObjectSchema: vi.fn().mockResolvedValue({ name: 'task', fields: {} }), +}); + +/** + * ⚠️ `cleanup()` is load-bearing: without it a previously mounted view keeps + * pushing into the sink, and the last entry answers the previous fixture (the + * objectui#9242 trap). + */ +function resetSink() { + cleanup(); + gridSchemas.length = 0; +} + +beforeEach(resetSink); + +/** + * The bucket-① members route 2 relays, each with a named-view value and, for + * the two this branch already read off the node (`table.pagination`, + * `table.selection`), a node value. Every other member carries a node value + * too, which must NOT arrive: route 2 gained no node read. + */ +const MEMBERS: Record = { + pagination: { named: { pageSize: 5, pageSizeOptions: [5, 10] }, table: { pageSize: 100 } }, + selection: { named: { type: 'single' }, table: { type: 'multiple' } }, + rowHeight: { named: 'tall', table: 'extra_tall' }, + resizable: { named: false, table: true }, + searchableFields: { named: ['subject'], table: ['node_search'] }, + conditionalFormatting: { + named: [{ condition: "stage == 'won'", style: { backgroundColor: '#dcfce7' } }], + table: [{ condition: "stage == 'open'", style: { backgroundColor: '#e0f2fe' } }], + }, + rowActions: { named: ['archive'], table: ['node_row'] }, + bulkActions: { named: ['delete'], table: ['node_bulk'] }, + bulkActionDefs: { + named: [{ name: 'close_all', label: 'Close all', operation: 'update', patch: { stage: 'closed' } }], + table: [{ name: 'node_def', label: 'Node', operation: 'delete' }], + }, + exportOptions: { named: { formats: ['csv'] }, table: { formats: ['xlsx'] } }, +}; + +/** The members whose node rung route 2 already had — `table.KEY`. */ +const NODE_RUNG = ['pagination', 'selection']; + +const pick = (source: 'named' | 'table'): Record => + Object.fromEntries(Object.entries(MEMBERS).map(([k, v]) => [k, v[source]])); + +/** + * The members route 2 read off a named view before this card, riding every + * fixture as the FIRING CONTROL: they arrive on the same render, so a member + * that reads `undefined` is a reading of that member, not of a named view that + * was never selected. + */ +const CONTROL = { + label: 'Open work', + type: 'grid', + columns: ['subject', 'stage'], + sort: [{ field: 'stage', order: 'desc' }], + grouping: { fields: [{ field: 'owner' }] }, +}; + +function expectControl(s: any) { + expect(s.type).toBe('object-grid'); + expect(s.columns).toEqual(['subject', 'stage']); + expect(s.fields).toEqual(['subject', 'stage']); + expect(s.sort).toEqual([{ field: 'stage', order: 'desc' }]); + expect(s.grouping).toEqual({ fields: [{ field: 'owner' }] }); +} + +/** + * The node `ObjectGrid` receives when the REGISTERED `object-view` renderer + * draws `view` as the active named view — through `SchemaRenderer`, so no + * `renderListView` is in play. + */ +async function gridNodeThroughRenderer(view: Record, table?: Record): Promise { + resetSink(); + render( + + + , + ); + await waitFor(() => expect(gridSchemas.length).toBeGreaterThan(0)); + return gridSchemas[gridSchemas.length - 1]; +} + +describe('objectui#10885 — the member set route 2 takes, derived from the installed protocol', () => { + const namedView = Object.keys(SpecObjectListViewSchema.shape); + const grid = Object.keys((ComponentPropsMap['object-grid'] as unknown as { shape: Record }).shape); + + it('every relayed member is declared on a named view AND on `object-grid`, under the same name', () => { + const members = Object.keys(MEMBERS); + expect(members.filter((m) => !namedView.includes(m))).toEqual([]); + expect(members.filter((m) => !grid.includes(m))).toEqual([]); + // Control on the same two lists: objectui's own `allowExport` (ruled on + // objectui#7924) is on neither, so "none missing" is a reading. + expect(namedView).not.toContain('allowExport'); + expect(grid).not.toContain('allowExport'); + }); + + it('`hiddenFields` is a named-view member `object-grid` does not declare — so it is applied, not relayed', () => { + expect(namedView).toContain('hiddenFields'); + expect(grid).not.toContain('hiddenFields'); + }); + + it('the named view carrying every relayed member is a PROTOCOL document — the strict `ObjectListViewSchema` parses it', () => { + const r = SpecObjectListViewSchema.safeParse({ ...CONTROL, ...pick('named'), hiddenFields: ['stage'] }); + expect(r.success, r.success ? '' : JSON.stringify(r.error.issues)).toBe(true); + }); +}); + +describe('objectui#10885 — through the registered `object-view` renderer, route 2 reads each grid member off the named view', () => { + it('THE FIX: each member\'s named value reaches `ObjectGrid`, and wins over the node', async () => { + const s = await gridNodeThroughRenderer({ ...CONTROL, ...pick('named') }, pick('table')); + expectControl(s); + for (const [member, v] of Object.entries(MEMBERS)) { + expect(s[member], `\`${member}\` did not arrive from the named view`).toEqual(v.named); + } + }); + + it('CONTROL: with the members absent from the named view, the node applies where route 2 already read it — and only there', async () => { + const s = await gridNodeThroughRenderer({ ...CONTROL }, pick('table')); + expectControl(s); + for (const [member, v] of Object.entries(MEMBERS)) { + if (NODE_RUNG.includes(member)) { + expect(s[member], `\`${member}\` no longer arrives from the node's \`table\``).toEqual(v.table); + } else { + expect(s[member], `\`${member}\` is now read off the node's \`table\``).toBeUndefined(); + } + } + }); +}); + +describe('objectui#10885 — `hiddenFields` subtracts from the projection route 2 hands the grid', () => { + it('THE FIX: a hidden field leaves both the `fields` and the `columns` slot, and no `hiddenFields` key reaches the grid', async () => { + const s = await gridNodeThroughRenderer({ ...CONTROL, columns: ['subject', 'amount', 'stage'], hiddenFields: ['amount'] }); + expect(s.fields).toEqual(['subject', 'stage']); + expect(s.columns).toEqual(['subject', 'stage']); + expect(s).not.toHaveProperty('hiddenFields'); + }); + + it('an object-shaped column keeps its shape — only the hidden entry is dropped', async () => { + const s = await gridNodeThroughRenderer({ + ...CONTROL, + columns: [{ field: 'subject', width: 240 }, { field: 'amount' }], + hiddenFields: ['amount'], + }); + expect(s.columns).toEqual([{ field: 'subject', width: 240 }]); + expect(s.fields).toEqual(['subject']); + }); + + it('the projection the named view does not declare is narrowed too — the node\'s `table.columns` loses the hidden field', async () => { + const { columns: _none, ...noColumns } = CONTROL; + const s = await gridNodeThroughRenderer({ ...noColumns, hiddenFields: ['amount'] }, { columns: ['subject', 'amount'] }); + expect(s.columns).toEqual(['subject']); + }); + + it('with no projection anywhere, nothing is invented — the grid derives its own columns', async () => { + const { columns: _none, ...noColumns } = CONTROL; + const s = await gridNodeThroughRenderer({ ...noColumns, hiddenFields: ['amount'] }); + expect(s.columns).toBeUndefined(); + expect(s.fields).toBeUndefined(); + }); + + it('every projected field hidden keeps the empty projection the author wrote', async () => { + const s = await gridNodeThroughRenderer({ ...CONTROL, hiddenFields: ['subject', 'stage'] }); + expect(s.columns).toEqual([]); + expect(s.fields).toEqual([]); + }); +}); From 3e323db8d1d5804e9508df73da3cde04e56e610a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 07:35:04 +0000 Subject: [PATCH 2/5] docs(plugin-view): the registered renderer's grid path reads a named view's grid members; changeset (objectui#10885) Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_014mXUNuFomfj24w7s1pZzhN --- .changeset/10885-object-view-route2-named-grid.md | 15 +++++++++++++++ content/docs/plugins/plugin-view.mdx | 11 +++++++++++ packages/plugin-view/README.md | 11 +++++++++++ 3 files changed, 37 insertions(+) create mode 100644 .changeset/10885-object-view-route2-named-grid.md diff --git a/.changeset/10885-object-view-route2-named-grid.md b/.changeset/10885-object-view-route2-named-grid.md new file mode 100644 index 0000000000..8d9c63ab5d --- /dev/null +++ b/.changeset/10885-object-view-route2-named-grid.md @@ -0,0 +1,15 @@ +--- +'@object-ui/plugin-view': patch +--- + +The registered `object-view` renderer reads a named grid view's own grid members off the named view (objectui#10885). + +`object-view` is registered without a `renderListView`, so an authored node, and the Studio's view preview (which renders a stored view through `SchemaRenderer`), draws a grid view as the `object-grid` node `ObjectView` builds for `ObjectGrid`. That node read `columns`, `filter`, `sort`, `grouping` and `rowColor` off the active named view and nothing else, so a stored or authored grid view's `rowHeight`, `pagination` and the rest were accepted and not shown. + +It now reads, off the active named view first, each member the protocol declares under the same name on both a named view and `object-grid`, and that `ObjectGrid` reads: `pagination`, `selection`, `rowHeight`, `resizable`, `searchableFields`, `conditionalFormatting`, `rowActions`, `bulkActions`, `bulkActionDefs` and `exportOptions`. `pagination` and `selection` still fall back to the node's `table` when the named view omits them; the others have no node fallback on this path, as before. + +A named view's `hiddenFields` is applied the way the protocol composes it: the hidden fields are removed from the column projection the grid receives. Only a declared projection is narrowed; with no `columns` anywhere the grid still derives its own. + +What moves: only a grid view that authors one of these members. A named view that carries none of them renders exactly as before, and the host `renderListView` delegation is unchanged. + +`Clause-②: no` — no declared type, accepted key or published export moves. A renderer starts honouring members the spec already declares on a named view and the grid already reads. diff --git a/content/docs/plugins/plugin-view.mdx b/content/docs/plugins/plugin-view.mdx index 82b373c966..7e08c0ec3f 100644 --- a/content/docs/plugins/plugin-view.mdx +++ b/content/docs/plugins/plugin-view.mdx @@ -431,6 +431,17 @@ const productViews: ObjectViewSchema = { }; ``` +**On the registered renderer.** An authored `object-view` node, and the Studio's +view preview, draws a grid named view through `ObjectGrid`. The grid members the +protocol declares under the same name on a named view and on `object-grid` — +`pagination`, `selection`, `rowHeight`, `resizable`, `searchableFields`, +`conditionalFormatting`, `rowActions`, `bulkActions`, `bulkActionDefs` and +`exportOptions` — come from the active named view first; `pagination` and +`selection` still fall back to `table` (objectui#10885). A named view's +`hiddenFields` removes those fields from the columns the grid draws, when a +column list is declared. +`src/__tests__/ObjectView.routeTwoNamedGridMembers-10885.test.tsx` pins each member. + **On a host's `renderListView`.** A host that composes `ObjectView` with both `listViews` and its own `renderListView` receives a `list-view` node for the active view. For the list members the protocol declares on a named view — list diff --git a/packages/plugin-view/README.md b/packages/plugin-view/README.md index c0184431f1..750ba70a59 100644 --- a/packages/plugin-view/README.md +++ b/packages/plugin-view/README.md @@ -497,6 +497,17 @@ const schema: ObjectViewSchema = { }; ``` +**On the registered renderer.** An authored `object-view` node, and the Studio's +view preview, draws a grid named view through `ObjectGrid`. The grid members the +protocol declares under the same name on a named view and on `object-grid` — +`pagination`, `selection`, `rowHeight`, `resizable`, `searchableFields`, +`conditionalFormatting`, `rowActions`, `bulkActions`, `bulkActionDefs` and +`exportOptions` — come from the active named view first; `pagination` and +`selection` still fall back to `table` (objectui#10885). A named view's +`hiddenFields` removes those fields from the columns the grid draws, when a +column list is declared. +`src/__tests__/ObjectView.routeTwoNamedGridMembers-10885.test.tsx` pins each member. + **On a host's `renderListView`.** A host that composes `ObjectView` with both `listViews` and its own `renderListView` receives a `list-view` node for the active view. For the list members the protocol declares on a named view — list From 695a6ce635116bc9af87680f42b38963797bd8d9 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 07:51:40 +0000 Subject: [PATCH 3/5] fix(plugin-view): route 2 hands the grid each member in the slot's declared shape (objectui#10885) A named view's exportOptions may be the protocol's legacy bare format array, which the protocol lifts to { formats } at parse; the grid reads exportOptions.formats, so the array is lifted here the way ListView lifts it for the host delegation. The conditionalFormatting and bulkActionDefs rungs assert the grid's declared element type: the protocol's expression wire is wider than it, and ObjectGrid hands both to the shared evaluator that reads it. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_014mXUNuFomfj24w7s1pZzhN --- packages/plugin-view/src/ObjectView.tsx | 47 ++++++++++++++++--- ...ew.routeTwoNamedGridMembers-10885.test.tsx | 10 ++++ 2 files changed, 51 insertions(+), 6 deletions(-) diff --git a/packages/plugin-view/src/ObjectView.tsx b/packages/plugin-view/src/ObjectView.tsx index e54270e5ea..dcce880456 100644 --- a/packages/plugin-view/src/ObjectView.tsx +++ b/packages/plugin-view/src/ObjectView.tsx @@ -263,14 +263,40 @@ function viewColumnFieldNames(columns: unknown): string[] | undefined { * other entry, and the entry's own shape, is kept. * - A projection that every entry leaves is kept as the empty projection the * author wrote; it does not fall back to the grid's defaults. + * + * Generic over the whole array type because the `columns` slot is a union of + * two array types (`string[] | ListColumn[]`): filtering keeps each entry as it + * was, so the result has the type the input had, which the one cast states. */ -function withoutHiddenFields(columns: T[] | undefined, hidden: readonly string[] | undefined): T[] | undefined { +function withoutHiddenFields(columns: C | undefined, hidden: readonly string[] | undefined): C | undefined { if (!Array.isArray(columns) || !Array.isArray(hidden) || hidden.length === 0) return columns; const drop = new Set(hidden); - return columns.filter((entry) => { + return (columns as readonly unknown[]).filter((entry) => { const name = columnIdentity(entry); return !name || !drop.has(name); - }); + }) as unknown as C; +} + +/** + * objectui#10885 — a named view's `exportOptions`, in the one shape the + * `object-grid` slot holds. + * + * The protocol declares the member on a named view as `{ formats?, maxRecords?, + * includeHeaders?, fileNamePrefix?, streaming? }`, and declares a bare format + * array as its legacy spelling, which "lifts to `{ formats: [...] }` at parse" + * (the member's own description in `@objectstack/spec`). A named view reaches + * this component unparsed, so the bare array can arrive as written. `ObjectGrid` + * reads `exportOptions.formats`, which an array does not have: relayed as is, + * the grid would offer its default formats instead of the ones the author + * listed. So the declared union is narrowed to the slot's branch here, by the + * protocol's own lift, the way `ListView` narrows it for the host delegation. + * It accepts no spelling the protocol does not; the node-shape fold of a + * declared union at the boundary is the objectui#5269 / objectui#8254 pattern + * `viewColumnFieldNames` follows. + */ +function gridExportOptions(options: NamedViewConfig['exportOptions']): ObjectGridSchema['exportOptions'] { + if (Array.isArray(options)) return { formats: options }; + return options; } /** @@ -2293,11 +2319,20 @@ export const ObjectView: React.FC = ({ rowHeight: currentNamedViewConfig?.rowHeight, resizable: currentNamedViewConfig?.resizable, searchableFields: currentNamedViewConfig?.searchableFields, - conditionalFormatting: currentNamedViewConfig?.conditionalFormatting, + // The protocol's rule `condition` is a string or the `{ dialect, source }` + // expression wire; `ObjectGrid` hands every rule to the shared evaluator + // (`resolveConditionalFormatting`), which reads both. The grid's declared + // rule type spells the string form only, hence the assertion — the same + // one `viewSort` above makes for the named view's `sort`. + conditionalFormatting: currentNamedViewConfig?.conditionalFormatting as ObjectGridSchema['conditionalFormatting'], rowActions: currentNamedViewConfig?.rowActions, bulkActions: currentNamedViewConfig?.bulkActions, - bulkActionDefs: currentNamedViewConfig?.bulkActionDefs, - exportOptions: currentNamedViewConfig?.exportOptions, + // Same reason as `conditionalFormatting`: the protocol's `visible` + // expression wire may carry `ast` without `source`, which the grid's + // declared `BulkActionDef` does not spell. It is the value the host + // delegation already hands `ObjectGrid` through `ListView`. + bulkActionDefs: currentNamedViewConfig?.bulkActionDefs as ObjectGridSchema['bulkActionDefs'], + exportOptions: gridExportOptions(currentNamedViewConfig?.exportOptions), // ⭐ objectui#8980 — the AUTHOR-REACHABLE read point for two of the // seventeen. `ObjectGrid` already reads both (`schema.grouping` in its // group-field memo and its reference collector, `useRowColor(schema.rowColor)`), diff --git a/packages/plugin-view/src/__tests__/ObjectView.routeTwoNamedGridMembers-10885.test.tsx b/packages/plugin-view/src/__tests__/ObjectView.routeTwoNamedGridMembers-10885.test.tsx index 50cf303814..38c4a0ef89 100644 --- a/packages/plugin-view/src/__tests__/ObjectView.routeTwoNamedGridMembers-10885.test.tsx +++ b/packages/plugin-view/src/__tests__/ObjectView.routeTwoNamedGridMembers-10885.test.tsx @@ -194,6 +194,16 @@ describe('objectui#10885 — through the registered `object-view` renderer, rout } }); + it('`exportOptions` in the protocol\'s legacy bare-array spelling reaches the grid in the slot\'s shape — the protocol\'s own lift', async () => { + // The protocol accepts a bare format array on a named view and lifts it to + // `{ formats }` at parse. `ObjectGrid` reads `exportOptions.formats`, which + // an array does not have, so the node carries the lifted shape. + expect(SpecObjectListViewSchema.shape.exportOptions.parse(['xlsx'])).toEqual({ formats: ['xlsx'] }); + const s = await gridNodeThroughRenderer({ ...CONTROL, exportOptions: ['xlsx'] }); + expectControl(s); + expect(s.exportOptions).toEqual({ formats: ['xlsx'] }); + }); + it('CONTROL: with the members absent from the named view, the node applies where route 2 already read it — and only there', async () => { const s = await gridNodeThroughRenderer({ ...CONTROL }, pick('table')); expectControl(s); From 56ad7e877433e6691472e052983d2ec5981283f7 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 08:11:06 +0000 Subject: [PATCH 4/5] test(plugin-view): the route-2 pin's header counts the two members it sets apart (objectui#10885) Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_014mXUNuFomfj24w7s1pZzhN --- .../ObjectView.routeTwoNamedGridMembers-10885.test.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/plugin-view/src/__tests__/ObjectView.routeTwoNamedGridMembers-10885.test.tsx b/packages/plugin-view/src/__tests__/ObjectView.routeTwoNamedGridMembers-10885.test.tsx index 38c4a0ef89..095c3af585 100644 --- a/packages/plugin-view/src/__tests__/ObjectView.routeTwoNamedGridMembers-10885.test.tsx +++ b/packages/plugin-view/src/__tests__/ObjectView.routeTwoNamedGridMembers-10885.test.tsx @@ -23,8 +23,8 @@ * * A member is taken when the protocol declares it on a named view AND on * `object-grid`, under the same name, and `ObjectGrid` reads it. The first - * describe below derives both declarations from the installed protocol. Three - * bucket-① members pass the declaration test and are still handled apart: + * describe below derives both declarations from the installed protocol. Two + * bucket-① members are handled apart from that rule: * * - `hiddenFields` — declared on the named view, NOT on `object-grid`, and * `ObjectGrid` has no read of it. It is honoured as the protocol composes it: From 097ba7914128d1575aa101996dd5ded85fd6f28d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 08:35:13 +0000 Subject: [PATCH 5/5] docs(plugin-view): the route-2 prose names ten members, not the whole declared-on-both set (objectui#10885) Contract review round 1, wording only. The changeset, README and docs paragraphs presented the ten members route 2 now reads as the whole set the protocol declares on both a named view and object-grid; label, data and navigation meet the same test and are still not read off the named view on that path, and now say so. The withoutHiddenFields docblock states the one way its subtraction differs from ListView's: an entry with no field identity is kept. No behaviour change. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_014mXUNuFomfj24w7s1pZzhN --- .changeset/10885-object-view-route2-named-grid.md | 2 +- content/docs/plugins/plugin-view.mdx | 9 +++++---- packages/plugin-view/README.md | 9 +++++---- packages/plugin-view/src/ObjectView.tsx | 6 ++++-- 4 files changed, 15 insertions(+), 11 deletions(-) diff --git a/.changeset/10885-object-view-route2-named-grid.md b/.changeset/10885-object-view-route2-named-grid.md index 8d9c63ab5d..ba1543e616 100644 --- a/.changeset/10885-object-view-route2-named-grid.md +++ b/.changeset/10885-object-view-route2-named-grid.md @@ -6,7 +6,7 @@ The registered `object-view` renderer reads a named grid view's own grid members `object-view` is registered without a `renderListView`, so an authored node, and the Studio's view preview (which renders a stored view through `SchemaRenderer`), draws a grid view as the `object-grid` node `ObjectView` builds for `ObjectGrid`. That node read `columns`, `filter`, `sort`, `grouping` and `rowColor` off the active named view and nothing else, so a stored or authored grid view's `rowHeight`, `pagination` and the rest were accepted and not shown. -It now reads, off the active named view first, each member the protocol declares under the same name on both a named view and `object-grid`, and that `ObjectGrid` reads: `pagination`, `selection`, `rowHeight`, `resizable`, `searchableFields`, `conditionalFormatting`, `rowActions`, `bulkActions`, `bulkActionDefs` and `exportOptions`. `pagination` and `selection` still fall back to the node's `table` when the named view omits them; the others have no node fallback on this path, as before. +It now also reads, off the active named view first, ten members the protocol declares under the same name on both a named view and `object-grid` and that `ObjectGrid` reads: `pagination`, `selection`, `rowHeight`, `resizable`, `searchableFields`, `conditionalFormatting`, `rowActions`, `bulkActions`, `bulkActionDefs` and `exportOptions`. `pagination` and `selection` still fall back to the node's `table` when the named view omits them; the others have no node fallback on this path, as before. Three other members declared on both, `label`, `data` and `navigation`, are still not read off the named view on this path. A named view's `hiddenFields` is applied the way the protocol composes it: the hidden fields are removed from the column projection the grid receives. Only a declared projection is narrowed; with no `columns` anywhere the grid still derives its own. diff --git a/content/docs/plugins/plugin-view.mdx b/content/docs/plugins/plugin-view.mdx index 7e08c0ec3f..616a8901b0 100644 --- a/content/docs/plugins/plugin-view.mdx +++ b/content/docs/plugins/plugin-view.mdx @@ -432,14 +432,15 @@ const productViews: ObjectViewSchema = { ``` **On the registered renderer.** An authored `object-view` node, and the Studio's -view preview, draws a grid named view through `ObjectGrid`. The grid members the +view preview, draws a grid named view through `ObjectGrid`. Ten grid members the protocol declares under the same name on a named view and on `object-grid` — `pagination`, `selection`, `rowHeight`, `resizable`, `searchableFields`, `conditionalFormatting`, `rowActions`, `bulkActions`, `bulkActionDefs` and `exportOptions` — come from the active named view first; `pagination` and -`selection` still fall back to `table` (objectui#10885). A named view's -`hiddenFields` removes those fields from the columns the grid draws, when a -column list is declared. +`selection` still fall back to `table` (objectui#10885). `label`, `data` and +`navigation`, also declared on both, are not read off the named view on this +path. A named view's `hiddenFields` removes those fields from the columns the +grid draws, when a column list is declared. `src/__tests__/ObjectView.routeTwoNamedGridMembers-10885.test.tsx` pins each member. **On a host's `renderListView`.** A host that composes `ObjectView` with both diff --git a/packages/plugin-view/README.md b/packages/plugin-view/README.md index 750ba70a59..a50cd6317e 100644 --- a/packages/plugin-view/README.md +++ b/packages/plugin-view/README.md @@ -498,14 +498,15 @@ const schema: ObjectViewSchema = { ``` **On the registered renderer.** An authored `object-view` node, and the Studio's -view preview, draws a grid named view through `ObjectGrid`. The grid members the +view preview, draws a grid named view through `ObjectGrid`. Ten grid members the protocol declares under the same name on a named view and on `object-grid` — `pagination`, `selection`, `rowHeight`, `resizable`, `searchableFields`, `conditionalFormatting`, `rowActions`, `bulkActions`, `bulkActionDefs` and `exportOptions` — come from the active named view first; `pagination` and -`selection` still fall back to `table` (objectui#10885). A named view's -`hiddenFields` removes those fields from the columns the grid draws, when a -column list is declared. +`selection` still fall back to `table` (objectui#10885). `label`, `data` and +`navigation`, also declared on both, are not read off the named view on this +path. A named view's `hiddenFields` removes those fields from the columns the +grid draws, when a column list is declared. `src/__tests__/ObjectView.routeTwoNamedGridMembers-10885.test.tsx` pins each member. **On a host's `renderListView`.** A host that composes `ObjectView` with both diff --git a/packages/plugin-view/src/ObjectView.tsx b/packages/plugin-view/src/ObjectView.tsx index dcce880456..d6503e71f1 100644 --- a/packages/plugin-view/src/ObjectView.tsx +++ b/packages/plugin-view/src/ObjectView.tsx @@ -253,8 +253,10 @@ function viewColumnFieldNames(columns: unknown): string[] | undefined { * (objectstack#15184 ruling B). `ObjectGrid` has no `hiddenFields` read, and * `object-grid` declares no such key, so relaying the member by name would put * a key on the grid node that nothing reads. The subtraction is therefore made - * here, on the resolved projection, the way `ListView` makes it for the host - * delegation (its `effectiveFields`). + * here, on the resolved projection, as `ListView` subtracts it for the host + * delegation (its `effectiveFields`), except that an entry with no field + * identity is kept: `ListView` drops such an entry whenever `hiddenFields` is + * non-empty. * * - Only a DECLARED projection is narrowed. With no `columns` anywhere the grid * derives its own defaults, and this returns `undefined` unchanged, as