diff --git a/.changeset/11013-view-row-declared-spellings.md b/.changeset/11013-view-row-declared-spellings.md new file mode 100644 index 0000000000..61672646fd --- /dev/null +++ b/.changeset/11013-view-row-declared-spellings.md @@ -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. diff --git a/.changeset/7924-density-mode-through-fold.md b/.changeset/7924-density-mode-through-fold.md index b381f1d109..47a80d30a5 100644 --- a/.changeset/7924-density-mode-through-fold.md +++ b/.changeset/7924-density-mode-through-fold.md @@ -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 diff --git a/.changeset/7924-named-list-view-retire.md b/.changeset/7924-named-list-view-retire.md index 0b01166bc4..9fc2c26046 100644 --- a/.changeset/7924-named-list-view-retire.md +++ b/.changeset/7924-named-list-view-retire.md @@ -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 diff --git a/packages/app-shell/src/services/MetadataService.viewInvalidation.test.ts b/packages/app-shell/src/services/MetadataService.viewInvalidation.test.ts index c801236ab7..adf7172400 100644 --- a/packages/app-shell/src/services/MetadataService.viewInvalidation.test.ts +++ b/packages/app-shell/src/services/MetadataService.viewInvalidation.test.ts @@ -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. diff --git a/packages/app-shell/src/utils/viewIdentity.ts b/packages/app-shell/src/utils/viewIdentity.ts index 434bc94429..81fe49e650 100644 --- a/packages/app-shell/src/utils/viewIdentity.ts +++ b/packages/app-shell/src/utils/viewIdentity.ts @@ -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 @@ -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; - 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).name; + return typeof name === 'string' && name !== '' ? name : undefined; } /** diff --git a/packages/app-shell/src/views/ObjectView.emptyColumnsNoProjection-10694.test.tsx b/packages/app-shell/src/views/ObjectView.emptyColumnsNoProjection-10694.test.tsx index de191106ad..33170e444d 100644 --- a/packages/app-shell/src/views/ObjectView.emptyColumnsNoProjection-10694.test.tsx +++ b/packages/app-shell/src/views/ObjectView.emptyColumnsNoProjection-10694.test.tsx @@ -114,7 +114,7 @@ vi.mock('./metadata-admin/inspectors/ViewVariantInspector', () => ({ @@ -157,8 +157,12 @@ 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 @@ -166,9 +170,9 @@ let drawnWrapHeaders: unknown; 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; @@ -257,13 +261,13 @@ async function openPanel(): Promise { /** Edit a field that is not `columns`, and wait until the edit reaches the grid. */ async function editUnrelatedField(): Promise { 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 { 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. */ diff --git a/packages/app-shell/src/views/ObjectView.overrideMasquerade.test.ts b/packages/app-shell/src/views/ObjectView.overrideMasquerade.test.ts index af899afbd9..1ebd83d585 100644 --- a/packages/app-shell/src/views/ObjectView.overrideMasquerade.test.ts +++ b/packages/app-shell/src/views/ObjectView.overrideMasquerade.test.ts @@ -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, })); } 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 1ed9854984..35652b9167 100644 --- a/packages/app-shell/src/views/ObjectView.relayRungCensus-7559.test.ts +++ b/packages/app-shell/src/views/ObjectView.relayRungCensus-7559.test.ts @@ -477,8 +477,25 @@ const ABSENCES: Record = { 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 `` element instead; a view record cannot carry a function.' }, diff --git a/packages/app-shell/src/views/ObjectView.setDefaultViewIdentity.test.tsx b/packages/app-shell/src/views/ObjectView.setDefaultViewIdentity.test.tsx index aa4c53178a..848b3b9000 100644 --- a/packages/app-shell/src/views/ObjectView.setDefaultViewIdentity.test.tsx +++ b/packages/app-shell/src/views/ObjectView.setDefaultViewIdentity.test.tsx @@ -22,10 +22,10 @@ * the entry is ABSENT rather than present-and-inert. * * WHICH ROUTE — settled here, statically, and it is route 2. Both routes consult - * the SAME predicate over the SAME array (`readonly: !saved` at - * ObjectView.tsx:2022 and `isSavedView` at :1003 both ask `viewRowId(sv) === id`), + * the SAME predicate over the SAME array (`readonly: !saved` in the switcher's + * tab mapping and the `isSavedView` callback both ask `viewRowId(sv) === id`), * and all four render sites of the set-default entry are gated on `!isReadonly` - * (`ViewTabBar.tsx:564`, `:667`; `ManageViewsDialog.tsx:300`, `:361`). So when the + * (two in `ViewTabBar.tsx`, two in `ManageViewsDialog.tsx`). So when the * ids diverge the entry is never rendered, and route 1's toast is unreachable * through the UI — there is no control left to click. Route 1 survives only as * the handler's own guard against a caller that ignores `readonly`; it is pinned @@ -82,16 +82,18 @@ const OBJECT_NAME = 'crm_lead'; /** The auto-generated "all records" tab, which no case here should reach. */ const fallbackTab = () => ({ id: 'all', label: 'All records', type: 'grid', columns: [] }); -/** `ObjectView`'s overlay normalization (ObjectView.tsx:706-713), verbatim. */ +/** + * `ObjectView`'s overlay normalization (the `listViews` effect's `.map`), + * verbatim. objectui#11013 dropped the `objectName` stamp it used to add. + */ const normalizeSavedViews = (rows: any[]) => rows.map((sv: any) => ({ ...sv, id: viewRowId(sv), - objectName: sv.objectName || sv.object || OBJECT_NAME, })); /** - * The tab's `readonly` flag, exactly as ObjectView.tsx:2022-2025 computes it: + * The tab's `readonly` flag, exactly as the switcher's tab mapping computes it: * `saved = savedViews.find(sv => viewRowId(sv) === view.id)`, `isSystem = !saved`. * Asserting `isSavedViewId` is asserting this same predicate — they are one * function now, which is the whole point of the fix. @@ -101,20 +103,21 @@ const isReadonlyTab = (savedViews: any[], tabId: string) => !isSavedViewId(saved // ───────────────────────────────────────────────────────────────────────────── describe('the identity seam — ONE spelling for a view row id (#4211)', () => { - it('prefers `name`, then `id`, then `_id`', () => { + it('reads `name`, and only `name` (objectui#11013)', () => { + // The spec declares `name` on every `view` member and a top-level `id` / + // `_id` on none. This read `name`, then `id`, then `_id` until + // objectui#11013 aligned it to the declared spelling. expect(viewRowId({ name: 'crm_lead.my_view', id: 'other', _id: 'third' })) .toBe('crm_lead.my_view'); - expect(viewRowId({ id: 'other', _id: 'third' })).toBe('other'); - expect(viewRowId({ _id: 'third' })).toBe('third'); + expect(viewRowId({ id: 'other', _id: 'third' })).toBeUndefined(); + expect(viewRowId({ _id: 'third' })).toBeUndefined(); expect(viewRowId({})).toBeUndefined(); expect(viewRowId(null)).toBeUndefined(); }); - it('skips an EMPTY name rather than answering with it', () => { - // A blank `name` used to win the old `sv.name || sv.id` chain only by - // falsiness; making that explicit keeps a row addressable by its next - // spelling instead of producing an id nothing can match. - expect(viewRowId({ name: '', id: 'crm_lead.my_view' })).toBe('crm_lead.my_view'); + it('an EMPTY name is no identity, and no undeclared spelling stands in for it', () => { + // Before objectui#11013 a blank `name` fell through to the row's `id`. + expect(viewRowId({ name: '', id: 'crm_lead.my_view' })).toBeUndefined(); }); it('is IDEMPOTENT across the overlay normalization — producer and readers agree', () => { @@ -381,15 +384,19 @@ describe('controls — what must NOT change (#4211)', () => { it('rename / delete / pin / config — the guard’s other consumers accept the same ids', () => { // All five handlers short-circuit on `isSavedView(vid)`; they share one - // predicate, so pinning it across the id shapes pins them together. + // predicate, so pinning it across the id shapes pins them together. Since + // objectui#11013 the one id shape is the declared `name`: a row that + // carries only a top-level `id` / `_id` is no saved view to any of them. const savedViews = normalizeSavedViews([ { name: 'crm_lead.by_name', object: OBJECT_NAME }, { id: 'by_id', object: OBJECT_NAME }, { _id: 'by_underscore_id', object: OBJECT_NAME }, ]); - for (const id of ['crm_lead.by_name', 'by_id', 'by_underscore_id']) { - expect(isSavedViewId(savedViews, id)).toBe(true); - expect(isReadonlyTab(savedViews, id)).toBe(false); + expect(isSavedViewId(savedViews, 'crm_lead.by_name')).toBe(true); + expect(isReadonlyTab(savedViews, 'crm_lead.by_name')).toBe(false); + for (const id of ['by_id', 'by_underscore_id']) { + expect(isSavedViewId(savedViews, id)).toBe(false); + expect(isReadonlyTab(savedViews, id)).toBe(true); } expect(isSavedViewId(savedViews, 'crm_lead.default')).toBe(false); }); diff --git a/packages/app-shell/src/views/ObjectView.tsx b/packages/app-shell/src/views/ObjectView.tsx index 9191705a8d..9bd39b0b03 100644 --- a/packages/app-shell/src/views/ObjectView.tsx +++ b/packages/app-shell/src/views/ObjectView.tsx @@ -48,7 +48,7 @@ import { MetadataPanel, useMetadataInspector } from './MetadataInspector.js'; import { ViewConfigPanel } from './ViewConfigPanel.js'; import { useMetadataClient } from './metadata-admin/useMetadata.js'; import { persistRuntimeMetadata, createRuntimeMetadata, viewEnvelope, type ViewEnvelope } from './runtime-metadata-persistence.js'; -import { ListViewSchema as SpecListViewSchema, normalizeFilterOperator } from '@objectstack/spec/ui'; +import { ListViewSchema as SpecListViewSchema, normalizeFilterOperator, VIEW_CONSOLE_ROUND_TRIP_KEYS } from '@objectstack/spec/ui'; import { CreateViewDialog } from './CreateViewDialog.js'; import { usePreviewDrafts, @@ -904,9 +904,9 @@ export function buildViewTabs({ columns: sv.columns, filter: sv.filter, sort: sv.sort, - showSearch: sv.showSearch, - showFilters: sv.showFilters, - showSort: sv.showSort, + // objectui#11013 — no `showSearch` / `showFilters` / `showSort` + // picks: the toolbar policy's declared spelling is `userActions`, + // which the row carries (and the spread below keeps) like any key. isPinned: sv.isPinned, isDefault: sv.isDefault, visibility: sv.visibility, @@ -1098,8 +1098,17 @@ export function buildPersistedViewBody( * body (objectstack `d5552ca13`). A view-config save is a whole-document PUT, so * these are carried forward at the envelope's top level; dropping them would * erase the default flag, the pin and the column widths the row held. + * + * objectui#11013 — read off the spec, not retyped: the keys of + * `VIEW_CONSOLE_ROUND_TRIP_KEYS` (objectstack#20456) declared on the `viewItem` + * member, which is the member this save's envelope is judged by. `_isOverride` + * is declared on the list overlay only, so it is not carried here. The same + * derivation is `@object-ui/data-objectstack`'s, where `listViews()` carries + * these keys back off the stored record. */ -const VIEW_ROW_STATE_KEYS = ['isDefault', 'isPinned', 'sortOrder', 'visibility', 'columnState'] as const; +const VIEW_ROW_STATE_KEYS: readonly string[] = ( + Object.keys(VIEW_CONSOLE_ROUND_TRIP_KEYS) as Array +).filter((key) => (VIEW_CONSOLE_ROUND_TRIP_KEYS[key] as readonly string[]).includes('viewItem')); /** * The keys a list view's `config` may carry — read off the spec's own closed @@ -1822,8 +1831,16 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: Co .then((rows: any[]) => { if (cancelled) return; // Normalize: ensure each view has an `id` for ViewTabBar - // (which is name-keyed downstream). Stamp `objectName` - // so the defensive filter in handlers still works. + // (which is name-keyed downstream). + // + // objectui#11013 — no `objectName` is stamped here any more. + // It was read `sv.objectName || sv.object`, the undeclared + // spelling first, and no reader of this array consumed the + // stamp: the handlers act on the route's `objectName`. What + // it did do was ride a saved view's whole-body toolbar save + // back into the stored row, an undeclared key the console + // itself minted. The row's bound object is its declared + // `object`, which `listViews()` carries. const normalized = (rows || []).map((sv: any) => ({ ...sv, // Overlay rows are keyed by `name`. Prefer that as the @@ -1834,7 +1851,6 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: Co // array too, so the key written here and the key read // back cannot drift apart (objectui#4211). id: viewRowId(sv), - objectName: sv.objectName || sv.object || objectName, })); setSavedViews(normalized); }) @@ -2837,7 +2853,10 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: Co onColumnStateChange: (state: { order?: string[]; widths?: Record }) => { persistViewPatch(viewDef.id, viewDef, { columnState: state }); }, - inlineEdit: viewDef.inlineEdit ?? viewDef.editRecordsInline ?? listSchema.inlineEdit, + // objectui#11013 — `inlineEdit` only. The `editRecordsInline` + // spelling this also read has no producer: no console surface + // writes it and the spec's view schema refuses it by name. + inlineEdit: viewDef.inlineEdit ?? listSchema.inlineEdit, // ADR-0047 — spec `appearance` (incl. allowedVisualizations, the // runtime visualization whitelist) flows from the view metadata; // the legacy bare `showDescription` flag is folded in on top. @@ -2870,8 +2889,14 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: Co ...(normalizeListViewSchema(listSchema ?? {}) as { userActions?: object }).userActions, ...(normalizeListViewSchema(viewDef ?? {}) as { userActions?: object }).userActions, }, - allowExport: viewDef.allowExport ?? listSchema.allowExport, - exportOptions: viewDef.allowExport === false ? undefined : (viewDef.exportOptions ?? listSchema.exportOptions), + // objectui#11013 — no `allowExport` rung, and `exportOptions` is no + // longer withheld on a view's `allowExport: false`. Nothing writes + // `allowExport` onto a view: no console surface does, and the + // spec's view schema refuses it by name (the seat's disposition on + // objectstack#20456: no producer, no declared spelling). The + // export gate `ListView` keeps reading is the HOST's, carried in + // by `...listSchema` from the object-view node. + exportOptions: viewDef.exportOptions ?? listSchema.exportOptions, color: viewDef.color ?? listSchema.color, /** * The spec-canonical row-colour CONFIGURATION the author put on @@ -2895,14 +2920,17 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: Co * versus 'what the colours are'). */ rowColor: viewDef.rowColor ?? listSchema.rowColor, - // Propagate view-config properties (Bug 4 / items 14-22) - wrapHeaders: viewDef.wrapHeaders ?? listSchema.wrapHeaders, - clickIntoRecordDetails: viewDef.clickIntoRecordDetails ?? listSchema.clickIntoRecordDetails, - addRecordViaForm: viewDef.addRecordViaForm ?? listSchema.addRecordViaForm, - addDeleteRecordsInline: viewDef.addDeleteRecordsInline ?? listSchema.addDeleteRecordsInline, - collapseAllByDefault: viewDef.collapseAllByDefault ?? listSchema.collapseAllByDefault, - fieldTextColor: viewDef.fieldTextColor ?? listSchema.fieldTextColor, - prefixField: viewDef.prefixField ?? listSchema.prefixField, + // objectui#11013 — the seven renderer flags this relay used to read + // off the active view (`wrapHeaders`, `clickIntoRecordDetails`, + // `addRecordViaForm`, `addDeleteRecordsInline`, + // `collapseAllByDefault`, `fieldTextColor`, `prefixField`) have no + // rung. 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, so a stored view never supplies them. What reaches + // `ListView` is the host's value, carried by `...listSchema` from + // the object-view node (the objectui#5097 host-composition keys). + // The census answers each in `ObjectView.relayRungCensus-7559.test.ts`. + // // ViewData source override (spec `data` key): a view authored with // `data: {provider:'api', read, write}` must survive this explicit // picklist, or ObjectGantt falls back to provider:'object'. @@ -3166,14 +3194,30 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: Co [views, activeViewId, viewDraft] ); + // objectui#11013 — the active view's toolbar policy, read under its declared + // spelling `userActions.search` / `.filter` / `.sort`. These used to be read + // as the bare `showSearch` / `showFilters` / `showSort` flags, so a view + // that declared `userActions: { search: false }` still got a search box + // here. A stored view that still carries a bare flag is folded onto + // `userActions` by `normalizeListViewSchema`, the one fold the relay's own + // `userActions` rung already runs; this read names no legacy key. Not + // memoised: `activeView` is a fresh object on every render while a draft is + // open, and the schema below keys on the three booleans, not on this value. + const activeViewUserActions = (normalizeListViewSchema(activeView ?? {}) as { + userActions?: { search?: boolean; filter?: boolean; sort?: boolean }; + }).userActions; + const activeViewSearch = activeViewUserActions?.search !== false; + const activeViewFilter = activeViewUserActions?.filter !== false; + const activeViewSort = activeViewUserActions?.sort !== false; + // Build the ObjectViewSchema for the plugin — reads from activeView (which merges draft) const objectViewSchema = useMemo(() => ({ type: 'object-view' as const, objectName: objectDef.name, layout: 'page' as const, - showSearch: activeView?.showSearch !== false, - showFilters: activeView?.showFilters !== false, - showSort: activeView?.showSort !== false, + showSearch: activeViewSearch, + showFilters: activeViewFilter, + showSort: activeViewSort, showCreate: false, // We render our own create button in the header allowCreateView: isAdmin, viewActions: isAdmin ? [ @@ -3198,7 +3242,7 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: Co } } }, - }), [objectDef, onEdit, activeView?.showSearch, activeView?.showFilters, activeView?.showSort, activeView?.name, activeView?.label, navigate, viewId, isAdmin, location.pathname, location.search, viewLabel, objectLabel]); + }), [objectDef, onEdit, activeViewSearch, activeViewFilter, activeViewSort, activeView?.name, activeView?.label, navigate, viewId, isAdmin, location.pathname, location.search, viewLabel, objectLabel]); return ( diff --git a/packages/app-shell/src/views/ObjectView.viewConfigSaveEnvelope-10210.test.ts b/packages/app-shell/src/views/ObjectView.viewConfigSaveEnvelope-10210.test.ts index 54af54cd69..2700161c96 100644 --- a/packages/app-shell/src/views/ObjectView.viewConfigSaveEnvelope-10210.test.ts +++ b/packages/app-shell/src/views/ObjectView.viewConfigSaveEnvelope-10210.test.ts @@ -156,10 +156,13 @@ function makeAdapter(meta: any) { return ds; } -/** `savedViews` exactly as `ObjectView` normalizes the adapter's rows. */ +/** + * `savedViews` exactly as `ObjectView` normalizes the adapter's rows + * (objectui#11013 dropped the `objectName` stamp that normalization carried). + */ async function readSavedViews(ds: any) { const rows: any[] = await ds.listViews(OBJECT_NAME); - return rows.map((sv) => ({ ...sv, id: viewRowId(sv), objectName: sv.objectName || sv.object || OBJECT_NAME })); + return rows.map((sv) => ({ ...sv, id: viewRowId(sv) })); } describe('objectui#10210 — the view-config save persists a ViewItem envelope', () => { diff --git a/packages/app-shell/src/views/ObjectView.viewRowDeclaredReads-11013.test.tsx b/packages/app-shell/src/views/ObjectView.viewRowDeclaredReads-11013.test.tsx new file mode 100644 index 0000000000..6515d3dbb8 --- /dev/null +++ b/packages/app-shell/src/views/ObjectView.viewRowDeclaredReads-11013.test.tsx @@ -0,0 +1,338 @@ +/** + * 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#11013 — the relays of a stored view read it by the spec's declared + * spellings, and drop the reads nothing writes (ruling 甲 on + * objectstack#20051, stage ii). + * + * ## What changed, and what each case measures + * + * Both relays of a view into the `list-view` node — `plugin-view`'s + * `renderListView` composition (HOST 1) and the object page's relay over it + * (HOST 2) — read nine keys off the view that no producer writes: + * `allowExport` and the renderer flags `wrapHeaders`, + * `clickIntoRecordDetails`, `addRecordViaForm`, `addDeleteRecordsInline`, + * `collapseAllByDefault`, `fieldTextColor`, `prefixField` (both hosts), and + * `editRecordsInline` as a second spelling of `inlineEdit` (HOST 2). The + * producer census on the card found no console surface and no authored view + * writing any of them, and the spec's view schema refuses each by name. So a + * view carrying one of them now hands `ListView` nothing for it: + * + * - THE CHANGE cases put every one of the nine on the view and read what + * `ListView` is handed. + * - The CONTROL cases put the node's own value on the object-view node (the + * objectui#5097 host-composition read, which stays) and a relayed neighbour + * on the same view, so an absence below is a reading and not a relay that + * stopped running. + * + * The toolbar policy is read as `userActions.search` / `.sort` / `.filter`, + * the declared spelling, where the bare `showSearch` / `showSort` / + * `showFilters` flags used to be read off the view: on the object page's + * `object-view` node, and on `plugin-view`'s non-grid route. A view that still + * carries a bare flag is folded onto `userActions` by `normalizeListViewSchema`, + * so it keeps its answer. + * + * ## Direction, written before the reverse-verification run + * + * Restoring the dropped view reads is PREDICTED to turn the `THE CHANGE` and + * `DECLARED SPELLING` cases red and to leave every `CONTROL` and `SAME ANSWER` + * case green. + */ + +import * as React from 'react'; +import { describe, it, expect, vi, beforeEach, afterEach, beforeAll, afterAll } from 'vitest'; +import { render, cleanup, waitFor } from '@testing-library/react'; +import { MemoryRouter, Routes, Route } from 'react-router-dom'; +import { ComponentRegistry } from '@object-ui/core'; + +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 list schema the object page hands down — captured, not rendered. */ +let captured: any = null; +vi.mock('@object-ui/plugin-list', async (importOriginal) => ({ + ...(await importOriginal()), + ListView: (props: any) => { + captured = props.schema; + return null; + }, +})); + +/** The `object-view` node the object page builds for the real plugin view. */ +let objectViewNode: any = null; +vi.mock('@object-ui/plugin-view', async (importOriginal) => { + const actual = await importOriginal(); + const Real = actual.ObjectView as React.ComponentType; + return { + ...actual, + ObjectView: (props: any) => { + objectViewNode = props.schema; + return ; + }, + }; +}); + +vi.mock('./MetadataInspector', () => ({ + MetadataPanel: () => null, + useMetadataInspector: () => ({ showDebug: false, toggle: () => {} }), +})); +vi.mock('./RecordDetailView', () => ({ RecordDetailView: () => null })); + +import { ObjectView } from './ObjectView'; +import { ObjectView as PluginObjectView } from '@object-ui/plugin-view'; +import { ExpressionProvider } from '../providers/ExpressionProvider'; + +const OBJECT_NAME = 'duly_task'; + +/** The eight keys both hosts read off a view until objectui#11013, each with a value it would carry. */ +const DROPPED = { + allowExport: false, + wrapHeaders: true, + clickIntoRecordDetails: false, + addRecordViaForm: true, + addDeleteRecordsInline: true, + collapseAllByDefault: true, + fieldTextColor: 'name', + prefixField: 'name', +} as const; + +function objectsWith(view: Record) { + return [ + { + name: OBJECT_NAME, + label: 'Task', + fields: { + id: { type: 'text', label: 'Id' }, + name: { type: 'text', label: 'Name' }, + stage: { type: 'text', label: 'Stage' }, + }, + listViews: { + probe: { label: 'Probe', type: 'grid', columns: ['name'], ...view }, + }, + }, + ]; +} + +function makeDataSource() { + return { + find: vi.fn(async () => ({ data: [], total: 0 })), + findOne: vi.fn(async () => null), + create: vi.fn(async () => ({})), + update: vi.fn(async () => ({})), + delete: vi.fn(async () => ({})), + } as any; +} + +/** HOST 2 — the object page over the real plugin-view: the schema `ListView` receives. */ +async function objectPageSchema(view: Record): Promise { + captured = null; + objectViewNode = null; + render( + + + + {}} />} + /> + + + , + ); + // `options` is written by the relay literal unconditionally, so its arrival + // is the signal the relay ran. + await waitFor(() => { + expect(captured?.options).toBeTruthy(); + }); + return captured; +} + +/** HOST 1 — plugin-view's `renderListView` composition, as a third-party host receives it. */ +function pluginViewSchema(view: Record, node: Record = {}): any { + let handed: any = null; + const renderListView = vi.fn(({ schema }: any) => { + handed = schema; + return null; + }); + render( + , + ); + expect(renderListView).toHaveBeenCalled(); + return handed; +} + +/** `plugin-view`'s non-grid route: the kanban node it hands the registry. */ +let kanbanNode: any = null; +let prevKanban: unknown; +beforeAll(() => { + prevKanban = ComponentRegistry.get('object-kanban'); + ComponentRegistry.register('object-kanban', ((props: { schema?: unknown }) => { + kanbanNode = props.schema; + return null; + }) as never); +}); +afterAll(() => { + if (prevKanban) ComponentRegistry.register('object-kanban', prevKanban as never); + else ComponentRegistry.unregister('object-kanban'); +}); + +async function kanbanRouteNode(view: Record, node: Record = {}): Promise { + kanbanNode = null; + render( + , + ); + await waitFor(() => expect(kanbanNode).toBeTruthy()); + return kanbanNode; +} + +beforeEach(() => { + cleanup(); + captured = null; + vi.stubGlobal( + 'fetch', + vi.fn(async () => + new Response(JSON.stringify({ data: [] }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }), + ), + ); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + vi.clearAllMocks(); +}); + +describe('objectui#11013 — HOST 1: the plugin-view composition drops the view reads nothing writes', () => { + it('THE CHANGE: none of the eight reaches `ListView` from the view', () => { + const schema = pluginViewSchema({ ...DROPPED, exportOptions: { formats: ['csv'] } }); + for (const key of Object.keys(DROPPED)) { + expect(schema[key], `\`${key}\` still arrives from the view`).toBeUndefined(); + } + // CONTROL on the same view: a relayed neighbour still arrives. + expect(schema.exportOptions).toEqual({ formats: ['csv'] }); + }); + + it('CONTROL: the object-view node\'s own value still reaches `ListView` (objectui#5097)', () => { + const schema = pluginViewSchema({}, { ...DROPPED }); + for (const [key, value] of Object.entries(DROPPED)) { + expect(schema[key], `\`${key}\` no longer arrives from the node`).toEqual(value); + } + }); +}); + +describe('objectui#11013 — HOST 2: the object page relays none of the nine off the view', () => { + it('THE CHANGE: none of the eight, and no `editRecordsInline`, reaches `ListView`', async () => { + const schema = await objectPageSchema({ ...DROPPED, editRecordsInline: true }); + for (const key of Object.keys(DROPPED)) { + expect(schema[key], `\`${key}\` still arrives from the view`).toBeUndefined(); + } + expect(schema.editRecordsInline).toBeUndefined(); + expect(schema.inlineEdit).toBeUndefined(); + }); + + it('THE CHANGE: a view\'s `allowExport: false` no longer withholds its `exportOptions`', async () => { + const schema = await objectPageSchema({ allowExport: false, exportOptions: { formats: ['csv'] } }); + expect(schema.exportOptions).toEqual({ formats: ['csv'] }); + expect(schema.allowExport).toBeUndefined(); + }); + + it('CONTROL: the declared neighbours on the same view are still relayed', async () => { + const schema = await objectPageSchema({ ...DROPPED, inlineEdit: true, rowColor: { field: 'stage' } }); + expect(schema.inlineEdit).toBe(true); + expect(schema.rowColor).toEqual({ field: 'stage' }); + }); +}); + +describe('objectui#11013 — the toolbar policy is read as `userActions`', () => { + it('DECLARED SPELLING: the object page\'s `object-view` node hears `userActions.search: false`', async () => { + await objectPageSchema({ userActions: { search: false, sort: false, filter: false } }); + expect(objectViewNode.showSearch).toBe(false); + expect(objectViewNode.showSort).toBe(false); + expect(objectViewNode.showFilters).toBe(false); + }); + + it('SAME ANSWER: a legacy bare flag still switches the control off, through the fold', async () => { + await objectPageSchema({ showSearch: false }); + expect(objectViewNode.showSearch).toBe(false); + expect(objectViewNode.showSort).toBe(true); + }); + + it('CONTROL: a view that says nothing leaves all three on', async () => { + await objectPageSchema({}); + expect(objectViewNode.showSearch).toBe(true); + expect(objectViewNode.showSort).toBe(true); + expect(objectViewNode.showFilters).toBe(true); + }); + + it('DECLARED SPELLING: plugin-view\'s non-grid route hears `userActions.search: false`', async () => { + const node = await kanbanRouteNode({ userActions: { search: false } }, { showSearch: true }); + expect(node.showSearch).toBe(false); + }); + + it('SAME ANSWER: a legacy bare flag on the view still wins over the node there, through the fold', async () => { + const node = await kanbanRouteNode({ showSearch: false }, { showSearch: true }); + expect(node.showSearch).toBe(false); + }); + + it('CONTROL: a view that says nothing takes the node\'s value there', async () => { + const node = await kanbanRouteNode({}, { showSearch: true, showSort: true }); + expect(node.showSearch).toBe(true); + expect(node.showSort).toBe(true); + }); +}); diff --git a/packages/app-shell/src/views/metadata-admin/ResourceEditPage.pickerLoadFailure.test.tsx b/packages/app-shell/src/views/metadata-admin/ResourceEditPage.pickerLoadFailure.test.tsx index 5b4c3802fa..a25c1e6726 100644 --- a/packages/app-shell/src/views/metadata-admin/ResourceEditPage.pickerLoadFailure.test.tsx +++ b/packages/app-shell/src/views/metadata-admin/ResourceEditPage.pickerLoadFailure.test.tsx @@ -140,7 +140,7 @@ vi.mock('./useMetadata', async (importOriginal) => { }; }); -import { MetadataResourceEditPage } from './ResourceEditPage'; +import { MetadataResourceEditPage, viewRefCatalog } from './ResourceEditPage'; function mountEditor() { render( @@ -174,7 +174,8 @@ beforeEach(() => { { name: 'showcase_task' }, ]); impl.listView.mockImplementation(async () => [ - { name: `${SOURCE_OBJECT}.default`, label: 'All records', objectName: SOURCE_OBJECT }, + // `object`, the spelling the spec declares on a view row (objectui#11013). + { name: `${SOURCE_OBJECT}.default`, label: 'All records', object: SOURCE_OBJECT }, ]); impl.getObject.mockImplementation(async () => ({ fields: { title: { label: 'Title', type: 'text' } }, @@ -349,6 +350,23 @@ describe('view-catalog loader: a failed list is not an object without views (#51 expect(screen.queryByDisplayValue(LOADING_OPTIONS)).toBeNull(); }); + it('objectui#11013: the catalog keeps a view bound by `object`, and not one that carries only `objectName`', () => { + // The loader hands `client.list('view')` to `viewRefCatalog`, which reads + // the row's binding under the one spelling the spec declares. `objectName` + // and `object_name` are declared on no `view` member, so the metadata door + // refuses a row bound by either alone. + const catalog = viewRefCatalog( + [ + { name: `${SOURCE_OBJECT}.declared`, label: 'Declared binding', object: SOURCE_OBJECT }, + { name: `${SOURCE_OBJECT}.undeclared`, label: 'Undeclared binding', objectName: SOURCE_OBJECT }, + { name: `${SOURCE_OBJECT}.snake`, label: 'Snake binding', object_name: SOURCE_OBJECT }, + { name: 'showcase_task.other', label: 'Other object', object: 'showcase_task' }, + ], + SOURCE_OBJECT, + ); + expect(catalog).toEqual([{ name: `${SOURCE_OBJECT}.declared`, label: 'Declared binding' }]); + }); + it('the field catalog failing does not take the view picker down with it', async () => { // The two loaders are separate requests and must stay separately // attributable — one catalog's fault is not evidence about another's. diff --git a/packages/app-shell/src/views/metadata-admin/ResourceEditPage.tsx b/packages/app-shell/src/views/metadata-admin/ResourceEditPage.tsx index b49874b926..100f39bbb7 100644 --- a/packages/app-shell/src/views/metadata-admin/ResourceEditPage.tsx +++ b/packages/app-shell/src/views/metadata-admin/ResourceEditPage.tsx @@ -274,6 +274,27 @@ export function shouldRenderDiagnostics(opts: { return opts.hasDiag || opts.hasClientValidator; } +/** + * The `view-ref` picker's catalog: the views bound to `objectName`, as + * `{ name, label }`, first occurrence of each name kept. + * + * A row's binding is read under `object` alone (objectui#11013, ruling 甲 on + * objectstack#20051). The spec declares `object` on every `view` member, and + * declares neither `objectName` nor `object_name`, which this filter used to + * read first and last. Extracted so the reading is assertable without opening + * the picker (Radix portals its options on open). + */ +export function viewRefCatalog( + rows: ReadonlyArray> | null | undefined, + objectName: string, +): Array<{ name: string; label?: string }> { + const seen = new Set(); + return (rows || []) + .filter((v) => v?.object === objectName) + .map((v) => ({ name: v?.name as string, label: (v?.label as string) || undefined })) + .filter((v) => !!v.name && !seen.has(v.name) && seen.add(v.name)); +} + export interface MetadataResourceEditPageProps { type?: string; name?: string; @@ -880,23 +901,17 @@ function MetadataResourceEditPageImpl({ // View catalog of the source object — fuels the `view-ref` picker for // `interfaceConfig.sourceView` so the author chooses an existing view // instead of typing (and mistyping) a name. Views are standalone metadata - // keyed to their object via `objectName`/`object`; the LIST endpoint returns - // name + label, which is all the picker needs. + // keyed to their object via `object` ({@link viewRefCatalog}); the LIST + // endpoint returns name + label, which is all the picker needs. const objectViewsState = usePickerLoad>( React.useMemo( () => sourceObjectName - ? async () => { - const all = (await client.list('view')) as Array>; - const forObject = (all || []).filter((v) => { - const obj = v?.objectName ?? v?.object ?? v?.object_name; - return obj === sourceObjectName; - }); - const seen = new Set(); - return forObject - .map((v) => ({ name: v?.name as string, label: (v?.label as string) || undefined })) - .filter((v) => !!v.name && !seen.has(v.name) && seen.add(v.name)); - } + ? async () => + viewRefCatalog( + (await client.list('view')) as Array>, + sourceObjectName, + ) : null, [client, sourceObjectName], ), diff --git a/packages/app-shell/src/views/metadata-admin/previews/ViewPreview.declaredObject-11013.test.ts b/packages/app-shell/src/views/metadata-admin/previews/ViewPreview.declaredObject-11013.test.ts new file mode 100644 index 0000000000..094431e6b5 --- /dev/null +++ b/packages/app-shell/src/views/metadata-admin/previews/ViewPreview.declaredObject-11013.test.ts @@ -0,0 +1,35 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectui#11013 — the Studio view preview reads the bound object of a stored + * `view` draft under the spellings the spec declares: the row's `object` and + * its config's `data.object` (ruling 甲 on objectstack#20051, stage ii). + * + * `resolveObjectName` also read `objectName`, off the body and off the draft. + * The spec declares it on no `view` member, so the metadata door refuses a row + * bound by it alone (pinned in `@object-ui/data-objectstack`'s + * `viewItemObjectName.declaredSpelling-11013.test.ts`). A draft whose only + * binding is the undeclared spelling now previews as unbound. + */ + +import { describe, it, expect } from 'vitest'; +import { resolveObjectName } from './ViewPreview'; + +describe('objectui#11013 — ViewPreview binds a view draft by its declared spelling', () => { + it('reads the body\'s `object`, then its `data.object`, then the draft\'s', () => { + expect(resolveObjectName({}, { object: 'task' })).toBe('task'); + expect(resolveObjectName({}, { data: { provider: 'object', object: 'task' } })).toBe('task'); + expect(resolveObjectName({ object: 'task' }, { type: 'grid' })).toBe('task'); + expect(resolveObjectName({ data: { provider: 'object', object: 'task' } })).toBe('task'); + }); + + it('a draft carrying both spellings binds by `object`', () => { + expect(resolveObjectName({ object: 'task', objectName: 'other' }, { type: 'grid' })).toBe('task'); + }); + + it('`objectName` alone binds nothing — on the body or on the draft', () => { + expect(resolveObjectName({}, { objectName: 'task' })).toBeUndefined(); + expect(resolveObjectName({ objectName: 'task' }, { type: 'grid' })).toBeUndefined(); + expect(resolveObjectName({ objectName: 'task' })).toBeUndefined(); + }); +}); diff --git a/packages/app-shell/src/views/metadata-admin/previews/ViewPreview.tsx b/packages/app-shell/src/views/metadata-admin/previews/ViewPreview.tsx index e444946175..1e00494e77 100644 --- a/packages/app-shell/src/views/metadata-admin/previews/ViewPreview.tsx +++ b/packages/app-shell/src/views/metadata-admin/previews/ViewPreview.tsx @@ -40,16 +40,23 @@ import { primaryVariantBinding } from '../view-variant-model.js'; import { t as tr } from '../i18n.js'; import { withNodes } from './row-nodes.js'; -function resolveObjectName( +/** + * The object a stored `view` draft is bound to, read under the spellings the + * spec declares: the row's `object` and its config's `data.object`. + * + * objectui#11013 (ruling 甲 on objectstack#20051): `objectName` was read here + * too, on the body and on the draft. The spec declares it on no `view` member, + * so the metadata door refuses a row bound by it alone; a draft that carries + * no declared binding previews as unbound, which is what it is. + */ +export function resolveObjectName( draft: Record, body?: Record, ): string | undefined { const candidates: any[] = [ body?.object, (body as any)?.data?.object, - (body as any)?.objectName, (draft as any).object, - (draft as any).objectName, (draft as any).data?.object, ]; for (const c of candidates) { diff --git a/packages/data-objectstack/src/index.ts b/packages/data-objectstack/src/index.ts index 8d6d97dc69..e1d9480ed2 100644 --- a/packages/data-objectstack/src/index.ts +++ b/packages/data-objectstack/src/index.ts @@ -9,6 +9,10 @@ import { ObjectStackClient, type QueryOptions as ObjectStackQueryOptions } from '@objectstack/client'; import type { DroppedFieldsEvent, EngineAggregateOptions } from '@objectstack/spec/data'; import type { ListViewGroupHeaderRow } from '@objectstack/spec/ui'; +// objectui#11013 — a VALUE import: the keys `listViews` carries off a stored +// ViewItem record are the spec's own record of the console's round-trip keys, +// read off the pin rather than hand-listed (see `VIEW_ITEM_ROUND_TRIP_KEYS`). +import { VIEW_CONSOLE_ROUND_TRIP_KEYS } from '@objectstack/spec/ui'; // #4934 — a VALUE import, not a type one: the write-warning boundary parses the // wire's `reason` against the enum the spec itself declares, so the accept set // is read off the pin instead of hand-copied here (a hand copy is the drift @@ -2810,6 +2814,26 @@ function withoutNoOpDrops( return out; } +/** + * The console's round-trip keys the spec declares on the ViewItem RECORD — the + * keys of `VIEW_CONSOLE_ROUND_TRIP_KEYS` whose members include `viewItem` + * (objectui#11013). Derived, never hand-listed: the spec's record says which + * keys the console writes onto a stored row and reads back, and on which + * members each is declared; `_isOverride` is declared on the list overlay only, + * so it is not one of these. + * + * {@link ObjectStackAdapter.listViews} carries exactly these off a record when + * it flattens the record's `config` to the row the switcher reads. They live + * at the record's TOP level: the pin toggle and the drag-reorder write them + * through {@link ObjectStackAdapter.updateView}, which merges + * `{ ...current, ...partial }`, and app-shell's view-config save carries them + * forward beside the envelope's `config`. A flatten that kept only `config` + * dropped them on the reload. + */ +const VIEW_ITEM_ROUND_TRIP_KEYS: readonly string[] = ( + Object.keys(VIEW_CONSOLE_ROUND_TRIP_KEYS) as Array +).filter((key) => (VIEW_CONSOLE_ROUND_TRIP_KEYS[key] as readonly string[]).includes('viewItem')); + /** * Resolve which object a `type='view'` metadata item belongs to. * @@ -2828,8 +2852,17 @@ function withoutNoOpDrops( * * `object` is the identity field the write path stamps (and that the * framework's overlay heals onto identity-less personalization rows — - * objectstack#2555); `data.object` is the config's data-provider target and - * `objectName` the legacy artifact spelling. + * objectstack#2555); `data.object` is the config's data-provider target. + * + * ⛔ No `objectName` leg (objectui#11013, ruling 甲 on objectstack#20051: + * objectui reads a stored view row by the spec's declared spellings). The + * spec declares `object` on every `view` member — required on the ViewItem + * record and on both flattened overlays — and declares `objectName` on none, + * so the metadata write door refuses a row that carries only `objectName` + * (`viewItemObjectName.declaredSpelling-11013.test.ts` pins that door beside + * this reader). Every write path in this package stamps `object` as well, so a + * row the console wrote carries it; a row that carries ONLY the undeclared + * spelling no longer matches any object here. * * **Exported** since objectui#4373, for the same one-spelling reason: a writer * outside this module that holds a view BODY but not its object name (app-shell's @@ -2841,7 +2874,7 @@ function withoutNoOpDrops( export function viewItemObjectName(item: any): string | undefined { // Handle both bare view spec and `{list: {...}}` artifact wrapper const spec = item?.list ?? item; - return spec?.data?.object ?? spec?.object ?? spec?.objectName; + return spec?.data?.object ?? spec?.object; } /** @@ -5608,14 +5641,33 @@ export class ObjectStackAdapter implements DataSource { // no top-level `type`, so ObjectView's saved-view normalization defaults // it to 'grid' and overrides the metadata entry — a kanban/gallery/ // calendar view then silently renders as a plain table. + // + // objectui#11013 — the flattened row also carries the record's declared + // members the console reads back off it: its bound `object`, and the + // round-trip keys the spec declares on the record + // (`VIEW_ITEM_ROUND_TRIP_KEYS`: `isPinned`, `sortOrder`, `visibility`, + // `columnState`, and `isDefault`, coerced to a boolean as before). The + // switcher sorts saved views by the `sortOrder` it reads off THIS row, + // and nothing else restored it, so a reordered record lost its place + // on the reload. Each is copied only when the record carries it, and + // after `config`, so a record-level key wins over a config key of the + // same name. (MetadataProvider's `applyViewItem` still flattens to + // `config` + identity: the tab it feeds is merged with the stored + // record through `loadViewOverrides`, which is where that path gets + // these keys back. The sort reads this row, not the tab.) if (spec && spec.config && typeof spec.config === 'object') { - return { + const row: Record = { ...spec.config, name: spec.name ?? spec.config.name, label: spec.label ?? spec.config.label, - isDefault: !!spec.isDefault, - ...(isDraft ? { _draft: true } : {}), }; + if (spec.object !== undefined) row.object = spec.object; + for (const key of VIEW_ITEM_ROUND_TRIP_KEYS) { + if (spec[key] !== undefined) row[key] = spec[key]; + } + row.isDefault = !!spec.isDefault; + if (isDraft) row._draft = true; + return row; } return isDraft ? { ...spec, _draft: true } : spec; }); diff --git a/packages/data-objectstack/src/listViews.roundTripKeys-11013.test.ts b/packages/data-objectstack/src/listViews.roundTripKeys-11013.test.ts new file mode 100644 index 0000000000..584f261ff8 --- /dev/null +++ b/packages/data-objectstack/src/listViews.roundTripKeys-11013.test.ts @@ -0,0 +1,176 @@ +/** + * 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#11013 — `listViews()` reads a stored ViewItem record by its declared + * members (ruling 甲 on objectstack#20051, stage ii; the spec end is + * objectstack#20474's `VIEW_CONSOLE_ROUND_TRIP_KEYS`). + * + * `listViews()` flattens a record's `config` to the row the switcher reads. + * It used to keep `config` plus `name` / `label` / `isDefault` / `_draft` and + * nothing else, so the keys the console writes at the record's TOP level — + * `isPinned` and `sortOrder` through `updateView`, `columnState` and + * `visibility` carried forward by the view-config save — did not come back on + * a reload of this read. The switcher sorts saved views by the `sortOrder` it + * reads off this row, so a reordered record lost its place. + * + * Each case below is a real round trip against a stub `sys_metadata` store: + * the adapter's own write, then a fresh `listViews()`. The carried set is read + * off the spec's record, so a key the spec adds to the `viewItem` member is + * pinned here without editing this file. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { VIEW_CONSOLE_ROUND_TRIP_KEYS } from '@objectstack/spec/ui'; +import { ObjectStackAdapter } from './index'; + +const OBJECT = 'crm_task'; +const NAME = `${OBJECT}.my_board`; + +/** A stored ViewItem record, the shape the console's create path writes. */ +const RECORD = { + name: NAME, + object: OBJECT, + viewKind: 'list', + label: 'My board', + config: { type: 'grid', columns: ['name', 'stage'], data: { provider: 'object', object: OBJECT } }, +}; + +/** The spec's round-trip keys declared on the ViewItem record. */ +const VIEW_ITEM_KEYS = (Object.keys(VIEW_CONSOLE_ROUND_TRIP_KEYS) as Array) + .filter((key) => (VIEW_CONSOLE_ROUND_TRIP_KEYS[key] as readonly string[]).includes('viewItem')); + +/** A stub metadata store keyed the way `sys_metadata` is: `type` + `name`. */ +function makeDS(seed: any[]) { + const rows = new Map(seed.map((r) => [`view::${r.name}`, structuredClone(r)])); + const meta = { + getItems: vi.fn(async (type: string) => ({ + type, + items: [...rows.entries()].filter(([k]) => k.startsWith(`${type}::`)).map(([, v]) => structuredClone(v)), + })), + getItem: vi.fn(async (type: string, name: string) => ({ type, name, item: structuredClone(rows.get(`${type}::${name}`)) })), + saveItem: vi.fn(async (type: string, name: string, item: any) => { + rows.set(`${type}::${name}`, structuredClone(item)); + return { success: true, item: structuredClone(item) }; + }), + }; + const json = (body: unknown, status = 200) => + new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } }); + const ds: any = new ObjectStackAdapter({ + baseUrl: 'http://test.local', + // `updateView` asks the draft home first; nothing is pending here. + fetch: vi.fn(async (input: RequestInfo | URL) => + String(input).includes('state=draft') + ? json({ error: 'not found' }, 404) + : json({ success: true, data: { capabilities: {}, routes: {} } })), + }); + ds.connected = true; + ds.connectionState = 'connected'; + ds.client = { meta }; + return { ds, rows }; +} + +async function reloadRow(ds: any): Promise> { + const listed = await ds.listViews(OBJECT); + const row = listed.find((v: any) => v.name === NAME); + expect(row, 'the record is no longer listed').toBeDefined(); + return row; +} + +describe('objectui#11013 — the carried set is the spec\'s', () => { + it('the ViewItem round-trip keys are the five the card names, read off the spec', () => { + expect([...VIEW_ITEM_KEYS].sort()).toEqual(['columnState', 'isDefault', 'isPinned', 'sortOrder', 'visibility']); + // Control on the same record: a key declared on the list overlay only is not one of them. + expect(VIEW_ITEM_KEYS).not.toContain('_isOverride'); + expect(Object.keys(VIEW_CONSOLE_ROUND_TRIP_KEYS)).toContain('_isOverride'); + }); + + it('every one of them, stored at the record\'s top level, comes back on the flattened row', async () => { + const stored: Record = { + isDefault: true, + isPinned: true, + sortOrder: 3, + visibility: 'team', + columnState: { order: ['stage', 'name'], widths: { name: 240 } }, + }; + for (const key of VIEW_ITEM_KEYS) expect(stored, `no probe value for \`${key}\``).toHaveProperty(key); + const { ds } = makeDS([{ ...RECORD, ...stored }]); + const row = await reloadRow(ds); + for (const key of VIEW_ITEM_KEYS) expect(row[key], key).toEqual(stored[key]); + }); +}); + +describe('objectui#11013 — a reload round trip per key', () => { + it('`isPinned`: the pin toggle\'s write comes back', async () => { + const { ds } = makeDS([RECORD]); + expect((await reloadRow(ds)).isPinned).toBeUndefined(); + await ds.updateView(OBJECT, NAME, { isPinned: true }); + expect((await reloadRow(ds)).isPinned).toBe(true); + }); + + it('`sortOrder`: the drag-reorder\'s write comes back', async () => { + const { ds } = makeDS([RECORD]); + await ds.updateView(OBJECT, NAME, { sortOrder: 2 }); + expect((await reloadRow(ds)).sortOrder).toBe(2); + }); + + it('`visibility`: a stored group survives another write and comes back', async () => { + // No console control sets it; the console carries a stored value forward. + const { ds } = makeDS([{ ...RECORD, visibility: 'organization' }]); + await ds.updateView(OBJECT, NAME, { isPinned: true }); + const row = await reloadRow(ds); + expect(row.visibility).toBe('organization'); + expect(row.isPinned).toBe(true); + }); + + it('`columnState`: a record-level column layout comes back', async () => { + const { ds } = makeDS([RECORD]); + const columnState = { order: ['stage', 'name'], widths: { stage: 120 } }; + await ds.updateView(OBJECT, NAME, { columnState }); + expect((await reloadRow(ds)).columnState).toEqual(columnState); + }); + + it('`isDefault`: the set-default write comes back, and an absent flag still reads `false`', async () => { + const { ds } = makeDS([RECORD]); + expect((await reloadRow(ds)).isDefault).toBe(false); + await ds.updateView(OBJECT, NAME, { isDefault: true }); + expect((await reloadRow(ds)).isDefault).toBe(true); + }); + + it('`object`: the record\'s declared binding is on the flattened row', async () => { + const { ds } = makeDS([RECORD]); + expect((await reloadRow(ds)).object).toBe(OBJECT); + }); +}); + +describe('objectui#11013 — controls: what the flatten still does', () => { + it('the record\'s `config` is still flattened to the top level, and the nested `config` is not handed on', async () => { + const { ds } = makeDS([RECORD]); + const row = await reloadRow(ds); + expect(row.type).toBe('grid'); + expect(row.columns).toEqual(['name', 'stage']); + expect(row.label).toBe('My board'); + expect(row).not.toHaveProperty('config'); + expect(row).not.toHaveProperty('viewKind'); + }); + + it('a key the record does not carry is not invented on the row', async () => { + const { ds } = makeDS([RECORD]); + const row = await reloadRow(ds); + for (const key of ['isPinned', 'sortOrder', 'visibility', 'columnState']) { + expect(row, key).not.toHaveProperty(key); + } + }); + + it('a flat saved row is returned whole, as before', async () => { + const flat = { name: `${OBJECT}.flat`, object: OBJECT, label: 'Flat', type: 'grid', isPinned: true, sortOrder: 1 }; + const { ds } = makeDS([flat]); + const listed = await ds.listViews(OBJECT); + expect(listed).toEqual([flat]); + }); +}); diff --git a/packages/data-objectstack/src/viewItemObjectName.declaredSpelling-11013.test.ts b/packages/data-objectstack/src/viewItemObjectName.declaredSpelling-11013.test.ts new file mode 100644 index 0000000000..ce3e142aca --- /dev/null +++ b/packages/data-objectstack/src/viewItemObjectName.declaredSpelling-11013.test.ts @@ -0,0 +1,126 @@ +/** + * 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#11013 — a stored `view` row's bound object is read under the + * spelling the spec declares, `object` (ruling 甲 on objectstack#20051, stage + * ii: "objectui aligns its reads to the declared spellings"). + * + * `viewItemObjectName` read `data.object ?? object ?? objectName`. The third leg + * is gone. Three facts make that safe, and each is measured here rather than + * recalled: + * + * 1. THE DOOR. The spec declares `object` on every `view` member and + * `objectName` on none, so the metadata write door + * (`ViewMetadataSchema`, which `saveMetaItem` validates every `view` body + * against) refuses a row bound by `objectName` alone — on the flattened + * overlay and on the ViewItem record. The same body bound by `object` + * parses: the firing control that the refusal is about the spelling. + * 2. THE WRITERS. Every write path in this adapter stamps `object`, so a row + * the console wrote carries it whatever else it carries. + * 3. THE ANSWER. A row carrying both spellings answers exactly as before (the + * `object` leg was already read first); only a row carrying the undeclared + * spelling alone stops matching — in `listViews` and `listViewOverrides` + * alike, since both narrow through this one accessor. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { ViewMetadataSchema } from '@objectstack/spec/ui'; +import { ObjectStackAdapter, viewItemObjectName } from './index'; + +const OBJECT = 'crm_task'; + +/** A stub metadata store keyed the way `sys_metadata` is: `type` + `name`. */ +function makeDS(seed: any[] = []) { + const rows = new Map(seed.map((r) => [`view::${r.name}`, r])); + const meta = { + getItems: vi.fn(async (type: string) => ({ + type, + items: [...rows.entries()].filter(([k]) => k.startsWith(`${type}::`)).map(([, v]) => v), + })), + getItem: vi.fn(async (type: string, name: string) => ({ type, name, item: rows.get(`${type}::${name}`) })), + saveItem: vi.fn(async (type: string, name: string, item: any) => { + rows.set(`${type}::${name}`, { ...item }); + return { success: true, item: rows.get(`${type}::${name}`) }; + }), + }; + const json = (body: unknown, status = 200) => + new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } }); + const ds: any = new ObjectStackAdapter({ + baseUrl: 'http://test.local', + // `updateView` asks the draft home first; nothing is pending here. + fetch: vi.fn(async (input: RequestInfo | URL) => + String(input).includes('state=draft') + ? json({ error: 'not found' }, 404) + : json({ success: true, data: { capabilities: {}, routes: {} } })), + }); + ds.connected = true; + ds.connectionState = 'connected'; + ds.client = { meta }; + return { ds, meta, rows }; +} + +describe('objectui#11013 — the door: `objectName` alone does not bind a stored view row', () => { + const overlay = { name: `${OBJECT}.mine`, viewKind: 'list', type: 'grid', columns: ['name'] }; + const record = { name: `${OBJECT}.mine`, viewKind: 'list', config: { type: 'grid', columns: ['name'] } }; + + it('the flattened overlay: refused bound by `objectName`, parsed bound by `object`', () => { + expect(ViewMetadataSchema.safeParse({ ...overlay, objectName: OBJECT }).success).toBe(false); + expect(ViewMetadataSchema.safeParse({ ...overlay, object: OBJECT }).success).toBe(true); + }); + + it('the ViewItem record: refused bound by `objectName`, parsed bound by `object`', () => { + expect(ViewMetadataSchema.safeParse({ ...record, objectName: OBJECT }).success).toBe(false); + expect(ViewMetadataSchema.safeParse({ ...record, object: OBJECT }).success).toBe(true); + }); +}); + +describe('objectui#11013 — the writers: every console write path stamps `object`', () => { + it('createView, updateViewConfig and updateView each leave `object` on the row', async () => { + // The `updateView` target is a row at rest with no binding at all: the + // merge stamps one on the way back out. + const { ds, rows } = makeDS([{ name: `${OBJECT}.at_rest`, label: 'At rest', type: 'grid' }]); + await ds.createView(OBJECT, { name: `${OBJECT}.created`, label: 'Created', type: 'grid' }); + await ds.updateViewConfig(OBJECT, `${OBJECT}.overlay`, { rowHeight: 'short' }); + await ds.updateView(OBJECT, `${OBJECT}.at_rest`, { isPinned: true }); + for (const name of [`${OBJECT}.created`, `${OBJECT}.overlay`, `${OBJECT}.at_rest`]) { + expect(rows.get(`view::${name}`)?.object, name).toBe(OBJECT); + expect(rows.get(`view::${name}`), name).not.toHaveProperty('objectName'); + } + }); +}); + +describe('objectui#11013 — the answer: `object` and `data.object` bind, `objectName` does not', () => { + it('reads `data.object`, then `object`', () => { + expect(viewItemObjectName({ object: OBJECT })).toBe(OBJECT); + expect(viewItemObjectName({ data: { provider: 'object', object: OBJECT } })).toBe(OBJECT); + expect(viewItemObjectName({ list: { data: { provider: 'object', object: OBJECT } } })).toBe(OBJECT); + }); + + it('a row carrying both spellings answers as before — by `object`', () => { + expect(viewItemObjectName({ object: OBJECT, objectName: OBJECT })).toBe(OBJECT); + expect(viewItemObjectName({ object: OBJECT, objectName: 'other' })).toBe(OBJECT); + }); + + it('a row carrying only `objectName` is bound to no object', () => { + expect(viewItemObjectName({ objectName: OBJECT })).toBeUndefined(); + expect(viewItemObjectName({ list: { objectName: OBJECT } })).toBeUndefined(); + }); + + it('listViews and listViewOverrides narrow by the same answer', async () => { + const declared = { name: `${OBJECT}.declared`, object: OBJECT, label: 'Declared', type: 'grid' }; + const undeclared = { name: `${OBJECT}.undeclared`, objectName: OBJECT, label: 'Undeclared', type: 'grid' }; + const { ds } = makeDS([declared, undeclared]); + + const listed = (await ds.listViews(OBJECT)).map((v: any) => v.name); + expect(listed).toEqual([`${OBJECT}.declared`]); + + const overrides = await ds.listViewOverrides(OBJECT); + expect(Object.keys(overrides)).toEqual([`${OBJECT}.declared`]); + }); +}); diff --git a/packages/plugin-view/src/ObjectView.tsx b/packages/plugin-view/src/ObjectView.tsx index 9b0570cc1f..9fe09f37ef 100644 --- a/packages/plugin-view/src/ObjectView.tsx +++ b/packages/plugin-view/src/ObjectView.tsx @@ -1899,6 +1899,15 @@ export const ObjectView: React.FC = ({ // --- Generate view component schema for non-grid views --- const generateViewSchema = useCallback((viewType: string): any => { + // objectui#11013 — the host `views` entry's toolbar policy, under its + // declared spelling `userActions`. `baseProps` below read the bare + // `showSearch` / `showSort` / `showFilters` flags off the entry, so a view + // declaring `userActions: { search: false }` was not heard. A stored entry + // that still carries a bare flag is folded onto `userActions` by + // `normalizeListViewSchema`, the fold the `list-view` relay already runs. + const activeViewUserActions = (normalizeListViewSchema(activeView ?? {}) as { + userActions?: { search?: boolean; sort?: boolean; filter?: boolean }; + }).userActions; const baseProps: Record = { objectName: schema.objectName, // objectui#5269 — the `table` segment reads the CANONICAL key first. @@ -1943,9 +1952,9 @@ export const ObjectView: React.FC = ({ fields: viewColumnFieldNames(currentNamedViewConfig?.columns) || activeView?.columns || tableColumnFieldNames(schema.table?.columns) || schema.table?.fields, className: 'h-full w-full', - showSearch: activeView?.showSearch ?? schema.showSearch ?? false, - showSort: activeView?.showSort ?? schema.showSort ?? false, - showFilters: activeView?.showFilters ?? schema.showFilters ?? false, + showSearch: activeViewUserActions?.search ?? schema.showSearch ?? false, + showSort: activeViewUserActions?.sort ?? schema.showSort ?? false, + showFilters: activeViewUserActions?.filter ?? schema.showFilters ?? false, color: activeView?.color, }; @@ -2831,7 +2840,11 @@ export const ObjectView: React.FC = ({ ...currentNamedViewConfig?.userActions, }, compactToolbar: currentNamedViewConfig?.compactToolbar ?? activeView?.compactToolbar ?? (schema as any).compactToolbar, - allowExport: activeView?.allowExport ?? (schema as any).allowExport, + // objectui#11013 — the host `views` entry is no longer read for + // `allowExport`: nothing writes it onto a view (no console surface, + // no authored view here, and the spec's view schema refuses it by + // name). The node's value is the objectui#5097 host-composition read. + allowExport: (schema as any).allowExport, exportOptions: currentNamedViewConfig?.exportOptions ?? activeView?.exportOptions, // Propagate display properties color: activeView?.color ?? (schema as any).color, @@ -2854,13 +2867,18 @@ export const ObjectView: React.FC = ({ rowColor: currentNamedViewConfig?.rowColor ?? activeView?.rowColor, // Propagate view-config properties (Bug 4 / items 14-22) inlineEdit: currentNamedViewConfig?.inlineEdit ?? activeView?.inlineEdit ?? (schema as any).inlineEdit, - wrapHeaders: activeView?.wrapHeaders ?? (schema as any).wrapHeaders, - clickIntoRecordDetails: activeView?.clickIntoRecordDetails ?? (schema as any).clickIntoRecordDetails, - addRecordViaForm: activeView?.addRecordViaForm ?? (schema as any).addRecordViaForm, - addDeleteRecordsInline: activeView?.addDeleteRecordsInline ?? (schema as any).addDeleteRecordsInline, - collapseAllByDefault: activeView?.collapseAllByDefault ?? (schema as any).collapseAllByDefault, - fieldTextColor: activeView?.fieldTextColor ?? (schema as any).fieldTextColor, - prefixField: activeView?.prefixField ?? (schema as any).prefixField, + // objectui#11013 — the seven renderer flags below are read off the + // NODE only (the objectui#5097 host-composition keys). The host + // `views` entry is a stored view row, and no producer writes any of + // them onto one: no console surface, no view authored here, and the + // spec's view schema refuses each by name. + wrapHeaders: (schema as any).wrapHeaders, + clickIntoRecordDetails: (schema as any).clickIntoRecordDetails, + addRecordViaForm: (schema as any).addRecordViaForm, + addDeleteRecordsInline: (schema as any).addDeleteRecordsInline, + collapseAllByDefault: (schema as any).collapseAllByDefault, + fieldTextColor: (schema as any).fieldTextColor, + prefixField: (schema as any).prefixField, showDescription: activeView?.showDescription ?? (schema as any).showDescription, // ViewData source override (spec `data` key) — e.g. gantt views fed // by a composite api endpoint; without this pick the api provider diff --git a/packages/plugin-view/src/__tests__/ObjectView.namedViewProtocolKeys-8980.test.tsx b/packages/plugin-view/src/__tests__/ObjectView.namedViewProtocolKeys-8980.test.tsx index f0b080f356..b20344cbd7 100644 --- a/packages/plugin-view/src/__tests__/ObjectView.namedViewProtocolKeys-8980.test.tsx +++ b/packages/plugin-view/src/__tests__/ObjectView.namedViewProtocolKeys-8980.test.tsx @@ -548,7 +548,8 @@ describe('objectui#10758 — the host delegation reads bucket ① off the named const protocol = Object.keys(SpecObjectListViewSchema.shape); expect(BUCKET_ONE.filter((m) => !protocol.includes(m))).toEqual([]); // Control on the same query: a key the protocol does NOT declare on a - // named view (objectui's retained `allowExport`) is refused by it. + // named view (objectui's own `allowExport`, a tombstone since + // objectui#11013) is refused by it. expect(protocol).not.toContain('allowExport'); }); diff --git a/packages/plugin-view/src/__tests__/objectViewHostSurface.test.tsx b/packages/plugin-view/src/__tests__/objectViewHostSurface.test.tsx index f590b965f2..52adb3a125 100644 --- a/packages/plugin-view/src/__tests__/objectViewHostSurface.test.tsx +++ b/packages/plugin-view/src/__tests__/objectViewHostSurface.test.tsx @@ -323,8 +323,9 @@ describe('the fence reads the named view first for every protocol member it rela const offProtocol = namedViewReadsIn(regionSlice()).filter((k) => !protocol.includes(k)); expect(offProtocol).toEqual([]); // Control on the same instrument: the fence DOES relay non-protocol keys — - // off the host entry and the node only (`allowExport` is objectui's own, - // ruled on objectui#7924) — so "none off the named view" is a reading. + // off the node only (`allowExport` is objectui's own, ruled on + // objectui#7924; objectui#11013 dropped its host-entry read) — so "none + // off the named view" is a reading. const rungs = namedViewRungs(); expect(rungs).toHaveProperty('allowExport', false); expect(protocol).not.toContain('allowExport'); diff --git a/packages/types/src/__tests__/object-view-unmirrored-keys-7779.test.ts b/packages/types/src/__tests__/object-view-unmirrored-keys-7779.test.ts index f758c77fb8..667e9822a2 100644 --- a/packages/types/src/__tests__/object-view-unmirrored-keys-7779.test.ts +++ b/packages/types/src/__tests__/object-view-unmirrored-keys-7779.test.ts @@ -113,9 +113,10 @@ * * ⚠️ The distinction the whole finding turns on: a member reached through * `activeView?.KEY` — the host's `views` prop — is NOT read off the named view. - * `allowExport`, read off `activeView` and the node only, is the pinned - * counter-control below (it was `rowHeight` until objectui#10758 made the - * delegation read that one off the named view first). + * `allowExport`, read off the node only (off `activeView` too until + * objectui#11013), is the pinned counter-control below (it was `rowHeight` + * until objectui#10758 made the delegation read that one off the named view + * first). */ import { describe, it, expect } from 'vitest'; import { readFileSync } from 'node:fs'; @@ -312,9 +313,10 @@ const WERE_ABSENT_FROM_RENDERER = ['bulkActionDefs', 'exportOptions'] as const; * * The negative twin was `rowHeight` until objectui#10758 made the delegation * read it off the named view too. `allowExport` replaces it on the same - * footing: declared, read off `activeView` and the node, and NOT off a named - * view — objectui's own member (retained by the objectui#7924 ruling), which the - * protocol does not declare there. + * footing: declared, read off the node, and NOT off a named view — objectui's + * own member, which the protocol does not declare there. Since objectui#11013 + * it is a `?: never` tombstone and no longer read off `activeView` either; a + * tombstone is still a declared member, which is what its refusal by name is. */ const NAMED_VIEW_READ_CONTROL = 'columns'; const NAMED_VIEW_UNREAD_CONTROL = 'allowExport'; @@ -434,17 +436,32 @@ const BUCKET_LOCAL_ONLY_UNREAD = [ ] as const; /** - * The bucket-③ member objectui#7924 did NOT tombstone, because it is read and - * acted on: `allowExport` gates `ListView`'s export control. It carries its - * read as a source assertion in the retirement block below. + * The bucket-③ member objectui#7924 did NOT tombstone, because it was read and + * acted on: both relays carried `allowExport` off the active view, and + * `ListView` gates its export control on it. * * ⚠️ This held TWO names until ruling A′ (objectui#7924): `densityMode` was kept * because both relays of a named view carried it by name into the fold. Once * both relays read the density THROUGH the fold (`normalizeListViewSchema`), * nothing outside the fold reads it, so it retired as a legacy spelling of * top-level `rowHeight` — see {@link DENSITY_MODE_RETIRED}. + * + * ⚠️ …and it holds NONE since objectui#11013 (ruling 甲 on objectstack#20051, + * the seat's disposition on objectstack#20456: no producer, no declared + * spelling). Both relays stopped reading `allowExport` off a view, so it + * retired too — see {@link RETIRED_BY_11013}. `ListView`'s gate now hears the + * `list-view` node only. + */ +const BUCKET_LOCAL_ONLY_RETAINED = [] as const; + +/** + * The bucket-③ member objectui#11013 retired as a `?: never` tombstone once no + * relay read it off a view: the eighteenth objectui-only tombstone, and the one + * objectui#7924 did not put there. Kept apart from + * {@link NAMED_LIST_VIEW_TOMBSTONES}, which is that card's set and carries its + * marker, the way the objectui#11073 pair is kept apart below. */ -const BUCKET_LOCAL_ONLY_RETAINED = ['allowExport'] as const; +const RETIRED_BY_11013 = ['allowExport'] as const; /** * The bucket-③ member retired LATER than its neighbours, by ruling A′ on @@ -1474,12 +1491,13 @@ describe('objectui#7924 — the per-member liveness census on `NamedListView`, r expect(reads).toContain(NAMED_VIEW_READ_CONTROL); expect(declared).toContain(NAMED_VIEW_READ_CONTROL); // …and its negative twin: declared, and NOT read off a named view. It is - // read off `activeView` and the node instead — which is the distinction the - // whole finding turns on. (`rowHeight` held this role until objectui#10758 - // made the delegation read it off the named view first.) + // read off the node instead — which is the distinction the whole finding + // turns on. (`rowHeight` held this role until objectui#10758 made the + // delegation read it off the named view first; objectui#11013 dropped the + // `activeView` half of this read.) expect(declared).toContain(NAMED_VIEW_UNREAD_CONTROL); expect(reads).not.toContain(NAMED_VIEW_UNREAD_CONTROL); - expect(readRepo(READER)).toContain('allowExport: activeView?.allowExport ?? (schema as any).allowExport,'); + expect(readRepo(READER)).toContain('allowExport: (schema as any).allowExport,'); // A spelling that is in neither set: non-vacuity for both probes at once. expect(declared).not.toContain(NAMED_VIEW_ABSENT_CONTROL); expect(reads).not.toContain(NAMED_VIEW_ABSENT_CONTROL); @@ -1792,7 +1810,8 @@ describe('objectui#7924 — the unread members, in the FOUR buckets the ruling w * Two bucket-③ members were measured READ at retirement time and were NOT * tombstoned then. Ruling A′ (objectui#7924) retired one of them later: * `densityMode`, once both relays of a named view read the density through the - * fold instead of by name. `allowExport` stays ({@link BUCKET_LOCAL_ONLY_RETAINED}). + * fold instead of by name. objectui#11013 retired the other, `allowExport`, once + * both relays stopped reading it off a view ({@link RETIRED_BY_11013}). */ const LISTVIEW = 'packages/plugin-list/src/ListView.tsx'; @@ -1807,8 +1826,9 @@ type TombstoneTypes = { [K in Tombstone]: NamedListView[K] }; // (to `boolean`, `string`, or `any` through a deletion) makes this union not // `undefined`, and the type-check goes red. export type _EveryTombstoneAdmitsOnlyAbsence = Expect>; -// The retained member keeps its declared type. -export type _AllowExportRetained = Expect>; +// `allowExport` — this pin asserted the member RETAINED (`boolean`) until +// objectui#11013; re-pointed, not deleted, it now asserts the tombstone. +export type _AllowExportTombstoned = Expect>; // `densityMode` — this pin asserted the member RETAINED (its three-value union) // until ruling A′ (objectui#7924); re-pointed, not deleted, it now asserts the // tombstone: the member admits absence and nothing else. @@ -1869,24 +1889,29 @@ export const _acceptedCanonical: NamedListView = { appearance: { ...PROTOCOL_APPEARANCE }, rowColor: { field: 'status' }, }; -// The retained member still accepts its value… -export const _acceptedRetained: NamedListView = { ...BASE_VIEW, allowExport: false }; +// `allowExport` is refused like the rest since objectui#11013… +// @ts-expect-error — `allowExport` is RETIRED (objectui#11013): the list-view node's gate, not a view key +export const _refusedAllowExport: NamedListView = { ...BASE_VIEW, allowExport: false }; // …and the density the retired `densityMode` spelled is authored as the // protocol's top-level `rowHeight`, which a named view accepts. export const _acceptedDensityCanonical: NamedListView = { ...BASE_VIEW, rowHeight: 'compact' }; -describe('objectui#7924 — the retirement: seventeen `?: never` tombstones, one retained read, the canonical blocks', () => { - it('the tombstone set is EXACTLY bucket ② plus bucket ③ minus the retained read and the one held member, derived off the AST', () => { +describe('objectui#7924 — the retirement: seventeen `?: never` tombstones, objectui#11013\'s eighteenth, the canonical blocks', () => { + it('the tombstone set is EXACTLY bucket ② plus bucket ③ minus the objectui#11013 retirement and the one held member, derived off the AST', () => { const { tombstones: allTombstones, names } = namedListViewMembers(); // The objectui#11073 pair are protocol-retirement tombstones, censused in - // their own bucket above; this row is about objectui#7924's seventeen. - const tombstones = allTombstones.filter((t) => !(BUCKET_PROTOCOL_RETIRED_TOMBSTONED as readonly string[]).includes(t)); - expect(allTombstones).toHaveLength(19); + // their own bucket above, and `allowExport` is objectui#11013's; this row is + // about objectui#7924's seventeen. + const tombstones = allTombstones.filter((t) => !(BUCKET_PROTOCOL_RETIRED_TOMBSTONED as readonly string[]).includes(t) + && !(RETIRED_BY_11013 as readonly string[]).includes(t)); + expect(allTombstones).toHaveLength(20); expect([...tombstones].sort()).toEqual([...NAMED_LIST_VIEW_TOMBSTONES]); expect(tombstones).toHaveLength(17); + expect(BUCKET_LOCAL_ONLY_RETAINED).toHaveLength(0); + for (const m of RETIRED_BY_11013) expect(allTombstones).toContain(m); const expected = [ ...BUCKET_LEGACY_SHOW_SPELLINGS, - ...BUCKET_LOCAL_ONLY_UNREAD.filter((m) => !(BUCKET_LOCAL_ONLY_RETAINED as readonly string[]).includes(m) + ...BUCKET_LOCAL_ONLY_UNREAD.filter((m) => !(RETIRED_BY_11013 as readonly string[]).includes(m) && !(BUCKET_LOCAL_ONLY_HELD as readonly string[]).includes(m)), ].sort(); expect([...tombstones].sort()).toEqual(expected); @@ -1932,9 +1957,19 @@ describe('objectui#7924 — the retirement: seventeen `?: never` tombstones, one for (const m of BUCKET_LOCAL_ONLY_UNREAD) expect(map).not.toHaveProperty(m); }); - it('`allowExport` is RETAINED because it is read: app-shell relays it off the named view and ListView gates export on it', () => { - expect(namedListViewMembers().tombstones).not.toContain('allowExport'); - expect(readRepo(APP_SHELL_OBJECT_VIEW)).toContain('allowExport: viewDef.allowExport ?? listSchema.allowExport,'); + it('`allowExport` is RETIRED (objectui#11013): neither relay reads it off a view, and ListView gates export on the node\'s value', () => { + // Re-pointed from the RETAINED pin it replaces: that pin held the app-shell + // relay line naming the key; this one holds the tombstone, the absence of + // the view read at BOTH relays, and — as the firing control on the same + // files — the node read upstream and the `ListView` gate that reads it. + const { tombstones, docs } = namedListViewMembers(); + expect(tombstones).toContain('allowExport'); + expect(docs.allowExport).toContain('RETIRED (objectui#11013)'); + const appShell = readRepo(APP_SHELL_OBJECT_VIEW); + const pluginView = readRepo(READER); + expect(appShell).not.toContain('viewDef.allowExport'); + expect(pluginView).not.toContain('activeView?.allowExport'); + expect(pluginView).toContain('allowExport: (schema as any).allowExport,'); expect(readRepo(LISTVIEW)).toContain('schema.allowExport !== false &&'); }); @@ -1974,11 +2009,17 @@ describe('objectui#7924 — the retirement: seventeen `?: never` tombstones, one } }); - it('the retained reads have a firing control: a tombstoned relay key that ListView never reads, on the same query', () => { - // app-shell relays `prefixField` exactly as it relays `allowExport`… - expect(readRepo(APP_SHELL_OBJECT_VIEW)).toContain('prefixField: viewDef.prefixField ?? listSchema.prefixField,'); - // …but nothing downstream reads it — which is why it was tombstoned and - // `allowExport` was not. Same file, same query, opposite answers. + it('objectui#11013: app-shell relays neither `prefixField` nor `allowExport` off a view, and ListView still reads only the second', () => { + // app-shell used to relay `prefixField` exactly as it relayed + // `allowExport`; objectui#11013 dropped both rungs (no producer writes either + // onto a view). The reader side is the control: nothing downstream reads + // `prefixField`, while `ListView` still reads `allowExport` — off the node's + // value now. Same files, same query, opposite answers. + const appShell = readRepo(APP_SHELL_OBJECT_VIEW); + expect(appShell).not.toContain('viewDef.prefixField'); + expect(appShell).not.toContain('viewDef.allowExport'); + // Firing control on the same file: the relay still reads other keys off the view. + expect(appShell).toContain('rowColor: viewDef.rowColor ?? listSchema.rowColor,'); const listView = readRepo(LISTVIEW); expect(listView).not.toMatch(/\bprefixField\b/); expect(listView).toMatch(/\ballowExport\b/); diff --git a/packages/types/src/objectql.ts b/packages/types/src/objectql.ts index b8116895bb..52d81f3bf7 100644 --- a/packages/types/src/objectql.ts +++ b/packages/types/src/objectql.ts @@ -2626,17 +2626,21 @@ export interface NamedListView { compactToolbar?: boolean; /** - * Allow data export @default undefined + * @deprecated RETIRED (objectui#11013) — the protocol declares no + * `allowExport` on a view, and no relay reads it off one any more. Whether a + * list offers export is the host's call, made on the `list-view` node + * (`ListView` gates its export control on that node's + * `schema.allowExport !== false`); which formats a view offers is its + * `exportOptions.formats`. * - * ⚠️ objectui-only (the protocol declares no `allowExport` on a view), but - * ⛔ NOT retired with its bucket-③ neighbours: it is READ. BOTH relays carry - * it off the active view into the `list-view` node — `plugin-view`'s - * `ObjectView` in its `renderListView` composition, and `app-shell`'s object - * page in its own relay over that composition — and `ListView` gates its - * export control on `schema.allowExport !== false`. Kept declared by ruling A - * on objectui#7924. + * Kept declared by ruling A on objectui#7924 while both relays of a view read + * it into the `list-view` node. objectui#11013 (ruling 甲 on + * objectstack#20051, and the seat's disposition on objectstack#20456: no + * producer, no declared spelling) removed both reads, since no console + * surface writes the key onto a view and the spec's view schema refuses it by + * name. Tombstoned rather than deleted so the refusal is by name. */ - allowExport?: boolean; + allowExport?: never; /** * @deprecated RETIRED (objectui#7924) — the protocol declares no `color` on a @@ -2821,8 +2825,10 @@ export interface NamedListView { * The 19 legacy spellings this interface declared BEYOND the protocol were * objectui#7924's remedy (ruling item 4) — ⛔ not touched by that card. It * retired seventeen of them as tombstones, `densityMode` last (ruling A′, - * once both of its relays read through the fold); `options` and - * `allowExport` stay declared because each is read. + * once both of its relays read through the fold). `allowExport` stayed + * declared because it was read, until objectui#11013 removed both reads and + * retired it as the eighteenth; `options` stays declared (objectui#7928, + * Q2 (i)). */ /**