Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .changeset/10885-object-view-route2-named-grid.md
Original file line number Diff line number Diff line change
@@ -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 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.

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.
12 changes: 12 additions & 0 deletions content/docs/plugins/plugin-view.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -431,6 +431,18 @@ 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`. 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). `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
`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
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string, unknown>) {
render(
<SchemaRendererProvider dataSource={createDataSource()} apiFetch={apiFetch}>
<ViewPreview
type="view"
name="all_tasks"
draft={{ name: 'task.all_tasks', object: 'task', viewKind: 'list', config }}
/>
</SchemaRendererProvider>,
);
}

/** 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();
});
});
12 changes: 12 additions & 0 deletions packages/plugin-view/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -497,6 +497,18 @@ 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`. 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). `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
`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
Expand Down
108 changes: 104 additions & 4 deletions packages/plugin-view/src/ObjectView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,63 @@ 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, 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
* `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.
*
* 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<C extends readonly unknown[]>(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 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;
}

/**
* One entry of `ObjectViewSchema.listViews` — the protocol's
* `ObjectListViewSchema`, by reference (objectui#7928). Derived from the member
Expand Down Expand Up @@ -2202,6 +2259,8 @@ export const ObjectView: React.FC<ObjectViewProps> = ({
// 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',
Expand All @@ -2214,8 +2273,16 @@ export const ObjectView: React.FC<ObjectViewProps> = ({
// 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
Expand All @@ -2233,8 +2300,41 @@ export const ObjectView: React.FC<ObjectViewProps> = ({
// 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,
// 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,
// 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)`),
Expand Down
Loading
Loading