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
27 changes: 27 additions & 0 deletions .changeset/11013-view-row-declared-spellings.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
---
'@object-ui/types': minor
'@object-ui/data-objectstack': patch
'@object-ui/app-shell': patch
'@object-ui/plugin-view': patch
---

**The console reads a saved view by the spellings `@objectstack/spec` declares, and stops reading the keys nothing writes (objectui#11013).** This is the console end of the ruling on objectstack#20051: the spec now declares the keys the console writes onto a stored view and reads back (`VIEW_CONSOLE_ROUND_TRIP_KEYS`), and the console reads a stored view under those spellings.

**A saved view keeps its switcher state across a reload.** A view saved as a record (the "+" tab's create, or "Edit view config → Save") used to come back from `listViews()` with only its configuration, name, label and default flag. It now also comes back with its pin (`isPinned`), its position among saved views (`sortOrder`), its switcher group (`visibility`), its column layout (`columnState`) and its bound `object`. Visible effect: a saved view you dragged to a new position keeps that position after a reload, including in a browser that never saw the drag. The carried keys are read off the spec's record, not hand-listed.

**Keys a saved view no longer steers.** No console surface writes any of these onto a view, no view in this repository authors one, and the spec's view schema refuses each by name. A stored view that still carries one now behaves as if it did not:

| key on the stored view | what changes for the user |
| --- | --- |
| `allowExport` | A view carrying `allowExport: false` no longer hides the list's export control or drops its `exportOptions`. Whether a list offers export is the page's setting (the `list-view` / `object-view` node's `allowExport`); which formats it offers is the view's `exportOptions.formats`. |
| `wrapHeaders` | Column-header wrapping follows the page's setting, not the view's. |
| `editRecordsInline` | On the object page this was read as a second spelling of `inlineEdit`. A view carrying only it no longer turns inline editing on; write `inlineEdit`. |
| `clickIntoRecordDetails`, `addRecordViaForm`, `addDeleteRecordsInline`, `collapseAllByDefault`, `fieldTextColor`, `prefixField` | Relayed into the list, which drew nothing from them. No visible change. |

The same keys authored on the `object-view` node itself are unchanged: that node's own values still reach the list.

**The toolbar policy is read as `userActions`.** The `object-view` node the object page builds, and `@object-ui/plugin-view`'s own kanban / calendar / gallery / timeline / gantt / map route (a host that renders the `object-view` node without `renderListView`), read a view's search, sort and filter toggles from `userActions.search`, `.sort` and `.filter`, where they used to read the bare `showSearch`, `showSort` and `showFilters` flags. On that plugin route, a view that declared `userActions: { search: false }` now hides the search control; the object page's list already honoured it. A stored view that still carries a bare flag keeps its answer: `normalizeListViewSchema` folds it onto `userActions`.

**A stored view is bound and identified by its declared keys only.** A view row is matched to its object by `object` (or its configuration's `data.object`), and no longer by `objectName`; it is identified by `name`, and no longer by a top-level `id` or `_id`. This holds in the view switcher (`listViews()` / `listViewOverrides()`), in Studio's view picker for `interfaceConfig.sourceView`, and in Studio's view preview. Every console write stamps both `object` and `name`, and the metadata door refuses a view record or overlay row bound by `objectName` alone, so a row the console wrote is unaffected. The console also no longer stamps an undeclared `objectName` onto the rows it reads, which a saved view's toolbar save used to write back into the stored row.

**BREAKING (TypeScript only) — `NamedListView.allowExport` is retired.** It is now a `?: never` tombstone, like the seventeen members objectui#7924 retired: a TypeScript author who writes it gets a compile error there. Ruling A on objectui#7924 had kept it declared only because both relays read it off a view, and those reads are gone.
6 changes: 6 additions & 0 deletions .changeset/7924-density-mode-through-fold.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,12 @@ carry it. The `@object-ui/react` `useDensityMode` JSDoc example reads and persis
`rowHeight` (through `rowHeightToDensityMode` / `DENSITY_MODE_TO_ROW_HEIGHT`) instead of
`densityMode`; that is a documentation fix with no behaviour change.

⚠️ **Dated note, 2026-09-30 — `allowExport` has since been retired too — objectui#11013.**
Later in this same release both relays stopped reading `allowExport` off a view, and
`NamedListView.allowExport` became a `?: never` tombstone. So "`allowExport` stays
declared" above no longer holds. `.changeset/11013-view-row-declared-spellings.md`
states what ships; the text above is kept as the reading of this change.

**ADR-0087 disposition:** `densityMode` is a **D2** conversion. Stored documents are
accepted and converted at load by the existing runtime fold (`normalizeListViewSchema`
maps it onto `rowHeight`), and the TypeScript face refuses the old spelling at authoring
Expand Down
8 changes: 8 additions & 0 deletions .changeset/7924-named-list-view-retire.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,14 @@ members are NOT retired" and "They stay declared as they were" now hold for
`rowHeight`. `.changeset/7924-density-mode-through-fold.md` (PR objectui#10793) states
what ships; the text above is kept as the reading of this change.

⚠️ **Dated note, 2026-09-30 — `allowExport` has since been retired as well — objectui#11013.**
Later in this same release both relays stopped reading `allowExport` off a view, and
`NamedListView.allowExport` became a `?: never` tombstone too. So neither of the two
bucket-③ members above stays declared as it was. `ListView` still gates its export
control on the `list-view` node's own `allowExport`.
`.changeset/11013-view-row-declared-spellings.md` states what ships; the text above is
kept as the reading of this change.

**Only the authoring face changes.** Stored view documents that still carry a legacy
spelling keep loading: `normalizeListViewSchema` (`@object-ui/core`) still folds the eight
`show*` flags onto `userActions` / `appearance` at runtime, and hosts that read a stored
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,8 @@ describe('MetadataService routes view writes through the adapter seam (#4373)',
it('reads the object binding the same way listViewOverrides narrows those rows', async () => {
// Not "any spelling of object" — the SAME spelling. `viewItemObjectName`
// (exported from the adapter for exactly this) reads
// `data.object ?? object ?? objectName` off the row, and it is the accessor
// `data.object ?? object` off the row (no `objectName` leg since
// objectui#11013), and it is the accessor
// `listViewOverrides` itself narrows those rows by. Using it here is what
// makes the key this names provably the map that write invalidates; a
// fourth private copy of "which object is this?" is the drift it prevents.
Expand Down
22 changes: 11 additions & 11 deletions packages/app-shell/src/utils/viewIdentity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,11 +78,15 @@ export function defaultListViewId(objectName: string, list: unknown): string | u
* that *is* saved, which both hid the set-default / rename / delete menu
* entries (`readonly: !saved`) and short-circuited the handlers behind them.
*
* Order is `name` → `id` → `_id`: the overlay is name-keyed
* (`/meta/view/:name` is name-addressed, and `ViewItemNameSchema` judges that
* string), so `name` is the identity whenever the row carries one. Empty
* strings are skipped rather than returned, so a blank `name` falls through to
* the next spelling instead of yielding an id nothing can match.
* The identity is `name`, and only `name` (objectui#11013, ruling 甲 on
* objectstack#20051: objectui reads a stored view row by the spec's declared
* spellings). The overlay is name-keyed (`/meta/view/:name` is name-addressed,
* and `ViewItemNameSchema` judges that string); the spec declares `name` on
* every `view` member and a top-level `id` / `_id` on none, and every write
* path stamps `name` on the row it saves. This used to fall back to `id`, then
* `_id`, for a row with no `name`; such a row now has no identity, so the tab
* list skips it rather than keying a tab on an undeclared spelling. An empty
* `name` is no identity either.
*
* IDEMPOTENT by construction, which is what lets one function serve both the
* producer and its readers: `viewRowId(normalized) === viewRowId(raw)` for
Expand All @@ -91,12 +95,8 @@ export function defaultListViewId(objectName: string, list: unknown): string | u
*/
export function viewRowId(row: unknown): string | undefined {
if (!row || typeof row !== 'object') return undefined;
const record = row as Record<string, unknown>;
for (const key of ['name', 'id', '_id'] as const) {
const value = record[key];
if (typeof value === 'string' && value !== '') return value;
}
return undefined;
const name = (row as Record<string, unknown>).name;
return typeof name === 'string' && name !== '' ? name : undefined;
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ vi.mock('./metadata-admin/inspectors/ViewVariantInspector', () => ({
<button
type="button"
data-testid="inspector-edit-unrelated"
onClick={() => onPatch({ config: { ...(draft?.config ?? {}), wrapHeaders: true } })}
onClick={() => onPatch({ config: { ...(draft?.config ?? {}), resizable: false } })}
>
unrelated
</button>
Expand Down Expand Up @@ -157,18 +157,22 @@ const OBJECT_NAME = 'duly_task';

/** The column identities the grid was last handed. */
let drawn: string[] | undefined;
/** The `wrapHeaders` the grid was last handed: the panel's unrelated edit. */
let drawnWrapHeaders: unknown;
/**
* The `resizable` the grid was last handed: the panel's unrelated edit. (It was
* `wrapHeaders` until objectui#11013 dropped that key's view rung: a stored view
* no longer supplies it, so an edit of it no longer reaches the grid.)
*/
let drawnResizable: unknown;

// `ListView` draws its rows through `object-grid`. The stub records what it is
// handed and nothing else, so what the grid would derive on its own stays out
// of the measurement.
let prevObjectGrid: unknown;
beforeAll(() => {
prevObjectGrid = ComponentRegistry.get('object-grid');
ComponentRegistry.register('object-grid', ((props: { schema?: { columns?: unknown; wrapHeaders?: unknown } }) => {
ComponentRegistry.register('object-grid', ((props: { schema?: { columns?: unknown; resizable?: unknown } }) => {
const cols = props.schema?.columns;
drawnWrapHeaders = props.schema?.wrapHeaders;
drawnResizable = props.schema?.resizable;
drawn = Array.isArray(cols)
? cols.map((c: any) => (typeof c === 'string' ? c : (c?.field ?? c?.name)))
: undefined;
Expand Down Expand Up @@ -257,13 +261,13 @@ async function openPanel(): Promise<void> {
/** Edit a field that is not `columns`, and wait until the edit reaches the grid. */
async function editUnrelatedField(): Promise<void> {
fireEvent.click(screen.getByTestId('inspector-edit-unrelated'));
await waitFor(() => expect(drawnWrapHeaders).toBe(true));
await waitFor(() => expect(drawnResizable).toBe(false));
}

/** Discard the panel, and wait until the revert reaches the grid. */
async function discardPanel(): Promise<void> {
fireEvent.click(screen.getByTestId('view-config-discard'));
await waitFor(() => expect(drawnWrapHeaders).toBeUndefined());
await waitFor(() => expect(drawnResizable).toBeUndefined());
}

/** The unprojected view the panel cases edit: both keys, and the hide toggle on. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,12 +52,14 @@ const DEFINED_VIEWS = {

const fallbackTab = () => ({ id: 'all', label: 'All records', type: 'grid', columns: [] });

/** `ObjectView.tsx`'s own `savedViews` normalization (the `normalized` map in its `listViews` effect), verbatim. */
/**
* `ObjectView.tsx`'s own `savedViews` normalization (the `normalized` map in its
* `listViews` effect), verbatim. objectui#11013 dropped its `objectName` stamp.
*/
function normalizeSavedViews(rows: any[]) {
return rows.map((sv: any) => ({
...sv,
id: viewRowId(sv),
objectName: sv.objectName || sv.object || OBJECT_NAME,
}));
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -477,8 +477,25 @@ const ABSENCES: Record<string, Absence> = {
tabs: { kind: 'unread', reason: "Spec view-tab list. ListView has no reader — the object page's tab bar is `ViewTabBar`, driven by `buildViewTabs`, and the `tabs` readers in the tree belong to `plugin-detail`'s DetailView." },
dataSource: { kind: 'unread', reason: "The spec's per-element binding (declared on the list-view arm by objectui#11070). `ListView` has no read of `schema.dataSource`: the binding is resolved one layer up, by the registered `list-view` renderer `ListViewBlock` through `ElementDataSourceGate`, and `renderListView` bypasses that layer. It renders `ListView` directly with `schema={fullSchema}` and takes the ADAPTER as its separate `dataSource` argument (`ds`), not off the node." },

// ── Read off the view until objectui#11013; no producer writes them ───────
// Ruling 甲 on objectstack#20051, stage ii: a stored view row is read by the
// spec's declared spellings, and a read that nothing writes is dropped. These
// eight had rungs; no console surface writes one onto a view, no view in this
// repository authors one, and the spec's view schema refuses each by name.
// The two `ListView` still reads get the HOST's value: `plugin-view` composes
// each off the object-view node (`(schema as any).KEY`, objectui#5097), and
// it arrives through `...listSchema`. The other six feed no reader at all.
allowExport: { kind: 'node-authored', reason: "objectui#11013 dropped the view rung (no producer; the spec refuses it on a view, and the seat's disposition on objectstack#20456 is no declared spelling). `ListView` gates export on `schema.allowExport !== false`, and that value is the object-view node's, composed upstream as `(schema as any).allowExport`." },
wrapHeaders: { kind: 'node-authored', reason: 'objectui#11013 dropped the view rung: no producer writes it onto a view, and the spec refuses it there. `ListView` still hands `schema.wrapHeaders` to the grid; the value is the object-view node\'s, composed upstream as `(schema as any).wrapHeaders`.' },
clickIntoRecordDetails: { kind: 'unread', reason: 'objectui#11013 dropped the view rung: no producer writes it onto a view, the spec refuses it there, and ListView has no reader to relay it into. Row-click behaviour is `navigation`, which is relayed.' },
addRecordViaForm: { kind: 'unread', reason: 'objectui#11013 dropped the view rung: no producer writes it onto a view, the spec refuses it there, and ListView has no reader. Record creation is `addRecord` / `userActions.addRecordForm`.' },
addDeleteRecordsInline: { kind: 'unread', reason: 'objectui#11013 dropped the view rung: no producer writes it onto a view, the spec refuses it there, and ListView has no reader to relay it into.' },
collapseAllByDefault: { kind: 'unread', reason: 'objectui#11013 dropped the view rung: no producer writes it onto a view, the spec refuses it there, and ListView has no reader to relay it into.' },
fieldTextColor: { kind: 'unread', reason: 'objectui#11013 dropped the view rung: no producer writes it onto a view, the spec refuses it there, and ListView has no reader to relay it into.' },
prefixField: { kind: 'unread', reason: 'objectui#11013 dropped the view rung: no producer writes it onto a view, the spec refuses it there, and ListView has no reader to relay it into.' },

// ── Authored on the NODE, resolved by the host ────────────────────────────
operations: { kind: 'node-authored', reason: "Legacy CRUD affordance authored on the object-view node (`examples/.../object-view-record-surface.json`), not on a view record; the host resolves it upstream (`schema.operations || schema.table?.operations || …`). ListView does read `schema.operations?.export`, but what would feed it here is the NODE's value, and forwarding it is the caller's composition to make — objectui#5097's surface, not a per-view rung." },
operations: { kind: 'node-authored', reason:"Legacy CRUD affordance authored on the object-view node (`examples/.../object-view-record-surface.json`), not on a view record; the host resolves it upstream (`schema.operations || schema.table?.operations || …`). ListView does read `schema.operations?.export`, but what would feed it here is the NODE's value, and forwarding it is the caller's composition to make — objectui#5097's surface, not a per-view rung." },

// ── The runtime-only half of the intersection ─────────────────────────────
onNavigate: { kind: 'host-runtime', reason: 'Host callback. This host wires record navigation through the `onRowClick` prop on the `<ListView>` element instead; a view record cannot carry a function.' },
Expand Down
Loading
Loading