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/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..4d30eccbd5 --- /dev/null +++ b/packages/app-shell/src/views/ObjectView.storedOptionsBag-10380.test.tsx @@ -0,0 +1,403 @@ +/** + * 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, and what was observed + * + * 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. + * + * 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'; +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..74af4be73d 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 && typeof viewDef === 'object' ? (viewDef as { options?: unknown }).options : undefined; + return bag && typeof bag === 'object' && !Array.isArray(bag) ? (bag as Record) : {}; +} + /** * 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