From 4d0668ddb0415bd858fbade53962d94ea5f04bcf Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 18:10:53 +0000 Subject: [PATCH 1/5] fix(app-shell): the object page renders a stored view's judged legacy options bag, as the interface page does The flattened list overlay of @objectstack/spec 17.6.0 declares a legacy `options` bag, judges each `options.KIND` block key by key and stores it. InterfaceListPage forwarded the bag to ListView and ObjectView dropped it, so one stored row handed the renderer two different configs. ObjectView now forwards the bag. For each kind the bag carries, the bag's block replaces the page's synthesized block (the mapCfg rule) and the view's own top-level block goes out at the top level, so ListView's existing per-key merge lays it over the bag. InterfaceListPage's derived defaults no longer fill a kind the bag carries, the rule mapCfg already follows, so a derived guess no longer outranks the row's declaration. Claude-Session: https://claude.ai/code/session_01FjqrwXPfSMkSfkKYDSRkN2 Co-authored-by: Claude --- .../app-shell/src/views/InterfaceListPage.tsx | 22 +- .../ObjectView.relayRungCensus-7559.test.ts | 22 +- ...ObjectView.storedOptionsBag-10380.test.tsx | 388 ++++++++++++++++++ packages/app-shell/src/views/ObjectView.tsx | 76 +++- 4 files changed, 483 insertions(+), 25 deletions(-) create mode 100644 packages/app-shell/src/views/ObjectView.storedOptionsBag-10380.test.tsx diff --git a/packages/app-shell/src/views/InterfaceListPage.tsx b/packages/app-shell/src/views/InterfaceListPage.tsx index f30717bcd3..820a3c9871 100644 --- a/packages/app-shell/src/views/InterfaceListPage.tsx +++ b/packages/app-shell/src/views/InterfaceListPage.tsx @@ -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 = + 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. // diff --git a/packages/app-shell/src/views/ObjectView.relayRungCensus-7559.test.ts b/packages/app-shell/src/views/ObjectView.relayRungCensus-7559.test.ts index 35652b9167..6cdcb1ab68 100644 --- a/packages/app-shell/src/views/ObjectView.relayRungCensus-7559.test.ts +++ b/packages/app-shell/src/views/ObjectView.relayRungCensus-7559.test.ts @@ -395,18 +395,16 @@ type Absence = | { kind: 'host-runtime'; reason: string }; const ABSENCES: Record = { - // ── 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 diff --git a/packages/app-shell/src/views/ObjectView.storedOptionsBag-10380.test.tsx b/packages/app-shell/src/views/ObjectView.storedOptionsBag-10380.test.tsx new file mode 100644 index 0000000000..121c0b0594 --- /dev/null +++ b/packages/app-shell/src/views/ObjectView.storedOptionsBag-10380.test.tsx @@ -0,0 +1,388 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectui#10380 — one stored view renders the same on the interface page and + * on the object page. + * + * ## The defect this pins + * + * `@objectstack/spec`'s flattened list overlay declares a legacy `options` bag + * (`ListViewOverlayOptionsSchema`). The view write door judges each + * `options.KIND` block key by key with that kind's block schema, refuses an + * out-of-contract key by name, and stores what it accepts. Every row below is + * one that door accepts. `InterfaceListPage` forwarded the bag to `ListView`, + * and `ObjectView` dropped it. So the same stored row handed the renderer two + * different configs: a timeline grouped by `region` on one page and ungrouped + * on the other, a kanban card titled by `region` on one page and by `name` on + * the other, and a map with a location binding on one page and none on the + * other. + * + * Ruling A (comment 5824043998 on the card) keeps the interface page's forward + * ("what passes the door is legal"), so the object page now forwards the bag + * too. `ListView`'s existing per-key merge lays the top-level block over the + * bag, which is the precedence the spec declares for it. This file pins that + * the two pages hand the RENDERER the same config. + * + * ## Why the renderer, and not the `ListView` schema + * + * The two pages build different `ListView` schemas on purpose. The object page + * relays the view's blocks under `options.KIND`, while the interface page sends + * them at the top level. So comparing the two schemas proves nothing. Each case + * captures the schema a page hands `ListView`, mounts the REAL `ListView` on it + * with a spy renderer registered for the kind, and compares what the renderer + * receives. That is what reaches the screen. + * + * ## Direction, written before the run + * + * On `origin/main` (before the fix), the four bag rows are predicted RED and + * the no-bag control GREEN. The two collision cases are predicted GREEN there + * too: the object page ignored the bag and the interface page let the + * top-level block win, so both answered with the top-level value. They guard + * the fix's layering, and an ablation that puts the bag OVER the declared block + * is what turns them red. Both runs are quoted in the PR body. + */ + +import * as React from 'react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, cleanup, waitFor } from '@testing-library/react'; +import { MemoryRouter, Routes, Route } from 'react-router-dom'; + +vi.mock('@object-ui/permissions', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + usePermissions: () => ({ + check: () => ({ allowed: true }), + checkField: () => true, + getFieldPermissions: () => [], + getRowFilter: () => undefined, + getObjectApiOperations: () => undefined, + roles: [], + isLoaded: false, + hasCapabilities: () => true, + can: () => true, + cannot: () => false, + }), + useFieldPermissions: () => ({ canRead: () => true, canWrite: () => true, permissions: [] }), + }; +}); + +vi.mock('@object-ui/auth', async (importOriginal) => ({ + ...(await importOriginal>()), + useAuth: () => ({ user: { id: 'u1', name: 'Ada' }, activeOrganization: null }), + useWorkspaceAdminStatus: () => ({ isAdmin: false, isResolved: true }), + createAuthenticatedFetch: () => vi.fn(), +})); + +vi.mock('@object-ui/collaboration', async (importOriginal) => ({ + ...(await importOriginal>()), + useRealtimeSubscription: () => ({ lastMessage: null }), + useConflictResolution: () => ({ hasConflicts: false, resolveAllConflicts: () => {} }), +})); + +vi.mock('sonner', () => ({ + toast: Object.assign(vi.fn(), { + success: vi.fn(), error: vi.fn(), info: vi.fn(), + warning: vi.fn(), loading: vi.fn(), dismiss: vi.fn(), + }), +})); + +/** The `ListView` schema each page builds, captured on the way in. */ +let capturedListSchema: any = null; +vi.mock('@object-ui/plugin-list', async (importOriginal) => ({ + ...(await importOriginal()), + ListView: (props: any) => { + capturedListSchema = props.schema; + return null; + }, +})); + +vi.mock('@object-ui/plugin-view', async (importOriginal) => ({ + ...(await importOriginal>()), + ObjectView: (props: any) => + props.renderListView?.({ + schema: { ...(props.schema ?? {}) }, + dataSource: props.dataSource, + onEdit: props.onEdit, + className: '', + refreshKey: 0, + }) ?? null, + ViewTabBar: () => null, + ManageViewsDialog: () => null, +})); + +vi.mock('./MetadataInspector', () => ({ + MetadataPanel: () => null, + useMetadataInspector: () => ({ showDebug: false, toggle: () => {} }), +})); +vi.mock('./RecordDetailView', () => ({ RecordDetailView: () => null })); + +/** `InterfaceListPage` reads its objects from the metadata provider. */ +let metadataObjects: any[] = []; +vi.mock('../providers/MetadataProvider.js', async (importOriginal) => ({ + ...(await importOriginal>()), + useMetadata: () => ({ objects: metadataObjects }), +})); + +vi.mock('@object-ui/react', async (importOriginal) => { + const actual = await (importOriginal as any)(); + return { ...actual, useAdapter: () => ({}) }; +}); + +import { ComponentRegistry } from '@object-ui/core'; +import { ObjectView, storedLegacyOptions } from './ObjectView'; +import { InterfaceListPage } from './InterfaceListPage'; +import { ExpressionProvider } from '../providers/ExpressionProvider'; + +const OBJECT_NAME = 'parity_task'; +const VIEW_KEY = `${OBJECT_NAME}.ops`; + +/** One stored flat list overlay, as the console merges it into `listViews`. */ +function objectsWith(row: Record) { + return [ + { + name: OBJECT_NAME, + label: 'Task', + fields: { + id: { type: 'text', label: 'Id' }, + name: { type: 'text', label: 'Name' }, + region: { type: 'text', label: 'Region' }, + status: { type: 'select', label: 'Status', options: [{ value: 'open', label: 'Open' }] }, + start_date: { type: 'date', label: 'Start' }, + location: { type: 'location', label: 'Location' }, + }, + listViews: { [VIEW_KEY]: { name: VIEW_KEY, columns: ['name', 'region'], ...row } }, + }, + ]; +} + +const makeDataSource = () => + ({ + find: vi.fn(async () => ({ + data: [{ id: '1', name: 'Alpha', region: 'EMEA', status: 'open', start_date: '2099-01-01' }], + total: 1, + })), + findOne: vi.fn(async () => null), + create: vi.fn(async () => ({})), + update: vi.fn(async () => ({})), + delete: vi.fn(async () => ({})), + getObjectSchema: vi.fn(async () => metadataObjects[0]), + }) as any; + +async function objectPageSchema(row: Record): Promise { + capturedListSchema = null; + metadataObjects = objectsWith(row); + render( + + + + {}} />} + /> + + + , + ); + // `options` is written by the same literal as every rung under test, so its + // arrival says the relay ran. + await waitFor(() => expect(capturedListSchema?.options).toBeTruthy()); + const schema = capturedListSchema; + cleanup(); + return schema; +} + +async function interfacePageSchema(row: Record, visualization: string): Promise { + capturedListSchema = null; + metadataObjects = objectsWith(row); + const page = { + name: 'parity_page', + label: 'Parity', + interfaceConfig: { + source: OBJECT_NAME, + sourceView: 'ops', + recordAction: 'none', + appearance: { allowedVisualizations: [visualization] }, + }, + }; + render( + + + , + ); + await waitFor(() => expect(capturedListSchema).toBeTruthy()); + const schema = capturedListSchema; + cleanup(); + return schema; +} + +/** Props each spy renderer received, newest last. */ +const received: Record = { 'object-timeline': [], 'object-kanban': [], 'object-map': [] }; +for (const type of Object.keys(received)) { + ComponentRegistry.register( + type, + (props: any) => { + received[type].push(props); + return
; + }, + { namespace: 'parity-10380', label: type, category: 'view' }, + ); +} + +/** Mount the REAL `ListView` on `schema` and return the props `rendererType` receives. */ +async function rendererProps(schema: any, viewType: string, rendererType: string): Promise { + const { ListView } = await vi.importActual('@object-ui/plugin-list'); + const { SchemaRendererProvider } = await vi.importActual('@object-ui/react'); + received[rendererType] = []; + const dataSource = makeDataSource(); + render( + + + , + ); + await waitFor(() => expect(received[rendererType].length).toBeGreaterThan(0)); + const props = received[rendererType][received[rendererType].length - 1]; + cleanup(); + return props.schema; +} + +/** The timeline config the renderer reads: the nested block and the flat keys. */ +const timelineConfig = (node: any) => ({ + timeline: node.timeline, + startDateField: node.startDateField, + endDateField: node.endDateField, + titleField: node.titleField, + groupByField: node.groupByField, + colorField: node.colorField, + scale: node.scale, +}); +const kanbanConfig = (node: any) => ({ groupBy: node.groupBy, titleField: node.titleField, cardFields: node.cardFields }); +const mapConfig = (node: any) => ({ + locationField: node.locationField, + titleField: node.titleField, + latitudeField: node.latitudeField, + longitudeField: node.longitudeField, + zoom: node.zoom, + center: node.center, +}); + +const RENDERERS: Record Record }> = { + timeline: { rendererType: 'object-timeline', project: timelineConfig }, + kanban: { rendererType: 'object-kanban', project: kanbanConfig }, + map: { rendererType: 'object-map', project: mapConfig }, +}; + +/** What each page's renderer receives for one stored row. */ +async function bothPages(row: Record) { + const viewType = row.type as string; + const { rendererType, project } = RENDERERS[viewType]; + const objectSchema = await objectPageSchema(row); + const interfaceSchema = await interfacePageSchema(row, viewType); + return { + objectPage: project(await rendererProps(objectSchema, viewType, rendererType)), + interfacePage: project(await rendererProps(interfaceSchema, viewType, rendererType)), + }; +} + +beforeEach(() => { + cleanup(); + vi.stubGlobal( + 'fetch', + vi.fn(async () => + new Response(JSON.stringify({ data: [] }), { status: 200, headers: { 'content-type': 'application/json' } }), + ), + ); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe('objectui#10380 — a stored row with a legacy `options` bag renders the same on both pages', () => { + it('a timeline block carried only in the bag (`options.timeline.titleField`)', async () => { + const { objectPage, interfacePage } = await bothPages({ + type: 'timeline', + options: { timeline: { titleField: 'region' } }, + }); + expect(objectPage).toEqual(interfacePage); + expect(objectPage.titleField).toBe('region'); + }); + + it('a map block carried only in the bag (the legacy `options.map` path)', async () => { + const { objectPage, interfacePage } = await bothPages({ + type: 'map', + options: { map: { locationField: 'location', titleField: 'region' } }, + }); + expect(objectPage).toEqual(interfacePage); + expect(objectPage).toMatchObject({ locationField: 'location', titleField: 'region' }); + }); + + it('a bag key under a declared top-level timeline block (`options.timeline.groupByField`)', async () => { + const { objectPage, interfacePage } = await bothPages({ + type: 'timeline', + timeline: { startDateField: 'start_date', titleField: 'name' }, + options: { timeline: { groupByField: 'region' } }, + }); + expect(objectPage).toEqual(interfacePage); + expect(objectPage.timeline).toMatchObject({ startDateField: 'start_date', titleField: 'name', groupByField: 'region' }); + }); + + it('a bag key under a declared top-level kanban block (`options.kanban.titleField`)', async () => { + const { objectPage, interfacePage } = await bothPages({ + type: 'kanban', + kanban: { groupByField: 'region', columns: ['name'] }, + options: { kanban: { titleField: 'region' } }, + }); + expect(objectPage).toEqual(interfacePage); + expect(objectPage).toMatchObject({ groupBy: 'region', titleField: 'region', cardFields: ['name'] }); + }); + + it('CONTROL: a row with no bag renders the same on both pages', async () => { + const { objectPage, interfacePage } = await bothPages({ + type: 'timeline', + timeline: { startDateField: 'start_date', titleField: 'name' }, + }); + expect(objectPage).toEqual(interfacePage); + expect(objectPage.timeline).toEqual({ startDateField: 'start_date', titleField: 'name' }); + }); +}); + +describe('objectui#10380 — the top-level block wins per key over the bag, on both pages', () => { + it('timeline: the declared `titleField` beats the bag’s', async () => { + const { objectPage, interfacePage } = await bothPages({ + type: 'timeline', + timeline: { startDateField: 'start_date', titleField: 'name' }, + options: { timeline: { titleField: 'region' } }, + }); + expect(objectPage).toEqual(interfacePage); + expect(objectPage.titleField).toBe('name'); + expect(objectPage.timeline.titleField).toBe('name'); + }); + + it('kanban: the declared lane beats the bag’s', async () => { + const { objectPage, interfacePage } = await bothPages({ + type: 'kanban', + kanban: { groupByField: 'region', columns: ['name'] }, + options: { kanban: { groupByField: 'status' } }, + }); + expect(objectPage).toEqual(interfacePage); + expect(objectPage.groupBy).toBe('region'); + }); +}); + +describe('storedLegacyOptions — what counts as a bag', () => { + it('a plain object is the bag; anything else is no bag', () => { + const bag = { kanban: { titleField: 'region' } }; + expect(storedLegacyOptions({ options: bag })).toBe(bag); + expect(storedLegacyOptions({})).toEqual({}); + expect(storedLegacyOptions(undefined)).toEqual({}); + expect(storedLegacyOptions({ options: ['kanban'] })).toEqual({}); + expect(storedLegacyOptions({ options: 'kanban' })).toEqual({}); + }); +}); diff --git a/packages/app-shell/src/views/ObjectView.tsx b/packages/app-shell/src/views/ObjectView.tsx index 49e402635a..715eef166c 100644 --- a/packages/app-shell/src/views/ObjectView.tsx +++ b/packages/app-shell/src/views/ObjectView.tsx @@ -460,6 +460,40 @@ export function kanbanViewOptions(viewDef: any, objectDef: any): Record { + const bag = viewDef?.options; + return bag && typeof bag === 'object' && !Array.isArray(bag) ? bag : {}; +} + /** * objectui#10046 — is `a` the same `options` bag as `b`, compared by VALUE? * (objectui#7237 holds the resolved `filter` through the same comparison.) @@ -2752,6 +2786,9 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: Co // objectui#7029: present only when the view actually declared one. const calendarOptions = calendarViewOptions(viewDef); + // objectui#10380: the stored row's judged legacy `options` bag. See + // `storedLegacyOptions` for how the relay below layers it. + const legacyOptions = storedLegacyOptions(viewDef); /** * objectui#10694, ruling 5839344270 (B) — where the hide-column @@ -3062,21 +3099,44 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: Co // (the legacy `filters` twin of the `filter` above lived here until // #2890 — see the note at its single remaining computation) ...(viewDef.sort?.length ? { sort: viewDef.sort } : {}), + // objectui#10380 — for each kind the stored row's legacy `options` + // bag carries, the view's own top-level block goes out at the top + // level, as `InterfaceListPage` sends it. `ListView` then lays it + // over the bag per key: the top-level block wins where both set a + // key, which is the precedence the spec declares for the bag. A kind + // the bag does not carry is relayed only through `options` below, + // exactly as before. See `storedLegacyOptions`. + ...(legacyOptions.kanban !== undefined ? { kanban: viewDef.kanban } : {}), + ...(legacyOptions.calendar !== undefined ? { calendar: viewDef.calendar } : {}), + ...(legacyOptions.timeline !== undefined ? { timeline: viewDef.timeline } : {}), + ...(legacyOptions.map !== undefined ? { map: viewDef.map } : {}), + ...(legacyOptions.gallery !== undefined ? { gallery: viewDef.gallery } : {}), + ...(legacyOptions.gantt !== undefined ? { gantt: viewDef.gantt } : {}), + ...(legacyOptions.tree !== undefined ? { tree: viewDef.tree } : {}), + ...(legacyOptions.chart !== undefined ? { chart: viewDef.chart } : {}), options: { + // objectui#10380 — the stored row's judged legacy bag, forwarded + // as the interface page forwards it. For each kind the bag + // carries, its block REPLACES this page's synthesized block below + // (the `mapCfg` rule of `InterfaceListPage`). Each synthesized + // block stands in only for a kind the bag does not carry. + ...legacyOptions, // The lane key is the spec's `groupByField`, not the legacy // `groupField` this used to write. See `kanbanViewOptions`. - kanban: kanbanViewOptions(viewDef, objectDef), + kanban: legacyOptions.kanban ?? kanbanViewOptions(viewDef, objectDef), // The calendar config the view DECLARED, or no calendar key at // all — never an invented field name (objectui#7029). With the // key absent, ListView's capability gate stops offering the // Calendar toggle for a view that configured none, and a view // forced onto the calendar renderer reaches its refusal screen. - ...(calendarOptions ? { calendar: calendarOptions } : {}), + ...((legacyOptions.calendar ?? calendarOptions) + ? { calendar: legacyOptions.calendar ?? calendarOptions } + : {}), // The date axis is resolved once, in ListView — this face only // forwards what the view declared, floored at 'name' // (objectui#3129, objectui#6557). See `timelineViewOptions`. - timeline: timelineViewOptions(viewDef), - map: { + timeline: legacyOptions.timeline ?? timelineViewOptions(viewDef), + map: legacyOptions.map ?? { locationField: viewDef.map?.locationField, titleField: viewDef.map?.titleField || 'name', latitudeField: viewDef.map?.latitudeField, @@ -3089,15 +3149,15 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: Co // cover binding to forward, ListView's capability gate stops // offering the Gallery toggle to a view that configured none. // See `galleryViewOptions`. - gallery: galleryViewOptions(viewDef), + gallery: legacyOptions.gallery ?? galleryViewOptions(viewDef), // The gantt config the view DECLARED, title floored at 'name' — // never an invented date field (`5f4514f7b`). With no date // binding to forward, ListView's capability gate stops offering // the Gantt toggle to a view that configured none, and a view // forced onto the gantt renderer reaches its refusal screen. // See `ganttViewOptions`. - gantt: ganttViewOptions(viewDef), - tree: { + gantt: legacyOptions.gantt ?? ganttViewOptions(viewDef), + tree: legacyOptions.tree ?? { // Self-referencing tree-grid config (plugin-tree). Spread the // full view-defined tree first so parentField/fields/ // defaultExpandedDepth survive; labelField falls back to the @@ -3162,7 +3222,7 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: Co // which are already relayed whole. Undeclared stays `undefined` // rather than the old permanently-truthy husk, which is what // keeps the gate from offering a chart nobody configured. - chart: viewDef.chart, + chart: legacyOptions.chart ?? viewDef.chart, }, }; // objectui#10046 — equal content keeps the identity `ListView`'s fetch From b763e31174dd83cc991a19bee2b38fe847ceda2b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 18:13:01 +0000 Subject: [PATCH 2/5] chore(app-shell): changeset and observed directions for the stored options bag parity pins Claude-Session: https://claude.ai/code/session_01FjqrwXPfSMkSfkKYDSRkN2 Co-authored-by: Claude --- .../10380-object-page-stored-options-bag.md | 12 +++++++++ ...ObjectView.storedOptionsBag-10380.test.tsx | 27 ++++++++++++++----- 2 files changed, 33 insertions(+), 6 deletions(-) create mode 100644 .changeset/10380-object-page-stored-options-bag.md diff --git a/.changeset/10380-object-page-stored-options-bag.md b/.changeset/10380-object-page-stored-options-bag.md new file mode 100644 index 0000000000..a9fd7409b3 --- /dev/null +++ b/.changeset/10380-object-page-stored-options-bag.md @@ -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. diff --git a/packages/app-shell/src/views/ObjectView.storedOptionsBag-10380.test.tsx b/packages/app-shell/src/views/ObjectView.storedOptionsBag-10380.test.tsx index 121c0b0594..760ad10be2 100644 --- a/packages/app-shell/src/views/ObjectView.storedOptionsBag-10380.test.tsx +++ b/packages/app-shell/src/views/ObjectView.storedOptionsBag-10380.test.tsx @@ -38,14 +38,29 @@ * with a spy renderer registered for the kind, and compares what the renderer * receives. That is what reaches the screen. * - * ## Direction, written before the run + * ## Direction, written before the run, and what was observed * - * On `origin/main` (before the fix), the four bag rows are predicted RED and - * the no-bag control GREEN. The two collision cases are predicted GREEN there + * On `origin/main` (before the fix), the four bag rows were predicted RED and + * the no-bag control GREEN. The two collision cases were predicted GREEN there * too: the object page ignored the bag and the interface page let the - * top-level block win, so both answered with the top-level value. They guard - * the fix's layering, and an ablation that puts the bag OVER the declared block - * is what turns them red. Both runs are quoted in the PR body. + * top-level block win, so both answered with the top-level value. + * + * Observed on `origin/main`: the four bag rows RED, the control GREEN and the + * timeline collision GREEN, as predicted. The kanban collision went RED, which + * was not predicted. Its lane agreed ('region' on both pages). It failed on the + * card title: the object page floored `titleField: 'name'` in + * `kanbanViewOptions`, and the interface page sent no title for a row whose + * bag carries a kanban block. With the fix, a kind the bag carries takes the + * bag's block in place of the synthesized one, so that floor no longer applies. + * + * Ablation on the fixed tree: the declared block was merged UNDER the bag in + * app-shell (`{ ...viewDef.KIND, ...legacyOptions.KIND }` at the top level, for + * timeline and kanban). That is the second merge, with the wrong precedence, + * that this fix must not write. Predicted: the two collision cases RED. + * Observed: those two RED, and the `options.timeline.groupByField` row RED as + * well. `ListView` reads `groupByField` as a flat timeline key off the + * top-level block only, so the merged block exposed the bag's key on the object + * page alone. Both runs are quoted in the PR body. */ import * as React from 'react'; From 24a5eedf070bac9bdebb0741b5cdb44843368368 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 18:19:16 +0000 Subject: [PATCH 3/5] test(app-shell): type the parity pin's renderer projections loosely enough for tsc Claude-Session: https://claude.ai/code/session_01FjqrwXPfSMkSfkKYDSRkN2 Co-authored-by: Claude --- .../src/views/ObjectView.storedOptionsBag-10380.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/app-shell/src/views/ObjectView.storedOptionsBag-10380.test.tsx b/packages/app-shell/src/views/ObjectView.storedOptionsBag-10380.test.tsx index 760ad10be2..4d30eccbd5 100644 --- a/packages/app-shell/src/views/ObjectView.storedOptionsBag-10380.test.tsx +++ b/packages/app-shell/src/views/ObjectView.storedOptionsBag-10380.test.tsx @@ -287,7 +287,7 @@ const mapConfig = (node: any) => ({ center: node.center, }); -const RENDERERS: Record Record }> = { +const RENDERERS: Record Record }> = { timeline: { rendererType: 'object-timeline', project: timelineConfig }, kanban: { rendererType: 'object-kanban', project: kanbanConfig }, map: { rendererType: 'object-map', project: mapConfig }, From ba7e170b27c858a71a960afb67a60cc3255e5fb3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 18:24:33 +0000 Subject: [PATCH 4/5] refactor(app-shell): type storedLegacyOptions' input instead of any Claude-Session: https://claude.ai/code/session_01FjqrwXPfSMkSfkKYDSRkN2 Co-authored-by: Claude --- packages/app-shell/src/views/ObjectView.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/app-shell/src/views/ObjectView.tsx b/packages/app-shell/src/views/ObjectView.tsx index 715eef166c..8e2a0e8a9e 100644 --- a/packages/app-shell/src/views/ObjectView.tsx +++ b/packages/app-shell/src/views/ObjectView.tsx @@ -489,9 +489,9 @@ export function kanbanViewOptions(viewDef: any, objectDef: any): Record { +export function storedLegacyOptions(viewDef: { options?: unknown } | null | undefined): Record { const bag = viewDef?.options; - return bag && typeof bag === 'object' && !Array.isArray(bag) ? bag : {}; + return bag && typeof bag === 'object' && !Array.isArray(bag) ? (bag as Record) : {}; } /** From 44e55cf78428020104155848d279ca911215f540 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 18:25:07 +0000 Subject: [PATCH 5/5] fix(app-shell): accept the view entry as unknown in storedLegacyOptions so tsc takes the relay's call Claude-Session: https://claude.ai/code/session_01FjqrwXPfSMkSfkKYDSRkN2 Co-authored-by: Claude --- packages/app-shell/src/views/ObjectView.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/app-shell/src/views/ObjectView.tsx b/packages/app-shell/src/views/ObjectView.tsx index 8e2a0e8a9e..74af4be73d 100644 --- a/packages/app-shell/src/views/ObjectView.tsx +++ b/packages/app-shell/src/views/ObjectView.tsx @@ -489,8 +489,8 @@ export function kanbanViewOptions(viewDef: any, objectDef: any): Record { - const bag = viewDef?.options; +export function storedLegacyOptions(viewDef: unknown): Record { + const bag = viewDef && typeof viewDef === 'object' ? (viewDef as { options?: unknown }).options : undefined; return bag && typeof bag === 'object' && !Array.isArray(bag) ? (bag as Record) : {}; }