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
12 changes: 12 additions & 0 deletions .changeset/10380-object-page-stored-options-bag.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
'@object-ui/app-shell': patch
---

A stored list view now renders the same on an interface page and on the object page when it carries a legacy `options` bag (objectui#10380).

`@objectstack/spec`'s flattened list view overlay accepts a legacy `options` bag (`options.kanban`, `options.timeline`, `options.map`, …). The view write door judges each block in it key by key with that kind's own schema, and stores what it accepts. The interface page passed the bag to the list renderer and the object page dropped it, so the same saved view could show a timeline grouped by one field on one page and ungrouped on the other, or a map with markers on one page and without them on the other.

- **Object page:** passes the stored bag to the list renderer as well. For each kind the bag carries, the view's own top-level block (`kanban`, `timeline`, …) still wins key by key, which is the precedence the spec declares for the bag.
- **Interface page:** a default binding that the page derives from the object (a kanban lane, a calendar or timeline date, a gallery cover, gantt dates) no longer fills a kind that the bag already carries. Before this change, that derived guess could outrank the view's own declaration. The derived `map` binding already followed this rule.

A stored row whose bag still holds a key the door now refuses is shown as stored, the same way on both pages, until its next save. That save is refused, and the refusal names the key.
22 changes: 17 additions & 5 deletions packages/app-shell/src/views/InterfaceListPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -409,16 +409,28 @@ export function InterfaceListPage({ page, className, onConfigChange, reserveEdit
// the author whitelisted a viz, derive a sensible default binding from the
// object so the switcher actually offers (and renders) it. Only derive for
// whitelisted types — an un-whitelisted viz is never reachable.
//
// objectui#10380 — nor for a kind the stored row's legacy `options` bag
// carries. That bag is declared metadata: the view write door judges it
// key by key and stores it, and `ListView` lays the top-level block over it
// per key. A derived default placed at the top level would therefore
// OUTRANK the row's own declaration, and the object page, which derives
// nothing, would render the row differently. So a derived default fills a
// kind only when the row declares that kind nowhere. That is the rule
// `mapCfg` below already follows (`view.options.map ?? derived`).
const legacyOptions: Record<string, unknown> =
view.options && typeof view.options === 'object' && !Array.isArray(view.options) ? view.options : {};
const derives = (kind: string): boolean => allowedSet.has(kind) && legacyOptions[kind] === undefined;
const kanban =
view.kanban ?? (allowedSet.has('kanban') ? defaultKanbanFromObject(objectDef) : undefined);
view.kanban ?? (derives('kanban') ? defaultKanbanFromObject(objectDef) : undefined);
const calendar =
view.calendar ?? (allowedSet.has('calendar') ? defaultCalendarFromObject(objectDef) : undefined);
view.calendar ?? (derives('calendar') ? defaultCalendarFromObject(objectDef) : undefined);
const timeline =
view.timeline ?? (allowedSet.has('timeline') ? defaultCalendarFromObject(objectDef) : undefined);
view.timeline ?? (derives('timeline') ? defaultCalendarFromObject(objectDef) : undefined);
const gallery =
view.gallery ?? (allowedSet.has('gallery') ? defaultGalleryFromObject(objectDef) : undefined);
view.gallery ?? (derives('gallery') ? defaultGalleryFromObject(objectDef) : undefined);
const gantt =
view.gantt ?? (allowedSet.has('gantt') ? defaultGanttFromObject(objectDef) : undefined);
view.gantt ?? (derives('gantt') ? defaultGanttFromObject(objectDef) : undefined);
// Map binding lives under options.map (locationField); auto-derive when
// whitelisted so a map interface page renders without hand-wiring.
//
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -395,18 +395,16 @@ type Absence =
| { kind: 'host-runtime'; reason: string };

const ABSENCES: Record<string, Absence> = {
// ── Relayed one level down, inside `options` ──────────────────────────────
// These are the per-view-type configuration blocks. `ListView` reads them off
// `schema.options.*`, which is why the rung is nested rather than top-level;
// the view's own value does reach the renderer.
kanban: { kind: 'relayed-nested', path: 'options.kanban', reason: 'Per-view-type block; relayed through `kanbanViewOptions(viewDef, objectDef)` into `options.kanban`, where ListView reads it.' },
calendar: { kind: 'relayed-nested', path: 'options.calendar', reason: 'Per-view-type block; relayed into `options.calendar` — and only when the view declared one (objectui#7029), which is why the property is written through a conditional spread.' },
gallery: { kind: 'relayed-nested', path: 'options.gallery', reason: 'Per-view-type block; relayed through `galleryViewOptions(viewDef)` into `options.gallery` (objectui#7547).' },
gantt: { kind: 'relayed-nested', path: 'options.gantt', reason: 'Per-view-type block; relayed through `ganttViewOptions(viewDef)` into `options.gantt` (objectui#7070).' },
timeline: { kind: 'relayed-nested', path: 'options.timeline', reason: 'Per-view-type block; relayed through `timelineViewOptions(viewDef)` into `options.timeline` (objectui#3129, objectui#6557).' },
tree: { kind: 'relayed-nested', path: 'options.tree', reason: "Per-view-type block; the view's whole `tree` block is spread into `options.tree`, with `labelField` floored at the legacy `titleField` rung and then `name` (objectui#8253, objectui#6557)." },
map: { kind: 'relayed-nested', path: 'options.map', reason: "Per-view-type block; the view's `map` keys are projected into `options.map`. ⚠️ A projection, not a forward — its key set is hand-listed and is the shape objectui#7823 retired for `chart`." },
chart: { kind: 'relayed-nested', path: 'options.chart', reason: 'Per-view-type block; forwarded WHOLE into `options.chart` (objectui#7823) rather than projected, so the block cannot lose keys as the chart vocabulary grows.' },
// ── The per-view-type blocks: no entry, they have top-level rungs ─────────
// `kanban`, `calendar`, `gallery`, `gantt`, `timeline`, `tree`, `map` and
// `chart` used to sit here as `relayed-nested`: the relay writes each one into
// `options.KIND`, where `ListView` reads it. objectui#10380 gave each of them a
// conditional TOP-LEVEL rung as well. When the stored row's legacy `options`
// bag carries a kind, the view's own block for that kind is written at the top
// level, so `ListView` lays it over the bag per key. The census therefore
// counts all eight as relayed, and an entry here would contradict it. Their
// nested paths are still written for every row and still asserted, by name,
// in the derivation checks below (`options.kanban`, `options.chart`).

// ── Supplied by the caller, from the active view, via `...listSchema` ─────
// `plugin-view`'s ObjectView composes the list schema this relay spreads. For
Expand Down
Loading
Loading