diff --git a/.changeset/12504-retire-view-write-family.md b/.changeset/12504-retire-view-write-family.md new file mode 100644 index 0000000000..0c8ee29e6d --- /dev/null +++ b/.changeset/12504-retire-view-write-family.md @@ -0,0 +1,22 @@ +--- +'@object-ui/types': minor +'@object-ui/data-objectstack': minor +'@object-ui/app-shell': patch +'@object-ui/i18n': patch +'@object-ui/plugin-grid': patch +'@object-ui/plugin-list': patch +'@object-ui/react': patch +'@object-ui/components': patch +'@object-ui/plugin-detail': patch +'@object-ui/console': patch +--- + +The console's list toolbar no longer has a write into the shared view, and the data-source method that write used is retired (objectui#12504). + +**BREAKING:** `DataSource.updateViewConfig` is removed from `@object-ui/types`, and `ObjectStackAdapter.updateViewConfig` is removed from `@object-ui/data-objectstack`. The capability gate that only that method read goes with it: `ObjectStackAdapter.setSystemCapabilities`, the `systemCapabilities` option of the adapter constructor and of `createObjectStackAdapter`, `VIEW_CONFIG_CAPABILITY`, `ViewConfigPermissionDeniedError` and `isViewConfigPermissionDeniedError`. A class that implements `DataSource` and keeps an `updateViewConfig` method still compiles, but nothing calls the method any more; an object literal annotated as `DataSource` that carries it is refused as an unknown property. Code that calls `updateViewConfig` through a `DataSource` or an `ObjectStackAdapter`, or imports one of the gate names, no longer compiles. To change a view for everyone, save the view: `createView` / `updateView` on the data source, or the console's view editor, which writes through the runtime metadata seam. + +Since objectui#12418 and objectui#12474, the toolbar's sort, column layout, density and Hide Fields are each user's own list state, so the toolbar write had no caller. The view editor's explicit save is now the one write path to the shared view, and it is unchanged. + +Personalization rows that earlier consoles wrote into `/meta/view` stay readable: the overlay marker, `narrowPersonalizationOverlay` and `listViews()` exclusion still apply to them. Nothing new writes such a row. + +`@object-ui/i18n` drops `console.objectView.viewConfigPermissionDenied` from all ten packs. It was the toast the retired write raised when the gate refused it. The other packages change only in comments and tests that named the retired path. diff --git a/apps/console/src/__tests__/public-block-binding-reach.test.tsx b/apps/console/src/__tests__/public-block-binding-reach.test.tsx index 8dec81897f..1b0528dadf 100644 --- a/apps/console/src/__tests__/public-block-binding-reach.test.tsx +++ b/apps/console/src/__tests__/public-block-binding-reach.test.tsx @@ -118,7 +118,6 @@ const DATA_SOURCE_METHODS = [ 'getView', 'listViews', 'listViewOverrides', - 'updateViewConfig', 'onMutation', ] as const; diff --git a/apps/console/src/__tests__/record-block-record-reach.test.tsx b/apps/console/src/__tests__/record-block-record-reach.test.tsx index df8a0b7b51..d03a7b4500 100644 --- a/apps/console/src/__tests__/record-block-record-reach.test.tsx +++ b/apps/console/src/__tests__/record-block-record-reach.test.tsx @@ -177,7 +177,7 @@ const PROBE_OBJECT_SCHEMA = { const DATA_SOURCE_METHODS = [ 'find', 'findOne', 'create', 'update', 'delete', 'aggregate', 'count', 'getObjectSchema', 'getObjects', 'getView', 'listViews', 'listViewOverrides', - 'updateViewConfig', 'onMutation', + 'onMutation', ] as const; /** diff --git a/content/docs/utilities/data-objectstack.mdx b/content/docs/utilities/data-objectstack.mdx index a9243ada53..649fc62f10 100644 --- a/content/docs/utilities/data-objectstack.mdx +++ b/content/docs/utilities/data-objectstack.mdx @@ -136,14 +136,6 @@ function createObjectStackAdapter(config: { autoReconnect?: boolean; // default true maxReconnectAttempts?: number; // default 3 reconnectDelay?: number; // default 1000 ms - - /** - * [ADR-0066] The session's system capabilities, when the host already has them - * at construction time. Most hosts do NOT — `/me/permissions` resolves after - * the adapter exists — and push them in later via `setSystemCapabilities`. - * Omit for "unreported". - */ - systemCapabilities?: string[]; }): ObjectStackAdapter; ``` @@ -250,11 +242,11 @@ either form): - `onBatchProgress(listener)` - Subscribe to bulk progress (returns unsubscribe) Those six bullets cover nine members, and they are the documented subset. -`ObjectStackAdapter` declares **20** members beyond `DataSource` in all -(`Exclude, keyof DataSource>`); the -other eleven are lower-level seams — client discovery, cache-key invalidation, -dataset and dashboard access, advisory subscriptions and capability configuration -among them — which this page does not document and which carry no compatibility +`ObjectStackAdapter` declares more members beyond `DataSource` than these — +`Exclude, keyof DataSource>` lists +them all; the rest are lower-level seams — client discovery, cache-key +invalidation, dataset and dashboard access and advisory subscriptions among +them — which this page does not document and which carry no compatibility promise here. ### Per-element data binding diff --git a/packages/app-shell/src/console/__tests__/consoleListStateWall-12418.test.tsx b/packages/app-shell/src/console/__tests__/consoleListStateWall-12418.test.tsx index 76d7bfec8b..1937c17599 100644 --- a/packages/app-shell/src/console/__tests__/consoleListStateWall-12418.test.tsx +++ b/packages/app-shell/src/console/__tests__/consoleListStateWall-12418.test.tsx @@ -29,9 +29,10 @@ * NO metadata write on the wire, raise no error, and land as ONE * preference write each, of the same row. * 3. CONTROL — the wall is live: a metadata write made in this same tree - * (the real adapter's own view write, the call the density toggle used - * to reach) is answered 403. Since objectui#12474 no list toolbar control - * makes one, so the control is the adapter's call itself. + * (the real adapter's own view write, `createView`) is answered 403. + * Since objectui#12474 no list toolbar control makes one, so the control + * is the adapter's call itself. (It was `updateViewConfig`, the call the + * density toggle used to reach, until objectui#12504 retired it.) */ import '@testing-library/jest-dom/vitest'; @@ -263,12 +264,12 @@ describe('a user\'s list state under a walled posture (objectui#12418)', () => { boot(); await waitFor(() => expect(typeof listProps?.schema?.onDensityChange).toBe('function'), { timeout: 5000 }); await wait(100); - expect(typeof pageAdapter?.updateViewConfig).toBe('function'); + expect(typeof pageAdapter?.createView).toBe('function'); let refused: unknown = null; await act(async () => { try { - await pageAdapter.updateViewConfig(OBJECT_NAME, ALL_ID, { rowHeight: 'compact' }, { isSavedView: false }); + await pageAdapter.createView(OBJECT_NAME, { name: `${OBJECT_NAME}.probe`, label: 'Probe', type: 'grid' }); } catch (err) { refused = err; } diff --git a/packages/app-shell/src/utils/viewIdentity.ts b/packages/app-shell/src/utils/viewIdentity.ts index 81fe49e650..826d74ae0e 100644 --- a/packages/app-shell/src/utils/viewIdentity.ts +++ b/packages/app-shell/src/utils/viewIdentity.ts @@ -71,9 +71,10 @@ export function defaultListViewId(objectName: string, list: unknown): string | u * stored override therefore REDEFINED the tab's identity. * * The third spelling is the defect. A stored view body can legitimately carry - * an `id` key — `persistViewPatch` writes the whole tab object (`id` included) - * back through `updateViewConfig`, and a duplicated view copies its source - * artifact's `id` verbatim — so the spread handed the tab an identity the + * an `id` key — the console's toolbar write (`persistViewPatch` → + * `updateViewConfig`, retired in objectui#12504) wrote the whole tab object + * (`id` included), and those rows are still at rest; a duplicated view copies + * its source artifact's `id` verbatim — so the spread handed the tab an identity the * overlay read had never heard of. `isSavedView` then answered false for a view * that *is* saved, which both hid the set-default / rename / delete menu * entries (`readonly: !saved`) and short-circuited the handlers behind them. diff --git a/packages/app-shell/src/views/InterfaceListPage.hollowOverlayHydration.test.tsx b/packages/app-shell/src/views/InterfaceListPage.hollowOverlayHydration.test.tsx index adfd162a33..7c1fe261bf 100644 --- a/packages/app-shell/src/views/InterfaceListPage.hollowOverlayHydration.test.tsx +++ b/packages/app-shell/src/views/InterfaceListPage.hollowOverlayHydration.test.tsx @@ -9,7 +9,7 @@ * > DOCUMENT … and `InterfaceListPage` hydrates a hollow view out of that * > document. * - * `InterfaceListPage`'s hollow-view hydration effect (lines ~270-315) fetches + * `InterfaceListPage`'s hollow-view hydration effect fetches * a view's per-view overlay row through `listViewOverrides`/`getView` — the * SAME undocked-narrowing read path the docblock is talking about — whenever * the ADR-0017 expansion serves a source view with no `columns`. Before @@ -17,17 +17,19 @@ * `{ ...baseViewDef, ...patch }`, so that row carried a frozen COPY of the * source view's columns at write time; the merge `{ ...resolvedView, * ...hydratedView }` picked those up and the page rendered STALE columns. - * After #5233 the same write site stores the patch alone — no `columns` key + * After #5233 the same write site stored the patch alone — no `columns` key * — so the merge contributes nothing towards `hasColumns`, and the page's own * defaulting (`defaultColumnsFromObject`) fires instead. * - * This is the card's step 1: CONSTRUCT the case (a hollow ADR-0017 expansion - * item for a system view that also carries a personalization overlay row), - * driven through the REAL `ObjectStackAdapter` write (`updateViewConfig` / - * `buildPersistedViewBody`, the same seam `ObjectView.overlayPatchOnly.test.ts` - * uses) and the REAL `InterfaceListPage` render — not reasoned about, and not - * a hand-written override fixture, because the coupling lives in what the - * hydration effect does with a row it actually fetched. + * That write site is retired (objectui#12504: `persistViewPatch` and the + * adapter's `updateViewConfig`), so both row shapes are now rows an install + * already carries, and the hydration effect still reads them. This is the + * card's step 1: CONSTRUCT the case (a hollow ADR-0017 expansion item for a + * system view that also carries a personalization overlay row), with the row + * put in the store in the shape the retired write stored it, read through the + * REAL `ObjectStackAdapter` and the REAL `InterfaceListPage` render — not + * reasoned about, because the coupling lives in what the hydration effect does + * with a row it actually fetched. * * Two cases, so the second is a control that PROVES the first discriminates * something real: with the CURRENT (post-#5233) thin overlay shape the page @@ -87,7 +89,6 @@ vi.mock('@object-ui/react', async (importOriginal) => { }); import { ObjectStackAdapter } from '@object-ui/data-objectstack'; -import { buildPersistedViewBody } from './ObjectView'; import { InterfaceListPage } from './InterfaceListPage'; const OBJECT_NAME = 'crm_lead'; @@ -119,6 +120,15 @@ function makeMetaStore() { return { meta, rows }; } +/** + * Put a personalization overlay in the store the way the retired toolbar write + * (`updateViewConfig`, objectui#12504) stored one: the body, with `object`, + * `name` and the `_isOverride` marker stamped on it. + */ +function plantOverlay(rows: Map, body: Record) { + rows.set(`view::${VIEW_ID}`, { ...body, object: OBJECT_NAME, name: VIEW_ID, _isOverride: true }); +} + function makeAdapter(meta: any) { const ds: any = new ObjectStackAdapter({ baseUrl: 'http://test.local', @@ -162,20 +172,15 @@ const page = { describe('objectui#5773 — InterfaceListPage hollow view + personalization overlay hydration', () => { it('REACHABLE: a hollow system view + a thin post-#5233 overlay row hydrates to renderer DEFAULTS, not a hollow grid', async () => { - const { meta } = makeMetaStore(); + const { meta, rows } = makeMetaStore(); const ds = makeAdapter(meta); testDataSource = ds; testObjects = [makeObjectDef()]; - // The CURRENT `persistViewPatch` write for a toolbar toggle on this - // system view (a density change) — patch alone, no `columns`, exactly - // objectui#5233's write half. Written through the REAL adapter, not - // dropped into the store as a fixture. - await ds.updateViewConfig( - OBJECT_NAME, - VIEW_ID, - buildPersistedViewBody({ viewKind: 'list' }, { rowHeight: 'compact' }, { isSavedView: false }), - ); + // The LAST shape the retired toolbar write stored for a toggle on this + // system view (a density change): the patch alone plus the tab's + // `viewKind`, no `columns` — objectui#5233's write half. + plantOverlay(rows, { rowHeight: 'compact', viewKind: 'list' }); // Confirms the row really is thin at rest — the premise this case // depends on, measured rather than assumed. @@ -198,18 +203,17 @@ describe('objectui#5773 — InterfaceListPage hollow view + personalization over }); it('CONTROL: the same hollow view with a PRE-#5233 fat overlay row still renders the frozen stale columns (proves the pin discriminates old vs. new row shapes)', async () => { - const { meta } = makeMetaStore(); + const { meta, rows } = makeMetaStore(); const ds = makeAdapter(meta); testDataSource = ds; testObjects = [makeObjectDef()]; - // What `persistViewPatch` USED TO send — the whole active tab (frozen at - // write time) plus the one changed key. An install that has not touched - // this view since before objectui#5233 is still carrying a row shaped - // exactly like this one (`ObjectView.overlayPatchOnly.test.ts`'s own - // "PRE-FIX" framing) — this is that row, written through the REAL write - // path (`updateViewConfig`), not hand-assembled as a display fixture. - await ds.updateViewConfig(OBJECT_NAME, VIEW_ID, { + // What `persistViewPatch` USED TO send before objectui#5233 — the whole + // active tab (frozen at write time) plus the one changed key. An install + // that has not touched this view since then is still carrying a row + // shaped exactly like this one (`ObjectView.overlayPatchOnly.test.ts`'s + // own "PRE-FIX" framing). + plantOverlay(rows, { viewKind: 'list', type: 'grid', columns: ['status'], diff --git a/packages/app-shell/src/views/ObjectView.defaultViewIdentity.test.tsx b/packages/app-shell/src/views/ObjectView.defaultViewIdentity.test.tsx index 5027b3c7fb..40cb405194 100644 --- a/packages/app-shell/src/views/ObjectView.defaultViewIdentity.test.tsx +++ b/packages/app-shell/src/views/ObjectView.defaultViewIdentity.test.tsx @@ -290,12 +290,15 @@ describe('default list view identity → _views translation key (objectui#3770)' /** * Stored-data surface, measured before the behaviour change and pinned here. * - * `ObjectView.persistViewPatch` writes a view override by VIEW ID: - * `dataSource.updateViewConfig(objectName, view.id, cfg)` → - * `client.meta.saveItem('view', view.id, { …cfg, object, name: view.id })`, i.e. - * one `sys_metadata` row of type `view` NAMED by the id. The id therefore doubles - * as the persistence key, and `ViewItemNameSchema` is the spec's judgment on - * which strings are legal view identities: + * A view's stored row is keyed by VIEW ID: the explicit save writes + * `persistRuntimeMetadata('view', vid, …)`, and the override rows the page + * reads back (`loadViewOverrides`) are found by the same id — one + * `sys_metadata` row of type `view` NAMED by the id. (The toolbar's + * `persistViewPatch` → `updateViewConfig` wrote its overlays the same way, + * `saveItem('view', view.id, { …cfg, object, name: view.id })`, until + * objectui#12504 retired it; those rows are still at rest.) The id therefore + * doubles as the persistence key, and `ViewItemNameSchema` is the spec's + * judgment on which strings are legal view identities: * * /^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$/ * diff --git a/packages/app-shell/src/views/ObjectView.densityInsideConfig-11625.test.ts b/packages/app-shell/src/views/ObjectView.densityInsideConfig-11625.test.ts deleted file mode 100644 index b46bbeb07f..0000000000 --- a/packages/app-shell/src/views/ObjectView.densityInsideConfig-11625.test.ts +++ /dev/null @@ -1,233 +0,0 @@ -/** - * 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#11625 — a toolbar patch on an envelope-shaped view row is written - * inside `config`, where the save door keeps it. - * - * The defect: the grid's density toggle on a served view (the stock - * `showcase_task.default` in the QA run) sent the whole active tab — which - * carries the stored ViewItem envelope `{ name, object, viewKind, config }` — - * with `rowHeight` spread BESIDE `config`. A body with a `config` is judged by - * the spec's `viewItem` member of the `view` union, whose top level is - * stripped, so the door answered `200` and stored no `rowHeight`; the density - * reverted on reload. `sort` and `hiddenFields` went through the same spread. - * - * The store below judges every PUT with the spec's own `ViewMetadataSchema` and - * keeps the parsed value, the way the door keeps "the parsed value of every key - * its body carried". That is what makes the pre-fix control below go red for - * the reason the live door drops the key, instead of passing because a stub - * kept everything it was handed. - */ - -import { describe, it, expect, vi } from 'vitest'; -import { ViewMetadataSchema } from '@objectstack/spec/ui'; -import { ObjectStackAdapter } from '@object-ui/data-objectstack'; -import { buildPersistedViewBody, buildViewTabs, loadViewOverrides } from './ObjectView'; - -const OBJECT_NAME = 'showcase_task'; -const VIEW_ID = 'showcase_task.default'; - -/** The stored row of a served view, as `/meta/view` serves it. */ -const STORED_ROW = { - name: VIEW_ID, - object: OBJECT_NAME, - viewKind: 'list', - label: 'All Tasks', - isDefault: true, - isPinned: true, - sortOrder: 2, - visibility: 'organization', - columnState: { widths: { name: 220 } }, - config: { - type: 'grid', - columns: ['name', 'status'], - data: { provider: 'object', object: OBJECT_NAME }, - rowHeight: 'compact', - }, -}; - -/** - * The active tab the toolbar fires on: the flattened body (`columns`, `type`, - * `rowHeight` …) merged with the stored row itself, `config` included, plus - * the tab's runtime `id` — the merge `buildViewTabs` produces for a served view. - */ -const ACTIVE_TAB = { - ...STORED_ROW.config, - ...STORED_ROW, - id: VIEW_ID, -}; - -const ROW_STATE = { - isDefault: STORED_ROW.isDefault, - isPinned: STORED_ROW.isPinned, - sortOrder: STORED_ROW.sortOrder, - visibility: STORED_ROW.visibility, - columnState: STORED_ROW.columnState, -}; - -/** A `sys_metadata`-shaped store whose PUT keeps what the spec's judge keeps. */ -function makeJudgingStore() { - const rows = new Map(); - 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) => { - const item = rows.get(`${type}::${name}`); - if (!item) { - const err: any = new Error(`Not found: ${type}/${name}`); - err.status = 404; - throw err; - } - return { type, name, item }; - }), - saveItem: vi.fn(async (type: string, name: string, item: any) => { - const judged = ViewMetadataSchema.safeParse(item); - if (!judged.success) { - const err: any = new Error(`422 INVALID_METADATA: ${judged.error.message}`); - err.status = 422; - throw err; - } - rows.set(`${type}::${name}`, judged.data); - return { success: true, item: judged.data }; - }), - }; - return { meta, rows }; -} - -function makeAdapter(meta: any) { - const ds: any = new ObjectStackAdapter({ - baseUrl: 'http://test.local', - fetch: vi.fn(async () => - new Response(JSON.stringify({ success: true, data: { capabilities: {}, routes: {} } }), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - })), - }); - ds.connected = true; - ds.connectionState = 'connected'; - ds.client = { meta }; - return ds; -} - -/** Write a toolbar patch the way `persistViewPatch` does on a saved view, then reload the tab. */ -async function writeThenReload(body: Record) { - const { meta, rows } = makeJudgingStore(); - rows.set(`view::${VIEW_ID}`, { ...STORED_ROW }); - const ds = makeAdapter(meta); - await ds.updateViewConfig(OBJECT_NAME, VIEW_ID, body, { isSavedView: true }); - const stored = rows.get(`view::${VIEW_ID}`); - const savedViews = (await ds.listViews(OBJECT_NAME)).map((sv: any) => ({ ...sv, id: sv.name })); - const viewOverrides = await loadViewOverrides(ds, OBJECT_NAME, [VIEW_ID]); - const tab = buildViewTabs({ - definedViews: { [VIEW_ID]: { ...STORED_ROW.config, name: VIEW_ID, label: STORED_ROW.label } }, - savedViews, - viewOverrides, - fallbackTab: () => ({ id: 'all', label: 'All records', type: 'grid', columns: [] }), - }).find((t) => t.id === VIEW_ID)!; - return { stored, tab }; -} - -describe('objectui#11625 — the saved branch writes list-view keys inside `config`', () => { - it('density lands in `config`, and no copy of it is left on the envelope', () => { - const body = buildPersistedViewBody(ACTIVE_TAB, { rowHeight: 'medium' }, { isSavedView: true }); - - expect(body.config.rowHeight).toBe('medium'); - expect(body).not.toHaveProperty('rowHeight'); - // The rest of the body the row is made of rides along unchanged. - expect(body.config.columns).toEqual(STORED_ROW.config.columns); - expect(body.config.data).toEqual(STORED_ROW.config.data); - expect(body.name).toBe(VIEW_ID); - expect(body.viewKind).toBe('list'); - expect(body.label).toBe(STORED_ROW.label); - }); - - it('`sort`, `hiddenFields` and `inlineEdit` go through the same door into `config`', () => { - const sort = [{ field: 'name', order: 'asc' }]; - const patch = { sort, hiddenFields: ['status'], inlineEdit: true }; - const body = buildPersistedViewBody(ACTIVE_TAB, patch, { isSavedView: true }); - - expect(body.config.sort).toEqual(sort); - expect(body.config.hiddenFields).toEqual(['status']); - expect(body.config.inlineEdit).toBe(true); - expect(body).not.toHaveProperty('sort'); - expect(body).not.toHaveProperty('hiddenFields'); - expect(body).not.toHaveProperty('inlineEdit'); - }); - - it('the row-owned keys stay on the envelope (objectui#11013) — `columnState` included', () => { - const columnState = { order: ['status', 'name'], widths: { name: 180 } }; - const body = buildPersistedViewBody(ACTIVE_TAB, { rowHeight: 'medium', columnState }, { isSavedView: true }); - - expect(body.columnState).toEqual(columnState); - expect(body.config).not.toHaveProperty('columnState'); - expect(body.isDefault).toBe(ROW_STATE.isDefault); - expect(body.isPinned).toBe(ROW_STATE.isPinned); - expect(body.sortOrder).toBe(ROW_STATE.sortOrder); - expect(body.visibility).toBe(ROW_STATE.visibility); - for (const key of Object.keys(ROW_STATE)) { - expect(body.config, `row-owned \`${key}\` was moved into config`).not.toHaveProperty(key); - } - }); - - it('a FLAT saved row keeps the flat spread — a `config` would move it to the member that strips it', () => { - const { config: _config, ...flatTab } = ACTIVE_TAB; - const body = buildPersistedViewBody(flatTab, { rowHeight: 'medium' }, { isSavedView: true }); - - expect(body).toEqual({ ...flatTab, rowHeight: 'medium' }); - expect(body).not.toHaveProperty('config'); - }); - - it('the overlay branch is unchanged: the flat patch plus `viewKind`', () => { - const body = buildPersistedViewBody(ACTIVE_TAB, { rowHeight: 'medium' }, { isSavedView: false }); - expect(body).toEqual({ rowHeight: 'medium', viewKind: 'list' }); - }); -}); - -describe('objectui#11625 — round trip through the judged store: the patch survives reload', () => { - it('control: the pre-fix spread is accepted and the density is dropped (the defect, reproduced)', async () => { - // EXACTLY the body the saved branch sent before this change. - const { stored, tab } = await writeThenReload({ ...ACTIVE_TAB, rowHeight: 'medium' }); - - expect(stored).not.toHaveProperty('rowHeight'); - expect(stored.config.rowHeight).toBe('compact'); - expect(tab.rowHeight).toBe('compact'); - }); - - it.each([ - ['rowHeight', 'medium'], - ['sort', [{ field: 'name', order: 'asc' }]], - ['hiddenFields', ['status']], - ] as const)('`%s` is stored in `config` and read back onto the tab', async (key, value) => { - const { stored, tab } = await writeThenReload( - buildPersistedViewBody(ACTIVE_TAB, { [key]: value }, { isSavedView: true }), - ); - - expect(stored.config[key]).toEqual(value); - expect(tab[key]).toEqual(value); - // The row state the envelope carried is still the row's. - expect(stored.isDefault).toBe(ROW_STATE.isDefault); - expect(stored.isPinned).toBe(ROW_STATE.isPinned); - expect(stored.sortOrder).toBe(ROW_STATE.sortOrder); - expect(stored.visibility).toBe(ROW_STATE.visibility); - expect(stored.columnState).toEqual(ROW_STATE.columnState); - }); - - it('a `columnState` patch is stored on the envelope and read back', async () => { - const columnState = { order: ['status', 'name'], widths: { name: 180 } }; - const { stored, tab } = await writeThenReload( - buildPersistedViewBody(ACTIVE_TAB, { columnState }, { isSavedView: true }), - ); - - expect(stored.columnState).toEqual(columnState); - expect(stored.config).not.toHaveProperty('columnState'); - expect(tab.columnState).toEqual(columnState); - }); -}); 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 e6e9af02c2..1fa76c46da 100644 --- a/packages/app-shell/src/views/ObjectView.emptyColumnsNoProjection-10694.test.tsx +++ b/packages/app-shell/src/views/ObjectView.emptyColumnsNoProjection-10694.test.tsx @@ -67,7 +67,9 @@ * so it records its `columns`. The renderer's own columns are read off a view * that declares no projection and carries neither key, so the defaults are * never restated here. The toggle is driven through `ListView`'s own hide-fields - * popover, and the writes are read off the data source's `updateViewConfig`. + * popover, and the writes are read off the data source's view writes + * (`updateView` / `createView`; the toolbar's own view write, + * `updateViewConfig`, retired in objectui#12504). */ import * as React from 'react'; @@ -278,9 +280,14 @@ async function hideThroughToggle(field: string): Promise { fireEvent.click(label.closest('label')!.querySelector('input')!); } -/** The `updateViewConfig` calls whose body carries `hiddenFields`. */ -const hiddenFieldsWrites = (dataSource: any) => - dataSource.updateViewConfig.mock.calls.filter((call: any[]) => call[2] && 'hiddenFields' in call[2]); +/** The data source's view writes, each a spy: what a write to the shared view would call. */ +const viewWriteSpies = () => ({ updateView: vi.fn(async () => ({})), createView: vi.fn(async () => ({})) }); + +/** Every view write the page made through the data source. */ +const viewWrites = (dataSource: any): unknown[][] => [ + ...dataSource.updateView.mock.calls, + ...dataSource.createView.mock.calls, +]; /** * objectui#12474 — the hidden fields this user stored for `viewId`, read off @@ -331,7 +338,7 @@ const SAVED_BESIDE = { function savedViewSource(extra: Record = {}) { const savedRow = { name: SAVED_ID, label: 'Mine', type: 'grid', userActions: { hideFields: true }, ...extra }; return makeDataSource({ - updateViewConfig: vi.fn(async () => ({})), + ...viewWriteSpies(), listViews: vi.fn(async () => [savedRow]), }); } @@ -341,7 +348,7 @@ async function expectSessionOnlyToggle(dataSource: any): Promise { await hideThroughToggle('region'); await waitFor(() => expect(drawn).not.toContain('region')); await new Promise((resolve) => setTimeout(resolve, PAST_DEBOUNCE_MS)); - expect(hiddenFieldsWrites(dataSource)).toEqual([]); + expect(viewWrites(dataSource)).toEqual([]); expect(storedHiddenFields(PROBE_ID)).toBeUndefined(); } @@ -390,7 +397,7 @@ describe('an unprojected view applies neither hiddenFields nor fieldOrder on the }); it('THE FIX: on a system view with no declared `columns`, the hide toggle is session-only', async () => { - const dataSource = makeDataSource({ updateViewConfig: vi.fn(async () => ({})) }); + const dataSource = makeDataSource(viewWriteSpies()); await mountRoute( { [PROBE_ID]: { name: PROBE_ID, label: 'Probe', type: 'grid', columns: [], userActions: { hideFields: true } } }, PROBE_ID, @@ -404,12 +411,12 @@ describe('an unprojected view applies neither hiddenFields nor fieldOrder on the // And nothing is written or stored: a `hiddenFields` on this view would // be read by nothing on reload (the CONTROL below shows the store is live). await new Promise((resolve) => setTimeout(resolve, PAST_DEBOUNCE_MS)); - expect(hiddenFieldsWrites(dataSource)).toEqual([]); + expect(viewWrites(dataSource)).toEqual([]); expect(storedHiddenFields(PROBE_ID)).toBeUndefined(); }); it('CONTROL: on a system view that declares `columns`, the toggle is stored as the user\'s own, and the view is not written (objectui#12474)', async () => { - const dataSource = makeDataSource({ updateViewConfig: vi.fn(async () => ({})) }); + const dataSource = makeDataSource(viewWriteSpies()); const listViews = { [PROBE_ID]: { name: PROBE_ID, label: 'Probe', type: 'grid', @@ -421,7 +428,7 @@ describe('an unprojected view applies neither hiddenFields nor fieldOrder on the await waitFor(() => expect(storedHiddenFields(PROBE_ID)).toEqual(['region']), { timeout: 3000 }); await waitFor(() => expect(drawn).toEqual(['name', 'stage'])); await new Promise((resolve) => setTimeout(resolve, PAST_DEBOUNCE_MS)); - expect(dataSource.updateViewConfig).not.toHaveBeenCalled(); + expect(viewWrites(dataSource)).toEqual([]); cleanup(); // Reload: the user's choice is applied over the view's own `columns`. @@ -442,8 +449,8 @@ describe('an unprojected view applies neither hiddenFields nor fieldOrder on the await waitFor(() => expect(storedHiddenFields(SAVED_ID)).toEqual(['region']), { timeout: 3000 }); await waitFor(() => expect(drawn).toEqual(defaults.filter((f) => f !== 'region'))); await new Promise((resolve) => setTimeout(resolve, PAST_DEBOUNCE_MS)); - // No `/meta/view` write: the adapter's view write is never called. - expect(first.updateViewConfig).not.toHaveBeenCalled(); + // No `/meta/view` write: no view write of the data source is called. + expect(viewWrites(first)).toEqual([]); cleanup(); // Reload: the stored list is applied over the drawn defaults. @@ -575,7 +582,7 @@ describe('an unprojected view applies neither hiddenFields nor fieldOrder on the }); it('THE FIX: after a panel edit of an unrelated field, the system view\'s hide toggle stays session-only', async () => { - const dataSource = makeDataSource({ updateViewConfig: vi.fn(async () => ({})) }); + const dataSource = makeDataSource(viewWriteSpies()); await mountRoute(UNPROJECTED_WITH_TOGGLE, PROBE_ID, dataSource); await openPanel(); await editUnrelatedField(); @@ -593,7 +600,7 @@ describe('an unprojected view applies neither hiddenFields nor fieldOrder on the }); it('THE FIX: after a panel edit and Discard, the system view\'s hide toggle stays session-only', async () => { - const dataSource = makeDataSource({ updateViewConfig: vi.fn(async () => ({})) }); + const dataSource = makeDataSource(viewWriteSpies()); await mountRoute(UNPROJECTED_WITH_TOGGLE, PROBE_ID, dataSource); await openPanel(); await editUnrelatedField(); diff --git a/packages/app-shell/src/views/ObjectView.fallbackTabSessionOnly-11643.test.tsx b/packages/app-shell/src/views/ObjectView.fallbackTabSessionOnly-11643.test.tsx index dceb22f0a6..624cd67eba 100644 --- a/packages/app-shell/src/views/ObjectView.fallbackTabSessionOnly-11643.test.tsx +++ b/packages/app-shell/src/views/ObjectView.fallbackTabSessionOnly-11643.test.tsx @@ -15,7 +15,8 @@ * * `buildViewTabs` pushes a console-made fallback tab when an object declares no * defined or primary view. Its toolbar changes went down `persistViewPatch`'s - * overlay branch like a served view's: measured live on objectstack `main` + * overlay branch (the toolbar's view write, retired in objectui#12504) like a + * served view's: measured live on objectstack `main` * (showcase, `showcase_account`), a density toggle sent `PUT * /api/v1/meta/view/all` with `{ rowHeight, object, name: 'all', _isOverride }` * and no `viewKind`, the door answered `422 INVALID_METADATA`, and the density @@ -28,13 +29,12 @@ * ## What runs * * The harness of `ObjectView.toolbarWritesCompose-11642.test.tsx`: the real - * object page and the real `persistViewPatch` (its 300 ms debounce included), - * over the real `ObjectStackAdapter`, whose metadata client is a store that - * judges every PUT with the spec's own `ViewMetadataSchema` and keeps the parsed - * value. `ListView` is stubbed to capture what the page hands it, so a toolbar - * change is the page's own callback — every one that reaches `persistViewPatch`. - * A "reload" unmounts the page and mounts it again over a fresh adapter on the - * same store. + * object page over the real `ObjectStackAdapter`, whose metadata client is a + * store that judges every PUT with the spec's own `ViewMetadataSchema` and + * keeps the parsed value. `ListView` is stubbed to capture what the page hands + * it, so a toolbar change is the page's own callback — every one that used to + * reach the toolbar's view write. A "reload" unmounts the page and mounts it + * again over a fresh adapter on the same store. * * ## Since objectui#12418 and objectui#12474 * @@ -42,8 +42,11 @@ * layout (objectui#12418), the density and the hidden fields (objectui#12474) * are the user's own list state. The console-made tab still sends nothing; the * real rows beside it, which this file pinned keeping their save path, now - * keep the change as the user's state too and are not written. The direction - * note below is the original fix's. + * keep the change as the user's state too and are not written. objectui#12504 + * retired the toolbar's view write itself (`persistViewPatch`, with the + * console-made tab's mark that guarded it, and the adapter's + * `updateViewConfig`), so these cases now pin that no toolbar control on any + * tab writes a view. The direction note below is the original fix's. * * ## Direction of the reverse-verification run * @@ -246,8 +249,6 @@ function pageDataSource(meta: any) { listViews: (objectName: string, o?: any) => ds.listViews(objectName, o), listViewOverrides: (objectName: string) => ds.listViewOverrides(objectName), getView: (objectName: string, viewId: string) => ds.getView(objectName, viewId), - updateViewConfig: vi.fn((objectName: string, viewId: string, config: any, o?: any) => - ds.updateViewConfig(objectName, viewId, config, o)), } as any; } @@ -285,7 +286,7 @@ async function reload(meta: any, object: Record, viewId: string) { return (normalizeListViewSchema(listSchema) as { rowHeight?: unknown }).rowHeight; } -/** The refusal `persistViewPatch` logs before it toasts. */ +/** What a refused view write logs (the view-config panel's save still does) before it toasts. */ function persistFailuresLogged(): unknown[][] { return (console.error as unknown as { mock: { calls: unknown[][] } }).mock.calls .filter((call) => String(call[0]).includes('Failed to persist view config')); @@ -321,11 +322,10 @@ afterEach(() => { */ async function densityIsTheUsers(meta: any, rows: Map, object: Record, viewId: string) { const before = structuredClone(rows.get(viewId)); - const ds = await openPage(meta, object, viewId); + await openPage(meta, object, viewId); act(() => listSchema.onDensityChange('comfortable')); await wait(700); - expect(ds.updateViewConfig).not.toHaveBeenCalled(); expect(meta.saveItem).not.toHaveBeenCalled(); expect(toast.error).not.toHaveBeenCalled(); expect(rows.get(viewId)).toEqual(before); @@ -335,12 +335,12 @@ async function densityIsTheUsers(meta: any, rows: Map, object: Reco describe('objectui#11643 — the console-made tab of a view-less object keeps its toolbar changes for the session', () => { it('every toolbar control on it sends no metadata write and raises no error', async () => { const { meta, rows } = makeStore([]); - const ds = await openPage(meta, VIEWLESS_OBJECT); + await openPage(meta, VIEWLESS_OBJECT); // The page opened the tab it made: the object declares nothing else. expect(listSchema.label).toBe(FALLBACK_TAB_LABEL); - // Every control that reaches `persistViewPatch`, through both the schema's - // and the list's own spelling of it. `onInlineEditChange` no longer reaches + // Every control that used to reach the toolbar's view write, through both + // the schema's and the list's own spelling of it. `onInlineEditChange` no longer reaches // it at all (objectui#5144, ruling E: the toggle is session-only); it stays // here because it must still write nothing. act(() => { @@ -356,7 +356,6 @@ describe('objectui#11643 — the console-made tab of a view-less object keeps it // Past the 300 ms debounce and any round trip it would have started. await wait(700); - expect(ds.updateViewConfig).not.toHaveBeenCalled(); expect(meta.saveItem).not.toHaveBeenCalled(); expect(meta.getItem).not.toHaveBeenCalled(); expect(rows.size).toBe(0); @@ -368,11 +367,10 @@ describe('objectui#11643 — the console-made tab of a view-less object keeps it const { meta, rows } = makeStore([SAVED_ROW]); // The console-made tab sits beside the saved view and still sends nothing. - const onFallback = await openPage(meta, VIEWLESS_OBJECT, 'all'); + await openPage(meta, VIEWLESS_OBJECT, 'all'); expect(listSchema.label).toBe(FALLBACK_TAB_LABEL); act(() => listSchema.onDensityChange('comfortable')); await wait(700); - expect(onFallback.updateViewConfig).not.toHaveBeenCalled(); expect(meta.saveItem).not.toHaveBeenCalled(); cleanup(); diff --git a/packages/app-shell/src/views/ObjectView.inlineEditSessionOnly-5144.test.tsx b/packages/app-shell/src/views/ObjectView.inlineEditSessionOnly-5144.test.tsx index 262474cdcc..62483f76f2 100644 --- a/packages/app-shell/src/views/ObjectView.inlineEditSessionOnly-5144.test.tsx +++ b/packages/app-shell/src/views/ObjectView.inlineEditSessionOnly-5144.test.tsx @@ -13,25 +13,27 @@ * The view's `inlineEdit` and `userActions.editInline` are the author's * permission keys, and `normalizeListViewSchema` folds the first into the * second. The toggle used to persist a user's edit mode into `inlineEdit` - * through `persistViewPatch`. After the fold, switching it off stored + * through `persistViewPatch` (the toolbar's view write, retired in + * objectui#12504). After the fold, switching it off stored * `inlineEdit: false`, which reads as "not offered", so the toggle was gone * from the next load. Ruling E removes that write. * * ## What runs * * The harness of `ObjectView.fallbackTabSessionOnly-11643.test.tsx`: the real - * object page and the real `persistViewPatch` (its 300 ms debounce included), - * over the real `ObjectStackAdapter`, whose metadata client is a store that - * judges every PUT with the spec's `ViewMetadataSchema`. `ListView` is stubbed + * object page over the real `ObjectStackAdapter`, whose metadata client is a + * store that judges every PUT with the spec's `ViewMetadataSchema`. `ListView` is stubbed * to capture what the page hands it. Whether the toggle is OFFERED is * `ListView`'s `inlineEditOffered`, which reads the folded * `userActions.editInline` (pinned in `@object-ui/plugin-list`'s * `ListView.permissions.test.tsx`). These cases read that input: the schema the * page hands `ListView`, through the fold. * - * Every "writes nothing" case carries its firing control: a density change on - * the same view in the same test DOES write, so an empty write log is a - * reading of the toggle and not of a harness that cannot see writes. + * The first "writes nothing" case carries its firing control: a view write + * made in the same test through the real adapter IS recorded by the store, so + * an empty write log is a reading of the toggle and not of a harness that + * cannot see writes. (It was a density change on the same view until + * objectui#12474 made the density the user's own list state.) */ import * as React from 'react'; @@ -105,6 +107,8 @@ import { ExpressionProvider } from '../providers/ExpressionProvider'; const OBJECT_NAME = 'track_note'; const SAVED_ID = `${OBJECT_NAME}.mine`; +/** The firing control's own view: a write beside the views under test. */ +const PROBE_ID = `${OBJECT_NAME}.probe`; const FIELDS = { id: { type: 'text', label: 'Id' }, @@ -157,6 +161,19 @@ const SERVED_OVERLAY = { _isOverride: true, }; +/** The firing control's write: a new view beside the served one, carrying no `inlineEdit`. */ +const PROBE_ROW = { + name: PROBE_ID, + object: OBJECT_NAME, + viewKind: 'list', + label: 'Probe', + config: { + type: 'grid', + data: { provider: 'object', object: OBJECT_NAME }, + columns: ['title'], + }, +}; + /** An overlay the OLD toggle wrote on the served view: `inlineEdit: false`. */ const OLD_TOGGLE_OVERLAY = { name: 'all', @@ -225,8 +242,7 @@ function pageDataSource(meta: any) { listViews: (objectName: string, o?: any) => ds.listViews(objectName, o), listViewOverrides: (objectName: string) => ds.listViewOverrides(objectName), getView: (objectName: string, viewId: string) => ds.getView(objectName, viewId), - updateViewConfig: vi.fn((objectName: string, viewId: string, config: any, o?: any) => - ds.updateViewConfig(objectName, viewId, config, o)), + createView: vi.fn((objectName: string, spec: any) => ds.createView(objectName, spec)), } as any; } @@ -299,22 +315,21 @@ describe('objectui#5144 (ruling E) — the console inline-edit toggle writes not listProps.onInlineEditChange(true); listProps.onInlineEditChange(false); }); - // Past the 300 ms debounce and any round trip it would have started. + // Past the old toolbar write's 300 ms debounce, with room to spare. await wait(700); - expect(ds.updateViewConfig).not.toHaveBeenCalled(); expect(meta.saveItem).not.toHaveBeenCalled(); expect(rows.get('all')).toEqual(SERVED_OVERLAY); // Firing control: the store records a view write when one is made. No - // list toolbar control makes one since objectui#12474 (the density, the - // last of them, is the user's own list state), so the write is the - // adapter's own, the call the density toggle used to reach. + // list toolbar control makes one (objectui#12474, and objectui#12504 + // retired the toolbar's view write), so the write is the real adapter's + // own, a view created beside the served one. await act(async () => { - await ds.updateViewConfig(OPTED_IN_OBJECT.name, 'all', { rowHeight: 'medium', viewKind: 'list' }, { isSavedView: false }); + await ds.createView(OPTED_IN_OBJECT.name, PROBE_ROW); }); - expect(ds.updateViewConfig).toHaveBeenCalledTimes(1); - expect(rows.get('all').rowHeight).toBe('medium'); - expect(rows.get('all')).not.toHaveProperty('inlineEdit'); + expect(meta.saveItem).toHaveBeenCalledTimes(1); + expect(rows.get(PROBE_ID).label).toBe('Probe'); + expect(rows.get('all')).toEqual(SERVED_OVERLAY); expect(bodiesCarryingInlineEdit(bodies)).toEqual([]); expect(toast.error).not.toHaveBeenCalled(); }); @@ -338,13 +353,12 @@ describe('objectui#5144 (ruling E) — the console inline-edit toggle writes not it('a saved view: the toggle switched off writes nothing, and the stored `inlineEdit: true` is what the next load reads', async () => { // Recorded cost of ruling E: the edit mode is not remembered across loads. const { meta, rows, bodies } = makeStore([SAVED_ROW]); - const ds = await openPage(meta, PLAIN_OBJECT, SAVED_ID); + await openPage(meta, PLAIN_OBJECT, SAVED_ID); expect(listSchema.label).toBe('Mine'); expect(folded()).toEqual({ editInline: true, inlineEdit: true }); act(() => listProps.onInlineEditChange(false)); await wait(700); - expect(ds.updateViewConfig).not.toHaveBeenCalled(); expect(meta.saveItem).not.toHaveBeenCalled(); // Since objectui#12474 a density change on the same saved view writes no @@ -352,7 +366,7 @@ describe('objectui#5144 (ruling E) — the console inline-edit toggle writes not // `inlineEdit` is untouched and no body carries the toggle's `false`. act(() => listSchema.onDensityChange('comfortable')); await wait(700); - expect(ds.updateViewConfig).not.toHaveBeenCalled(); + expect(meta.saveItem).not.toHaveBeenCalled(); expect(rows.get(SAVED_ID).config.inlineEdit).toBe(true); expect(bodiesCarryingInlineEdit(bodies)).toEqual([]); diff --git a/packages/app-shell/src/views/ObjectView.overlayPatchOnly.test.ts b/packages/app-shell/src/views/ObjectView.overlayPatchOnly.test.ts index 887765f4c1..a922a9945f 100644 --- a/packages/app-shell/src/views/ObjectView.overlayPatchOnly.test.ts +++ b/packages/app-shell/src/views/ObjectView.overlayPatchOnly.test.ts @@ -9,52 +9,47 @@ /** * objectui#5233 — a personalization overlay must not freeze the source view. * - * `persistViewPatch` sends `{ ...baseViewDef, ...patch }`, so an overlay - * written by a mere column drag copies the view's CURRENT effective `filter` - * (and `columns`, `label`, `type`, `isDefault` …) into the stored row. The - * display merge is `{ ...source, ...override }`, so that copy then outranks - * the source view forever: an admin edits the view's filter and every user who - * once resized a column keeps the old filter, with nothing reporting it. + * The console's toolbar write (`persistViewPatch` → `updateViewConfig`) used + * to send `{ ...baseViewDef, ...patch }`, so an overlay written by a mere + * column drag copied the view's CURRENT effective `filter` (and `columns`, + * `label`, `type`, `isDefault` …) into the stored row. The display merge is + * `{ ...source, ...override }`, so that copy then outranked the source view + * forever: an admin edits the view's filter and every user who once resized a + * column keeps the old filter, with nothing reporting it. * * Ruled by the maintainer on 2026-08-12 (objectstack#7494, comment * 5261754173): "`persistViewPatch` 只存 patch,不存 merged base … 这与 per-user * 之争无关,是纯粹的存储形状错误." * - * BOTH halves are pinned here now. - * * - **read** (PR #5272): an overlay contributes only the keys it owns * (`VIEW_OVERLAY_OWNED_KEYS`), so every base key already frozen into every * stored row stops shadowing the source. That is the issue's third * disposition for existing rows — tolerate on read — chosen deliberately and * pinned rather than assumed, which is the difference the issue draws between - * tolerance and SILENT tolerance. - * - **write** (this change): `persistViewPatch` stores the PATCH ALONE for a - * system view's overlay ({@link buildPersistedViewBody}), so no new row - * freezes anything — and because `saveItem` is a whole-document PUT, the next - * toggle also strips an old fat row. It was blocked until `columnState` was - * admitted to the view-metadata surface as an explicitly runtime-only overlay - * key (objectstack#9933, released in `@objectstack/spec` 17.1.0); before that - * a `columnState`-only patch was refused `422 INVALID_METADATA` and the fat - * copy was the only thing supplying a recognized key. - * - * A *saved* view's own row is deliberately NOT narrowed: for it the body IS the - * view, `saveItem` PUTs the whole document, and a patch-only write would delete - * the user's view rather than narrow it. That branch is pinned too — a guard - * that correctly SURVIVES is the one that says the narrowing stopped where it - * should. + * tolerance and SILENT tolerance. It still runs: the rows are at rest. + * - **write**: the toolbar write stored the patch alone from objectui#5233 on, + * and since objectui#12504 there is no toolbar write at all — the sort and + * the column layout (objectui#12418), the density and the hidden fields + * (objectui#12474) are the user's own list state, and the writer retired + * with the adapter method it was the last caller of. The census at the end of + * this file pins that absence. Every overlay this file reads is therefore a + * row an install already carries, put in the store in the shape the retired + * write stored it (`object`, `name` and the `_isOverride` marker stamped on + * the body). * - * Written round-trip through the REAL adapter write + read and the REAL - * consumer merge (`loadViewOverrides` → `buildViewTabs`), not against a - * hand-written override fixture: the defect lives in what the write path - * stores, so a fixture is exactly where it would hide. + * Read through the REAL adapter read and the REAL consumer merge + * (`loadViewOverrides` → `buildViewTabs`), not against a hand-written + * override map: the merge is where a frozen copy would win. */ import { describe, it, expect, vi } from 'vitest'; import { readFileSync } from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; -import { ObjectStackAdapter, VIEW_OVERLAY_OWNED_KEYS } from '@object-ui/data-objectstack'; -import { buildPersistedViewBody, buildViewTabs, loadViewOverrides, sanitizeViewOverride } from './ObjectView'; +import type { DataSource } from '@object-ui/types'; +import * as dataObjectstack from '@object-ui/data-objectstack'; +import { ObjectStackAdapter } from '@object-ui/data-objectstack'; +import { buildViewTabs, loadViewOverrides, sanitizeViewOverride } from './ObjectView'; // @ts-expect-error — plain-JS shared helper, intentionally untyped (`allowJs: false`) import { stripComments as stripCommentsUntyped } from '../../../../scripts/js-comment-mask.mjs'; @@ -122,6 +117,15 @@ function makeAdapter(meta: any) { return ds; } +/** + * Put a personalization overlay in the store the way the retired toolbar write + * (`updateViewConfig`, objectui#12504) stored one: the body, with `object`, + * `name` and the `_isOverride` marker stamped on it. + */ +function plantOverlay(rows: Map, body: Record) { + rows.set(`view::${VIEW_ID}`, { ...body, object: OBJECT_NAME, name: VIEW_ID, _isOverride: true }); +} + const fallbackTab = () => ({ id: 'all', label: 'All records', type: 'grid', columns: [] }); /** The tab the switcher renders for `VIEW_ID`, built the way `ObjectView` builds it. */ @@ -138,19 +142,14 @@ async function tabAfterAdminEdit(ds: any) { describe('objectui#5233 — a source-view filter change reaches users who have an overlay', () => { it('a PRE-FIX fat overlay no longer shadows the source view on read', async () => { - const { meta } = makeMetaStore(); + const { meta, rows } = makeMetaStore(); const ds = makeAdapter(meta); - // EXACTLY what `persistViewPatch` USED TO send for a column drag on a - // system view: the whole active tab, plus the one key the user - // actually changed. Since the write half landed it sends the patch - // alone (pinned in the WRITE describe below), so this body is now a - // row an install has been carrying since before the fix — written here - // through the REAL adapter rather than dropped into the store, so the - // legacy shape is produced by a real write path and not by a fixture. - // `isSavedView` is omitted — this is the system-view case, the one - // that lays an overlay ON TOP of a code-defined view. - await ds.updateViewConfig(OBJECT_NAME, VIEW_ID, { + // EXACTLY what the retired toolbar write stored for a column drag on a + // system view before objectui#5233: the whole active tab, plus the one + // key the user actually changed, with the marker the write stamped. A + // row an install has been carrying since then. + plantOverlay(rows, { ...SOURCE_VIEW_AT_WRITE_TIME, columnState: { order: ['status', 'name'], widths: { name: 220 } }, }); @@ -170,10 +169,10 @@ describe('objectui#5233 — a source-view filter change reaches users who have a }); it('the overlay still carries what it was written for', async () => { - const { meta } = makeMetaStore(); + const { meta, rows } = makeMetaStore(); const ds = makeAdapter(meta); - await ds.updateViewConfig(OBJECT_NAME, VIEW_ID, { + plantOverlay(rows, { ...SOURCE_VIEW_AT_WRITE_TIME, columnState: { order: ['status', 'name'], widths: { name: 220 } }, sort: [{ field: 'created_at', order: 'desc' }], @@ -195,251 +194,6 @@ describe('objectui#5233 — a source-view filter change reaches users who have a }); }); -/** - * The write half — the ruled change, and the one this card was reopened for. - * - * Driven through the REAL seam `persistViewPatch` calls - * ({@link buildPersistedViewBody}) and the REAL adapter write + read, not a - * hand-written body: the defect lives in what the write path stores, so a - * fixture is exactly where it would hide. - */ -describe('objectui#5233 WRITE — a system view\'s overlay stores the patch ONLY', () => { - /** The active tab a column drag fires on: source view + its runtime id. */ - const ACTIVE_TAB = { id: VIEW_ID, viewKind: 'list', ...SOURCE_VIEW_AT_WRITE_TIME }; - const DRAG = { columnState: { order: ['status', 'name'], widths: { name: 220 } } }; - - it('the body carries the patch and NOTHING the source view owns', () => { - const body = buildPersistedViewBody(ACTIVE_TAB, DRAG, { isSavedView: false }); - - expect(body.columnState).toEqual(DRAG.columnState); - // Every key the old `{ ...baseViewDef, ...patch }` spread copied in. - // Spelled out one per line rather than as a key-set equality so a - // failure names the key that leaked. - expect(body).not.toHaveProperty('filter'); - expect(body).not.toHaveProperty('columns'); - expect(body).not.toHaveProperty('label'); - expect(body).not.toHaveProperty('type'); - expect(body).not.toHaveProperty('isDefault'); - expect(body).not.toHaveProperty('id'); - expect(body).not.toHaveProperty('name'); - // Identity the reader keeps (`VIEW_OVERLAY_IDENTITY_KEYS`) — `object`, - // `name` and the marker are stamped by `updateViewConfig` itself. - expect(body.viewKind).toBe('list'); - expect(Object.keys(body).sort()).toEqual(['columnState', 'viewKind']); - }); - - it('omits `viewKind` when the active tab has none — it is inherited, not invented', () => { - const { viewKind: _dropped, ...tabWithoutKind } = ACTIVE_TAB; - const body = buildPersistedViewBody(tabWithoutKind, DRAG, { isSavedView: false }); - expect(Object.keys(body)).toEqual(['columnState']); - }); - - it('writes a row that carries no source key AT REST, and the admin edit still reaches the user', async () => { - const { meta } = makeMetaStore(); - const ds = makeAdapter(meta); - - // What `persistViewPatch` now sends for that drag. - await ds.updateViewConfig( - OBJECT_NAME, - VIEW_ID, - buildPersistedViewBody(ACTIVE_TAB, DRAG, { isSavedView: false }), - ); - - // Half one of the pair: the stored BYTES. `listViewOverrides` answers - // with the stored document (it does not narrow — that is - // `sanitizeViewOverride`'s job), so this reads the row as any consumer - // outside objectui sees it: Studio, the server-side switcher. - const stored = (await ds.listViewOverrides(OBJECT_NAME))[VIEW_ID]; - expect(stored).toBeDefined(); - expect(stored).not.toHaveProperty('filter'); - expect(stored).not.toHaveProperty('columns'); - expect(stored).not.toHaveProperty('label'); - expect(stored).not.toHaveProperty('type'); - expect(stored).not.toHaveProperty('isDefault'); - expect(stored.columnState).toEqual(DRAG.columnState); - // Still addressable, and still says what kind of row it is. - expect(stored.name).toBe(VIEW_ID); - expect(stored.object).toBe(OBJECT_NAME); - expect(stored.viewKind).toBe('list'); - expect(stored._isOverride).toBe(true); - - // Half two: the user-visible defect. The admin edits the view's filter - // and the user who dragged a column sees the NEW one. - const tab = await tabAfterAdminEdit(ds); - expect(tab.filter).toEqual(SOURCE_VIEW_AFTER_ADMIN_EDIT.filter); - expect(tab.columns).toEqual(SOURCE_VIEW_AFTER_ADMIN_EDIT.columns); - expect(tab.label).toBe(SOURCE_VIEW_AFTER_ADMIN_EDIT.label); - // Narrowing is not amnesia — the drag survived the reload. - expect(tab.columnState).toEqual(DRAG.columnState); - }); - - it('a reader OUTSIDE objectui — no read-side narrowing — now sees the admin edit too', async () => { - // The half the read-side narrowing could never reach, and the reason - // the maintainer called the surviving fat write a transition rather - // than an end state: `narrowPersonalizationOverlay` lives in objectui's - // own merge seam. Studio and the server-side switcher merge the STORED - // DOCUMENT over the source view with no such pass, so for them the - // frozen copy still won. Modelled as the bare merge those readers do. - const { meta } = makeMetaStore(); - const ds = makeAdapter(meta); - const rawMerge = async () => ({ - ...SOURCE_VIEW_AFTER_ADMIN_EDIT, - ...(await ds.listViewOverrides(OBJECT_NAME))[VIEW_ID], - }); - - // Control first — the SAME reader against a pre-fix row, so the - // assertions below are a measurement and not a tautology. - await ds.updateViewConfig(OBJECT_NAME, VIEW_ID, { ...SOURCE_VIEW_AT_WRITE_TIME, ...DRAG }); - const before: any = await rawMerge(); - expect(before.filter).toEqual(SOURCE_VIEW_AT_WRITE_TIME.filter); - expect(before.label).toBe(SOURCE_VIEW_AT_WRITE_TIME.label); - expect(before.columns).toEqual(SOURCE_VIEW_AT_WRITE_TIME.columns); - - // The same drag, written the way it is written now. - await ds.updateViewConfig( - OBJECT_NAME, - VIEW_ID, - buildPersistedViewBody(ACTIVE_TAB, DRAG, { isSavedView: false }), - ); - const after: any = await rawMerge(); - expect(after.filter).toEqual(SOURCE_VIEW_AFTER_ADMIN_EDIT.filter); - expect(after.label).toBe(SOURCE_VIEW_AFTER_ADMIN_EDIT.label); - expect(after.columns).toEqual(SOURCE_VIEW_AFTER_ADMIN_EDIT.columns); - expect(after.columnState).toEqual(DRAG.columnState); - }); - - it('the batched multi-key patch is written whole, and still carries nothing else', async () => { - const { meta } = makeMetaStore(); - const ds = makeAdapter(meta); - - // `persistViewPatch` debounces concurrent toggles into ONE payload, so - // the seam must pass a merged multi-key patch through unfiltered — all - // five owned keys at once is the widest real payload. - const batched = { - ...DRAG, - sort: [{ field: 'created_at', order: 'desc' }], - hiddenFields: ['owner'], - rowHeight: 'compact', - inlineEdit: true, - }; - await ds.updateViewConfig( - OBJECT_NAME, - VIEW_ID, - buildPersistedViewBody(ACTIVE_TAB, batched, { isSavedView: false }), - ); - - const stored = (await ds.listViewOverrides(OBJECT_NAME))[VIEW_ID]; - expect(stored).not.toHaveProperty('filter'); - for (const key of VIEW_OVERLAY_OWNED_KEYS) { - expect(stored, `the write dropped the owned key \`${key}\``).toHaveProperty(key); - } - - const tab = await tabAfterAdminEdit(ds); - expect(tab.sort).toEqual(batched.sort); - expect(tab.hiddenFields).toEqual(['owner']); - expect(tab.rowHeight).toBe('compact'); - expect(tab.inlineEdit).toBe(true); - expect(tab.filter).toEqual(SOURCE_VIEW_AFTER_ADMIN_EDIT.filter); - }); - - it('the NEXT write strips a row already stored in the fat shape — PUT replaces the document', async () => { - const { meta, rows } = makeMetaStore(); - const ds = makeAdapter(meta); - - // An install that has been running the pre-fix write path. - rows.set(`view::${VIEW_ID}`, { - ...SOURCE_VIEW_AT_WRITE_TIME, - object: OBJECT_NAME, - viewKind: 'list', - columnState: { order: ['name', 'status'] }, - _isOverride: true, - }); - - // The user drags a column once more. - await ds.updateViewConfig( - OBJECT_NAME, - VIEW_ID, - buildPersistedViewBody(ACTIVE_TAB, DRAG, { isSavedView: false }), - ); - - // `client.meta.saveItem` is a PUT of the whole document, so the frozen - // copy is gone from the BYTES — not merely masked on read. This is the - // issue's "strip on next write" disposition arriving as a consequence - // of the narrowing, alongside the tolerate-on-read half that already - // shipped; neither is silent, both are pinned. - const stored = (await ds.listViewOverrides(OBJECT_NAME))[VIEW_ID]; - expect(stored).not.toHaveProperty('filter'); - expect(stored).not.toHaveProperty('columns'); - expect(stored.columnState).toEqual(DRAG.columnState); - }); -}); - -describe('objectui#5233 WRITE — a SAVED view\'s own row is deliberately not narrowed', () => { - const SAVED_ID = 'crm_lead.my_pipeline'; - - /** - * The row a user's own view has, and the tab `buildViewTabs` builds from - * it (flattened — `...sv` spreads the row's top-level keys onto the tab). - */ - const SAVED_ROW = { - name: SAVED_ID, - object: OBJECT_NAME, - viewKind: 'list', - label: 'My Pipeline', - type: 'kanban', - columns: ['name', 'owner'], - filter: [{ field: 'owner', operator: 'equals', value: 'u1' }], - config: { type: 'kanban', columns: ['name', 'owner'] }, - }; - const SAVED_TAB = { id: SAVED_ID, ...SAVED_ROW }; - - it('carries the whole body — a patch-only PUT here would DELETE the user\'s view', () => { - const body = buildPersistedViewBody(SAVED_TAB, { sort: [{ field: 'name', order: 'asc' }] }, { isSavedView: true }); - - // This is the guard that must SURVIVE the narrowing, and it is worth - // more than the ones that had to move: it says where the fix stops. - // The row IS the view here — there is no source view underneath it for - // a copied key to shadow, so the ruled harm cannot arise, while a - // patch-only write would drop `config`/`columns`/`filter`/`label` on a - // whole-document PUT and take the user's view with it. - // - // objectui#11625 — this row carries a `config`, so the door judges it - // by the spec's `viewItem` member and strips its top level: the patch - // is written INSIDE `config` (a top-level `sort` here was answered - // `200` and dropped), and nothing else in the body moves. - expect(body.config).toEqual({ ...SAVED_ROW.config, sort: [{ field: 'name', order: 'asc' }] }); - expect(body.columns).toEqual(SAVED_ROW.columns); - expect(body.filter).toEqual(SAVED_ROW.filter); - expect(body.label).toBe('My Pipeline'); - expect(body).not.toHaveProperty('sort'); - }); - - it('a toolbar toggle on a saved view leaves its definition intact at rest', async () => { - const { meta, rows } = makeMetaStore(); - const ds = makeAdapter(meta); - rows.set(`view::${SAVED_ID}`, { ...SAVED_ROW }); - - await ds.updateViewConfig( - OBJECT_NAME, - SAVED_ID, - buildPersistedViewBody(SAVED_TAB, { rowHeight: 'compact' }, { isSavedView: true }), - { isSavedView: true }, - ); - - const stored = rows.get(`view::${SAVED_ID}`); - // objectui#11625 — the density is stored where the door keeps it on - // an envelope-shaped row: inside `config`, beside the definition. - expect(stored.config).toEqual({ ...SAVED_ROW.config, rowHeight: 'compact' }); - expect(stored.columns).toEqual(SAVED_ROW.columns); - expect(stored.filter).toEqual(SAVED_ROW.filter); - expect(stored.label).toBe('My Pipeline'); - expect(stored).not.toHaveProperty('rowHeight'); - // The marker stays withheld: stamping it would make `listViews()` - // exclude the user's own view from the switcher (objectui#4227). - expect(stored).not.toHaveProperty('_isOverride'); - }); -}); - describe('objectui#5233 — rows written BEFORE this fix (the disposition, pinned)', () => { /** * The decision this test encodes: existing rows are TOLERATED ON READ, not @@ -532,88 +286,94 @@ describe('objectui#5233 — what must NOT be narrowed', () => { }); }); -describe('objectui#5233 ratchet — the owned-key list tracks the writers', () => { +/** + * objectui#12504 — THE ABSENCE CENSUS: the toolbar-to-view write family is + * retired, and none of it comes back. + * + * objectui#12474 left `persistViewPatch` with no caller and pinned that here + * as a reference count. The maintainer's ruling on objectui#12504 (letter A) + * retired the whole family at once: the in-component writer and its machinery, + * `DataSource.updateViewConfig` (`@object-ui/types`) and + * `ObjectStackAdapter.updateViewConfig` (`@object-ui/data-objectstack`) with + * the capability gate that only that method read. The shared view is written + * by an explicit save alone. So the census now pins ABSENCE, on all three + * homes, each with a control that proves the reader is reading the real thing. + * + * Read off the code with comments stripped (`scripts/js-comment-mask.mjs`), so + * the docblocks that tell the history do not count, and a call, a reference + * handed to a prop and a re-export all do. `DataSource` is pinned twice: off + * its source (red under `vitest`), and as a type (red under this package's + * `type-check`, which compiles its tests — a member declared any other way, + * inherited or as a property, is caught there). + */ +describe('objectui#12504 census — the toolbar-to-view write family is retired', () => { const here = path.dirname(fileURLToPath(import.meta.url)); - const objectViewSrc = readFileSync(path.join(here, 'ObjectView.tsx'), 'utf8'); - - /** - * Every `persistViewPatch(…, { key … })` in the file. The overlay's owned - * keys and the calls that WRITE them are two statements of one fact, in two - * packages; when they drift, the read silently drops a key the user just - * set and nothing fails. This is the guard that makes that drift loud. - */ - const PERSIST_CALLS = /persistViewPatch\s*\([^;]*?\{\s*([A-Za-z_][A-Za-z0-9_]*)\s*[:,}]/gs; + const objectViewCode = stripComments(readFileSync(path.join(here, 'ObjectView.tsx'), 'utf8')); + const dataSourceCode = stripComments( + readFileSync(path.join(here, '..', '..', '..', 'types', 'src', 'data.ts'), 'utf8'), + ); + + /** Every name of the retired family that `ObjectView.tsx` used to define or call. */ + const RETIRED_IN_OBJECT_VIEW = [ + 'persistViewPatch', + 'buildPersistedViewBody', + 'toolbarWriteInputs', + 'CONSOLE_MADE_TAB', + 'updateViewConfig', + 'setSystemCapabilities', + 'isViewConfigPermissionDeniedError', + 'viewConfigPermissionDenied', + ]; + + it('ObjectView.tsx defines and reaches none of the family', () => { + const present = RETIRED_IN_OBJECT_VIEW.filter((name) => new RegExp(`\\b${name}\\b`).test(objectViewCode)); + expect(present, 'a toolbar-to-view writer is back in ObjectView.tsx').toEqual([]); + }); - it('the ratchet is reading real source', () => { - expect(objectViewSrc.length).toBeGreaterThan(10_000); - expect(objectViewSrc).toContain('persistViewPatch'); + it('CONTROL: the explicit save still writes the shared view through `persistRuntimeMetadata`', () => { + // The reader is reading the real component, and the one write path to + // the shared view this retirement keeps is still there. + expect(objectViewCode.length).toBeGreaterThan(10_000); + expect(objectViewCode).toMatch(/const handleViewConfigSave = useCallback\(/); + expect(objectViewCode).toMatch(/await persistRuntimeMetadata\('view', vid, buildViewConfigSaveBody\(objectName, draft\)/); }); - /** - * The seam above is pure and exported, so its behaviour is pinned without - * mounting the view — but nothing in those assertions says `ObjectView` - * still USES it. A revert to the old spread would leave every - * `buildPersistedViewBody` test green while the product went back to - * freezing the source view, which is the one failure this file exists to - * make impossible. Source-level because that wiring has no other seam: the - * caller is a `useCallback` inside a ~2700-line component. - */ - it('the toolbar write routes its body through `buildPersistedViewBody`', () => { - // The call is an optional member call since objectui#7483 typed the - // `dataSource` prop (the contract declares `updateViewConfig` optional - // and the guard above the debounce does not narrow into its closure), - // so both spellings of the call site are the same call site. - const call = objectViewSrc.match(/dataSource\.updateViewConfig(?:\?\.)?\(([\s\S]{0,400}?)\{\s*isSavedView/); - // Vacuous-pass guard: no match means the call site was renamed or - // restructured, not that it is clean. - expect(call, 'the `dataSource.updateViewConfig(...)` call site was not found').toBeTruthy(); - expect(call![1]).toContain('buildPersistedViewBody('); - // The exact spread the ruling removed, in the shape it had. - expect(call![1]).not.toMatch(/\.\.\.\s*baseViewDef/); + it('CONTROL: the doors objectui#12474 moved still write the user\'s own list state', () => { + expect(objectViewCode).toMatch(/onDensityChange:\s*\(mode\)\s*=>\s*\{\s*listPrefs\.patchView\(/); + expect(objectViewCode).toMatch(/const persistHiddenFields = \(hidden: string\[\]\) => \{[\s\S]{0,120}?listPrefs\.patchView\(viewDef\.id, \{ hiddenFields: hidden \}\)/); }); - it('every key the toolbar persists is a key the overlay is allowed to own', () => { - const written = [...new Set([...objectViewSrc.matchAll(PERSIST_CALLS)].map((m) => m[1]!))]; - // Four since objectui#5144 (triage's ruling E): the inline-edit toggle - // is session state and no longer persists `inlineEdit`. Two since - // objectui#12418: the sort and the column layout are the user's own - // list state. NONE since objectui#12474: the density and the hidden - // fields are the user's too (`sys_user_preference`, never a view). The - // adapter still owns all of these keys, so an overlay an earlier - // console wrote is still read. The census below is what proves the - // empty set is real rather than a regex that stopped matching. - expect(written).toEqual([]); - for (const key of written) { - expect( - VIEW_OVERLAY_OWNED_KEYS as readonly string[], - `\`persistViewPatch\` writes \`${key}\`, which a personalization overlay is not allowed to own — ` - + 'add it to VIEW_OVERLAY_OWNED_KEYS (@object-ui/data-objectstack) or stop persisting it (objectui#5233)', - ).toContain(key); - } + it('`DataSource` declares no `updateViewConfig` (@object-ui/types)', () => { + // Vacuous-pass guard: the interface and its surviving view writes are read. + expect(dataSourceCode).toMatch(/export interface DataSource \{/); + expect(dataSourceCode).toMatch(/\bupdateView\?\(objectName: string, viewName: string/); + expect(dataSourceCode).not.toMatch(/\bupdateViewConfig\b/); + + // The same fact as a type, red under `type-check`. + type Absent = 'updateViewConfig' extends keyof DataSource ? false : true; + const absent: Absent = true; + expect(absent).toBe(true); }); - /** - * objectui#12474 — the census: no list toolbar control reaches - * `persistViewPatch`. The sort and the column layout left it in - * objectui#12418, the density and the Hide Fields popover here, so the - * shared view is written only by an explicit save (the view editor's, - * `handleViewConfigSave`; pinned live by - * `ObjectView.userListState-12418.test.tsx`). Read off the code, comments - * stripped, so a call, a reference handed to a prop and a re-export are all - * counted; only the declaration itself is allowed. - */ - it('no list toolbar door reaches `persistViewPatch` (objectui#12474 census)', () => { - // The one comment reader (`scripts/js-comment-mask.mjs`); the census - // reports no line or offset, so the stripped projection is enough. - const code = stripComments(objectViewSrc); - const refs = [...code.matchAll(/\bpersistViewPatch\b/g)].length; - const declarations = [...code.matchAll(/\bconst\s+persistViewPatch\s*=/g)].length; - // Vacuous-pass guard: the declaration is still found, so the reader is - // reading the function this census is about. - expect(declarations).toBe(1); - expect(refs - declarations, 'a list toolbar door reaches `persistViewPatch` again').toBe(0); - // The two doors this card moved write the user's own list state. - expect(code).toMatch(/onDensityChange:\s*\(mode\)\s*=>\s*\{\s*listPrefs\.patchView\(/); - expect(code).toMatch(/const persistHiddenFields = \(hidden: string\[\]\) => \{[\s\S]{0,120}?listPrefs\.patchView\(viewDef\.id, \{ hiddenFields: hidden \}\)/); + it('`ObjectStackAdapter` has no `updateViewConfig`, and the package exports none of its gate (@object-ui/data-objectstack)', () => { + // CONTROL first: the probe reads the real class, which keeps its other view writes. + expect(typeof ObjectStackAdapter.prototype.updateView).toBe('function'); + expect(typeof ObjectStackAdapter.prototype.createView).toBe('function'); + expect('updateViewConfig' in ObjectStackAdapter.prototype).toBe(false); + expect('setSystemCapabilities' in ObjectStackAdapter.prototype).toBe(false); + + // The gate only `updateViewConfig` read, exported for its one caller. + const exported = Object.keys(dataObjectstack); + expect(exported).toContain('ObjectStackAdapter'); + expect(exported.filter((name) => [ + 'ViewConfigPermissionDeniedError', + 'isViewConfigPermissionDeniedError', + 'VIEW_CONFIG_CAPABILITY', + ].includes(name))).toEqual([]); + + // The same fact as a type, red under `type-check`. + type Absent = 'updateViewConfig' extends keyof ObjectStackAdapter ? false : true; + const absent: Absent = true; + expect(absent).toBe(true); }); }); diff --git a/packages/app-shell/src/views/ObjectView.setDefaultViewIdentity.test.tsx b/packages/app-shell/src/views/ObjectView.setDefaultViewIdentity.test.tsx index 848b3b9000..cdec7522e7 100644 --- a/packages/app-shell/src/views/ObjectView.setDefaultViewIdentity.test.tsx +++ b/packages/app-shell/src/views/ObjectView.setDefaultViewIdentity.test.tsx @@ -155,8 +155,9 @@ describe('the identity seam — ONE spelling for a view row id (#4211)', () => { describe('divergence A — a stored override row carrying a foreign `id` (#4211)', () => { /** * The row `persistViewPatch` → `updateViewConfig` USED TO leave behind: the - * whole tab object, `id` included, stored under `name: `. Since - * objectui#5233 an overlay is written as the patch alone, so this is a row + * whole tab object, `id` included, stored under `name: `. From + * objectui#5233 an overlay was written as the patch alone, and since + * objectui#12504 retired that write it is not written at all, so this is a row * already at rest rather than one a new write produces. Its `id` was written * under an earlier tab-id spelling (objectui#3770 re-keyed the default list), * so from then on the stored `id` and the row's `name` disagree — and the diff --git a/packages/app-shell/src/views/ObjectView.titleFieldConvergence.test.tsx b/packages/app-shell/src/views/ObjectView.titleFieldConvergence.test.tsx index 6cd7be0646..9ca7468fe2 100644 --- a/packages/app-shell/src/views/ObjectView.titleFieldConvergence.test.tsx +++ b/packages/app-shell/src/views/ObjectView.titleFieldConvergence.test.tsx @@ -60,7 +60,7 @@ * The last case is structural rather than behavioural on purpose: the four * inline seams are closures inside `ObjectViewInner`, and "these five now have * the same SHAPE as those two" is a statement about the expressions, not about - * one pair of values. Same posture as `ObjectView.viewConfigGate.test.ts`. + * one pair of values. Same posture as `view-filter-fold.ratchet.test.ts`. */ import * as React from 'react'; diff --git a/packages/app-shell/src/views/ObjectView.toolbarWritesCompose-11642.test.tsx b/packages/app-shell/src/views/ObjectView.toolbarWritesCompose-11642.test.tsx index fbe55779da..03c87e4214 100644 --- a/packages/app-shell/src/views/ObjectView.toolbarWritesCompose-11642.test.tsx +++ b/packages/app-shell/src/views/ObjectView.toolbarWritesCompose-11642.test.tsx @@ -12,7 +12,7 @@ * * ## The defect this pins * - * `persistViewPatch` sends a whole-document PUT (`updateViewConfig` → + * `persistViewPatch` sent a whole-document PUT (`updateViewConfig` → * `client.meta.saveItem`). It built every body from the active tab as it was * when the page loaded — or, for an overlay, from the pending patch alone — and * nothing moved that base after a write landed. So the second change was built @@ -29,9 +29,9 @@ * * ## What runs * - * The real object page and the real `persistViewPatch` (its 300 ms debounce - * included), over the real `ObjectStackAdapter`, whose metadata client is a - * store that judges every PUT with the spec's own `ViewMetadataSchema`, keeps + * The real object page over the real `ObjectStackAdapter`, whose metadata + * client is a store that judges every PUT with the spec's own + * `ViewMetadataSchema`, keeps * the parsed value (so it drops what the door drops), and answers the PUT the * way the door was measured to answer it: with no row. `ListView` is stubbed to * capture the schema the page hands it, so a toolbar change is the page's own @@ -43,7 +43,9 @@ * objectui#12474 the density and the hide-field toggle are the user's too, so * no toolbar change writes the view at all: each case now pins that both * changes survive the reload from the user's state while the stored row stays - * exactly as it was. The direction note below is the original fix's. + * exactly as it was. `persistViewPatch` itself retired in objectui#12504, with + * the adapter's `updateViewConfig`. The direction note below is the original + * fix's. * * ## Direction, written before the reverse-verification run * @@ -249,8 +251,6 @@ function pageDataSource(meta: any) { listViews: (objectName: string, o?: any) => ds.listViews(objectName, o), listViewOverrides: (objectName: string) => ds.listViewOverrides(objectName), getView: (objectName: string, viewId: string) => ds.getView(objectName, viewId), - updateViewConfig: vi.fn((objectName: string, viewId: string, config: any, o?: any) => - ds.updateViewConfig(objectName, viewId, config, o)), } as any; } @@ -334,10 +334,9 @@ describe('objectui#11642 — two toolbar changes in one session both survive a r */ async function bothSurviveWithNoViewWrite(seed: Record, viewId: string) { const { meta, rows } = makeStore([seed]); - const ds = await openView(meta, viewId); + await openView(meta, viewId); await densityThenHide(); - expect(ds.updateViewConfig).not.toHaveBeenCalled(); expect(meta.saveItem).not.toHaveBeenCalled(); expect(toast.error).not.toHaveBeenCalled(); // The stored row is the view, untouched. @@ -363,6 +362,7 @@ describe('objectui#11642 — two toolbar changes in one session both survive a r // The debounce-and-chain cases this file carried pinned `persistViewPatch`'s // own write queue (a 300 ms debounce, then each PUT waiting for the previous -// one). Since objectui#12474 no list toolbar control reaches that function, so -// there is no toolbar write left to queue; the user's state has its own -// debounce, pinned by `ObjectView.userListState-12418.test.tsx`. +// one). Since objectui#12474 no list toolbar control reached that function, and +// objectui#12504 retired it, so there is no toolbar write left to queue; the +// user's state has its own debounce, pinned by +// `ObjectView.userListState-12418.test.tsx`. diff --git a/packages/app-shell/src/views/ObjectView.tsx b/packages/app-shell/src/views/ObjectView.tsx index 375ed254b2..887d98dda1 100644 --- a/packages/app-shell/src/views/ObjectView.tsx +++ b/packages/app-shell/src/views/ObjectView.tsx @@ -25,7 +25,7 @@ import { buildListFilterKey, readListFilterState, writeListFilterState } from '. import { useObjectListPreferences, type ListViewPreference } from '../context/ListPreferencesProvider.js'; import { VALUELESS_FILTER_OPERATORS } from './viewFilterFold.js'; import { parseUrlEqualityFilterTriples } from './drillUrlFilters.js'; -import { narrowPersonalizationOverlay, isViewConfigPermissionDeniedError, formatMetadataError, VIEW_OVERLAY_OWNED_KEYS } from '@object-ui/data-objectstack'; +import { narrowPersonalizationOverlay, formatMetadataError } from '@object-ui/data-objectstack'; const ObjectChart = lazy(() => import('@object-ui/plugin-charts').then((m) => ({ default: m.ObjectChart })), ); @@ -804,10 +804,11 @@ export function buildAddViewSpec(config: Record, objectDef: any): R * Filter entries are matched in both at-rest shapes: spec `ViewFilterRule` * objects (`{ field, operator, value }`, what the fold writes) and the legacy * runtime triple `[field, operator, value]` a source view may declare and which - * `persistViewPatch` USED TO copy into the overlay along with the rest of the - * view — it now stores the patch alone (objectui#5233, - * {@link buildPersistedViewBody}), so a triple can only reach this pass from a - * row written before that landed, or from a saved view's own body. + * the console's toolbar write (`persistViewPatch`) USED TO copy into the + * overlay along with the rest of the view, until objectui#5233 narrowed it to + * the patch. That write is retired (objectui#12504), so a triple can only reach + * this pass from a row written before objectui#5233, or from a saved view's own + * body. * * Whether a matched entry's VALUE counts as supplied is asked of the builder's * own {@link isFilterValueComplete}, not of a local predicate (objectstack#8815, @@ -996,11 +997,11 @@ function viewOriginLabel( * They disagreed because the metadata side spelled identity `{ id: key, * ...body, ...override }` — `id` FIRST, so an `id` key inside the stored body * or the stored override silently replaced it. Both are stored documents that - * really do carry one: `persistViewPatch` WROTE the whole tab object (its `id` - * included) back through `updateViewConfig` — since objectui#5233 it writes the - * patch alone for an overlay ({@link buildPersistedViewBody}) but still the - * whole body for a saved view's own row, and every row written before that - * landed still carries an `id` — and a duplicated view copies its + * really do carry one: the console's toolbar write (`persistViewPatch` → + * `updateViewConfig`, both retired in objectui#12504) WROTE the whole tab + * object, its `id` included — objectui#5233 narrowed an overlay to the patch, + * but a saved view's own row kept the whole body, and every row written before + * that still carries an `id` — and a duplicated view copies its * source artifact's `id` verbatim. `viewEntry` stamps identity LAST at all * three sites, so the tab id is now a property of the key the caller looked the * view up by, and can never be a property of the data. @@ -1166,237 +1167,6 @@ export function dispatchViewPatches( ); } -/** - * The body a toolbar toggle persists — **the patch only, for an overlay** - * (objectui#5233). - * - * `persistViewPatch` used to send `{ ...baseViewDef, ...patch }`, so an overlay - * written by a mere column drag copied the view's CURRENT effective `filter` — - * and its `columns`, `label`, `type`, `isDefault` … — into the stored row. The - * display merge is `{ ...source, ...override }`, so that copy then outranked - * the source view forever: an admin edited the view's filter and every user who - * had once resized a column kept the old one, with nothing reporting it. - * - * Ruled by the maintainer on 2026-08-12 (objectstack#7494, comment - * `5261754173`): 「**`persistViewPatch` 只存 patch,不存 merged base** —— 独立小 - * 修,现在做:它把写入时的有效 filter 冻进 overlay,导致源视图后续的 filter 变更 - * 到不了带 overlay 的用户,这与 per-user 之争无关,是纯粹的存储形状错误。」 - * - * ## Why the two branches are not one - * - * `updateViewConfig` persists through `client.meta.saveItem`, which is a **PUT - * of the whole document** — the adapter's own `updateView` reads the current row - * and merges onto it precisely because `saveItem` does not ("there is nothing to - * merge onto. Fail loudly instead of emitting the partial write"). So what this - * function returns is what the row BECOMES, not what is layered onto it: - * - * - **`isSavedView: false`** — a code-defined *system* view. The row is a - * personalization OVERLAY laid on top of a definition that lives elsewhere; - * nothing in it but the patch is an opinion the user expressed. Store the - * patch. The source view is then free to change under it, which is the card. - * - **`isSavedView: true`** — a genuinely user-created *saved* view. The row IS - * the view; there is no source underneath it to shadow, so the ruled harm - * cannot arise here. A patch-only PUT would not narrow this row, it would - * **delete the user's view definition** — its `config`/`columns`/`filter`/ - * `label` are not a frozen copy of anything, they are the view. This branch - * therefore still carries the body — with the patch placed where the body's - * own shape keeps it (next section). - * - * ## Where the saved branch puts the patch (objectui#11625) - * - * The active tab of a served view carries the stored row's nested `config` - * (the ViewItem envelope `{ name, object, viewKind, config }`, merged into the - * tab by `loadViewOverrides`). A body with a `config` is judged by the spec's - * `viewItem` member of the `view` union, whose top level is stripped: the save - * door keeps only the keys that member declares (ADR-0005 appendix (c)), so a - * `rowHeight` spread beside `config` was answered `200` and dropped, and the - * density reverted on reload. For an envelope-shaped base the patch is - * therefore split by the spec's own vocabulary: - * - * - a key `ListViewSchema` declares (`rowHeight`, `sort`, `hiddenFields`, - * `inlineEdit`) is written into `config`, and any stale copy of it on the - * envelope is removed, so the wire carries the one spelling the door keeps; - * - any other key stays on the envelope. That is where the row-owned keys live - * ({@link VIEW_ROW_STATE_KEYS}: `columnState` is the toolbar's one), and the - * envelope's own row state (`isDefault`, `isPinned`, …) is never moved. - * - * A FLAT base (no `config`) is judged by the flattened `listOverlay` member, - * which declares the list-view keys at its top level, so it keeps the flat - * spread. Putting a `config` on such a body would move it to the `viewItem` - * member and strip every flat key the user's view is made of. - * - * The same `isSavedViewId` classification already decides the switcher's - * readonly flag, its five mutating handlers, and whether `updateViewConfig` - * stamps the overlay marker — so a row cannot be a saved view for one of them - * and an overlay for another. - * - * ## What the overlay branch keeps besides the patch - * - * `viewKind` only, and only when the active tab carries one — **identity, not - * content**, the same line `VIEW_OVERLAY_IDENTITY_KEYS` - * (`@object-ui/data-objectstack`) draws on the read side, so the write now - * produces exactly the shape the reader would keep. `object`, `name` and the - * overlay marker are stamped by `updateViewConfig` itself; `label` is - * deliberately NOT kept (the platform inherits it from the shadowed registry - * entry — a stored one is a snapshot of the source view's label at write time, - * which is the very class of frozen key this narrowing exists to stop). - * - * The patch is written verbatim rather than filtered through - * `VIEW_OVERLAY_OWNED_KEYS`: every call site passes exactly the one key the - * user just changed, and a filter here would SILENTLY drop a sixth key someone - * later persists. The ratchet in `ObjectView.overlayPatchOnly.test.ts` is what - * makes that drift loud instead. - * - * ## Consequence for rows written before this shipped - * - * They are fat at rest and stay that way until touched — already harmless on - * read since PR #5272 narrowed the merge (`sanitizeViewOverride` → - * `narrowPersonalizationOverlay`). Because this write is a full-document PUT, - * the next toolbar toggle on such a view **replaces** the fat row with the thin - * one. So the issue's three dispositions land as: tolerate on read (shipped), - * strip on next write (here), no migration — and both halves are pinned. - * - * ## What the caller hands in (objectui#11642) - * - * `persistViewPatch` passes the row the store holds when the write runs, read - * back, not the tab as it was at page load: for a saved view that row is - * `baseViewDef`, and for an overlay the stored overlay's own keys arrive inside - * `patch` while the tab still supplies `viewKind` only. See - * {@link toolbarWriteInputs}. This function's shapes are unchanged. - * - * Extracted from `persistViewPatch` so the write shape is assertable without - * mounting the view, the same reason `buildViewTabs` and - * `setDefaultViewPatches` above are exported. - */ -export function buildPersistedViewBody( - baseViewDef: Record | null | undefined, - patch: Record, - opts: { isSavedView: boolean }, -): Record { - if (opts.isSavedView) { - const base = baseViewDef || {}; - const baseConfig = base.config; - if (!baseConfig || typeof baseConfig !== 'object' || Array.isArray(baseConfig)) { - return { ...base, ...patch }; - } - // objectui#11625 — an envelope-shaped row: list-view keys go inside - // `config`, everything else stays on the envelope (see the doc above). - const configKeys = getListViewConfigKeys(); - const envelope: Record = { ...base }; - const config: Record = { ...baseConfig }; - for (const [key, value] of Object.entries(patch)) { - if (configKeys.has(key)) { - config[key] = value; - delete envelope[key]; - } else { - envelope[key] = value; - } - } - return { ...envelope, config }; - } - const viewKind = (baseViewDef as any)?.viewKind; - // Identity is stamped LAST for the same reason `updateViewConfig` stamps - // `object`/`name`/the marker last: nothing in the payload can shadow it. - return viewKind === undefined ? { ...patch } : { ...patch, viewKind }; -} - -/** - * What a toolbar write starts from (objectui#11642): **the row the store holds - * when the write runs**, read through {@link loadViewOverrides} — the reader a - * reload uses — and never the tab as it was when the page loaded. - * - * `updateViewConfig` is a whole-document PUT, so each write REPLACES the row. - * The write used to start from the active tab as it was at page load (or, for - * an overlay, from the pending patch alone), and nothing moved that base after - * a write landed. So a second toolbar change in one session was built from a - * row that no longer existed and silently dropped what the first one stored: - * measured live, a density change reverted after a header sort, on every row - * kind. Triage's ruling: after a successful write, the base for the next write - * is the row that write stored, on every row kind, with no client-side merge - * of guessed fields. - * - * Why the stored row is READ BACK rather than taken from the write itself, - * measured against the platform's save door (objectstack `main`, showcase app): - * the `PUT /api/v1/meta/view/NAME` answer carries no row — `{ success, version, - * seq, state, message }`, the shape `SaveMetaItemResponseSchema` declares — and - * the body sent is not what the door kept: it drops undeclared keys - * (ADR-0005 appendix (c)), a sort entry's row `id` among them. The read is the - * `GET /api/v1/meta/view` the page load takes, and the adapter drops its cache - * on every view write (`invalidateViewKeys`), so it is the post-write row, not - * the page-load copy. Reading when the write runs, rather than keeping a copy - * of the row after each write, also composes with a row another handler on this - * page wrote in between (set as default, pin, rename) and leaves no copy that - * has to be kept fresh. - * - * - **Saved view** (an envelope row or a flat row): the stored row is the base - * {@link buildPersistedViewBody} places the patch on. The active tab is the - * base only when no stored row was read — a draft-only view in preview mode, - * or a failed read — which is how every write behaved before this change. - * - **Overlay**: the stored overlay's own keys ({@link VIEW_OVERLAY_OWNED_KEYS}, - * the keys `loadViewOverrides` narrows a marked overlay to) join the pending - * patch, so the row becomes the user's patches composed. It still carries - * nothing the shadowed view owns (objectui#5233): the tab supplies `viewKind` - * only, exactly as before, and a key the stored row holds outside the owned - * set is not carried forward. - */ -async function toolbarWriteInputs( - dataSource: unknown, - objectName: string, - viewId: string, - activeTab: Record, - pending: Record, - isSavedView: boolean, -): Promise<{ base: Record; patch: Record }> { - const read: unknown = (await loadViewOverrides(dataSource, objectName, [viewId]))[viewId]; - const stored = read && typeof read === 'object' && !Array.isArray(read) - ? (read as Record) - : undefined; - if (isSavedView) return { base: stored ?? activeTab, patch: pending }; - const owned: Record = {}; - for (const key of VIEW_OVERLAY_OWNED_KEYS) { - if (stored?.[key] !== undefined) owned[key] = stored[key]; - } - return { base: activeTab, patch: { ...owned, ...pending } }; -} - -/** - * The provenance mark on the tab this page MAKES for an object that declares no - * list view (objectui#11643): the `fallbackTab` the views memo hands - * {@link buildViewTabs}, which pushes it only when no defined or primary view - * exists. - * - * That tab is not a view anybody authored or served. No server serves it, and - * unless a stored row of the same name shadows it, no row backs it. Its id - * `all` is the console's placeholder, not a view name: a bare `all` would - * collide across objects, and storing a toolbar change under it would mint a - * metadata row nobody authored. Before this mark, a toolbar change on it took - * the overlay branch of `persistViewPatch` like a served view's and was sent as - * `PUT /meta/view/all` carrying no `viewKind`; with no registry entry of that - * name to inherit one from, the door judged the body as a view container and - * refused it (`422 INVALID_METADATA`), and the change was gone on reload. - * - * Triage's ruling (objectui#11643, comment `5988346142`): the fallback tab's - * toolbar changes apply for the session and send no metadata write, and no view - * is created to hold them. An object that wants saved personalization declares - * a list view, and the served-view path then applies. - * - * Why a mark and not the id: an object may declare a served list view whose tab - * id is `all` as well (a bare key in the object's own `listViews`), and that one - * keeps saving. Provenance is decided where the tab is made. Object spread and - * `Object.assign` copy a symbol-keyed property, so the mark rides every copy of - * the tab this page takes (the views memo, the active view, a config draft - * spread over it); `Object.entries`, `Object.keys` and `JSON.stringify` skip - * it, so it never reaches a request body. Module-local and not exported: it is - * a fact about this page's own tab, not a contract. - */ -const CONSOLE_MADE_TAB = Symbol('objectui#11643 console-made fallback tab'); - -/** Whether `tab` is the tab this page made for a view-less object — see {@link CONSOLE_MADE_TAB}. */ -function isConsoleMadeTab(tab: unknown): boolean { - return !!tab && typeof tab === 'object' - && (tab as { [CONSOLE_MADE_TAB]?: unknown })[CONSOLE_MADE_TAB] === true; -} - /** * Item-level keys a switcher tab carries that belong to the ROW, not to the * view body — the ones this surface's own handlers write through `updateView` @@ -1462,7 +1232,7 @@ function getListViewConfigKeys(): ReadonlySet { * {@link VIEW_ROW_STATE_KEYS}. * * Extracted so the persisted shape is assertable without mounting the view, - * like {@link buildPersistedViewBody} above. + * like {@link buildViewTabs} above. */ export function buildViewConfigSaveBody( objectName: string | undefined, @@ -1681,137 +1451,14 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: Co // Draft state for view config edits — cached locally, saved on demand const [viewDraft, setViewDraft] = useState | null>(null); - // Per-view debounce timers + latest pending patch payloads. Keyed by - // viewId so toggles on different views don't clobber each other. We - // merge incoming patches into a single payload so rapid successive - // toggles (e.g. resize-drag emitting 60 events/sec) collapse into one - // network write. - const persistTimers = useRef>>({}); - const persistPending = useRef>>({}); - // objectui#11642 — the writes to one view, chained. Each write reads the - // stored row it starts from (`toolbarWriteInputs`), so it must not start - // before the previous write to the same view has settled: a read taken - // while that PUT is still in flight returns the row from BEFORE it, and - // the second write would put the first change back. - const persistChains = useRef>>({}); - // `persistViewPatch` is defined (and its `useCallback` deps evaluated) - // BEFORE `savedViews` state exists below — closing over it directly in - // the dependency array would read it in its temporal dead zone. Mirror - // it into a ref on every render instead (same pattern as - // `identityPolicyRef` above), read only from inside the callback body. - const savedViewsRef = useRef([]); - // ⚠️ objectui#12474 — NO caller. It was the list toolbar's write into the - // shared view; the sort and the column layout left it in objectui#12418, - // the density and the Hide Fields popover in objectui#12474, and all four - // are the user's own list state (`listPrefs`). ⛔ Do not wire a toolbar - // control back to it: `ObjectView.overlayPatchOnly.test.ts` counts its - // references. Its retirement (with the adapter's `updateViewConfig`, which - // it was the last caller of) is a separate decision. - const persistViewPatch = useCallback( - (viewIdLocal: string, baseViewDef: Record, patch: Record) => { - if (!dataSource?.updateViewConfig || !objectName || !viewIdLocal) return; - // objectui#11643 — the tab this page made for an object that - // declares no list view has no row to write into. Its toolbar - // changes apply for the session (the list keeps them in its own - // state) and nothing is scheduled here: no read, no PUT, no toast. - // One rule for every control that reaches this function — density, - // sort, hidden fields, column order and widths. (The inline-edit - // toggle no longer reaches it: objectui#5144, ruling E.) A - // stored row that shadows the tab is a real row (`isSavedViewId`, - // the classification the write below uses) and keeps its save - // path. See `CONSOLE_MADE_TAB`. - if (isConsoleMadeTab(baseViewDef) && !isSavedViewId(savedViewsRef.current, viewIdLocal)) return; - // Merge into pending payload — every key present is the latest - // value the user intended. - const prev = persistPending.current[viewIdLocal] || {}; - persistPending.current[viewIdLocal] = { ...prev, ...patch }; - const existing = persistTimers.current[viewIdLocal]; - if (existing) clearTimeout(existing); - persistTimers.current[viewIdLocal] = setTimeout(() => { - const merged = persistPending.current[viewIdLocal] || {}; - delete persistPending.current[viewIdLocal]; - delete persistTimers.current[viewIdLocal]; - // A toolbar toggle fires through this ONE path for BOTH a - // system view (no overlay row yet) and a genuinely saved - // view — `baseViewDef` is the active tab either way, and - // nothing upstream of here branches on which. Tell the - // adapter which case this is via the SAME classification the - // switcher's own readonly gate and its five mutating - // handlers already use (`isSavedViewId`), rather than - // leaving it to (re-)infer from the write's shape: writing - // this as an unconditional personalization-overlay marker - // (objectui#4227) would flag the saved view's OWN row and - // make `listViews()` exclude it on the very next read — the - // user's own view would vanish from the switcher after they - // merely toggled its density (objectui#4227 follow-up, - // PM review on PR #4713). - const targetIsSavedView = isSavedViewId(savedViewsRef.current, viewIdLocal); - // objectui#5233 — for a system view's personalization overlay - // this is the PATCH ONLY: the row used to be written as - // `{ ...baseViewDef, ...merged }`, which froze the source - // view's effective `filter` (and `columns`, `label`, `type`, - // `isDefault` …) into it as of the drag. See - // `buildPersistedViewBody` for why the saved-view branch is - // deliberately NOT narrowed — its row IS the view, and - // `saveItem` is a whole-document PUT. - // - // objectui#11642 — and what the body starts from is the row - // the store holds when this write runs, read back, not the - // tab as it was at page load: see `toolbarWriteInputs`. The - // write waits for the previous write to this view first. - // - // Written as an assignment of one chain, not through a named - // local function: the write-refusal census - // (`writeRefusalCensus-11583.test.ts`) keys this write by its - // nearest named function, `persistViewPatch`, and reads its - // refusal path off the `.catch` below. - persistChains.current[viewIdLocal] = (persistChains.current[viewIdLocal] ?? Promise.resolve()) - .then(async () => { - const { base, patch: toWrite } = await toolbarWriteInputs( - dataSource, objectName, viewIdLocal, baseViewDef, merged, targetIsSavedView, - ); - // Optional call: the guard at the top of `persistViewPatch` - // does not narrow across this `setTimeout` closure, and the - // contract declares `updateViewConfig` optional. `?.()` on - // the member keeps the adapter as `this`. - await dataSource.updateViewConfig?.( - objectName, - viewIdLocal, - buildPersistedViewBody(base, toWrite, { isSavedView: targetIsSavedView }), - { isSavedView: targetIsSavedView }, - ); - }) - .catch((err: any) => { - // objectstack#7494's ruling — the gate refuses ORG-WIDE - // view-config writes for a session without the authoring - // capability. The toggle that triggered this has ALREADY - // moved on screen, so the refusal has to be SAID: swallowing - // it into console.error leaves the operator with a density - // they did not get and no way to learn why until a reload - // silently puts it back. - if (isViewConfigPermissionDeniedError(err)) { - toast.error(t('console.objectView.viewConfigPermissionDenied')); - return; - } - console.error('[ObjectView] Failed to persist view config:', err); - // objectui#11583: every other refusal is said too, with the - // door's message. The client gate above answers only for a - // session whose capabilities were reported; an unreported - // one passes it, and the server's 403 lands here. - toast.error(t('form.saveError'), { - description: formatMetadataError(err), - classNames: { description: 'whitespace-pre-line' }, - }); - }); - // Drop the settled tail so the map holds only writes in flight. - const chained = persistChains.current[viewIdLocal]; - void chained.then(() => { - if (persistChains.current[viewIdLocal] === chained) delete persistChains.current[viewIdLocal]; - }); - }, 300); - }, - [dataSource, objectName] - ); + // objectui#12504 — no toolbar control writes the shared view. The sort and + // the column layout (objectui#12418), the density and the Hide Fields + // popover (objectui#12474) are the user's own list state (`listPrefs`), and + // the shared view is written only by an explicit save + // (`handleViewConfigSave`, below). The toolbar-to-view writer that lived + // here, `persistViewPatch`, retired with the adapter method it was the last + // caller of. ⛔ Do not bring one back: `ObjectView.overlayPatchOnly.test.ts` + // pins its absence. /* * There is deliberately NO `persistViewFilter` here (objectui#4155). @@ -1956,27 +1603,6 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: Co const { user, activeOrganization } = useAuth(); const { isAdmin } = useWorkspaceAdminStatus(); const perms = usePermissions(); - - // [ADR-0066 / objectstack#7494] Hand the adapter the session's REPORTED - // system capabilities so its `updateViewConfig` gate has something to judge - // by. The adapter is constructed by the host long before `/me/permissions` - // resolves, so this is a push, not a constructor argument. - // - // `systemPermissions` is passed straight through, `undefined` included: - // "never reported" and "reported empty" are different answers and the - // adapter decides between them (unknown fails OPEN — the server enforces - // the capability on the metadata door either way). Collapsing them here - // would re-derive, badly, the one distinction `MePermissionsProvider` goes - // out of its way to preserve (objectui#4656). - // - // Wired at THIS component because `updateViewConfig` has exactly one - // production caller — the `persistViewPatch` below (see the adapter method's - // own docblock). The gate itself is on the write, so a future second caller - // is refused whether or not it remembers to do this. - const { systemPermissions } = perms; - useEffect(() => { - (dataSource as any)?.setSystemCapabilities?.(systemPermissions); - }, [dataSource, systemPermissions]); // Get Object Definition. The outer ObjectView wrapper already guards the // missing-object case, so this always resolves while this component is @@ -2256,7 +1882,6 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: Co // into `views` so the ViewTabBar renders them alongside metadata-defined // listViews. const [savedViews, setSavedViews] = useState([]); - savedViewsRef.current = savedViews; // objectui#12418 — the signed-in user's OWN list state for this object: // the tab order, and per view the sort, the Filter panel's conditions and @@ -2316,12 +1941,12 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: Co return () => { cancelled = true; }; }, [dataSource, objectName, refreshKey, previewDrafts]); - // Persisted per-view config overrides (e.g. density toggle). Saved - // separately from `objectDef.listViews` (the embedded definition) via - // `dataSource.updateViewConfig` and read back here so the toggle state - // survives a hard reload. Org-wide shared settings (objectstack#7494's - // ruling), not a per-user preference. Keyed by viewId → partial view - // config to merge. + // Persisted per-view config overrides: the rows stored under a view's id, + // separately from `objectDef.listViews` (the embedded definition) — an + // explicit save's, and the personalization overlays an earlier console's + // toolbar wrote (that write retired in objectui#12504; the rows at rest are + // still read). Org-wide shared settings (objectstack#7494's ruling), not a + // per-user preference. Keyed by viewId → partial view config to merge. // // Reading strategy (batch first, per-view fallback) lives in the exported // `loadViewOverrides` above so it can be pinned directly — see its doc for @@ -2340,7 +1965,7 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: Co const ids = Object.keys(definedViews); // Include the primary view id so overrides apply to it too. Its identity // comes from the view composer (objectui#3770) — the same id the override - // was persisted under, since `persistViewPatch` writes by view id. + // was persisted under, since a view's stored row is named by its view id. const primaryId = defaultListViewId(objectDef.name, (objectDef as any).list); if (primaryId && !ids.includes(primaryId)) ids.unshift(primaryId); if (ids.length === 0) { @@ -2377,9 +2002,6 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: Co id: 'all', label: t('console.objectView.allRecords'), type: 'grid', - // objectui#11643 — made here, so marked here: its toolbar - // changes are session-only. See `CONSOLE_MADE_TAB`. - [CONSOLE_MADE_TAB]: true, }), }); diff --git a/packages/app-shell/src/views/ObjectView.userListState-12418.test.tsx b/packages/app-shell/src/views/ObjectView.userListState-12418.test.tsx index 47b3f869bc..91d25b4809 100644 --- a/packages/app-shell/src/views/ObjectView.userListState-12418.test.tsx +++ b/packages/app-shell/src/views/ObjectView.userListState-12418.test.tsx @@ -5,8 +5,9 @@ * * ## The defect this pins * - * A header sort went through `persistViewPatch` into the view (`/meta/view`), - * a column drag the same way, and a Manage Views drag PATCHed a `sortOrder` + * A header sort went through `persistViewPatch` (the toolbar's view write, + * retired in objectui#12504) into the view (`/meta/view`), a column drag the + * same way, and a Manage Views drag PATCHed a `sortOrder` * onto every saved view's row: one user's click changed the list for every * user. The Filter panel and the tab order lived in this browser only, so they * were gone on another device. @@ -17,7 +18,7 @@ * debounce included) and a list-state family that records its `load` and * `save` the way `@object-ui/data-objectstack`'s does (`sys_user_preference`, * one `ui.list.` row per object). The view writes are the adapter's - * `updateView` / `updateViewConfig` spies and, for the view editor's save, a + * `updateView` spy and, for the view editor's save, a * real `MetadataClient` whose `PUT /meta/view/…` is counted. `ListView` is * captured (a toolbar change is the page's own callback) and Manage Views is * reduced to its reorder and its config entry. @@ -204,7 +205,6 @@ function makeDataSource(savedRow: Record = SAVED_ROW) { getObjectSchema: vi.fn(async () => ({ name: OBJECT_NAME, fields: {} })), listViews: vi.fn(async () => [savedRow]), updateView: vi.fn(async () => ({})), - updateViewConfig: vi.fn(async () => ({})), deleteView: vi.fn(async () => ({})), } as unknown as ConsoleObjectViewProps['dataSource']; } @@ -291,7 +291,7 @@ async function overflowTestIds(): Promise { /** Every view write the page can make, counted. */ function viewWrites(ds: any) { - return ds.updateView.mock.calls.length + ds.updateViewConfig.mock.calls.length + puts.length; + return ds.updateView.mock.calls.length + puts.length; } beforeEach(() => { diff --git a/packages/app-shell/src/views/ObjectView.viewConfigGate.test.ts b/packages/app-shell/src/views/ObjectView.viewConfigGate.test.ts deleted file mode 100644 index 7e7fe56cf7..0000000000 --- a/packages/app-shell/src/views/ObjectView.viewConfigGate.test.ts +++ /dev/null @@ -1,124 +0,0 @@ -/** - * 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. - */ - -/** - * objectstack#7494 — the console half of the org-wide view-config gate. - * - * The gate itself lives on the WRITE (`ObjectStackAdapter.updateViewConfig`, - * pinned in `data-objectstack/src/viewConfigPermissionGate.test.ts`). This file - * pins the two things the console still owes it, because a gate whose refusal - * nobody can see is a gate that reads as a bug: - * - * 1. the session's capabilities actually REACH the adapter, and - * 2. the refusal is SHOWN rather than swallowed into `console.error`. - * - * ## Why part of this reads the source - * - * `persistViewPatch`'s catch is a closure inside `ObjectViewInner`; reaching it - * behaviourally means rendering the whole object workspace and driving a - * debounced toolbar toggle. This file instead closes the loop from both ends, - * which is what actually needs proving: - * - * - the REAL adapter's refusal is classified `true` by the REAL guard the - * catch branches on (round-trip, not a hand-written error fixture — a - * fixture is exactly where a code/name mismatch would hide); - * - the key that branch renders EXISTS in the `en` pack, so the toast shows a - * sentence and not a raw key path; - * - and the wiring lines are present in the source. - * - * `type-check` supplies the piece source-reading cannot: both - * `isViewConfigPermissionDeniedError` and `setSystemCapabilities` are a real - * import and a real method, so a typo in either name is a compile error, not a - * silently-passing string match here. - * - * ## Which of these would still pass if the change were reverted - * - * None of them. Every assertion below names something this change introduced: - * revert it and the guard export, the `en` key and all three source markers - * disappear together. The controls that are supposed to survive a revert live - * in the adapter's own test file, where the permitted-user write is asserted - * beside the refusal. - */ - -import { describe, it, expect, vi } from 'vitest'; -import { readFileSync } from 'node:fs'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { - ObjectStackAdapter, - isViewConfigPermissionDeniedError, -} from '@object-ui/data-objectstack'; -import en from '@object-ui/i18n/locales/en'; - -const SOURCE = readFileSync( - path.join(path.dirname(fileURLToPath(import.meta.url)), 'ObjectView.tsx'), - 'utf8', -); - -/** The key `persistViewPatch`'s catch renders when the write is refused. */ -const REFUSAL_KEY = 'console.objectView.viewConfigPermissionDenied'; - -describe('the console surfaces the org-wide view-config refusal (objectstack#7494)', () => { - it('the guard the catch branches on classifies what the adapter really throws', async () => { - // Round-trip: a real adapter, a real reported-but-insufficient grant, and - // the real exported guard — so the catch's condition is proven against the - // error that will actually arrive, not against a shape written here. - const meta = { - saveItem: vi.fn(async () => ({ success: true, item: {} })), - getItems: vi.fn(async () => ({ type: 'view', items: [] })), - }; - const ds: any = new ObjectStackAdapter({ - baseUrl: 'http://test.local', - fetch: vi.fn(async () => - new Response(JSON.stringify({ success: true, data: { capabilities: {}, routes: {} } }), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - })), - }); - ds.connected = true; - ds.connectionState = 'connected'; - ds.client = { meta }; - ds.setSystemCapabilities(['setup.access']); - - const err = await ds - .updateViewConfig('crm_lead', 'crm_lead.default', { rowHeight: 40 }) - .then(() => null, (e: unknown) => e); - - expect(err).not.toBeNull(); - expect(isViewConfigPermissionDeniedError(err)).toBe(true); - expect(meta.saveItem).not.toHaveBeenCalled(); - }); - - it('the refusal renders a sentence, not a raw key path', () => { - const value = REFUSAL_KEY.split('.').reduce((o, k) => o?.[k], en as any); - expect(typeof value).toBe('string'); - // The scope is the half an operator cannot otherwise see, so it has to be - // in the sentence — naming only the missing permission would explain the - // refusal without ever explaining what the setting does. - expect(value).toContain('everyone who uses this view'); - expect(value).toContain('Manage Metadata'); - }); - - it('persistViewPatch shows the refusal instead of swallowing it', () => { - expect(SOURCE).toContain('isViewConfigPermissionDeniedError(err)'); - expect(SOURCE).toContain(`toast.error(t('${REFUSAL_KEY}'))`); - // The generic console.error must SURVIVE for every other failure — the - // branch is an addition, not a replacement. A guard that correctly survives - // is what says the narrowing stopped where it should. - expect(SOURCE).toContain("console.error('[ObjectView] Failed to persist view config:', err)"); - }); - - it('the session capabilities are pushed into the adapter', () => { - expect(SOURCE).toContain('setSystemCapabilities?.(systemPermissions)'); - // Passed through verbatim, `undefined` included: "never reported" and - // "reported empty" are different answers and the adapter decides between - // them. A `?? []` here would have collapsed the two and gated a - // pre-ADR-0066 deployment closed. - expect(SOURCE).not.toContain('setSystemCapabilities?.(systemPermissions ?? [])'); - }); -}); diff --git a/packages/app-shell/src/views/ObjectView.viewWriteRefusal-11583.test.tsx b/packages/app-shell/src/views/ObjectView.viewWriteRefusal-11583.test.tsx index fc3a51d061..3e2af59a43 100644 --- a/packages/app-shell/src/views/ObjectView.viewWriteRefusal-11583.test.tsx +++ b/packages/app-shell/src/views/ObjectView.viewWriteRefusal-11583.test.tsx @@ -24,7 +24,8 @@ * client-side permission gate's refusal; every other refusal, a server 403 * included, was `console.error` only. (Since objectui#12474 no toolbar * toggle writes a view: the density and the hidden fields are the user's - * own list state, and its block pins that instead.) + * own list state, and its block pins that instead. objectui#12504 retired + * the write itself, with its gate.) * * - `handleSetDefaultView` raised the untranslated literal * 'Failed to set default view', with no description, so the refusal's own @@ -65,7 +66,7 @@ import * as React from 'react'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { render, cleanup, act, screen, fireEvent, waitFor, within } from '@testing-library/react'; import { MemoryRouter, Routes, Route } from 'react-router-dom'; -import { MetadataClient, ViewConfigPermissionDeniedError } from '@object-ui/data-objectstack'; +import { MetadataClient } from '@object-ui/data-objectstack'; import { toast } from 'sonner'; vi.mock('@object-ui/permissions', async (importOriginal) => { @@ -217,7 +218,7 @@ const FIELDS = { /** The saved view: an overlay row the adapter's `listViews` serves. */ const SAVED_ROW = { name: SAVED_ID, object: OBJECT_NAME, label: 'Pipeline', type: 'grid', columns: ['name'] }; -/** A refusal as the adapter's `updateView` / `updateViewConfig` raise it. */ +/** A refusal as the adapter's `updateView` raises it. */ const refusal = (message: string) => Object.assign(new Error(message), { status: 403 }); function makeDataSource(writes: Record = {}) { @@ -230,7 +231,6 @@ function makeDataSource(writes: Record = {}) { getObjectSchema: vi.fn(async () => ({ name: OBJECT_NAME, fields: {} })), listViews: vi.fn(async () => [SAVED_ROW]), updateView: vi.fn(async () => ({})), - updateViewConfig: vi.fn(async () => ({})), deleteView: vi.fn(async () => ({})), ...writes, } as unknown as ConsoleObjectViewProps['dataSource']; @@ -376,7 +376,6 @@ describe('reorder is no view write any more (objectui#12418, handleReorderViews) await settle(); expect((dataSource as any).updateView).not.toHaveBeenCalled(); - expect((dataSource as any).updateViewConfig).not.toHaveBeenCalled(); expect(puts).toEqual([]); expect(toast.error).not.toHaveBeenCalled(); }); @@ -388,9 +387,9 @@ describe('a toolbar toggle is no view write any more (objectui#12474, density an // density and the hidden fields are the user's own list state, as the sort // and the column layout became in objectui#12418: nothing reaches a view, so // a view write that would be refused is never attempted, and nothing is said. + // objectui#12504 retired that toolbar write and the adapter method with it. it('a density change and a Hide Fields change write no view and raise nothing, even where every view write is refused', async () => { const dataSource = makeDataSource({ - updateViewConfig: vi.fn(async () => { throw new ViewConfigPermissionDeniedError(OBJECT_NAME, SAVED_ID); }), updateView: vi.fn(async () => { throw refusal('View settings are refused for this session'); }), }); await mountOnSavedView(dataSource); @@ -403,7 +402,6 @@ describe('a toolbar toggle is no view write any more (objectui#12474, density an await settle(); await settle(); - expect((dataSource as any).updateViewConfig).not.toHaveBeenCalled(); expect((dataSource as any).updateView).not.toHaveBeenCalled(); expect(puts).toEqual([]); expect(toast.error).not.toHaveBeenCalled(); diff --git a/packages/app-shell/src/views/view-filter-fold.ratchet.test.ts b/packages/app-shell/src/views/view-filter-fold.ratchet.test.ts index b747e6599f..211a10457d 100644 --- a/packages/app-shell/src/views/view-filter-fold.ratchet.test.ts +++ b/packages/app-shell/src/views/view-filter-fold.ratchet.test.ts @@ -46,14 +46,6 @@ const widgetsPath = path.join(here, 'metadata-admin/widgets.tsx'); const objectViewSrc = readFileSync(objectViewPath, 'utf8'); const widgetsSrc = readFileSync(widgetsPath, 'utf8'); -/** - * Every `persistViewPatch(…, { filter … })` in the file — the call that shipped - * the group, and (via the fold) the call that shipped the overlay. Since #4155 - * there must be NONE: the toolbar persists sort / hiddenFields / columnState / - * rowHeight, never the filter. - */ -const FILTER_PERSIST_CALLS = /persistViewPatch\s*\([^;]*?\{\s*filter\s*(?::\s*([A-Za-z0-9_.]+))?/gs; - /** * The initializer of `const NAME = …` in `ObjectView.tsx` — for a handler, its * `useCallback(…)` call, so the function body and its dependency list — found @@ -99,14 +91,10 @@ describe('objectui#4155 ratchet — the filter panel persists nothing', () => { it('the ratchet is reading real source, not an empty string', () => { expect(objectViewSrc.length).toBeGreaterThan(10_000); expect(widgetsSrc.length).toBeGreaterThan(10_000); - // Sanity: the sibling persist helper this file reasons about still - // exists under this name (the toolbar DOES persist other keys). - expect(objectViewSrc).toContain('persistViewPatch'); - }); - - it('no `filter` is written through the view-config persist path at all', () => { - const values = [...objectViewSrc.matchAll(FILTER_PERSIST_CALLS)].map((m) => m[1] ?? '{ filter } shorthand'); - expect(values, 'a filter persist reappeared on the toolbar path — see #4155').toEqual([]); + // The toolbar's view-config persist path (`persistViewPatch`), whose + // `{ filter }` calls this file used to count, is retired since + // objectui#12504: no toolbar control writes the view at all, and + // `ObjectView.overlayPatchOnly.test.ts` pins that absence. }); it('the `persistViewFilter` helper is gone — there is no filter-persist seam to call', () => { @@ -125,9 +113,8 @@ describe('objectui#4155 ratchet — the filter panel persists nothing', () => { expect(sites.length, 'expected at least one onFilterChange handler').toBeGreaterThanOrEqual(1); for (const site of sites) { // Window = this handler only. It ends where the NEXT `onXxx` - // binding starts, so the sibling handlers (which legitimately call - // `persistViewPatch` for density / hiddenFields) stay - // out of the assertion. + // binding starts, so the sibling handlers stay out of the + // assertion. const rest = objectViewSrc.slice(site.index! + 1, site.index! + 401); const next = rest.search(/\bon[A-Z]\w*\s*[=:]/); const body = next === -1 ? rest : rest.slice(0, next); diff --git a/packages/app-shell/src/views/writeRefusalCensus-11583.test.ts b/packages/app-shell/src/views/writeRefusalCensus-11583.test.ts index fc74e753fa..f2e408446a 100644 --- a/packages/app-shell/src/views/writeRefusalCensus-11583.test.ts +++ b/packages/app-shell/src/views/writeRefusalCensus-11583.test.ts @@ -18,11 +18,13 @@ * TypeScript parser, so a comment or doc that names a write is never * counted. The callee is matched by NAME in any spelling: a bare call, a * method call, `?.`, `(x as any).name(…)`. The population is the ruling's - * five names plus three of the same family: `updateViewConfig` (the + * five names plus two of the same family: `deleteView` (`updateView`'s + * sibling on the adapter), and any symbol a `relays` row names (a helper + * that hands the write's promise back to its callers, whose own calls are + * then the rows that answer). It counted a third, `updateViewConfig` (the * toolbar toggles' write, the site triage named beyond the card's table), - * `deleteView` (`updateView`'s sibling on the adapter), and any symbol a - * `relays` row names (a helper that hands the write's promise back to - * its callers, whose own calls are then the rows that answer). + * until objectui#12504 retired that method and its one call site; its + * absence is pinned by `ObjectView.overlayPatchOnly.test.ts`. * * SITE KEY — `file › enclosing symbol › callee`, never a line address * (AGENTS.md #11: a stored line number is a ledger key that rots). The @@ -66,7 +68,7 @@ const VIEWS_DIR = 'packages/app-shell/src/views'; /** The seam's four writes (`runtime-metadata-persistence.ts`). */ const SEAM_WRITES = ['createRuntimeMetadata', 'persistRuntimeMetadata', 'publishRuntimeMetadata', 'discardRuntimeDraft']; /** The adapter's view writes. */ -const ADAPTER_WRITES = ['updateView', 'updateViewConfig', 'deleteView']; +const ADAPTER_WRITES = ['updateView', 'deleteView']; const WRITE_NAMES = [...SEAM_WRITES, ...ADAPTER_WRITES]; type Row = @@ -85,11 +87,6 @@ const LEDGER: Row[] = [ kind: 'relays', reason: 'Issues one `updateView` per patch and hands every promise back; set-as-default and reorder await them and answer for the refusal.', }, - { - file: 'ObjectView.tsx', symbol: 'persistViewPatch', callee: 'updateViewConfig', calls: 1, - kind: 'surfaces', pin: 'ObjectView.viewWriteRefusal-11583.test.tsx', - reason: 'The toolbar toggles (density, sort, columns, hidden fields): the client gate\'s refusal keeps its own message; every other refusal is a toast with the door\'s message (objectui#11583).', - }, { file: 'ObjectView.tsx', symbol: 'handleViewConfigSave', callee: 'persistRuntimeMetadata', calls: 1, kind: 'surfaces', pin: 'ObjectView.viewWriteRefusal-11583.test.tsx', @@ -273,7 +270,7 @@ for (const rel of FILES) { } // An adapter write read off its object as a value that escapes: stored, // assigned, passed or returned. A probe (`typeof o.updateView`, - // `!o?.updateViewConfig`) reads no write and is not this. + // `!o?.deleteView`) reads no write and is not this. if (ts.isPropertyAccessExpression(node) && ADAPTER_WRITES.includes(node.name.text)) { let up: ts.Node = node; while ( diff --git a/packages/components/src/renderers/complex/__tests__/data-table-column-resize-callback.test.tsx b/packages/components/src/renderers/complex/__tests__/data-table-column-resize-callback.test.tsx index e120e8a944..1f6f3b9867 100644 --- a/packages/components/src/renderers/complex/__tests__/data-table-column-resize-callback.test.tsx +++ b/packages/components/src/renderers/complex/__tests__/data-table-column-resize-callback.test.tsx @@ -14,7 +14,8 @@ * (`data-display.ts:791`) all along, and this renderer invoked it NOWHERE — the * resize drag mutated local `columnWidths` state and stopped there. ObjectGrid's * `saveColumnState` hung off exactly that key, so no user-dragged column width - * ever reached `localStorage` or the host's `dataSource.updateViewConfig`. + * ever reached `localStorage` or the host's own store (then the shared view, + * through `dataSource.updateViewConfig`, since retired in objectui#12504). * * ⚠️ These assertions observe the CALL, not a rendered width: the drag already * applied the width to local state before the fix, so anything that only inspects diff --git a/packages/components/src/renderers/complex/data-table.tsx b/packages/components/src/renderers/complex/data-table.tsx index 3501ccf64f..fda5fcbaf3 100644 --- a/packages/components/src/renderers/complex/data-table.tsx +++ b/packages/components/src/renderers/complex/data-table.tsx @@ -2095,8 +2095,9 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => { // mousemove. `onColumnResize` was declared here and invoked nowhere, which // is why ObjectGrid's `saveColumnState` never ran and column widths never // persisted. The host turns this into a real write (localStorage plus - // `onColumnStateChange` -> `dataSource.updateViewConfig`), so a per-move - // callback would be a write storm on shared view config. + // `onColumnStateChange` -> the host's own store; the console keeps it on + // the user's own list state), so a per-move callback would be a write + // storm. if (resizedColumn && finalWidth != null) { schema.onColumnResize?.(resizedColumn, finalWidth); } diff --git a/packages/data-objectstack/src/adapterFactoryReturn.types.test.ts b/packages/data-objectstack/src/adapterFactoryReturn.types.test.ts index f64b242a83..f1bc4e967c 100644 --- a/packages/data-objectstack/src/adapterFactoryReturn.types.test.ts +++ b/packages/data-objectstack/src/adapterFactoryReturn.types.test.ts @@ -23,8 +23,9 @@ import { createObjectStackAdapter, ObjectStackAdapter } from './index'; * with TS2339: `getClient`, `getCacheStats`, `invalidateCache`, `clearCache`, * `getConnectionState`, `isConnected`, `onConnectionStateChange`, * `onBatchProgress`, `setSystemCapabilities`. Eight of those are exactly the - * members the package README's API Reference documents, and the ninth is the - * one the factory's own JSDoc links to. + * members the package README's API Reference documents, and the ninth was the + * one the factory's own JSDoc linked to — retired since objectui#12504 with the + * `updateViewConfig` gate it fed, so the eight are what these pins read. * * ## Where these pins get their colour * @@ -70,7 +71,7 @@ describe('createObjectStackAdapter declares the adapter it returns (#7323)', () // The card's own TS2339 reproduction, inverted into a pin. Each read is a // compile error the moment the declared return narrows back. They are real // calls rather than a type-only region because every one of them is inert - // on an adapter that has never connected, so the same nine lines carry the + // on an adapter that has never connected, so the same eight lines carry the // runtime half too. expect(dataSource.getClient()).toBeDefined(); expect(dataSource.getCacheStats()).toBeDefined(); @@ -81,9 +82,8 @@ describe('createObjectStackAdapter declares the adapter it returns (#7323)', () expect(dataSource.isConnected()).toBe(false); expect(typeof dataSource.onConnectionStateChange(() => {})).toBe('function'); expect(typeof dataSource.onBatchProgress(() => {})).toBe('function'); - dataSource.setSystemCapabilities(['manage_view_config']); - // ...and the same nine through the named type, because a consumer who + // ...and the same eight through the named type, because a consumer who // annotates a field or a hook's return writes the type, not the call. type _HasHiddenMembers = Assert< 'getClient' extends keyof FactoryReturn @@ -94,9 +94,7 @@ describe('createObjectStackAdapter declares the adapter it returns (#7323)', () ? 'isConnected' extends keyof FactoryReturn ? 'onConnectionStateChange' extends keyof FactoryReturn ? 'onBatchProgress' extends keyof FactoryReturn - ? 'setSystemCapabilities' extends keyof FactoryReturn - ? true - : false + ? true : false : false : false @@ -152,7 +150,6 @@ describe('createObjectStackAdapter declares the adapter it returns (#7323)', () 'isConnected', 'onConnectionStateChange', 'onBatchProgress', - 'setSystemCapabilities', ] as const) { expect(typeof dataSource[member]).toBe('function'); } diff --git a/packages/data-objectstack/src/index.ts b/packages/data-objectstack/src/index.ts index 29f8cefdc9..1a202c9730 100644 --- a/packages/data-objectstack/src/index.ts +++ b/packages/data-objectstack/src/index.ts @@ -1169,78 +1169,6 @@ function metaEnvelopeCarriesItem(envelope: unknown): boolean { return (envelope as { item?: unknown }).item != null; } -/** - * [ADR-0066] The system capability a view-configuration write requires. - * - * objectstack#7494's ruling (maintainer, 2026-08-12) settled what this store - * IS: the `sort` / `hiddenFields` / `columnState` / `rowHeight` persisted by - * {@link ObjectStackAdapter.updateViewConfig} are ORG-WIDE view configuration - * — shared by every user of the view — not a per-user preference. A per-user - * scope is parked (objectstack#7611, v18) and deliberately NOT built here, so - * there is no second, narrower store for an ungated write to fall back to: - * the only thing this write can do is change the view for everyone. - * - * That makes a toolbar toggle a metadata-authoring act, so it gates on the - * capability this repo ALREADY uses for metadata authoring rather than a newly - * minted name. `manage_metadata` is what `HomePage`'s `AUTHORING_CAPABILITY` - * gates the builder CTAs on, what the server itself refuses metadata writes - * without (`PackageFormDialog` reads that 403), and what - * `capability.label.manage_metadata` is already translated as in all ten - * locale packs. Decisively: the write gated here goes through - * `client.meta.saveItem` — the very same ADR-0005 metadata door — so this is - * the same authority the server is already applying, not an analogous one. - */ -export const VIEW_CONFIG_CAPABILITY = 'manage_metadata'; - -/** - * Thrown by {@link ObjectStackAdapter.updateViewConfig} when the session's - * REPORTED capability set does not contain {@link VIEW_CONFIG_CAPABILITY}. - * - * Raised BEFORE `connect()` and before `saveItem`, so a refused call issues no - * request at all — the org-wide row is not touched, not even optimistically. - * - * Deliberately an ERROR and not a silent `return`. The production caller is a - * debounced toolbar toggle whose UI has ALREADY moved by the time this runs, so - * a quiet no-op would leave the operator looking at a density they did not get, - * with nothing to explain it and a reload to discover it. The message names the - * SCOPE first and the capability second, in that order, because the scope is - * the part the operator cannot otherwise see — and it is written to be shown - * as-is, the same contract {@link AnalyticsNotInstalledError} states. - */ -export class ViewConfigPermissionDeniedError extends Error { - readonly code = 'VIEW_CONFIG_PERMISSION_DENIED'; - /** The capability that was required and not held. */ - readonly capability = VIEW_CONFIG_CAPABILITY; - /** The object whose view configuration the refused write targeted. */ - readonly objectName: string; - /** The view id the refused write targeted. */ - readonly viewId: string; - constructor(objectName: string, viewId: string) { - super( - `View configuration is shared: changing "${viewId}" changes it for everyone who uses this view. ` + - `That requires the "${VIEW_CONFIG_CAPABILITY}" capability, which this session does not hold.`, - ); - this.name = 'ViewConfigPermissionDeniedError'; - this.objectName = objectName; - this.viewId = viewId; - } -} - -/** - * True when `error` is a {@link ViewConfigPermissionDeniedError}. - * - * Duck-checks `code`/`name` rather than using `instanceof`, matching - * {@link isConcurrentUpdateError}: a host that bundles this package twice - * (or re-throws across a worker boundary) still gets the right verdict. - */ -export function isViewConfigPermissionDeniedError( - error: unknown, -): error is ViewConfigPermissionDeniedError { - if (!error || typeof error !== 'object') return false; - const e = error as Record; - return e.code === 'VIEW_CONFIG_PERMISSION_DENIED' || e.name === 'ViewConfigPermissionDeniedError'; -} - /** * Thrown when the deployment has no analytics capability installed * (framework#3891 / objectstack-ai/objectstack#4019). @@ -1787,11 +1715,9 @@ export class ConcurrentUpdateError extends Error { * reads `httpStatus` for neither. This paragraph exists because the doc used * to name only the wire shape, which left the `name` limb reading as drift * (objectui#6375). It is not drift: it is the deliberate cross-realm - * duck-check that {@link isViewConfigPermissionDeniedError} documents and - * cites *this* function as its precedent for — a host that bundles this - * package twice (or re-throws across a worker boundary) ends up holding two - * copies of the class, `instanceof` fails, and the `name` string is the only - * discriminator left. That host is out of tree by construction, so an in-repo + * duck-check — a host that bundles this package twice (or re-throws across a + * worker boundary) ends up holding two copies of the class, `instanceof` + * fails, and the `name` string is the only discriminator left. That host is out of tree by construction, so an in-repo * consumer census cannot see the case the limb was written for and is not * evidence against it; `@object-ui/plugin-form` and `@object-ui/plugin-detail` * each carry their own copy of the same two-limb check for adapters they must @@ -2897,30 +2823,23 @@ export function viewItemObjectName(item: any): string | undefined { } /** - * The explicit discriminant {@link ObjectStackAdapter.updateViewConfig} stamps - * on the rows it writes for a **system**-view target, and - * {@link ObjectStackAdapter.listViews} excludes on read (objectui#4227). It is - * the ONLY thing that classifies a row as an overlay (objectui#10210, ruling B — - * see {@link isPersonalizationOverlayRow}). - * - * `updateViewConfig` has exactly ONE production caller — `ObjectView`'s - * `persistViewPatch`, invoked only for the toolbar-driven density / sort / - * hiddenFields / columnState toggles. (The inline-edit toggle wrote - * `inlineEdit` through it until objectui#5144; the console now keeps that - * toggle session-only and writes nothing for it.) That single call site is - * NOT itself the explicit "create/save a view" path (that goes through - * {@link ObjectStackAdapter.createView} or the ADR-0034 metadata seam, - * `viewEnvelope` in app-shell) — but it fires for a toggle on EITHER kind of - * active tab, system or already-saved, so "every row this method writes is a - * personalization overlay" is only true for the system-view case. A toggle - * on a genuinely saved view targets that view's own row (same `(type='view', - * name=viewId)` key its create path used), and stamping the marker there - * would make {@link ObjectStackAdapter.listViews} exclude the user's own - * view on the next read (objectui#4227 follow-up, PM review on PR #4713) — - * so `updateViewConfig`'s `opts.isSavedView` withholds the marker for that - * case. The caller passes it from the same `isSavedViewId` classification - * the switcher's readonly gate already computes, rather than this layer - * re-deriving it from the write's shape. + * The explicit discriminant the console's toolbar write stamped on the rows it + * wrote for a **system**-view target, and {@link ObjectStackAdapter.listViews} + * excludes on read (objectui#4227). It is the ONLY thing that classifies a row + * as an overlay (objectui#10210, ruling B — see + * {@link isPersonalizationOverlayRow}). + * + * That write was this adapter's `updateViewConfig`, whose one production + * caller was `ObjectView`'s `persistViewPatch`, for the toolbar-driven density + * / sort / hiddenFields / columnState toggles; it withheld the marker when the + * toggle targeted a genuinely saved view's own row (`opts.isSavedView`, + * objectui#4227 follow-up, PM review on PR #4713), so a saved view was never + * flagged. Both are retired (objectui#12504): those toggles are the user's own + * list state, and nothing writes a new overlay. The marked rows already at + * rest are still read, which is why this constant and its readers stay. The + * explicit "create/save a view" path ({@link ObjectStackAdapter.createView}, + * or the ADR-0034 metadata seam, `viewEnvelope` in app-shell) never stamped + * it. * * Survives the round trip against a real server: the platform's `view` * metadata schema `.strip()`s its flattened-overlay members only for @@ -2928,7 +2847,8 @@ export function viewItemObjectName(item: any): string | undefined { * `saveMetaItem` persists the AUTHORED body verbatim — never the stripped * `parsed.data` — specifically so "Studio-only auxiliary fields" (its own * words for `isPinned`/`isDefault`/`sortOrder`) ride along on the stored - * document. This marker rides through the same door. + * document. This marker rode through the same door, so the rows at rest carry + * it. * * The value itself is org-wide, not per-user (objectstack#7494's ruling: the * overlay this row belongs to is shared view SETTINGS, not a personal @@ -2971,8 +2891,9 @@ const VIEW_OVERLAY_MARKER = '_isOverride' as const; * deployment is named as holding one. * * ⛔ Do not bring a shape test back to win the second population back: a shape - * that another writer can produce is not a discriminant. The marker is one, and - * the write side stamps it ({@link ObjectStackAdapter.updateViewConfig}). + * that another writer can produce is not a discriminant. The marker is one: the + * write side stamped it (`updateViewConfig`, retired in objectui#12504), and the + * rows it marked are still at rest. */ function isPersonalizationOverlayRow(item: any, spec: any): boolean { return item?.[VIEW_OVERLAY_MARKER] === true || spec?.[VIEW_OVERLAY_MARKER] === true; @@ -2982,12 +2903,14 @@ function isPersonalizationOverlayRow(item: any, spec: any): boolean { * The keys a personalization overlay row legitimately OWNS (objectui#5233). * * One per `persistViewPatch` call site in app-shell's `ObjectView` — the ONLY - * production writer of these rows — read off the tree rather than recalled: + * production writer of these rows, read off the tree when it was written: * `rowHeight` (the density toggle, spec-canonical since #2890), `sort`, * `hiddenFields` and `columnState`. Plus `inlineEdit`, which no console call - * site writes since objectui#5144 (triage's ruling E: the inline-edit toggle - * is session-only). It stays owned so an overlay the old toggle wrote is still - * read; the ruling keeps that data rather than migrating it. Nothing else in + * site wrote after objectui#5144 (triage's ruling E: the inline-edit toggle + * is session-only). That writer is retired (objectui#12504): no toolbar + * control writes an overlay, and every key here is kept so an overlay an + * earlier console wrote is still read; the rulings keep that data rather than + * migrating it. Nothing else in * such a row is an opinion the user expressed; anything else it carries is a * COPY of the source view as it stood at write time, because * `persistViewPatch` USED TO send `{ ...baseViewDef, ...patch }` and this @@ -3001,22 +2924,19 @@ function isPersonalizationOverlayRow(item: any, spec: any): boolean { * view's filter and every user who once resized a column kept the old one, * with nothing anywhere reporting it. * - * Both halves of that ruling have now landed, and this adapter's behaviour is - * unchanged by either — it still persists what it is given: + * Both halves of that ruling landed, and this adapter's behaviour was + * unchanged by either — it persisted what it was given: * * - **read** (PR #5272, {@link narrowPersonalizationOverlay}): the consumer * that MERGES an overlay over a source view contributes only these keys, so * every already-stored fat row that carries the marker stops shadowing its * source. A fat row written before the marker is no longer narrowed * (objectui#10210, ruling B — see {@link isPersonalizationOverlayRow}). - * - **write** (objectui#5233, `buildPersistedViewBody` in app-shell's - * `ObjectView`, unblocked by `columnState`'s admission to the view-metadata - * surface as a runtime-only overlay key — objectstack `d5552ca13`, released in - * `@objectstack/spec` 17.1.0): a *system view's* overlay is now written as - * the patch alone, so no new row freezes anything, and because the write is - * a whole-document PUT the next toggle also strips an old fat row. A *saved - * view's* own row is deliberately still written whole — for it the body IS - * the view, not a copy of one. + * - **write** (objectui#5233, unblocked by `columnState`'s admission to the + * view-metadata surface as a runtime-only overlay key — objectstack + * `d5552ca13`, released in `@objectstack/spec` 17.1.0): a *system view's* + * overlay was written as the patch alone from then on, and the whole write + * is retired since objectui#12504. * * A fat row is therefore a legacy shape, not a shape this product still * produces; the list below is still what a reader is allowed to trust from one. @@ -3024,10 +2944,10 @@ function isPersonalizationOverlayRow(item: any, spec: any): boolean { * ⛔ Do not grow this list to make some other key "stick" through an overlay. * A key that belongs to the view belongs in the view; the overlay is a patch, * and a patch that carries the whole document is what this list exists to - * stop. Adding an entry is only correct alongside a `persistViewPatch` call - * site that writes it — {@link narrowPersonalizationOverlay} is what a reader - * checks that against. `inlineEdit` is the one entry with no such call site, - * kept for reading rows written before objectui#5144. + * stop. No writer of these rows is left (objectui#12504), so there is no new + * key for an entry to answer; each entry is kept for reading the rows already + * at rest — `inlineEdit` for rows written before objectui#5144, the other four + * for rows written before objectui#12474 / objectui#12418. */ export const VIEW_OVERLAY_OWNED_KEYS = Object.freeze([ 'rowHeight', @@ -3323,12 +3243,6 @@ export class ObjectStackAdapter implements DataSource { // one a read that did not happen. private metadataReadWarningListeners = new Set(); - // [ADR-0066] The session's REPORTED system capabilities, pushed in by the - // host (see `setSystemCapabilities`). `undefined` means NO answer was ever - // reported — which is NOT the same as a reported-empty grant, and the two - // are treated differently by `maySetViewConfig` below. - private systemCapabilities: string[] | undefined; - constructor(config: { baseUrl: string; token?: string; @@ -3340,13 +3254,6 @@ export class ObjectStackAdapter implements DataSource { autoReconnect?: boolean; maxReconnectAttempts?: number; reconnectDelay?: number; - /** - * [ADR-0066] The session's system capabilities, when the host already has - * them at construction time. Most hosts do NOT — `/me/permissions` - * resolves after the adapter exists — and push them in later via - * {@link ObjectStackAdapter.setSystemCapabilities}. Omit for "unreported". - */ - systemCapabilities?: string[]; }) { // objectui#12295 — ONE fetch carries every request this adapter makes: the // client's (data, metadata, analytics, and every caller of `getClient()`) @@ -3378,7 +3285,6 @@ export class ObjectStackAdapter implements DataSource { this.reconnectDelay = config.reconnectDelay ?? 1000; this.baseUrl = config.baseUrl; this.token = config.token; - this.systemCapabilities = config.systemCapabilities; } /** @@ -4141,8 +4047,8 @@ export class ObjectStackAdapter implements DataSource { * * `ObjectStackClient.meta.saveItem` is the second client class that writes * through `PUT /api/v1/meta/:type/:name`, and every one of its callers reaches - * it through an adapter this class constructed — the four inside this file - * (`updateViewConfig`, the two view paths, `updateDashboard`) via + * it through an adapter this class constructed — the three inside this file + * (the two view paths, `updateDashboard`) via * `this.client`, and every caller outside it via {@link getClient}, which * hands back this same instance: `MetadataService` (app-shell, five saves), * `useNavigationSync`, and plugin-designer's Create/EditAppPage. Wrapping the @@ -5562,9 +5468,10 @@ export class ObjectStackAdapter implements DataSource { * Batch-fetch all persisted view overrides for an object. * * Per-view runtime overrides (density, column widths, sort, hidden - * columns, inlineEdit …) live in the SAME metadata namespace the - * write path uses: `type='view'`, `name=` (see - * {@link updateViewConfig}). Loading them per-view fires N HTTP GETs + * columns, inlineEdit …) live in the SAME metadata namespace the view + * writes use: `type='view'`, `name=` (see {@link createView} / + * {@link updateView}; the toolbar's `updateViewConfig` wrote overlays there + * until objectui#12504 retired it). Loading them per-view fires N HTTP GETs * that 404 for every view the user never customized — console noise on * every page load. This batch method performs a single * `GET /api/v1/meta/view` (returns `{type, items}`) and narrows the @@ -5588,9 +5495,9 @@ export class ObjectStackAdapter implements DataSource { * * Result is cached identically to {@link getView}, and EVERY view write — * on this adapter and above it — invalidates it through the one seam, - * {@link invalidateViewKeys}. For a long time only {@link updateViewConfig} - * did (objectui#4363), which left the other three adapter paths stale - * for the cache's 5-minute TTL. That gap does not self-heal: the consumer + * {@link invalidateViewKeys}. For a long time only `updateViewConfig` (since + * retired, objectui#12504) did (objectui#4363), which left the other three + * adapter paths stale for the cache's 5-minute TTL. That gap does not self-heal: the consumer * (`loadViewOverrides`, app-shell `ObjectView`) treats a RESOLVED map as * authoritative and deliberately does not re-probe per view — objectui#3774, * and correct, since re-probing reinstates the 404 flurry the batch read @@ -5615,8 +5522,8 @@ export class ObjectStackAdapter implements DataSource { for (const it of items) { if (!it || typeof it !== 'object') continue; if (viewItemObjectName(it) !== objectName) continue; - // Keyed by the item's canonical `name` — the SAME identity - // `updateViewConfig` writes under and `getView` reads back by, which + // Keyed by the item's canonical `name` — the SAME identity the view + // writes save under and `getView` reads back by, which // is what makes this a drop-in substitute for the per-view fetch. // No `?? id ?? _name` alias chain: those are not view identities on // any route (`/meta/view/:name` is name-addressed), and a batch map @@ -5657,132 +5564,6 @@ export class ObjectStackAdapter implements DataSource { } } - /** - * Persist a toolbar-driven view config patch — density, column widths, - * sort, hidden columns, inline edit. Symmetric counterpart to - * {@link getView}: writes the row to the server metadata store via - * `client.meta.saveItem`, then invalidates the matching cache entry so the - * next {@link getView} reflects the new payload. Returns the persisted item - * when the server echoes it, otherwise undefined. - * - * Called from exactly ONE production site — `ObjectView`'s - * `persistViewPatch`, for the toolbar toggle — but that ONE call site - * fires for BOTH kinds of active tab: a code-defined **system** view (no - * row of its own yet) and a genuinely user-created **saved** view (already - * has a row — the toggle is editing ITS OWN definition, not laying an - * overlay on top of it). Which one a given call means is NOT re-derived - * here from the write's shape (objectui#4227's own lesson: shape inference - * on this namespace is exactly what let a system view masquerade as - * saved) — the caller already knows, via the same `isSavedViewId` - * classification that gates the switcher's readonly flag and its five - * mutating handlers, and passes it as {@link opts.isSavedView}. - * - * - `isSavedView` false/omitted (system-view target, the common case and - * the default for backward compatibility): stamps - * {@link VIEW_OVERLAY_MARKER} so {@link listViews} excludes the row — - * the original objectui#4227 fix. - * - `isSavedView` true: the marker is withheld. Stamping it here would - * flag the saved view's OWN row as a personalization overlay, and - * `listViews()` would exclude it on the very next read — the user's own - * view would vanish from the switcher the moment they toggled its - * density (objectui#4227 follow-up, PM review on PR #4713, measured: - * `persistViewPatch` has no gate on which kind of tab is active, and - * this method writes to the exact same `(type='view', name=viewId)` key - * {@link createView}/the ADR-0034 `viewEnvelope` seam already used for - * that view, so the write is an upsert onto the saved view's row, not a - * new one). - * - * Per objectstack#7494's ruling, the overlay this writes (system-view - * case) is ORG-WIDE shared view settings, not a per-user preference — a - * true per-user scope is a parked v18 direction on the platform side. - * - * @param objectName - Object name (e.g. 'lead') - * @param viewId - View identifier (e.g. 'all_leads') - * @param config - Full view definition to persist - * @param opts.isSavedView - Whether `viewId` already names a saved view - * (vs. a system view being personalized for the first time). Omit / - * `false` for the default overlay-marking behavior. - */ - /** - * [ADR-0066] Tell this adapter which system capabilities the session holds. - * - * Pass `undefined` for "no answer was reported" (a backend predating - * ADR-0066, or no permission provider mounted). Pass `[]` for a real, - * reported, empty grant — {@link maySetViewConfig} treats those two - * differently and callers must not collapse them, which is the same - * distinction `MePermissionsProvider` preserves natively (objectui#4656). - */ - setSystemCapabilities(capabilities: string[] | undefined): void { - this.systemCapabilities = capabilities ? [...capabilities] : undefined; - } - - /** - * May this session write ORG-WIDE view configuration? - * - * **Unknown fails OPEN**, deliberately, and this is the one judgement in the - * gate worth reading twice. It is the doctrine `PermissionContextValue` - * .`hasCapabilities` states for every ADR-0066 gate in this repo - * (objectui#4656, framework#3923): the server enforces `manage_metadata` on - * the metadata door regardless of what the client believes, so a client-side - * denial on MISSING DATA cannot protect anything the server was not already - * protecting — it can only break a permitted user. Failing closed here would - * have refused the write on every deployment predating ADR-0066 and every - * host with no permission provider, which is "break the write for everyone" - * wearing the costume of a security fix. - * - * A REPORTED empty array gates strictly: "holds nothing" is a real answer. - */ - private maySetViewConfig(): boolean { - if (this.systemCapabilities === undefined) return true; - return this.systemCapabilities.includes(VIEW_CONFIG_CAPABILITY); - } - - async updateViewConfig( - objectName: string, - viewId: string, - config: Record, - opts?: { isSavedView?: boolean } - ): Promise | void> { - // objectstack#7494's ruling — THE GATE. Checked first: before `connect()`, - // before `saveItem`, before the payload is even assembled, so a refused - // call puts nothing on the wire and cannot half-write the org-wide row. - // - // This is on the WRITE PATH on purpose. Withholding the toolbar affordance - // would leave this method still accepting the call from anything else - // holding the adapter; the ruling gates the write, so the write is where - // the refusal lives — and every caller, present or future, inherits it. - if (!this.maySetViewConfig()) { - throw new ViewConfigPermissionDeniedError(objectName, viewId); - } - await this.connect(); - // ADR-0005 metadata customization overlay: persist views under - // `type='view'` (NOT `type=` — that was a pre-overlay - // misuse that hit `/api/v1/meta//`, which the - // server never wired). The view's `data.object` field is what - // associates it back to the object on read. `VIEW_OVERLAY_MARKER` is - // stamped LAST, alongside `object`/`name`, so nothing in `config` can - // shadow it (objectui#4227) — this is what lets `listViews()` exclude - // the row instead of `savedViews.find` matching it and presenting a - // system view as user-created/mutable. Withheld entirely when the - // caller says this write targets a saved view's own row (see the - // doc comment above). - const merged = { - ...(config || {}), - object: (config as any)?.object || objectName, - name: viewId, - ...(opts?.isSavedView ? {} : { [VIEW_OVERLAY_MARKER]: true }), - }; - const result: any = await this.client.meta.saveItem( - 'view', - viewId, - merged - ); - // One seam names the key set for every writer (objectui#4373). - this.invalidateViewKeys(objectName, viewId); - if (result && result.item) return result.item; - return result ?? undefined; - } - /** * List user-created views for a given object via the metadata overlay * API (ADR-0005). Replaces the legacy `find('sys_view', {...})` path @@ -5872,7 +5653,8 @@ export class ObjectStackAdapter implements DataSource { const viewKind = v.viewKind ?? spec?.viewKind; if (viewKind && FORM_FAMILY.has(viewKind)) return false; // Personalization overlays (density/sort/hiddenFields/columnState/ - // inlineEdit — written by `updateViewConfig`) are NOT saved views: + // inlineEdit — written by the retired `updateViewConfig`, still at + // rest) are NOT saved views: // returning one here is what let a system view's override row read // back as user-created and gain Rename/Delete/Set-default/Pin // (objectui#4227). Only a row carrying the marker is excluded: the @@ -7327,8 +7109,8 @@ export class ObjectStackAdapter implements DataSource { * `DataSource` interface: the object this hands back is an * `ObjectStackAdapter`, and the adapter-only members the package README * documents — `getClient`, the metadata-cache controls, the connection-state - * and batch-progress subscriptions, and `setSystemCapabilities` — live on the - * class, not on `DataSource`. Declaring the interface here narrowed all of them + * and batch-progress subscriptions — live on the class, not on `DataSource`. + * Declaring the interface here narrowed all of them * away from the factory while leaving them on the value (objectui#7323), so * `createObjectStackAdapter(...)` and `new ObjectStackAdapter(...)` — the two * documented ways to obtain the same object — handed back different type @@ -7360,8 +7142,6 @@ export function createObjectStackAdapter(config: { autoReconnect?: boolean; maxReconnectAttempts?: number; reconnectDelay?: number; - /** [ADR-0066] See {@link ObjectStackAdapter.setSystemCapabilities}. */ - systemCapabilities?: string[]; }): ObjectStackAdapter { return new ObjectStackAdapter(config); } diff --git a/packages/data-objectstack/src/listViewOverrides.test.ts b/packages/data-objectstack/src/listViewOverrides.test.ts index 0d318a3240..7787737461 100644 --- a/packages/data-objectstack/src/listViewOverrides.test.ts +++ b/packages/data-objectstack/src/listViewOverrides.test.ts @@ -9,9 +9,11 @@ /** * objectui#3774 — the view-override read and write must share one key space. * - * `updateViewConfig` persists a personalization (density, column widths, sort, - * hidden columns, inlineEdit …) as `PUT /api/v1/meta/view/{viewId}`, i.e. a - * `sys_metadata` row with `type='view'`. `listViewOverrides` used to enumerate + * A view write persists a view row as `PUT /api/v1/meta/view/{viewId}`, i.e. a + * `sys_metadata` row with `type='view'` — `createView` / `updateView` today, + * and the toolbar's `updateViewConfig` for a personalization (density, column + * widths, sort, hidden columns, inlineEdit …) until objectui#12504 retired it; + * the rows it wrote are still at rest and still read here. `listViewOverrides` used to enumerate * `GET /api/v1/meta/{objectName}` — the OBJECT name in the metadata TYPE slot. * The two key spaces are disjoint, so the batch map came back empty for every * object forever and every saved preference read back as "setting didn't save". @@ -81,8 +83,10 @@ function makeDS(meta: any) { /** * What ObjectView's `persistViewPatch` USED TO send on a density toggle + * column sort: the view's raw config plus the personalization state. The - * adapter stamps `object` / `name` on top (and the framework's overlay heals - * the same identity fields onto rows that lack them — objectstack#2555). + * adapter's write stamps `object` / `name` on top (and the framework's overlay + * heals the same identity fields onto rows that lack them — objectstack#2555). + * Written below through `createView`, the adapter's surviving write into the + * same key space. */ const PERSONALIZATION = { type: 'grid', @@ -93,11 +97,11 @@ const PERSONALIZATION = { }; describe('ObjectStackAdapter.listViewOverrides — read/write key space (#3774)', () => { - it('round-trips: a config saved through updateViewConfig comes back from listViewOverrides', async () => { + it('round-trips: a config saved through createView comes back from listViewOverrides', async () => { const { meta, getItemsTypes } = makeMetaStore(); const ds = makeDS(meta); - await ds.updateViewConfig('crm_lead', 'crm_lead.all', { ...PERSONALIZATION }); + await ds.createView('crm_lead', { ...PERSONALIZATION, name: 'crm_lead.all' }); const map = await ds.listViewOverrides('crm_lead'); // The bug in one assertion: pre-fix this was `{}`. @@ -120,7 +124,7 @@ describe('ObjectStackAdapter.listViewOverrides — read/write key space (#3774)' const { meta } = makeMetaStore(); const ds = makeDS(meta); - await ds.updateViewConfig('crm_lead', 'crm_lead.all', { ...PERSONALIZATION }); + await ds.createView('crm_lead', { ...PERSONALIZATION, name: 'crm_lead.all' }); const batched = (await ds.listViewOverrides('crm_lead'))['crm_lead.all']; const perView = await ds.getView('crm_lead', 'crm_lead.all'); @@ -131,13 +135,16 @@ describe('ObjectStackAdapter.listViewOverrides — read/write key space (#3774)' }); it('finds an override whose only object association is the top-level `object` stamp', async () => { - const { meta } = makeMetaStore(); + const { meta, rows } = makeMetaStore(); const ds = makeDS(meta); - // A saved view with no `data.object` (e.g. a kanban personalization whose - // provider block was never sent) — `updateViewConfig`'s stamp is the only - // association, and it must be enough. - await ds.updateViewConfig('crm_lead', 'crm_lead.pipeline', { type: 'kanban', density: 'comfortable' }); + // A row with no `data.object` (e.g. a kanban personalization whose provider + // block was never sent) — the `object` the retired `updateViewConfig` + // stamped is the only association, and it must be enough. At rest, since + // that write is gone (objectui#12504). + rows.set('view::crm_lead.pipeline', { + type: 'kanban', density: 'comfortable', object: 'crm_lead', name: 'crm_lead.pipeline', + }); const map = await ds.listViewOverrides('crm_lead'); expect(map['crm_lead.pipeline']).toMatchObject({ object: 'crm_lead', density: 'comfortable' }); @@ -147,9 +154,9 @@ describe('ObjectStackAdapter.listViewOverrides — read/write key space (#3774)' const { meta } = makeMetaStore(); const ds = makeDS(meta); - await ds.updateViewConfig('crm_lead', 'crm_lead.all', { ...PERSONALIZATION }); - await ds.updateViewConfig('crm_account', 'crm_account.all', { - type: 'grid', data: { provider: 'object', object: 'crm_account' }, density: 'compact', + await ds.createView('crm_lead', { ...PERSONALIZATION, name: 'crm_lead.all' }); + await ds.createView('crm_account', { + name: 'crm_account.all', type: 'grid', data: { provider: 'object', object: 'crm_account' }, density: 'compact', }); expect(Object.keys(await ds.listViewOverrides('crm_lead'))).toEqual(['crm_lead.all']); @@ -189,7 +196,7 @@ describe('ObjectStackAdapter.listViewOverrides — empty vs unknown (#3774)', () it('a rejection is not cached — the next call re-reads instead of pinning the failure', async () => { const { meta } = makeMetaStore(); const ds = makeDS(meta); - await ds.updateViewConfig('crm_lead', 'crm_lead.all', { ...PERSONALIZATION }); + await ds.createView('crm_lead', { ...PERSONALIZATION, name: 'crm_lead.all' }); meta.getItems.mockRejectedValueOnce(new Error('transient')); await expect(ds.listViewOverrides('crm_lead')).rejects.toThrow('transient'); diff --git a/packages/data-objectstack/src/listViews.test.ts b/packages/data-objectstack/src/listViews.test.ts index d03ddfdc91..d4a1617f04 100644 --- a/packages/data-objectstack/src/listViews.test.ts +++ b/packages/data-objectstack/src/listViews.test.ts @@ -115,9 +115,10 @@ describe('ObjectStackDataSource.listViews', () => { // the guess is rewritten to pin the ruled rule, not deleted. describe('excludes personalization overlays (objectui#4227)', () => { it('excludes a row carrying the explicit write-side marker', async () => { - // Exactly what `updateViewConfig` writes today: the marker plus a full - // copy of the system view's body (`persistViewPatch` USED TO spread the whole - // active tab into the write). + // Exactly what the toolbar's `updateViewConfig` wrote (retired in + // objectui#12504; the rows are still at rest): the marker plus a full + // copy of the system view's body (`persistViewPatch` USED TO spread the + // whole active tab into the write). const override = { name: 'crm_lead.default', object: 'crm_lead', type: 'grid', data: { provider: 'object', object: 'crm_lead' }, columns: ['name'], @@ -155,8 +156,8 @@ describe('ObjectStackDataSource.listViews', () => { it('does NOT exclude a genuine ViewItem-record saved view, even one named like a system view', async () => { // The nested `config` wrapper is only ever produced by an explicit - // create/save path (createView / the ADR-0034 seam) — never by - // `updateViewConfig`, marker or no marker. + // create/save path (createView / the ADR-0034 seam) — never by the + // retired `updateViewConfig`, marker or no marker. const savedRecord = { name: 'crm_lead.default', object: 'crm_lead', viewKind: 'list', label: 'My Rebuild', config: { type: 'grid', data: { provider: 'object', object: 'crm_lead' }, columns: ['name'] }, diff --git a/packages/data-objectstack/src/occ.test.ts b/packages/data-objectstack/src/occ.test.ts index 9803c9c6a8..70582fc126 100644 --- a/packages/data-objectstack/src/occ.test.ts +++ b/packages/data-objectstack/src/occ.test.ts @@ -160,9 +160,8 @@ describe('Optimistic Concurrency Control errors', () => { // The accepted set of the exported predicate, pinned because objectui#6375 // DECIDED to keep its `name === 'ConcurrentUpdateError'` limb rather than // narrow it to the code. The limb is the cross-realm discriminator — see - // the rationale quoted in the doc comment above `isConcurrentUpdateError` - // and stated in full above `isViewConfigPermissionDeniedError`. A future - // reader who removes it as drift meets this row first. + // the rationale stated in the doc comment above `isConcurrentUpdateError`. + // A future reader who removes it as drift meets this row first. describe('isConcurrentUpdateError: code OR class name, and never the status', () => { it('accepts the class name with no wire code — the cross-realm limb', () => { expect(isConcurrentUpdateError({ name: 'ConcurrentUpdateError' })).toBe(true); diff --git a/packages/data-objectstack/src/onSaveAdvisory.test.ts b/packages/data-objectstack/src/onSaveAdvisory.test.ts index be12bbba13..54de35e1c7 100644 --- a/packages/data-objectstack/src/onSaveAdvisory.test.ts +++ b/packages/data-objectstack/src/onSaveAdvisory.test.ts @@ -267,12 +267,12 @@ describe('save-advisory mode reflects the door the server reports (D1)', () => { * that never mention the interceptor. */ describe('the enumerated callers are covered without a per-site edit', () => { - it("covers the adapter's own view save path (updateViewConfig)", async () => { + it("covers the adapter's own view save path (createView)", async () => { const { ds } = makeAdapter({ ...CLEAN_BODY, advisories: [PURGE_ADVISORY] }); const events: MetadataSaveAdvisoryEvent[] = []; ds.onSaveAdvisory((e: MetadataSaveAdvisoryEvent) => events.push(e)); - await ds.updateViewConfig('lead', 'all_leads', { object: 'lead' }); + await ds.createView('lead', { name: 'all_leads', object: 'lead' }); expect(events).toHaveLength(1); expect(events[0]!.type).toBe('view'); diff --git a/packages/data-objectstack/src/viewCacheInvalidation.pin.test.ts b/packages/data-objectstack/src/viewCacheInvalidation.pin.test.ts index f912ba84d2..4bdb29525d 100644 --- a/packages/data-objectstack/src/viewCacheInvalidation.pin.test.ts +++ b/packages/data-objectstack/src/viewCacheInvalidation.pin.test.ts @@ -35,7 +35,8 @@ import { ObjectStackAdapter } from './index'; * ## objectui#4363 — every write path names BOTH keys * * Removing the dead key made the surviving asymmetry visible: only - * `updateViewConfig` invalidated `view-overrides:{object}`, so `createView` / + * `updateViewConfig` (retired since objectui#12504) invalidated + * `view-overrides:{object}`, so `createView` / * `updateView` / `deleteView` left the batch map stale for the cache's * 5-minute TTL. It does not self-heal — `loadViewOverrides` (app-shell * `ObjectView`) treats a RESOLVED map as authoritative and deliberately does @@ -44,8 +45,9 @@ import { ObjectStackAdapter } from './index'; * * So the rule these pins now enforce is uniform and per-METHOD, not * per-branch: **a write to a view row invalidates the per-view key and the - * object's override map.** Four paths (five call sites — `updateView` has a - * draft half and a published half) emit the same ordered pair. The two sweep + * object's override map.** Three paths (four call sites — `updateView` has a + * draft half and a published half) emit the same ordered pair; a fourth path, + * `updateViewConfig`, did too until objectui#12504 retired it. The two sweep * pins are untouched controls: `listViews` stays uncached, and no path names a * `views:` key. */ @@ -171,16 +173,6 @@ describe('view metadata cache — invalidation names only keys with readers (#37 expect(cacheReads).toEqual([]); }); - it('updateViewConfig invalidates exactly the two keys that have readers', async () => { - const { ds, invalidated } = makeDS(); - - await ds.updateViewConfig('account', 'v1', { label: 'Renamed' }); - - // `view:{object}:{viewId}` → getView; `view-overrides:{object}` → - // listViewOverrides. Nothing else is read back under a view-shaped key. - expect(invalidated).toEqual(['view:account:v1', 'view-overrides:account']); - }); - it('listViewOverrides reads back under the key the write paths invalidate', async () => { // The pairing, not the string: every assertion below names // `view-overrides:account` because THIS is the key the batch reader caches @@ -198,6 +190,8 @@ describe('view metadata cache — invalidation names only keys with readers (#37 await ds.createView('account', { name: 'account.mine', object: 'account' }); + // `view:{object}:{viewId}` → getView; `view-overrides:{object}` → + // listViewOverrides. Nothing else is read back under a view-shaped key. // A created view is a new row in the batch map; nothing else notices. // `saveItem` is an upsert, so the per-view key is named too — an explicit // `name` that already exists overwrites a row `getView` may hold cached. @@ -247,13 +241,10 @@ describe('view metadata cache — invalidation names only keys with readers (#37 const created = makeDS(); await created.ds.createView('account', { name: 'account.mine', object: 'account' }); - const config = makeDS(); - await config.ds.updateViewConfig('account', 'v1', { label: 'Renamed' }); - const removed = makeDS(); await removed.ds.deleteView('account', 'v1'); - for (const { invalidated } of [created, config, removed]) { + for (const { invalidated } of [created, removed]) { expect(invalidated.filter((k) => k.startsWith('views:'))).toEqual([]); } }); diff --git a/packages/data-objectstack/src/viewConfigPermissionGate.test.ts b/packages/data-objectstack/src/viewConfigPermissionGate.test.ts deleted file mode 100644 index f31a449840..0000000000 --- a/packages/data-objectstack/src/viewConfigPermissionGate.test.ts +++ /dev/null @@ -1,213 +0,0 @@ -/** - * 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. - */ - -/** - * objectstack#7494 (maintainer ruling, 2026-08-12) — view configuration is - * ORG-WIDE, and its write path is gated. - * - * `sort` / `hiddenFields` / `columnState` / `rowHeight` persisted through - * `updateViewConfig` are shared by every user of the view; there is no - * per-user scope behind them (parked as objectstack#7611, v18) and none is - * built here. So an ordinary user toggling density from the toolbar was - * re-styling the view for the whole organization, and the ruling puts a - * view-management-class permission on the WRITE — not on the affordance. - * - * ## What each cell is for - * - * The two load-bearing cells run in the SAME file and the same run on - * purpose: "the gate works" must not be satisfiable by a change that simply - * broke the write for everybody. `refuses` is only meaningful next to a - * `permits` that still goes through, and vice versa. - * - * Every cell builds its OWN `makeMetaStore()`. A store shared across cells - * would let one case's `saveItem` call satisfy the next case's assertion — - * a refusal cell can pass while measuring nothing if the spy it reads was - * already dirty. - * - * ## Which of these would still pass if the gate were reverted - * - * Stated so a reviewer does not have to guess: - * - * - `permits` and `unreported capabilities fail OPEN` would BOTH still - * pass — they assert the write happens, which is what the code did - * before the gate existed. They are controls, not the measurement. - * - Every cell under `refuses` would FAIL on a revert: without the gate - * `updateViewConfig` resolves and calls `saveItem`, so the rejection - * assertion and the `not.toHaveBeenCalled()` both go red. Those are the - * cells that carry the ruling. - */ - -import { describe, it, expect, vi } from 'vitest'; -import { - ObjectStackAdapter, - ViewConfigPermissionDeniedError, - isViewConfigPermissionDeniedError, - VIEW_CONFIG_CAPABILITY, -} from './index'; - -/** A stub metadata store keyed the way `sys_metadata` is: `type` + `name`. */ -function makeMetaStore() { - const rows = new Map(); - const meta = { - getItems: vi.fn(async (type: string) => ({ type, items: [] as any[] })), - getItem: vi.fn(async (type: string, name: string) => { - const item = rows.get(`${type}::${name}`); - if (!item) { - const err: any = new Error(`Not found: ${type}/${name}`); - err.status = 404; - throw err; - } - return { type, name, item }; - }), - saveItem: vi.fn(async (type: string, name: string, item: any) => { - rows.set(`${type}::${name}`, { ...item }); - return { success: true, item: rows.get(`${type}::${name}`) }; - }), - }; - return { meta, rows }; -} - -function makeDS(meta: any, capabilities?: string[] | undefined, viaConstructor = false) { - const ds: any = new ObjectStackAdapter({ - baseUrl: 'http://test.local', - ...(viaConstructor ? { systemCapabilities: capabilities } : {}), - fetch: vi.fn(async () => - new Response(JSON.stringify({ success: true, data: { capabilities: {}, routes: {} } }), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - })), - }); - ds.connected = true; - ds.connectionState = 'connected'; - ds.client = { meta }; - if (!viaConstructor) ds.setSystemCapabilities(capabilities); - return ds; -} - -/** The four properties the ruling names, as one realistic toolbar payload. */ -const RULED_PATCH = { - sort: [{ field: 'name', direction: 'asc' }], - hiddenFields: ['description'], - columnState: { order: ['name', 'amount'], widths: { name: 220 } }, - rowHeight: 40, -}; - -describe('updateViewConfig is gated on a view-management capability (objectstack#7494)', () => { - describe('permits — the control that keeps a green refusal honest', () => { - it('a session holding the capability still writes view config', async () => { - const { meta, rows } = makeMetaStore(); - const ds = makeDS(meta, ['setup.access', VIEW_CONFIG_CAPABILITY]); - - await expect( - ds.updateViewConfig('crm_lead', 'crm_lead.default', RULED_PATCH), - ).resolves.toBeDefined(); - - expect(meta.saveItem).toHaveBeenCalledTimes(1); - // Not merely "called" — the ruled payload actually landed in the store. - expect(rows.get('view::crm_lead.default')).toMatchObject(RULED_PATCH); - }); - - it('unreported capabilities fail OPEN — a pre-ADR-0066 backend still writes', async () => { - // `undefined` means "no answer was ever reported", which is NOT a - // denial: the server enforces `manage_metadata` on the metadata door - // regardless, and refusing here would break every deployment predating - // ADR-0066 and every host with no permission provider mounted. - const { meta } = makeMetaStore(); - const ds = makeDS(meta, undefined); - - await expect( - ds.updateViewConfig('crm_lead', 'crm_lead.default', RULED_PATCH), - ).resolves.toBeDefined(); - expect(meta.saveItem).toHaveBeenCalledTimes(1); - }); - }); - - describe('refuses — the cells that carry the ruling', () => { - it('a reported set WITHOUT the capability is refused, and nothing is written', async () => { - const { meta } = makeMetaStore(); - // objectstack#8270's EE workspace owner: a real, non-empty grant that - // simply does not include metadata authoring. - const ds = makeDS(meta, ['manage_org_users', 'setup.access', 'setup.write']); - - await expect( - ds.updateViewConfig('crm_lead', 'crm_lead.default', RULED_PATCH), - ).rejects.toBeInstanceOf(ViewConfigPermissionDeniedError); - - // The refusal is BEFORE the wire. An org-wide row that is written and - // then complained about is not a gate. - expect(meta.saveItem).not.toHaveBeenCalled(); - }); - - it('a reported EMPTY grant gates strictly — "holds nothing" is a real answer', async () => { - const { meta } = makeMetaStore(); - const ds = makeDS(meta, []); - - await expect( - ds.updateViewConfig('crm_lead', 'crm_lead.default', RULED_PATCH), - ).rejects.toBeInstanceOf(ViewConfigPermissionDeniedError); - expect(meta.saveItem).not.toHaveBeenCalled(); - }); - - it('the capability injected at construction gates the same way', async () => { - const { meta } = makeMetaStore(); - const ds = makeDS(meta, ['setup.access'], /* viaConstructor */ true); - - await expect( - ds.updateViewConfig('crm_lead', 'crm_lead.default', RULED_PATCH), - ).rejects.toBeInstanceOf(ViewConfigPermissionDeniedError); - expect(meta.saveItem).not.toHaveBeenCalled(); - }); - - it('the refusal is LOUD and self-describing — never a silent no-op', async () => { - const { meta } = makeMetaStore(); - const ds = makeDS(meta, ['setup.access']); - - const err = await ds - .updateViewConfig('crm_lead', 'crm_lead.default', RULED_PATCH) - .then(() => null, (e: unknown) => e); - - // A resolved promise here would be the exact failure mode the ruling's - // reviewers called out: the toolbar moves, the write vanishes, and the - // operator learns about it on the next reload. - expect(err).toBeInstanceOf(Error); - expect(isViewConfigPermissionDeniedError(err)).toBe(true); - expect((err as any).code).toBe('VIEW_CONFIG_PERMISSION_DENIED'); - expect((err as any).capability).toBe('manage_metadata'); - expect((err as any).objectName).toBe('crm_lead'); - expect((err as any).viewId).toBe('crm_lead.default'); - // The message states the SCOPE, which is the half an operator cannot - // otherwise see, and then the capability that unlocks it. - expect((err as any).message).toContain('changes it for everyone who uses this view'); - expect((err as any).message).toContain('manage_metadata'); - }); - }); - - describe('the guard', () => { - it('does not match unrelated errors', () => { - expect(isViewConfigPermissionDeniedError(new Error('boom'))).toBe(false); - expect(isViewConfigPermissionDeniedError(null)).toBe(false); - expect(isViewConfigPermissionDeniedError({ code: 'CONCURRENT_UPDATE' })).toBe(false); - }); - - it('matches a wire twin that crossed a bundle boundary', () => { - // Duck-checked rather than `instanceof`, so a host bundling this package - // twice still gets the right verdict. - expect(isViewConfigPermissionDeniedError({ code: 'VIEW_CONFIG_PERMISSION_DENIED' })).toBe(true); - expect(isViewConfigPermissionDeniedError({ name: 'ViewConfigPermissionDeniedError' })).toBe(true); - }); - }); - - it('gates on the capability this repo already uses for metadata authoring', () => { - // Pinned rather than asserted loosely: the ruling names a CLASS - // ("view-management-class permission"), and the choice of `manage_metadata` - // is the reviewable decision. `HomePage`'s AUTHORING_CAPABILITY is the - // same string, and the write gated here goes through the same ADR-0005 - // metadata door the server already refuses without it. - expect(VIEW_CONFIG_CAPABILITY).toBe('manage_metadata'); - }); -}); diff --git a/packages/data-objectstack/src/viewItemObjectName.declaredSpelling-11013.test.ts b/packages/data-objectstack/src/viewItemObjectName.declaredSpelling-11013.test.ts index ce3e142aca..130f4f5a9f 100644 --- a/packages/data-objectstack/src/viewItemObjectName.declaredSpelling-11013.test.ts +++ b/packages/data-objectstack/src/viewItemObjectName.declaredSpelling-11013.test.ts @@ -81,14 +81,14 @@ describe('objectui#11013 — the door: `objectName` alone does not bind a stored }); describe('objectui#11013 — the writers: every console write path stamps `object`', () => { - it('createView, updateViewConfig and updateView each leave `object` on the row', async () => { + it('createView 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. + // merge stamps one on the way back out. (`updateViewConfig` was the third + // writer here until objectui#12504 retired it.) 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`]) { + for (const name of [`${OBJECT}.created`, `${OBJECT}.at_rest`]) { expect(rows.get(`view::${name}`)?.object, name).toBe(OBJECT); expect(rows.get(`view::${name}`), name).not.toHaveProperty('objectName'); } diff --git a/packages/data-objectstack/src/viewOverlayMarker.test.ts b/packages/data-objectstack/src/viewOverlayMarker.test.ts index 7fd2fde62c..6fe0ffee69 100644 --- a/packages/data-objectstack/src/viewOverlayMarker.test.ts +++ b/packages/data-objectstack/src/viewOverlayMarker.test.ts @@ -14,15 +14,22 @@ * genuine user-created view, so the switcher tab lost its readonly lock and * gained Rename/Delete/Set-default/Pin against a view that lives in code. * - * The fix has two layers, both pinned here end-to-end against a fake - * `sys_metadata` store (round-trip, not a hand-written read fixture — the - * write path is exactly where a shape mismatch would hide): + * The fix had two layers: * * 1. `updateViewConfig` — the ONLY production writer of personalization - * rows — stamps an explicit `_isOverride` marker on every row it saves. + * rows — stamped an explicit `_isOverride` marker on every row it saved + * for a system view. * 2. `listViews()` excludes any row carrying that marker — and, since * objectui#10210, ONLY such a row. * + * objectui#12504 retired the writer (`updateViewConfig`, with the console's + * toolbar write that was its one caller): no new overlay is written. The rows + * it marked are at rest in every install that toggled a system view, so layer + * 2 still runs and is pinned here end-to-end against a fake `sys_metadata` + * store, with each overlay put in the store in the shape the retired write + * stored it. So is the other half of layer 1 that survives: the adapter's + * remaining view writer, `createView`, never stamps the marker. + * * The second layer used to be wider. For rows persisted before the marker * shipped it also excluded a best-effort legacy SHAPE: a flat body plus a * `viewKind` the platform backfills from a REGISTRY baseline (objectstack#2555 / @@ -85,36 +92,7 @@ function makeDS(meta: any) { return ds; } -describe('updateViewConfig stamps the overlay marker (objectui#4227)', () => { - it('every saved item carries `_isOverride: true`', async () => { - const { meta } = makeMetaStore(); - const ds = makeDS(meta); - - await ds.updateViewConfig('crm_lead', 'crm_lead.default', { rowHeight: 40 }); - - expect(meta.saveItem).toHaveBeenCalledWith( - 'view', - 'crm_lead.default', - expect.objectContaining({ _isOverride: true }), - ); - }); - - it('a caller-supplied `_isOverride` cannot un-mark the row — stamped last', async () => { - const { meta } = makeMetaStore(); - const ds = makeDS(meta); - - // `persistViewPatch` USED TO spread the whole active tab into the write - // (objectui#5233 narrowed the overlay branch to the patch); nothing a - // caller sends — spread or patch — may defeat the marker. - await ds.updateViewConfig('crm_lead', 'crm_lead.default', { _isOverride: false, rowHeight: 40 } as any); - - expect(meta.saveItem).toHaveBeenCalledWith( - 'view', - 'crm_lead.default', - expect.objectContaining({ _isOverride: true }), - ); - }); - +describe('the surviving writer never stamps the overlay marker (objectui#4227)', () => { it('createView does NOT stamp the marker — it is a genuine saved view', async () => { const { meta } = makeMetaStore(); const ds = makeDS(meta); @@ -125,36 +103,27 @@ describe('updateViewConfig stamps the overlay marker (objectui#4227)', () => { expect(saved._isOverride).toBeUndefined(); }); - it('opts.isSavedView withholds the marker — a toggle on a SAVED view must not flag its own row (objectui#4227 follow-up, PR #4713)', async () => { - const { meta } = makeMetaStore(); - const ds = makeDS(meta); - - // Exactly what `persistViewPatch` sends for a toggle on the ACTIVE tab - // when that tab is already a saved view — `isSavedView: true`. - await ds.updateViewConfig('crm_lead', 'crm_lead.my_pipeline', { - type: 'kanban', label: 'My Pipeline', rowHeight: 40, - }, { isSavedView: true }); - - expect(meta.saveItem).toHaveBeenCalledWith( - 'view', - 'crm_lead.my_pipeline', - expect.not.objectContaining({ _isOverride: true }), - ); + it('the adapter has no overlay writer left (objectui#12504)', () => { + expect('updateViewConfig' in ObjectStackAdapter.prototype).toBe(false); }); }); -describe('end-to-end: a toolbar toggle on a system view never masquerades as saved (objectui#4227)', () => { - it('round-trips through the real write + read: excluded from listViews, still present in listViewOverrides', async () => { - const { meta } = makeMetaStore(); +describe('end-to-end: a system-view overlay at rest never masquerades as saved (objectui#4227)', () => { + it('read through the real adapter: excluded from listViews, still present in listViewOverrides', async () => { + const { meta, rows } = makeMetaStore(); const ds = makeDS(meta); - // The exact user action the issue reports: density toggle on a - // code-defined view, going through the real adapter write path. - await ds.updateViewConfig('crm_lead', 'crm_lead.default', { + // The row the exact user action the issue reports left behind: a density + // toggle on a code-defined view, stored the way the retired + // `updateViewConfig` stored it (the body, `object`, `name`, the marker). + rows.set('view::crm_lead.default', { type: 'grid', data: { provider: 'object', object: 'crm_lead' }, columns: ['name', 'status'], rowHeight: 40, + object: 'crm_lead', + name: 'crm_lead.default', + _isOverride: true, }); // `listViews()` — feeds `savedViews` / the switcher's mutability gate — @@ -181,79 +150,4 @@ describe('end-to-end: a toolbar toggle on a system view never masquerades as sav const savedViews = await ds.listViews('crm_lead'); expect(savedViews.map((v: any) => v.name)).toEqual(['crm_lead.my_pipeline']); }); - - // ── objectui#4227 follow-up (PM review on PR #4713): the danger sequence - // createView -> toggle its own toolbar -> listViews() -- that PR's own - // round-trip tests covered createView->listViews (no toggle) and a - // toggle on a SYSTEM view's id, but not a toggle on a SAVED view's own - // id, which is exactly what a user does immediately after creating a - // view and adjusting its density. ────────────────────────────────────── - describe('a toggle on a SAVED view must not make it vanish (objectui#4227 follow-up)', () => { - /** The real production shape a runtime "Add View" produces (app-shell's - * `viewEnvelope`, via the ADR-0034 seam) -- nested `config`, `viewKind` - * OUTSIDE it. `ObjectStackAdapter.createView`'s own flat `fullSpec` is - * NOT this shape and is not the path the console's UI actually uses - * (`viewEnvelope` + `persistRuntimeMetadata` write through a separate - * `MetadataClient`, bypassing this adapter's `createView` entirely) -- - * using the real shape here is what makes this a genuine regression - * pin rather than one that only exercises the adapter's own writer. - */ - const savedViewRow = { - name: 'crm_lead.my_pipeline', - object: 'crm_lead', - viewKind: 'list', - label: 'My Pipeline', - config: { - type: 'kanban', - data: { provider: 'object', object: 'crm_lead' }, - columns: ['name', 'status'], - }, - }; - - it('createView -> toggle density on THAT SAME view -> listViews() still returns it (the fix)', async () => { - const { meta, rows } = makeMetaStore(); - rows.set('view::crm_lead.my_pipeline', savedViewRow); - const ds = makeDS(meta); - - // Sanity: the saved view is visible before any toggle. - expect((await ds.listViews('crm_lead')).map((v: any) => v.name)) - .toEqual(['crm_lead.my_pipeline']); - - // `persistViewPatch` spreads the ACTIVE TAB (the flattened saved view - // `listViews()` just returned) plus the toggle's patch, and — with - // this fix — passes `isSavedView: true` because `isSavedViewId` - // already found this id in `savedViews`. - await ds.updateViewConfig('crm_lead', 'crm_lead.my_pipeline', { - type: 'kanban', - label: 'My Pipeline', - columns: ['name', 'status'], - rowHeight: 40, // the toggle itself - }, { isSavedView: true }); - - const afterToggle = await ds.listViews('crm_lead'); - expect(afterToggle.map((v: any) => v.name)).toEqual(['crm_lead.my_pipeline']); - // The tab stays fully manageable: `isSavedViewId`/`viewRowId` match on - // `name`, which the write preserved. - expect(afterToggle[0]).toMatchObject({ name: 'crm_lead.my_pipeline', rowHeight: 40 }); - }); - - it('the same sequence WITHOUT the fix (isSavedView omitted) is the regression this pins against', async () => { - const { meta, rows } = makeMetaStore(); - rows.set('view::crm_lead.my_pipeline', savedViewRow); - const ds = makeDS(meta); - - // Same toggle, but as `updateViewConfig` behaved before this - // follow-up (no `isSavedView` signal) — demonstrates why the signal - // is load-bearing, not decorative. - await ds.updateViewConfig('crm_lead', 'crm_lead.my_pipeline', { - type: 'kanban', - label: 'My Pipeline', - columns: ['name', 'status'], - rowHeight: 40, - }); - - // The user's own saved view has vanished from the switcher. - expect(await ds.listViews('crm_lead')).toEqual([]); - }); - }); }); diff --git a/packages/data-objectstack/src/viewOverlayMarkerOnly-10210.test.ts b/packages/data-objectstack/src/viewOverlayMarkerOnly-10210.test.ts index 8188cfe8a2..c0cac93314 100644 --- a/packages/data-objectstack/src/viewOverlayMarkerOnly-10210.test.ts +++ b/packages/data-objectstack/src/viewOverlayMarkerOnly-10210.test.ts @@ -90,8 +90,9 @@ const DEFINITION_NOW = { /** * What `updateViewConfig` stored before the marker existed (objectui#4227): * the whole active tab plus the one key the toggle changed, and no marker. - * Today's `updateViewConfig` stamps the marker, so this row can only be put in - * the store directly — it is the state of an install that has carried it since. + * `updateViewConfig` stamped the marker from then on, and is retired since + * objectui#12504, so this row can only be put in the store directly — it is the + * state of an install that has carried it since. */ const PRE_MARKER_OVERLAY_WRITE = { ...DEFINITION_AT_OVERLAY_TIME, diff --git a/packages/data-objectstack/src/viewOverlayPatchOnly.test.ts b/packages/data-objectstack/src/viewOverlayPatchOnly.test.ts index 22882383b7..f05499cb06 100644 --- a/packages/data-objectstack/src/viewOverlayPatchOnly.test.ts +++ b/packages/data-objectstack/src/viewOverlayPatchOnly.test.ts @@ -14,17 +14,19 @@ * this adapter persists what it is given, so a row written by a column drag * carried the whole source view — `filter` included — as of that moment. The * display merge is `{ ...source, ...override }`, which handed that stale copy - * authority over the source declaration it was copied from. The write half has - * since landed (`buildPersistedViewBody` in app-shell's `ObjectView`), so a fat - * row is now a LEGACY shape — which is exactly what the fixtures below model, - * and why this read-side policy still has work to do. + * authority over the source declaration it was copied from. The write half + * landed (objectui#5233: the toolbar wrote the patch alone), and the toolbar's + * write itself is retired since objectui#12504 (`persistViewPatch` and this + * adapter's `updateViewConfig`), so a fat row is a LEGACY shape — which is + * exactly what the fixtures below model, and why this read-side policy still + * has work to do. * * {@link narrowPersonalizationOverlay} is the read-side half: the policy for * WHAT an overlay may contribute, owned by this package because this package - * owns the row's shape (it stamps the marker `listViews()` excludes rows by). - * The end-to-end pin — write, read, merge, admin edits the source filter, user - * sees the new one — lives beside the consumer that does the merging, - * `packages/app-shell/src/views/ObjectView.overlayPatchOnly.test.ts`. + * owns the row's shape (its retired writer stamped the marker `listViews()` + * excludes rows by). The end-to-end pin — read, merge, admin edits the source + * filter, user sees the new one — lives beside the consumer that does the + * merging, `packages/app-shell/src/views/ObjectView.overlayPatchOnly.test.ts`. * * Deliberately NOT applied inside {@link ObjectStackAdapter.listViewOverrides} * or {@link ObjectStackAdapter.getView}: both answer with the stored DOCUMENT, @@ -195,7 +197,7 @@ describe('narrowPersonalizationOverlay — what an overlay may contribute', () = expect(narrowPersonalizationOverlay(arr)).toBe(arr); }); - it('the owned-key list is the five the toolbar writes, and is frozen', () => { + it('the owned-key list is the five the retired toolbar write wrote, and is frozen', () => { expect([...VIEW_OVERLAY_OWNED_KEYS]).toEqual( ['rowHeight', 'sort', 'hiddenFields', 'columnState', 'inlineEdit'], ); @@ -204,11 +206,13 @@ describe('narrowPersonalizationOverlay — what an overlay may contribute', () = }); describe('narrowPersonalizationOverlay — the round trip it is defined against', () => { - it('a real updateViewConfig write comes back narrowed to its patch', async () => { - const { meta } = makeMetaStore(); + it('a fat overlay row at rest comes back narrowed to its patch', async () => { + const { meta, rows } = makeMetaStore(); const ds = makeDS(meta); - await ds.updateViewConfig('crm_lead', 'crm_lead.default', { ...FAT_WRITE }); + // The row the retired `updateViewConfig` (objectui#12504) stored for this + // write: the body, with `object`, `name` and the marker stamped on it. + rows.set('view::crm_lead.default', { ...FAT_WRITE, object: 'crm_lead', _isOverride: true }); const stored = (await ds.listViewOverrides('crm_lead'))['crm_lead.default']; // The document readers still get the document — this is the boundary, and diff --git a/packages/i18n/src/__tests__/de-quote-pairing-3876.test.ts b/packages/i18n/src/__tests__/de-quote-pairing-3876.test.ts index fa3f2e9347..ad00c88775 100644 --- a/packages/i18n/src/__tests__/de-quote-pairing-3876.test.ts +++ b/packages/i18n/src/__tests__/de-quote-pairing-3876.test.ts @@ -331,7 +331,10 @@ describe('objectui#3876 — de pack closes „ with “ and not with a straight // `.unmanagedIgnored`, the unmanaged install's answer and the runtime that // ignored it, which each quote the package name („{{name}}“) — two // interpolated spans, runtime data. - expect(okSpans, 'correctly paired spans').toBe(87); + // 86 once objectui#12504 retired `console.objectView.viewConfigPermissionDenied` + // (its producer, the toolbar's view write, retired with the adapter's gate), + // taking its one literal span („Metadaten verwalten“) with it. + expect(okSpans, 'correctly paired spans').toBe(86); }); it('keeps the count identity that replaces the card’s count(„) === count(“)', () => { @@ -380,8 +383,10 @@ describe('objectui#3876 — de pack closes „ with “ and not with a straight // seven matched pairs between them. 85 / 85 / 0 after objectui#12248 added // `detail.deleteAttachmentConfirm`, one more matched pair. 87 / 87 / 0 after // objectui#12288 added `marketplace.install.unmanagedSuccess` and - // `.unmanagedIgnored`, one matched pair each. - expect({ open, close, rdq }).toEqual({ open: 87, close: 87, rdq: 0 }); + // `.unmanagedIgnored`, one matched pair each. 86 / 86 / 0 after + // objectui#12504 retired `console.objectView.viewConfigPermissionDenied`, + // one matched pair fewer. + expect({ open, close, rdq }).toEqual({ open: 86, close: 86, rdq: 0 }); // The durable shape: every „ closed by a “, every surplus “ an English // opener answered by a ”. Survived translating the two English values. expect(close).toBe(open + rdq); diff --git a/packages/i18n/src/__tests__/objectView-config-keys-retired-4730.test.ts b/packages/i18n/src/__tests__/objectView-config-keys-retired-4730.test.ts index 5783766a13..8b30c5e1c3 100644 --- a/packages/i18n/src/__tests__/objectView-config-keys-retired-4730.test.ts +++ b/packages/i18n/src/__tests__/objectView-config-keys-retired-4730.test.ts @@ -140,6 +140,15 @@ const at = (pack: unknown, path: string): unknown => * eight new keys label the dataset, measure and dimension picks (`dataset`, * `datasetHelp`, `chartMeasure`, `chartMeasureHelp`, `chartDimension`, * `chartDimensionHelp`, `viewTypeUnavailableDataset`, `noDatasetMeasure`). + * + * ## The objectui#12504 change — 123 retired become 124 + * + * `viewConfigPermissionDenied` (objectui#5232's addition, above) was the toast + * the console's toolbar write raised when the adapter's capability gate refused + * an org-wide view-config change. The maintainer's ruling on objectui#12504 + * retired that write (`persistViewPatch`) with the adapter method and its gate + * (`updateViewConfig`), and the toast was its only producer, so the key left + * all ten packs. A retirement moves the split, not the total. */ const RETIRED_OBJECT_VIEW_KEYS = [ 'accessibility', @@ -269,17 +278,20 @@ const RETIRED_OBJECT_VIEW_KEYS = [ 'xAxisFieldHelp', 'yAxisField', 'yAxisFieldHelp', + // objectui#12504 — the refused toolbar view-write's toast; the write and the + // adapter gate that refused it retired together, and it had no other reader. + 'viewConfigPermissionDenied', ] as const; /** How many keys the namespace keeps — the live + indirect-reference remainder. */ -const SURVIVING_KEY_COUNT = 104; +const SURVIVING_KEY_COUNT = 103; describe('console.objectView config-panel keys are retired (objectui#4730)', () => { it('the retired list is the measured set', () => { // Guards the premise the rest of the file rests on. If this arithmetic ever // stops holding, the assertions below are checking a set nobody chose. - expect(RETIRED_OBJECT_VIEW_KEYS).toHaveLength(123); - expect(new Set(RETIRED_OBJECT_VIEW_KEYS).size).toBe(123); + expect(RETIRED_OBJECT_VIEW_KEYS).toHaveLength(124); + expect(new Set(RETIRED_OBJECT_VIEW_KEYS).size).toBe(124); // 209 at objectui#4730's landing; 210 since objectui#5232 added // `viewConfigPermissionDenied`; 211 since objectui#10636 added // `recordCountOne`, the record-count footer's singular; 217 since @@ -287,7 +299,9 @@ describe('console.objectView config-panel keys are retired (objectui#4730)', () // which moved the split, not the total); 225 since objectui#11576 added the // create-view dialog's eight dataset-binding keys (and retired its four axis // keys); 227 since objectui#12418 added `resetView` and `resetViewTitle`, - // the header's "Reset view" entry. The RETIRED half is a ratchet that never + // the header's "Reset view" entry (and objectui#12504 retired + // `viewConfigPermissionDenied`, which moved the split, not the total). The + // RETIRED half is a ratchet that never // runs BACKWARDS — it is pinned twice above, and objectui#8754 advanced it // 116 -> 118 — while the surviving half is a live namespace that grows on a // new key and shrinks by exactly what the retired half gains. Splitting diff --git a/packages/i18n/src/locales/ar.ts b/packages/i18n/src/locales/ar.ts index 9092df3a38..45b9c04983 100644 --- a/packages/i18n/src/locales/ar.ts +++ b/packages/i18n/src/locales/ar.ts @@ -2306,7 +2306,6 @@ const ar = { viewNameInvalid: "استخدم أحرفًا إنجليزية صغيرة وأرقامًا وشرطات سفلية؛ وابدأ بحرف أو شرطة سفلية.", systemViewReadonly: "عرض النظام معرَّف في الكود — للقراءة فقط.", cannotEditMetaView: "هذا عرض مدمج معرَّف في الكود — لا يمكن تعديله.", - viewConfigPermissionDenied: "تنطبق إعدادات العرض على جميع المستخدمين الذين يستخدمون هذا العرض، لذا يتطلب تغييرها إذن «إدارة البيانات الوصفية». اطلب من المسؤول إجراء هذا التغيير.", resetView: "إعادة تعيين العرض", resetViewTitle: "إعادة هذا العرض إلى الفرز وعوامل التصفية وعرض الأعمدة الخاصة به. تتغير إعداداتك فقط.", cannotDeleteMetaView: "هذا عرض مدمج معرَّف في الكود — لا يمكن حذفه.", diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts index 7cae908fd5..bc8b3bb1cb 100644 --- a/packages/i18n/src/locales/de.ts +++ b/packages/i18n/src/locales/de.ts @@ -2109,7 +2109,6 @@ const de = { viewNameInvalid: "Verwenden Sie Kleinbuchstaben, Ziffern und Unterstriche; beginnen Sie mit einem Buchstaben oder Unterstrich.", systemViewReadonly: "Systemansicht — im Code definiert, schreibgeschützt.", cannotEditMetaView: "Diese Ansicht ist integriert und im Code definiert – sie kann nicht geändert werden.", - viewConfigPermissionDenied: "Ansichtseinstellungen gelten für alle Benutzer dieser Ansicht. Ihre Änderung erfordert daher die Berechtigung „Metadaten verwalten“. Bitten Sie einen Administrator, diese Änderung vorzunehmen.", resetView: "Ansicht zurücksetzen", resetViewTitle: "Sortierung, Filter und Spaltenbreiten dieser Ansicht auf ihre eigenen Einstellungen zurücksetzen. Nur Ihre Einstellungen ändern sich.", cannotDeleteMetaView: "Diese Ansicht ist integriert und im Code definiert – sie kann nicht gelöscht werden.", diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts index 494cbc38d4..fb330b910b 100644 --- a/packages/i18n/src/locales/en.ts +++ b/packages/i18n/src/locales/en.ts @@ -2603,7 +2603,6 @@ const en = { // (rename / pin / set-as-default / configure), so the copy has to cover // any change to a built-in view rather than name one operation. cannotEditMetaView: 'This view is built in and defined in code — it cannot be changed.', - viewConfigPermissionDenied: 'View settings apply to everyone who uses this view, so changing them requires the Manage Metadata permission. Ask an administrator to make this change.', resetView: 'Reset view', resetViewTitle: 'Set this view back to its own sort, filters and column widths. Only yours change.', cannotDeleteMetaView: 'This view is built in and defined in code — it cannot be deleted.', diff --git a/packages/i18n/src/locales/es.ts b/packages/i18n/src/locales/es.ts index 8d53f8cd0b..84c24a3da5 100644 --- a/packages/i18n/src/locales/es.ts +++ b/packages/i18n/src/locales/es.ts @@ -2158,7 +2158,6 @@ const es = { viewNameInvalid: "Use minúsculas, números y guiones bajos; empiece por una letra o un guion bajo.", systemViewReadonly: "Vista del sistema definida en el código: solo lectura.", cannotEditMetaView: "Esta vista está integrada y definida en el código: no se puede modificar.", - viewConfigPermissionDenied: "La configuración de la vista se aplica a todos los usuarios que la utilizan, por lo que modificarla requiere el permiso «Administrar metadatos». Pida a un administrador que realice este cambio.", resetView: "Restablecer vista", resetViewTitle: "Devolver esta vista a su propia ordenación, filtros y anchos de columna. Solo cambia su configuración.", cannotDeleteMetaView: "Esta vista está integrada y definida en el código: no se puede eliminar.", diff --git a/packages/i18n/src/locales/fr.ts b/packages/i18n/src/locales/fr.ts index 6870e5e4f7..43bdae3079 100644 --- a/packages/i18n/src/locales/fr.ts +++ b/packages/i18n/src/locales/fr.ts @@ -2155,7 +2155,6 @@ const fr = { viewNameInvalid: "Utilisez des minuscules, des chiffres et des tirets bas ; commencez par une lettre ou un tiret bas.", systemViewReadonly: "Vue système définie dans le code — en lecture seule.", cannotEditMetaView: "Cette vue est intégrée et définie dans le code — elle ne peut pas être modifiée.", - viewConfigPermissionDenied: "Les paramètres de vue s’appliquent à tous les utilisateurs de cette vue ; les modifier requiert donc l’autorisation « Gérer les métadonnées ». Demandez à un administrateur d’effectuer ce changement.", resetView: "Réinitialiser la vue", resetViewTitle: "Rétablir le tri, les filtres et les largeurs de colonnes propres à cette vue. Seuls vos réglages changent.", cannotDeleteMetaView: "Cette vue est intégrée et définie dans le code — elle ne peut pas être supprimée.", diff --git a/packages/i18n/src/locales/ja.ts b/packages/i18n/src/locales/ja.ts index 8c328f9904..d6f29607d8 100644 --- a/packages/i18n/src/locales/ja.ts +++ b/packages/i18n/src/locales/ja.ts @@ -2111,7 +2111,6 @@ const ja = { viewNameInvalid: "英小文字、数字、アンダースコアを使用し、先頭は文字かアンダースコアにしてください。", systemViewReadonly: "システムビューはコードで定義されており、読み取り専用です。", cannotEditMetaView: "このビューはコードで定義された組み込みビューのため、変更できません。", - viewConfigPermissionDenied: "ビュー設定はこのビューを使用するすべてのユーザーに適用されるため、変更には「メタデータの管理」権限が必要です。管理者に変更を依頼してください。", resetView: "ビューをリセット", resetViewTitle: "このビューの並べ替え、フィルター、列幅をビュー本来の設定に戻します。変わるのはあなたの表示だけです。", cannotDeleteMetaView: "このビューはコードで定義された組み込みビューのため、削除できません。", diff --git a/packages/i18n/src/locales/ko.ts b/packages/i18n/src/locales/ko.ts index 771862f222..210905f829 100644 --- a/packages/i18n/src/locales/ko.ts +++ b/packages/i18n/src/locales/ko.ts @@ -2109,7 +2109,6 @@ const ko = { viewNameInvalid: "소문자, 숫자, 밑줄을 사용하고 문자 또는 밑줄로 시작하세요.", systemViewReadonly: "시스템 보기는 코드에 정의되어 있어 읽기 전용입니다.", cannotEditMetaView: "이 보기는 코드에 정의된 기본 제공 보기이므로 변경할 수 없습니다.", - viewConfigPermissionDenied: "보기 설정은 이 보기를 사용하는 모든 사용자에게 적용되므로 변경하려면 '메타데이터 관리' 권한이 필요합니다. 관리자에게 변경을 요청하세요.", resetView: "보기 재설정", resetViewTitle: "이 보기의 정렬, 필터, 열 너비를 보기 자체의 설정으로 되돌립니다. 본인의 설정만 변경됩니다.", cannotDeleteMetaView: "이 보기는 코드에 정의된 기본 제공 보기이므로 삭제할 수 없습니다.", diff --git a/packages/i18n/src/locales/pt.ts b/packages/i18n/src/locales/pt.ts index 9af05157c3..4fff583146 100644 --- a/packages/i18n/src/locales/pt.ts +++ b/packages/i18n/src/locales/pt.ts @@ -2153,7 +2153,6 @@ const pt = { viewNameInvalid: "Use letras minúsculas, números e sublinhados; comece com uma letra ou sublinhado.", systemViewReadonly: "Exibição do sistema definida no código — somente leitura.", cannotEditMetaView: "Esta exibição é integrada e definida no código — não é possível alterá-la.", - viewConfigPermissionDenied: "As configurações da exibição se aplicam a todos os usuários que a utilizam, portanto alterá-las exige a permissão 'Gerenciar metadados'. Peça a um administrador para fazer essa alteração.", resetView: "Redefinir exibição", resetViewTitle: "Retornar esta exibição à sua própria ordenação, filtros e larguras de coluna. Apenas as suas configurações mudam.", cannotDeleteMetaView: "Esta exibição é integrada e definida no código — não é possível excluí-la.", diff --git a/packages/i18n/src/locales/ru.ts b/packages/i18n/src/locales/ru.ts index a7798882b9..44ae5a328e 100644 --- a/packages/i18n/src/locales/ru.ts +++ b/packages/i18n/src/locales/ru.ts @@ -2216,7 +2216,6 @@ const ru = { viewNameInvalid: "Используйте строчные латинские буквы, цифры и подчёркивания; начните с буквы или подчёркивания.", systemViewReadonly: "Системное представление определено в коде — только для чтения.", cannotEditMetaView: "Это встроенное представление, определённое в коде, — изменить его нельзя.", - viewConfigPermissionDenied: "Настройки представления применяются ко всем пользователям этого представления, поэтому их изменение требует разрешения «Управление метаданными». Обратитесь к администратору, чтобы внести это изменение.", resetView: "Сбросить представление", resetViewTitle: "Вернуть сортировку, фильтры и ширину столбцов этого представления к его собственным настройкам. Изменятся только ваши настройки.", cannotDeleteMetaView: "Это встроенное представление, определённое в коде, — удалить его нельзя.", diff --git a/packages/i18n/src/locales/zh.ts b/packages/i18n/src/locales/zh.ts index 3153e01d48..fea41424c0 100644 --- a/packages/i18n/src/locales/zh.ts +++ b/packages/i18n/src/locales/zh.ts @@ -2196,7 +2196,6 @@ const zh = { objectView: { systemViewReadonly: '系统视图由代码定义,只读。', cannotEditMetaView: '该视图为内置视图,由代码定义,无法修改。', - viewConfigPermissionDenied: '视图配置对所有使用该视图的用户生效,因此修改它需要「管理元数据」权限。请联系管理员进行修改。', resetView: '重置视图', resetViewTitle: '将此视图的排序、筛选和列宽恢复为视图自身的设置。只影响你自己。', cannotDeleteMetaView: '该视图为内置视图,由代码定义,无法删除。', diff --git a/packages/plugin-detail/src/ConcurrentUpdateDialog.tsx b/packages/plugin-detail/src/ConcurrentUpdateDialog.tsx index 126beb164b..824a538c01 100644 --- a/packages/plugin-detail/src/ConcurrentUpdateDialog.tsx +++ b/packages/plugin-detail/src/ConcurrentUpdateDialog.tsx @@ -229,8 +229,7 @@ export default ConcurrentUpdateDialog; * cross-realm discriminator, and it stays: a host that bundles the adapter * twice makes `instanceof` fail, leaving the class name as the only thing * left to match on. The rationale is recorded above `isConcurrentUpdateError` - * in `packages/data-objectstack/src/index.ts` and, in full, above - * `isViewConfigPermissionDeniedError` in the same file. + * in `packages/data-objectstack/src/index.ts`. * * That limb is also why `code` is declared OPTIONAL below (objectui#6421). An * error matched by `name` alone carries no `code` at all, so promising diff --git a/packages/plugin-grid/src/ObjectGrid.tsx b/packages/plugin-grid/src/ObjectGrid.tsx index 3c96c672a7..f9d815a9fc 100644 --- a/packages/plugin-grid/src/ObjectGrid.tsx +++ b/packages/plugin-grid/src/ObjectGrid.tsx @@ -613,7 +613,8 @@ export interface ObjectGridExternalPaginationProps * `onColumnReorder(newOrder)` — whereas this reports the MERGED, persisted * `{ order, widths }` layout after ObjectGrid has folded either event into * the state it also writes to `localStorage`, so a host can save one blob - * through `dataSource.updateViewConfig`. Deriving it from either table + * (the console stores it on the signed-in user's own list state). Deriving + * it from either table * callback would misstate both the payload and when it fires. */ onColumnStateChange?: (state: ObjectGridColumnState) => void; @@ -1753,10 +1754,10 @@ export const ObjectGrid: React.FC = ({ * unlisted, not missed. * * It is the USER-STATE persistence payload, not authoring surface: the host - * hands in whatever the user last saved (`app-shell/src/views/ObjectView.tsx` - * :1848 reads it off the view def) and writes their drags back through - * `dataSource.updateViewConfig` (`persistViewPatch`, same file :1867; - * `saveColumnState` below is the outbound half). Publishing it would put + * hands in whatever the user last saved (app-shell's `ObjectView` reads it + * off the user's own list state, then off the view def) and stores their + * drags back on that state (`persistListColumnState` there; `saveColumnState` + * below is the outbound half). Publishing it would put * column widths and order in the designer panel and in the generated * `sdui-intrinsics.d.ts` — i.e. bless hand-authoring a payload the product * writes on the user's behalf. @@ -1805,7 +1806,8 @@ export const ObjectGrid: React.FC = ({ } catch (e) { console.warn('Failed to persist column state:', e); } - // Notify parent so it can persist via dataSource.updateViewConfig. + // Notify the host so it can persist the layout (the console stores it on + // the signed-in user's own list state). if (typeof onColumnStateChange === 'function') { try { onColumnStateChange(state); } catch (e) { console.warn('onColumnStateChange threw:', e); } } @@ -5816,9 +5818,9 @@ export const ObjectGrid: React.FC = ({ * — but it says nothing about a sort that is ALREADY in stored view state, * persisted before the signal existed. Replayed, that entry would put a * refused `$orderby` on the wire, paint an active-sort arrow on a header - * that offers no click, and ride along into the next - * `persistViewPatch({ sort })` the toolbar issues for an unrelated reason — - * the exact half-fix where the affordance is gone and the PUT still fires. + * that offers no click, and ride along into the next sort the host stores + * for an unrelated reason — the exact half-fix where the affordance is gone + * and the write still fires. * * So both directions run through the same filter: what the grid RENDERS the * sort state as, and what it EMITS back to whoever persists it. A stale diff --git a/packages/plugin-grid/src/__tests__/columnSortabilitySignal.test.tsx b/packages/plugin-grid/src/__tests__/columnSortabilitySignal.test.tsx index a5fa4c8b3f..98c00fee9a 100644 --- a/packages/plugin-grid/src/__tests__/columnSortabilitySignal.test.tsx +++ b/packages/plugin-grid/src/__tests__/columnSortabilitySignal.test.tsx @@ -196,7 +196,7 @@ describe('#5729 scope item 2 — the personalization PUT never carries a refused it('a RESTORED sort on a refused column is inert: no affordance, no emission', async () => { // The half-fix this leg exists to prevent: the header click is gone, but a // sort persisted before the signal existed is replayed out of stored view - // state and rides into the next `persistViewPatch({ sort })`. + // state and rides into the next sort the host stores. const onSortChange = vi.fn(); const ds = makeDataSource('crm_opportunity'); const { container } = render( diff --git a/packages/plugin-grid/src/__tests__/columnStatePersistence.test.tsx b/packages/plugin-grid/src/__tests__/columnStatePersistence.test.tsx index 7c9174c794..549943ca30 100644 --- a/packages/plugin-grid/src/__tests__/columnStatePersistence.test.tsx +++ b/packages/plugin-grid/src/__tests__/columnStatePersistence.test.tsx @@ -14,7 +14,8 @@ * channels of `saveColumnState`: * 1. `localStorage.setItem(columnStorageKey, …)` — the per-browser fallback, and * 2. the `onColumnStateChange` prop — the host channel that `ObjectView` turns - * into `dataSource.updateViewConfig`. + * into a write of the user's own list state (it was + * `dataSource.updateViewConfig` until objectui#12418 / objectui#12504). * * Every spy and the storage are recreated per case (no module-level mock object * carrying one case's write into the next). @@ -86,7 +87,7 @@ describe('ObjectGrid column-state persistence (outbound half)', () => { dragResize('Name', 160); - // Channel 1 — the host callback that reaches dataSource.updateViewConfig. + // Channel 1 — the host callback (the console stores it on the user's own list state). await waitFor(() => expect(onColumnStateChange).toHaveBeenCalled()); const notified = onColumnStateChange.mock.calls.at(-1)![0]; expect(notified.widths).toEqual({ name: 160 }); diff --git a/packages/plugin-grid/src/__tests__/gridNonAuthorKeys.test.tsx b/packages/plugin-grid/src/__tests__/gridNonAuthorKeys.test.tsx index 6dea9f6fd3..6ef5a2e188 100644 --- a/packages/plugin-grid/src/__tests__/gridNonAuthorKeys.test.tsx +++ b/packages/plugin-grid/src/__tests__/gridNonAuthorKeys.test.tsx @@ -157,19 +157,19 @@ const NON_AUTHOR_KEYS = [ { key: 'columnState', ruled: '2026-08-18', - producer: 'app-shell/src/views/ObjectView.tsx:1848 in, :1867 back out via persistViewPatch', + producer: "app-shell ObjectView: the list schema's `columnState` in (the user's list state, then the view), `persistListColumnState` back out", sample: { order: ['name'] }, }, { key: 'hideRowHeightToggle', ruled: '2026-08-18', - producer: 'plugin-list/src/ListView.tsx:1866 — the embedding view owns density', + producer: 'plugin-list ListView: the child view schema it builds sets it — the embedding view owns density', sample: true, }, { key: 'maxInlineRowActions', ruled: '2026-08-18', - producer: 'apps/console/src/dev/DevRowActions.tsx:51 — an embedder layout call', + producer: 'apps/console DevRowActions (`EnvGrid`) — an embedder layout call', sample: 2, }, { @@ -178,8 +178,8 @@ const NON_AUTHOR_KEYS = [ // key into the manifest as `bulkActionDefs`'s symmetric partner. ruled: '2026-08-19 (reversing 2026-08-18)', producer: - "app-shell/src/views/ObjectView.tsx:1968 — DERIVED from objectDef.actions filtered by locations:['list_item'], " - + 'whereas bulkActionDefs twenty lines below is passed through from the author', + "app-shell ObjectView: the list schema's `rowActionDefs` — DERIVED from objectDef.actions filtered by locations:['list_item'], " + + 'whereas bulkActionDefs beside it is passed through from the author', sample: [{ name: 'approve', label: 'Approve' }], }, ] as const; diff --git a/packages/plugin-list/src/ListView.tsx b/packages/plugin-list/src/ListView.tsx index b8003fc773..d444f2e313 100644 --- a/packages/plugin-list/src/ListView.tsx +++ b/packages/plugin-list/src/ListView.tsx @@ -4533,9 +4533,11 @@ export const ListView = React.forwardRef(({ * REMOVE a sort the server refuses outright. But the picker used to render * and emit from the SAME array, so any OTHER edit in the popover — adding a * second key, flipping a direction — re-emitted the refused entry, and the - * host's `onSortChange` turned it into `persistViewPatch({ sort })`: a - * personalization PUT storing a column the platform answers - * `400 INVALID_SORT` for, written by a user who never touched that row. + * host's `onSortChange` stored it (then through `persistViewPatch({ sort })`, + * the toolbar's view write, retired in objectui#12504; today on the user's + * own list state): a personalization write storing a column the platform + * answers `400 INVALID_SORT` for, written by a user who never touched that + * row. * * So the two legs part company HERE, at the one boundary every emit crosses, * exactly as #5729 parted them at the grid seam (`ObjectGrid`'s diff --git a/packages/plugin-list/src/__tests__/ListView.sortPersistLeak.test.tsx b/packages/plugin-list/src/__tests__/ListView.sortPersistLeak.test.tsx index de9693e3b6..a1cd408a67 100644 --- a/packages/plugin-list/src/__tests__/ListView.sortPersistLeak.test.tsx +++ b/packages/plugin-list/src/__tests__/ListView.sortPersistLeak.test.tsx @@ -19,9 +19,11 @@ * * The picker then rendered and emitted from the SAME array. So an edit to a * DIFFERENT part of the sort — a second key, a direction, a reset — re-emitted - * the refused entry, and the host turned that into `persistViewPatch({ sort })`: - * a personalization PUT storing a column the platform answers - * `400 INVALID_SORT` for, written by a user who never touched that row. + * the refused entry, and the host stored it (then through + * `persistViewPatch({ sort })`, the toolbar's view write, retired in + * objectui#12504; today on the user's own list state): a personalization write + * storing a column the platform answers `400 INVALID_SORT` for, written by a + * user who never touched that row. * * ## What each test is holding down * @@ -39,8 +41,9 @@ * as before, and a deployment that serves NO projection is untouched. * * The host is modelled as `ObjectView` writes it — `onSortChange` → a - * `persistViewPatch({ sort })` spy — so what these tests read is the payload - * that reaches stored view state, not an intermediate array. + * `persistSort({ sort })` spy (the console's `persistListSort`, the user's own + * list state) — so what these tests read is the payload that reaches stored + * state, not an intermediate array. */ import { describe, it, expect, vi } from 'vitest'; import { render, screen, fireEvent, waitFor, within } from '@testing-library/react'; @@ -114,23 +117,23 @@ async function openSortPopover( dsOpts: { servesSignal?: boolean } = {}, ) { const dataSource = makeDataSource(dsOpts); - // The host, spelled as `ObjectView` spells it: - // `onSortChange: (sort) => persistViewPatch(viewDef.id, viewDef, { sort })`. - const persistViewPatch = vi.fn(); + // The host, spelled as `ObjectView` spells it: `onSortChange: persistListSort`, + // which stores `{ sort }` on the user's own list state. + const persistSort = vi.fn(); render( persistViewPatch({ sort: next })} + onSortChange={(next: any) => persistSort({ sort: next })} /> , ); await waitFor(() => expect(dataSource.getObjectSchema).toHaveBeenCalled()); fireEvent.click(screen.getByRole('button', { name: /^sort/i })); await screen.findByText('Sort Records'); - persistViewPatch.mockClear(); - return { persistViewPatch }; + persistSort.mockClear(); + return { persistSort }; } /** @@ -173,7 +176,7 @@ describe('ListView sort picker — what it lists is not what it persists (#6455) it('drops the platform-refused entry from the write an UNRELATED edit causes', async () => { // A view stored before the signal existed: its sort names a column the // platform now answers `400 INVALID_SORT` for. - const { persistViewPatch } = await openSortPopover([ + const { persistSort } = await openSortPopover([ { field: 'remote_status', order: 'asc' }, ]); @@ -181,8 +184,8 @@ describe('ListView sort picker — what it lists is not what it persists (#6455) addSortRow(); // The write carries the edit the user made — and nothing else. Before this - // card it carried `remote_status` too, straight into `persistViewPatch`. - expect(persistedSort(persistViewPatch)).toEqual([{ field: 'name', order: 'asc' }]); + // card it carried `remote_status` too, straight into `persistSort`. + expect(persistedSort(persistSort)).toEqual([{ field: 'name', order: 'asc' }]); }); it('keeps the refused entry LISTED while it is the current sort', async () => { @@ -195,7 +198,7 @@ describe('ListView sort picker — what it lists is not what it persists (#6455) }); it('persists the REMOVAL when the user deletes the refused row', async () => { - const { persistViewPatch } = await openSortPopover([ + const { persistSort } = await openSortPopover([ { field: 'remote_status', order: 'asc' }, ]); @@ -203,11 +206,11 @@ describe('ListView sort picker — what it lists is not what it persists (#6455) // Listed, removable, and the removal reaches stored view state — the whole // point of keeping the entry visible. - expect(persistedSort(persistViewPatch)).toEqual([]); + expect(persistedSort(persistSort)).toEqual([]); }); it('leaves a sort with nothing refused in it exactly as it was', async () => { - const { persistViewPatch } = await openSortPopover([ + const { persistSort } = await openSortPopover([ { field: 'amount', order: 'asc' }, ]); @@ -215,7 +218,7 @@ describe('ListView sort picker — what it lists is not what it persists (#6455) // Both entries survive, in order: the filter is identity over a sort the // platform will order by. - expect(persistedSort(persistViewPatch)).toEqual([ + expect(persistedSort(persistSort)).toEqual([ { field: 'amount', order: 'asc' }, { field: 'name', order: 'asc' }, ]); @@ -225,7 +228,7 @@ describe('ListView sort picker — what it lists is not what it persists (#6455) // The third door onto the same stored `sort`. Reset restores the view's // DECLARED array whole — so a view whose declared sort names a refused // column would re-persist it on every reset. - const { persistViewPatch } = await openSortPopover([ + const { persistSort } = await openSortPopover([ { field: 'remote_status', order: 'asc' }, ]); @@ -235,7 +238,7 @@ describe('ListView sort picker — what it lists is not what it persists (#6455) fireEvent.click(screen.getByTestId('sort-reset-default')); // The declared sort, minus what the platform refuses. - expect(persistedSort(persistViewPatch)).toEqual([]); + expect(persistedSort(persistSort)).toEqual([]); }); it('changes nothing where the deployment serves NO projection', async () => { @@ -243,14 +246,14 @@ describe('ListView sort picker — what it lists is not what it persists (#6455) // deployment older than objectstack#10235, or an inline/mock data source. // There is no verdict to filter by, so the pre-#10235 behaviour stands — // this is the pre-#6455 payload, byte for byte. - const { persistViewPatch } = await openSortPopover( + const { persistSort } = await openSortPopover( [{ field: 'remote_status', order: 'asc' }], { servesSignal: false }, ); addSortRow(); - expect(persistedSort(persistViewPatch)).toEqual([ + expect(persistedSort(persistSort)).toEqual([ { field: 'remote_status', order: 'asc' }, { field: 'name', order: 'asc' }, ]); diff --git a/packages/plugin-list/src/__tests__/ListView.sortabilitySignal.test.tsx b/packages/plugin-list/src/__tests__/ListView.sortabilitySignal.test.tsx index 85530cbdad..2ffa66b676 100644 --- a/packages/plugin-list/src/__tests__/ListView.sortabilitySignal.test.tsx +++ b/packages/plugin-list/src/__tests__/ListView.sortabilitySignal.test.tsx @@ -211,7 +211,7 @@ describe('ListView sort picker — the served sortability signal (#6108)', () => * only exists after this card. A view saved before the signal existed can * name a field the platform now refuses; withholding the option unconditionally * would render a blank row the user cannot remove — and re-emit the refused - * entry into the next `persistViewPatch({ sort })`. + * entry into the next sort the host stores. */ it('keeps a platform-refused field the CURRENT sort already names, so it can be removed', async () => { await openSortPopover({ ...baseSchema, sort: [{ field: 'remote_status', order: 'asc' }] } as any); diff --git a/packages/react/src/hooks/useDensityMode.ts b/packages/react/src/hooks/useDensityMode.ts index 37c8d65321..885a24de3d 100644 --- a/packages/react/src/hooks/useDensityMode.ts +++ b/packages/react/src/hooks/useDensityMode.ts @@ -79,9 +79,11 @@ const DENSITY_ORDER: DensityModeValue[] = ['compact', 'comfortable', 'spacious'] * import { DENSITY_MODE_TO_ROW_HEIGHT, rowHeightToDensityMode } from '@object-ui/core'; * * // A view stores the protocol's `rowHeight`; the legacy `densityMode` key is - * // retired on `NamedListView` (objectui#7924), so persist the spec value. + * // retired on `NamedListView` (objectui#7924), so persist the spec value — + * // here into the host's own per-user store (`saveUserRowHeight` is the + * // host's; the console keeps the choice on the user's own list state). * const density = useDensityMode(rowHeightToDensityMode(activeView?.rowHeight) ?? 'comfortable', { - * onChange: (m) => dataSource.updateViewConfig(obj, vid, { rowHeight: DENSITY_MODE_TO_ROW_HEIGHT[m] }), + * onChange: (m) => saveUserRowHeight(vid, DENSITY_MODE_TO_ROW_HEIGHT[m]), * }); * ``` */ diff --git a/packages/types/src/data.ts b/packages/types/src/data.ts index 228e5c4c33..3b39964030 100644 --- a/packages/types/src/data.ts +++ b/packages/types/src/data.ts @@ -640,10 +640,11 @@ export interface DataSource { * * TWO CONTRACT TERMS, and they are not the same value (objectui#3774): * - * - The map MUST be keyed by the SAME view identity {@link updateViewConfig} - * persists under and {@link getView} reads back by. An implementation that - * enumerates a different key space than the one it writes to answers `{}` - * forever, and every saved preference reads back as "didn't save". + * - The map MUST be keyed by the SAME view identity the view writes + * ({@link createView}, {@link updateView}) persist under and {@link getView} + * reads back by. An implementation that enumerates a different key space + * than the one it writes to answers `{}` forever, and every saved + * preference reads back as "didn't save". * - An empty map means "no overrides exist" and is AUTHORITATIVE — callers * may trust it and skip the per-view reads. A failure MUST therefore * REJECT, never resolve to `{}`: swallowing it into an empty map is @@ -656,33 +657,6 @@ export interface DataSource { */ listViewOverrides?(objectName: string): Promise>; - /** - * Persist a view configuration to the backend. - * Called when a user saves view settings (columns, filters, sort, toggles, etc.) - * from the inline ViewConfigPanel. - * Optional — implementations that do not support view persistence may omit this. - * - * @param objectName - Object name - * @param viewId - View identifier (e.g., 'all', 'pipeline') - * @param config - The full view configuration to persist - * @param opts.isSavedView - Whether `viewId` already names a saved - * (user-created) view rather than a code-defined one being personalized - * for the first time (objectui#4227 follow-up). A caller that tracks - * which views are saved — the same classification that gates rename/ - * delete/pin/set-default affordances — SHOULD pass this so an - * implementation that distinguishes overlay rows from saved-view rows - * (to keep the two from masquerading as each other) does not mistake - * an edit to a saved view's own row for a new overlay on top of it. - * Omit when the distinction does not apply to a given implementation. - * @returns Promise resolving to the persisted config (or void) - */ - updateViewConfig?( - objectName: string, - viewId: string, - config: Record, - opts?: { isSavedView?: boolean }, - ): Promise | void>; - /** * List user-created overlay views for an object (ADR-0005 metadata * customization overlay). Returns view specs (not physical sys_view diff --git a/packages/types/src/objectql.ts b/packages/types/src/objectql.ts index 4ca50a3af9..d8fcd77c0e 100644 --- a/packages/types/src/objectql.ts +++ b/packages/types/src/objectql.ts @@ -3380,7 +3380,8 @@ export interface ListViewRuntimeProps { /** * Callback fired when the user toggles row density/height via the toolbar. Lets the host - * persist the choice (e.g. dataSource.updateViewConfig). Without it the toggle is local-only. + * persist the choice (the console keeps it as the signed-in user's own list state). Without + * it the toggle is local-only. */ onDensityChange?: (mode: 'compact' | 'comfortable' | 'spacious') => void; diff --git a/scripts/check-doc-example-types.mjs b/scripts/check-doc-example-types.mjs index 7071272979..eee09be20d 100644 --- a/scripts/check-doc-example-types.mjs +++ b/scripts/check-doc-example-types.mjs @@ -904,7 +904,7 @@ export const UNGATED_EXAMPLES = { card: null, codes: [2304], reason: - 'usage fragment: references `activeView`, `dataSource`, `obj`, `vid`, which the example never declares', + 'usage fragment: references `activeView`, `saveUserRowHeight`, `vid`, which the example never declares', }, 'packages/react/src/hooks/useDiscovery.ts useDiscovery #1': { card: null, diff --git a/skills/objectui/guides/data-integration.md b/skills/objectui/guides/data-integration.md index b4810f8a07..93909b9753 100644 --- a/skills/objectui/guides/data-integration.md +++ b/skills/objectui/guides/data-integration.md @@ -54,7 +54,7 @@ interface DataSource { // View support (optional) getView?(objectName: string, viewId: string): Promise; - createView?(...); updateView?(...); updateViewConfig?(...); deleteView?(...); + createView?(...); updateView?(...); deleteView?(...); // Analytics (optional) aggregate?(resource: string, params: AggregateParams): Promise; @@ -65,11 +65,11 @@ interface DataSource { ``` Six members are **required**; the optional half is much larger than the excerpt -above — **32** optional members at `origin/main`, covering bulk/transaction, +above — `data.ts` lists every optional member, covering bulk/transaction, views, apps & pages, file upload, and the export / import job lifecycles. Read -`data.ts` before concluding a capability is missing, and note the two spellings -that do **not** exist: there is no `saveView` (write through `updateViewConfig` -/ `createView` / `updateView`) and no generic `execute`. +it before concluding a capability is missing, and note the spellings that do +**not** exist: there is no `saveView` and no `updateViewConfig` (write a view +through `createView` / `updateView`) and no generic `execute`. ### QueryParams