Skip to content
Merged
69 changes: 69 additions & 0 deletions .changeset/7928-listviews-by-reference-fold.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
---
'@object-ui/types': minor
'@object-ui/plugin-view': minor
'@object-ui/app-shell': minor
---

**`ObjectViewSchema.listViews` is the protocol's named-view record, by reference**
(objectui#7928: maintainer ruling A, then the director ruling on `options`).

⚠️ **Breaking for authored metadata** (labelled `minor` under this repo's
version-alignment policy). A named view on an `object-view` node is now judged by
`@objectstack/spec`'s strict `ObjectListViewSchema`:

- the zod mirror declares `listViews` as the spec's own `ViewSchema.shape.listViews`,
crossed through the import boundary so no protocol default is written into the
parsed document;
- the TypeScript face is `Record<string, z.input<typeof ObjectListViewSchema>>`.

So a named view needs `columns`, takes `filter` as `{ field, operator, value }`
rules, and puts each view kind's config in the top-level block of that kind
(`kanban`, `calendar`, …). A key the protocol does not declare there is refused
`unrecognized_keys` by name. That includes the objectui#8365 stray
`kanban.groupBy`, which a named view used to accept while a `list-view` document
refused it. The docs have taught this shape since objectui#8255, and `ObjectView`
has honoured it since objectui#8254.

**`options` on a named view is refused, and no longer read.** The legacy per-kind
bag (`options.kanban`, `options.calendar`, …) is not a member of the protocol's
authoring shape. `plugin-view`'s `ObjectView` stopped reading it off a named view.
The host `views` entry keeps its own rung, so nothing changes there. A STORED list
body may still carry the bag, because the protocol declares it on that wire. The
one door that relays a stored body into `listViews`, `app-shell`'s `ViewPreview`,
now folds each `options.KIND` onto the top-level `KIND` block before it builds the
node. Where both spell a key, the top-level value wins, the same per-key merge
`ObjectView` used to apply. A saved view that carried the bag therefore previews
exactly as before. Migrate an authored `options: { kanban: { groupByField } }` to
`kanban: { groupByField, columns }`.

The named-view alias pointers from objectui#8355 and objectui#10321 still reach
a named view. The protocol now refuses `calendar.dateField` / `calendar.endField`
and `kanban.groupBy` itself (`unrecognized_keys`, naming the key). The two checks
on the `object-view` door add this repository's pointer at the key:
`Did you mean dateField → startDateField?`, `endField → endDateField`, or
`groupBy → groupByField`. Without them, only the protocol's near-miss hint would
answer, and for `dateField` that hint says `endDateField`. The legacy
`options.calendar` / `options.kanban` nesting is refused whole (`options` by
name) rather than judged inside.

**Keys the protocol's blocks do not declare are refused on a named view.** The
by-reference record judges each view-kind block with the protocol's strict block,
so a renderer-ahead knob or legacy alias inside one is an authoring error now.
Measured on 17.4.0: `kanban.titleField` / `groupField` / `swimlaneField` /
`conditionalFormatting`, `gallery.imageField`, `timeline.dateField` / `endField`,
`calendar.allDayField` / `defaultView` and `map.style`. `ObjectView` still reads
them off a named view that reaches it unvalidated, so a stored body keeps
rendering. Use the canonical key where one exists (`groupByField`, `coverField`,
`startDateField`, `grouping` for swimlanes).

**Superseded statements in this release.** Pending entries for objectui#7779,
objectui#8355 and objectui#10321 say `ObjectViewSchema.listViews` stays
unmirrored, and the objectui#8355, #8365, #9242 and #10321 entries say the
named-view doors judge the `options` nesting. This entry supersedes both: the key
is mirrored by reference, and the `options` bag is refused whole.

`NamedListView` is exported, and this entry does not change it (objectui#7924's
own entry in this release retires its `densityMode`). It no longer types
`ObjectViewSchema.listViews` or `ObjectView`'s named-view config. Its retirement
or narrowing follows objectui#7924's ruling A′ and the ruling on `allowExport`,
not this entry.
2 changes: 1 addition & 1 deletion content/docs/api/schema-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -914,7 +914,7 @@ A complete object management interface combining grid, form, search, filters, an
| `objectName` | `string` | **Required.** ObjectQL object API name. |
| `title` | `string` | View title. |
| `defaultViewType` | `string` | Initial view: `"grid"`, `"kanban"`, `"gallery"`, `"calendar"`, `"timeline"`, `"gantt"`, `"map"`. |
| `listViews` | `Record<string, NamedListView>` | Named list views with filters and sort. |
| `listViews` | `Record<string, …>` | Named list views with filters and sort. Each entry is `@objectstack/spec`'s `ObjectListViewSchema`: it needs `columns`, takes `filter` as `{ field, operator, value }` rules, and puts view-kind config in the top-level block of that kind (`kanban`, `calendar`, …). A legacy `options` bag is refused. |
| `defaultListView` | `string` | Key of the default list view. |
| `table` | `Partial<ObjectGridSchema>` | Grid configuration overrides. |
| `form` | `Partial<ObjectFormSchema>` | Form configuration overrides. |
Expand Down
2 changes: 1 addition & 1 deletion content/docs/plugins/plugin-view.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -603,7 +603,7 @@ under your own key" — it ships **no schema types**. The authored
| Import from `@object-ui/types` | What it types |
| --- | --- |
| `ObjectViewSchema` | the whole `type: 'object-view'` node — `objectName` (required), `title`, `description`, `layout`, `defaultViewType`, `listViews`, `defaultListView`, `navigation`, `table`, `form`, `searchableFields`, `filterableFields`, the `show*` flags, `operations`, `onNavigate`, `allowCreateView`, `viewActions` (`viewTabBar` is retired — objectui#7779 — and refused by name) |
| `NamedListView` | one entry of `listViews` — toolbar toggles go under `userActions` (`search`, `sort`, `filter`, `group`, `hideFields`, `rowHeight`, `rowColor`) and `showDescription` under `appearance`; the legacy bare `show*` spellings are `never` on this type (objectui#7924), though stored views carrying them still load |
| `NamedListView` | the former type of a `listViews` entry, still exported. Since objectui#7928 an entry is `@objectstack/spec`'s `ObjectListViewSchema` (`NonNullable<ObjectViewSchema['listViews']>[string]`): toolbar toggles go under `userActions` (`search`, `sort`, `filter`, `group`, `hideFields`, `rowHeight`, `rowColor`), `showDescription` under `appearance`, and view-kind config in the top-level `kanban` / `calendar` / … block, never a legacy `options` bag |
| `ViewNavigationConfig` | `navigation` — row/item click behaviour |
| `ViewTabBarConfig` | the `config` prop of `ViewTabBar` — tab-bar UX (inline add, overflow, indicators), composed by the host; not an `object-view` node key |

Expand Down
13 changes: 12 additions & 1 deletion examples/byo-backend-console/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,17 @@ function Home() {
);
}

/**
* The grid columns of each object's named view. A named view on an
* `object-view` node is the protocol's `ObjectListViewSchema`, which requires
* `columns` (objectui#7928), so each object lists the fields it declares in
* `mockDataSource.ts`.
*/
const LIST_COLUMNS: Record<string, string[]> = {
contact: ['name', 'email', 'phone', 'notes'],
account: ['name', 'industry', 'website'],
};

function ObjectPage() {
const { objectName } = useParams<{ objectName: string }>();

Expand All @@ -143,7 +154,7 @@ function ObjectPage() {
type: 'object-view',
objectName: objectName || '',
listViews: {
default: { label: 'All', type: 'grid' },
default: { label: 'All', type: 'grid', columns: LIST_COLUMNS[objectName ?? ''] ?? ['name'] },
},
}}
dataSource={mockDataSource}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

/**
* objectui#7928 — the stored `options.KIND` bag is folded at `ViewPreview`,
* the one door that relays a STORED list body into `object-view`'s `listViews`.
*
* Director ruling on objectui#7928 (comment 5856694523, Q1 A): the named-view
* record is the protocol's strict `ObjectListViewSchema` by reference, which
* refuses `options`, and `plugin-view` no longer reads `options` off a named
* view. A stored list overlay may still carry the bag — the protocol declares it
* on that wire (objectstack#20051) and objectui#10380 ruling A keeps it legal
* there — so `ViewPreview` folds each `options.KIND` onto the top-level `KIND`
* block before it builds the node, the TOP-LEVEL value winning per key.
*
* The lane cases below are the inverted objectui#8980 legacy-nesting pins
* (`ObjectView.namedViewProtocolKeys-8980.test.tsx`) pointed at the fold: the
* same stored body, captured as the node `ViewPreview` hands to
* `SchemaRenderer`, then rendered through the REAL `plugin-view` `ObjectView`.
* Before objectui#7928 that renderer read the bag itself; these assert it
* resolves the same lane now that the fold does the work.
*/

import * as React from 'react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, waitFor, cleanup } from '@testing-library/react';
import { safeValidateSchema } from '@object-ui/types/zod';
import { ObjectView } from '@object-ui/plugin-view';
import { ViewPreview, foldStoredListOptions } from './ViewPreview';

/** Every schema handed to `SchemaRenderer`, in order. */
const rendered: any[] = [];

vi.mock('@object-ui/react', async (importOriginal) => {
const ReactMod = await import('react');
return {
...(await importOriginal<Record<string, unknown>>()),
SchemaRenderer: ({ schema }: any) => {
rendered.push(schema);
return <div data-testid="schema-renderer">{schema?.type}</div>;
},
SchemaRendererContext: ReactMod.createContext(null),
subscribeDataChanges: () => () => {},
notifyDataChanged: () => {},
};
});

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 `rendered`, and the last entry answers the previous fixture
* (the objectui#9242 trap, recorded in the objectui#8980 pin file too).
*/
function reset() {
cleanup();
rendered.length = 0;
}

/** The `object-view` node `ViewPreview` builds for a stored list body. */
function previewNode(config: Record<string, unknown>): any {
reset();
render(<ViewPreview type="view" name="board" draft={{ name: 'task.board', object: 'task', viewKind: 'list', config }} />);
const node = rendered.find((s) => s?.type === 'object-view');
expect(node, 'ViewPreview did not hand an object-view node to SchemaRenderer').toBeDefined();
return node;
}

/** The node the REAL `plugin-view` `ObjectView` generates for that preview node. */
async function renderedKindNode(node: any): Promise<any> {
reset();
render(<ObjectView schema={node} dataSource={dataSource()} />);
await waitFor(() => expect(rendered.length).toBeGreaterThan(0));
return rendered[rendered.length - 1];
}

beforeEach(reset);

describe('objectui#7928 — `foldStoredListOptions`, the wire → named-view translation', () => {
it('a body with no `options` comes back as the SAME object — nothing moves for the population that never carried the bag', () => {
const body = { type: 'kanban', columns: ['name'], kanban: { groupByField: 'stage', columns: ['name'] } };
expect(foldStoredListOptions(body)).toBe(body);
});

it('each `options.KIND` becomes the top-level `KIND` block, and `options` is not relayed', () => {
const folded = foldStoredListOptions({ type: 'kanban', columns: ['name'], options: { kanban: { groupByField: 'legacy_lane' } } });
expect(folded.kanban).toEqual({ groupByField: 'legacy_lane' });
expect(folded).not.toHaveProperty('options');
});

it('TOP-LEVEL wins per key, and a key only the bag spells survives — the merge is per key, not wholesale', () => {
const folded = foldStoredListOptions({
type: 'kanban',
columns: ['name'],
kanban: { groupByField: 'canonical_lane' },
options: { kanban: { groupByField: 'legacy_lane', titleField: 'subject' } },
});
expect(folded.kanban).toEqual({ groupByField: 'canonical_lane', titleField: 'subject' });
});

it('every view kind the protocol carries at the top level is folded', () => {
const kinds = ['kanban', 'calendar', 'gallery', 'timeline', 'gantt', 'map', 'chart', 'tree'];
const options = Object.fromEntries(kinds.map((k) => [k, { marker: k }]));
const folded = foldStoredListOptions({ columns: ['name'], options });
for (const k of kinds) expect(folded[k], k).toEqual({ marker: k });
});

it('a key under `options` that is not a view kind is not lifted to the top level', () => {
// The protocol's stored bag carries per-kind blocks and nothing else
// (objectstack#20051), and the renderer read nothing else from it.
const folded = foldStoredListOptions({ columns: ['name'], options: { grid: { x: 1 }, zzqxNotAKind: true } });
expect(folded).not.toHaveProperty('grid');
expect(folded).not.toHaveProperty('zzqxNotAKind');
expect(folded).not.toHaveProperty('options');
});

it('the input body is not mutated', () => {
const body = { columns: ['name'], kanban: { groupByField: 'a' }, options: { kanban: { titleField: 'b' } } };
const snapshot = JSON.parse(JSON.stringify(body));
foldStoredListOptions(body);
expect(body).toEqual(snapshot);
});
});

describe('objectui#7928 — the inverted objectui#8980 legacy-nesting pins, pointed at the fold', () => {
it('the legacy nesting alone resolves the lane — through the fold, on the real renderer', async () => {
const node = previewNode({ type: 'kanban', columns: ['name'], options: { kanban: { groupByField: 'legacy_lane' } } });
expect(node.listViews.board).not.toHaveProperty('options');
expect(node.listViews.board.kanban).toEqual({ groupByField: 'legacy_lane' });
const kanban = await renderedKindNode(node);
expect(kanban.type).toBe('object-kanban');
expect(kanban.groupBy).toBe('legacy_lane');
});

it('the canonical block WINS for a key both spell', async () => {
const node = previewNode({
type: 'kanban',
columns: ['name'],
kanban: { groupByField: 'canonical_lane' },
options: { kanban: { groupByField: 'legacy_lane' } },
});
const kanban = await renderedKindNode(node);
expect(kanban.groupBy).toBe('canonical_lane');
expect(kanban.groupBy).not.toBe('legacy_lane');
});

it('a key the canonical block does NOT restate survives from the legacy nesting — the merge is per-key, not wholesale', async () => {
const node = previewNode({
type: 'kanban',
columns: ['name'],
kanban: { groupByField: 'canonical_lane' },
options: { kanban: { titleField: 'subject' } },
});
const kanban = await renderedKindNode(node);
expect(kanban.groupBy).toBe('canonical_lane');
expect(kanban.titleField).toBe('subject');
});

it('CONTROL: without the fold, the same body on the same renderer does NOT resolve the lane', async () => {
// The firing control for the three cases above: the stored body handed to
// the renderer as-is, which is what `ViewPreview` did before the fold. The
// renderer stopped reading `options` off a named view at objectui#7928, so
// only the fold makes the cases above pass.
const kanban = await renderedKindNode({
type: 'object-view',
objectName: 'task',
listViews: { board: { type: 'kanban', columns: ['name'], options: { kanban: { groupByField: 'legacy_lane' } } } },
defaultListView: 'board',
});
expect(kanban.groupBy).not.toBe('legacy_lane');
});
});

describe('objectui#7928 — the node `ViewPreview` builds is one the contract accepts', () => {
it('a stored body carrying the legacy bag becomes a named view `ObjectViewSchema` parses green', () => {
const node = previewNode({
type: 'kanban',
columns: ['name'],
options: { kanban: { groupByField: 'stage', columns: ['name'] } },
});
const r = safeValidateSchema(node);
expect(r.success, r.success ? '' : JSON.stringify(r.error.issues)).toBe(true);
});

it('CONTROL: the same body relayed unfolded is refused at `options`, by name', () => {
const r = safeValidateSchema({
type: 'object-view',
objectName: 'task',
listViews: { board: { label: 'Board', type: 'kanban', columns: ['name'], options: { kanban: { groupByField: 'stage', columns: ['name'] } } } },
});
expect(r.success).toBe(false);
const issues = r.success ? [] : r.error.issues;
const refusal = issues.find((i) => i.code === 'unrecognized_keys' && i.path.join('.') === 'listViews.board');
expect(refusal, JSON.stringify(issues)).toBeDefined();
expect((refusal as { keys?: string[] }).keys).toEqual(['options']);
});
});
Loading
Loading