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
2 changes: 2 additions & 0 deletions .changeset/10885-object-view-route2-named-grid.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,5 @@ A named view's `hiddenFields` is applied the way the protocol composes it: the h
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.

⚠️ **Dated note, 2026-09-28 — `navigation` has since been read off the named view — objectui#10885.** Later in this same release, the row click on this path follows the active named view's `navigation`, which replaces the node's `navigation` as a whole, and so do the surface and width of the forms it opens. The host `renderListView` delegation's row click follows it too, because both paths hand down the same click handler. A host `onRowClick` passed to `ObjectView` still wins on both paths. The same change applies a named view's `fieldOrder` to the projected columns, after `hiddenFields`, and hands the grid its `inlineEdit`, which `ObjectGrid` still gates on the object's inline-edit grant. `label` and `data` are not handed to the grid on this path, by ruling: `label` is already the tab's text, and `data` waits on objectui#10971. The rest of this entry is kept as the reading of this change; the objectui#10885 member 4 entry states what the path reads now.
17 changes: 17 additions & 0 deletions .changeset/10885-object-view-route2-named-view-rest.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
'@object-ui/plugin-view': patch
---

A named view's `navigation`, `fieldOrder` and `inlineEdit` now reach the registered `object-view` renderer's grid (objectui#10885, member 4).

`object-view` is registered without a `renderListView`, so an authored node, and the Studio's view preview, draws a grid named view as the `object-grid` node `ObjectView` builds for `ObjectGrid`. That path now reads three more members off the active named view:

- `navigation` replaces the node's `navigation` as a whole while the named view is active. Everything `ObjectView` derives from it follows: the row click, the surface the record, create and edit forms open on, and that surface's width. Under a named `split` or `popover`, as under the node's own, the New button opens no form, because those two surfaces open only beside a selected record. The host `renderListView` delegation's row click follows it too: both paths hand down the same click handler, and until now that handler read the node's `navigation` only, so a named view's `navigation` was overruled there as well. A host `onRowClick` passed to `ObjectView` still wins on both paths.
- `fieldOrder` orders the projected columns, after `hiddenFields` has removed its fields, the same way `ListView` orders them on the delegation. Columns `fieldOrder` does not name keep their order after the named ones. A name the projection does not carry orders nothing, and with no projection nothing is added.
- `inlineEdit` is handed to the grid as `editable`. `ObjectGrid` still turns in-cell editing on only where the object grants inline edit and the user may update the record.

`label` and `data`, also declared on both a named view and `object-grid`, are not handed to the grid on this path, by ruling. The named view's `label` is already the tab's text, and handing it to the grid would paint a second caption the delegation never paints. `data` is held back because `ListView` and `ObjectGrid` pick different objects when `data.object` differs from the node's `objectName` (objectui#10971).

What moves: only a named view that authors one of these three members. The objectui#5097 host-composition relay 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.
27 changes: 21 additions & 6 deletions content/docs/plugins/plugin-view.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -352,7 +352,8 @@ const userDetail: ObjectViewSchema = {

Without an `onNavigate` handler, `page` mode has nowhere to send the user, so
keep the two together. `navigation: { mode: 'none' }` (or `preventNavigation`)
makes rows inert.
makes rows inert. An active named view (`listViews`, under Read/List) that
declares its own `navigation` replaces this one, as a whole, while it is shown.

With no host `onRowClick`, a Cmd/Ctrl-click or middle-click on a row opens the
record as a full page in a new browser tab. Inert rows stay inert: `mode: 'none'`,
Expand Down Expand Up @@ -437,11 +438,25 @@ 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.
`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, and its `fieldOrder` then orders the columns that
remain, the way `ListView` orders them on a host's `renderListView`. Its
`inlineEdit` turns on in-cell editing, and only where the object grants inline
edit to the user. Its `navigation` replaces the node's `navigation` as a whole:
the row click, and the surface and width of the record, create and edit forms,
follow the active named view, on this path, and on a host's `renderListView`
when the host wires the `onRowClick` it is handed and passes `ObjectView` no
`onRowClick` of its own. `label` and `data`, also declared on both, are not
handed to the grid on this path: the named view's `label` is already the tab's
text, and `data` waits on objectui#10971, because `ListView` and `ObjectGrid`
pick different objects for it.
`src/__tests__/ObjectView.routeTwoNamedGridMembers-10885.test.tsx` pins the ten
grid members and `hiddenFields`; `ObjectView.namedViewNavigation-10885.test.tsx`
and `ObjectView.namedViewInlineEdit-10885.test.tsx` pin `navigation` and
`inlineEdit`, and `@object-ui/app-shell`'s
`objectViewRouteParity.fieldOrder-10885.test.tsx` pins `fieldOrder` against
`ListView`.

**On a host's `renderListView`.** A host that composes `ObjectView` with both
`listViews` and its own `renderListView` receives a `list-view` node for the
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* objectui#10885 member 4 — route 2 orders a named view's columns by its
* `fieldOrder` exactly as the host delegation's `ListView` does.
*
* The protocol composes three members of a named list view (objectstack#15184
* ruling B): `columns` projects, `hiddenFields` subtracts, `fieldOrder` orders
* what survives. `ObjectGrid` reads none of the last two, so each route applies
* them before the grid:
*
* - the host delegation relays all three to `ListView`, whose
* `effectiveFields` applies them and hands the grid the result;
* - route 2 (the registered `object-view` renderer, which the Studio's view
* preview uses) applies them to the projection it hands `ObjectGrid`.
*
* The claim is parity, so every case below mounts BOTH routes of the same
* `ObjectView` on the same named view and compares the two projections the
* grid receives. Nothing is compared with a hand-written order.
*
* This file lives in `@object-ui/app-shell` because it needs both
* `@object-ui/plugin-view` and `@object-ui/plugin-list`, and this is the
* package that depends on both.
*
* Sinks: route 2 renders the `ObjectGrid` export, replaced below; `ListView`
* renders the registry's `object-grid`, replaced for the file's duration.
*/

import * as React from 'react';
import { describe, it, expect, vi, beforeAll, afterAll, afterEach } from 'vitest';
import { render, waitFor, cleanup } from '@testing-library/react';
import { ComponentRegistry } from '@object-ui/core';
import { SchemaRendererProvider } from '@object-ui/react';
import { ObjectView } from '@object-ui/plugin-view';
import { ListView } from '@object-ui/plugin-list';

/** The node each route handed its grid, in mount order. */
const routeTwoNodes: any[] = [];
const delegationNodes: any[] = [];

vi.mock('@object-ui/plugin-grid', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
ObjectGrid: ({ schema }: any) => {
routeTwoNodes.push(schema);
return <div data-testid="route-two-grid" />;
},
}));

let previousGrid: any;
beforeAll(() => {
previousGrid = ComponentRegistry.get('object-grid');
ComponentRegistry.register('object-grid', ({ schema }: any) => {
delegationNodes.push(schema);
return <div data-testid="delegation-grid" />;
});
});
afterAll(() => {
if (previousGrid) ComponentRegistry.register('object-grid', previousGrid);
else ComponentRegistry.unregister('object-grid');
});
afterEach(() => {
cleanup();
routeTwoNodes.length = 0;
delegationNodes.length = 0;
});

function createDataSource(): any {
return {
find: vi.fn(async () => ({ data: [{ id: 't1', a: 'A', b: 'B', c: 'C', d: 'D' }], total: 1 })),
findOne: vi.fn(async () => null),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
getObjectSchema: vi.fn(async () => ({
name: 'task',
label: 'Task',
fields: Object.fromEntries(['a', 'b', 'c', 'd'].map((f) => [f, { name: f, type: 'text', label: f.toUpperCase() }])),
})),
};
}

function objectView(view: Record<string, unknown>, delegate: boolean) {
const ds = createDataSource();
const renderListView = ({ schema, dataSource }: any) => <ListView schema={schema} dataSource={dataSource} />;
return (
<SchemaRendererProvider dataSource={ds}>
<ObjectView
schema={{ type: 'object-view', objectName: 'task', defaultListView: 'v1', listViews: { v1: { type: 'grid', ...view } } } as any}
dataSource={ds}
{...(delegate ? { renderListView } : {})}
/>
</SchemaRendererProvider>
);
}

/** The projection each route hands the grid for the same named view. */
async function bothRoutes(view: Record<string, unknown>): Promise<{ routeTwo: any; delegation: any }> {
render(objectView(view, false));
await waitFor(() => expect(routeTwoNodes.length).toBeGreaterThan(0));
const routeTwo = routeTwoNodes[routeTwoNodes.length - 1];
cleanup();
render(objectView(view, true));
await waitFor(() => expect(delegationNodes.length).toBeGreaterThan(0));
const delegation = delegationNodes[delegationNodes.length - 1];
return { routeTwo, delegation };
}

const identity = (c: unknown) => (typeof c === 'string' ? c : (c as { field?: string }).field);

describe('objectui#10885 — route 2 applies a named view\'s `fieldOrder` as `ListView` does', () => {
it('LIT CONTROL: with no `fieldOrder`, both routes hand the grid the same projection', async () => {
const { routeTwo, delegation } = await bothRoutes({ columns: ['a', 'b', 'c', 'd'] });
expect(delegation.columns).toEqual(['a', 'b', 'c', 'd']);
expect(routeTwo.columns).toEqual(delegation.columns);
});

it('fields `fieldOrder` does not name sort after the named ones, in their projected order', async () => {
const { routeTwo, delegation } = await bothRoutes({ columns: ['a', 'b', 'c', 'd'], fieldOrder: ['c', 'a'] });
// Non-vacuity: the delegation did reorder, so equality below is a reading.
expect(delegation.columns).not.toEqual(['a', 'b', 'c', 'd']);
expect(routeTwo.columns).toEqual(delegation.columns);
// Route 2's names slot carries the same order.
expect(routeTwo.fields).toEqual(routeTwo.columns.map(identity));
});

it('a name the projection does not carry orders nothing', async () => {
const { routeTwo, delegation } = await bothRoutes({ columns: ['a', 'b', 'c'], fieldOrder: ['zz', 'c', 'yy'] });
expect(delegation.columns).not.toEqual(['a', 'b', 'c']);
expect(routeTwo.columns).toEqual(delegation.columns);
});

it('`hiddenFields` subtracts first, then `fieldOrder` orders what survives', async () => {
const { routeTwo, delegation } = await bothRoutes({ columns: ['a', 'b', 'c', 'd'], hiddenFields: ['b'], fieldOrder: ['d', 'b', 'a'] });
expect(delegation.columns).not.toContain('b');
expect(delegation.columns[0]).toBe('d');
expect(routeTwo.columns).toEqual(delegation.columns);
});

it('object-shaped columns keep their shape in the new order', async () => {
const { routeTwo, delegation } = await bothRoutes({
columns: [{ field: 'a', width: 120 }, { field: 'b' }, { field: 'c' }],
fieldOrder: ['c', 'a'],
});
expect(delegation.columns.map(identity)).not.toEqual(['a', 'b', 'c']);
expect(routeTwo.columns).toEqual(delegation.columns);
expect(routeTwo.fields).toEqual(routeTwo.columns.map(identity));
});

it('with no projection, neither route invents one', async () => {
const { routeTwo, delegation } = await bothRoutes({ fieldOrder: ['c', 'a'] });
expect(delegation.columns).toBeUndefined();
expect(routeTwo.columns).toEqual(delegation.columns);
expect(routeTwo.fields).toBeUndefined();
});
});
27 changes: 21 additions & 6 deletions packages/plugin-view/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -418,7 +418,8 @@ const schema: ObjectViewSchema = {

Without an `onNavigate` handler, `page` mode has nowhere to send the user, so
keep the two together. `navigation: { mode: 'none' }` (or `preventNavigation`)
makes rows inert.
makes rows inert. An active named view (`listViews`, under Read/List) that
declares its own `navigation` replaces this one, as a whole, while it is shown.

With no host `onRowClick`, a Cmd/Ctrl-click or middle-click on a row opens the
record as a full page in a new browser tab. Inert rows stay inert: `mode: 'none'`,
Expand Down Expand Up @@ -503,11 +504,25 @@ 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.
`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, and its `fieldOrder` then orders the columns that
remain, the way `ListView` orders them on a host's `renderListView`. Its
`inlineEdit` turns on in-cell editing, and only where the object grants inline
edit to the user. Its `navigation` replaces the node's `navigation` as a whole:
the row click, and the surface and width of the record, create and edit forms,
follow the active named view, on this path, and on a host's `renderListView`
when the host wires the `onRowClick` it is handed and passes `ObjectView` no
`onRowClick` of its own. `label` and `data`, also declared on both, are not
handed to the grid on this path: the named view's `label` is already the tab's
text, and `data` waits on objectui#10971, because `ListView` and `ObjectGrid`
pick different objects for it.
`src/__tests__/ObjectView.routeTwoNamedGridMembers-10885.test.tsx` pins the ten
grid members and `hiddenFields`; `ObjectView.namedViewNavigation-10885.test.tsx`
and `ObjectView.namedViewInlineEdit-10885.test.tsx` pin `navigation` and
`inlineEdit`, and `@object-ui/app-shell`'s
`objectViewRouteParity.fieldOrder-10885.test.tsx` pins `fieldOrder` against
`ListView`.

**On a host's `renderListView`.** A host that composes `ObjectView` with both
`listViews` and its own `renderListView` receives a `list-view` node for the
Expand Down
Loading
Loading