diff --git a/.changeset/22444-list-view-component-props-row.md b/.changeset/22444-list-view-component-props-row.md new file mode 100644 index 00000000000..9c41663c13d --- /dev/null +++ b/.changeset/22444-list-view-component-props-row.md @@ -0,0 +1,12 @@ +--- +"@objectstack/spec": minor +--- + +feat(spec): `ComponentPropsMap` gains a `list-view` row, so a list view embedded on a page is judged as a list view; stored page nodes in the old `objectName` / `viewType` spelling convert to `data` / `type` on read + +Clause-②: yes (widening) + +- **The row.** `ComponentPropsMap['list-view']` is the list view's own members by reference (`ListViewSchema.shape`), with both of its object-level checks attached (the calendar visualization and the chart dataset binding), plus the two members the list overlay member declares instead: `columns` is optional, and the legacy `options` bag is judged per kind (`options.kanban` by the kanban block's schema, `options.grid` refused because a grid has no per-kind block). Until this row a page `list-view` node's `properties` were judged by nothing, so a kanban block's `groupField` or an `options.grid` bag, refused at every list view door, saved green on a page. `os validate`, `os build`, `os lint` and the page save door now report them through the existing component-props rule (`component-props-unknown-key`), naming the key. No rule, tier or door is added, and `PageSchema`'s accept set does not move. +- **The page carrier's old spelling.** A page `list-view` node names its object in `properties.data` and its list kind in `properties.type`, the spelling every other list view door uses. `properties.objectName` and `properties.viewType` are refused by name, with the prescription the react tier gives the same two aliases on `ListView` (`REACT_RETIRED_OVERLAY_PROPS.ListView`, now declared once for both tiers). Fix: replace `objectName: 'deal'` with `data: { provider: 'object', object: 'deal' }` (or name the object on the node's `dataSource.object`), and `viewType: 'kanban'` with `type: 'kanban'`; the node's own `type` stays `list-view`. `os migrate meta --from 17 --write` makes the edit where it can prove it. +- **Stored pages.** The ADR-0087 D2 conversion `page-list-view-object-name-view-type-to-data-type` moves the two aliases to `data` and `type` on every page `list-view` node it reaches (regions, slots, nested containers), retired from the load path: stored `sys_metadata` page rows, compiled artifacts and installed packages replay it on read, while an authored source meets the refusal. An alias beside a spec key that says something different is left as stored and reported as a TODO (`OS_METADATA_CONVERSION_TODO`). Its D3 record is the semantic entry `page-list-view-object-name-view-type-retired`. +- **Also classified:** a `list-view` inside a print page is refused with its reason (it pages its rows), and the view-level API data source refusal names `list-view` among the page components whose `data` prop takes a view data source. diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/ui.md b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/ui.md index a95f31caf2c..3a466662dad 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/ui.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/ui.md @@ -21,7 +21,7 @@ The `strict` column is the one the campaign schedules against; it counts both th | Dir | Sites | strict | passthrough | catchall | strip | |---|---|---|---|---|---| -| `ui/` | 213 | 202 | 4 | 0 | 7 | +| `ui/` | 214 | 203 | 4 | 0 | 7 | ## `ui/` — sites @@ -36,7 +36,7 @@ classify and is not listed (it becomes reportable the day it grows its first sit | `app.zod.ts` | 19 | | `bulk-action.zod.ts` | 4 | | `chart.zod.ts` | 8 | -| `component.zod.ts` | 80 | +| `component.zod.ts` | 81 | | `dashboard.zod.ts` | 11 | | `dataset.zod.ts` | 4 | | `i18n.zod.ts` | 1 | @@ -46,7 +46,7 @@ classify and is not listed (it becomes reportable the day it grows its first sit | `sharing.zod.ts` | 1 | | `view.zod.ts` | 59 | | `widget.zod.ts` | 1 | -| **total** | **213** | +| **total** | **214** | ## `ui/` — open @@ -54,7 +54,7 @@ Per file, how many of its sites still silently discard unknown keys. The `Class` column that decides the bucket split is hand-written in the ledger; the arithmetic over it is here. -**7 strip of 213**, in 4 file(s). +**7 strip of 214**, in 4 file(s). | File | Strip | Sites | |---|---|---| @@ -62,7 +62,7 @@ over it is here. | `app.zod.ts` | 1 | 19 | | `view.zod.ts` | 4 | 59 | | `widget.zod.ts` | 1 | 1 | -| **total** | **7** | **213** | +| **total** | **7** | **214** | | Bucket | Sites | |---|---| diff --git a/packages/lint/src/validate-component-props-list-view-22444.test.ts b/packages/lint/src/validate-component-props-list-view-22444.test.ts new file mode 100644 index 00000000000..40f98b0503c --- /dev/null +++ b/packages/lint/src/validate-component-props-list-view-22444.test.ts @@ -0,0 +1,82 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #22444, ruling C — a page `list-view` node's `properties` are judged by + * `ComponentPropsMap['list-view']`, the row's finding at this door. + * + * Until the row, `validateComponentProps` skipped the type as unregistered, so + * a bag holding `kanban.groupField` or `options.grid` — refused at every list + * view door — drew no finding at all (the census on the card measured 0). The + * row needs no dispatch change here: the rule reads `ComponentPropsMap` by + * type. The pins are the ruling's list, at this door: `kanban.groupField` and + * `options.grid` flagged naming the key; contract keys only pass; the retired + * `objectName` / `viewType` flagged with the react tier's prescription. The + * schema half is pinned in `@objectstack/spec` + * (`ui/component-list-view-row.test.ts`). + * + * Severity is the gate's own, the same for every `ComponentPropsMap` row: no + * rule, tier or door is added here. + */ +import { REACT_RETIRED_OVERLAY_PROPS } from '@objectstack/spec/ui'; +import { describe, expect, it } from 'vitest'; + +import { runAuthoringRules } from './authoring-rules.js'; +import { COMPONENT_PROPS_UNKNOWN_KEY, validateComponentProps } from './validate-component-props.js'; + +type AnyRec = Record; + +const pageWith = (node: AnyRec): AnyRec => ({ + name: 'sales_home', + label: 'Sales Home', + regions: [{ name: 'main', components: [node] }], +}); + +const AT = 'pages[0].regions[0].components[0]'; +const BOUND = { data: { provider: 'object', object: 'deal' } }; + +const judge = (properties: AnyRec, node: AnyRec = {}) => + validateComponentProps({ pages: [pageWith({ type: 'list-view', ...node, properties })] }); +const shape = (findings: ReturnType) => + findings.map((f) => [f.rule, f.severity, f.path]); + +describe('a page `list-view` node is judged as a list view (#22444)', () => { + it('`kanban.groupField` is flagged, naming the key', () => { + const findings = judge({ ...BOUND, kanban: { groupByField: 'stage', columns: ['name'], groupField: 'owner' } }); + expect(shape(findings)).toEqual([[COMPONENT_PROPS_UNKNOWN_KEY, 'error', `${AT}.properties.kanban.groupField`]]); + }); + + it('`options.grid` is flagged, naming the key', () => { + const findings = judge({ ...BOUND, options: { grid: { pageSize: 10 } } }); + expect(shape(findings)).toEqual([[COMPONENT_PROPS_UNKNOWN_KEY, 'error', `${AT}.properties.options.grid`]]); + }); + + it.each([ + ['an object-bound kanban', { ...BOUND, type: 'kanban', columns: ['name'], kanban: { groupByField: 'stage', columns: ['name'] } }, {}], + ['a column-less node bound by a saved view', {}, { dataSource: { object: 'deal', view: 'open_deals' } }], + ['an absent bag', undefined, { dataSource: { object: 'deal' } }], + ])('contract keys only pass — %s', (_label, properties, node) => { + const findings = validateComponentProps({ + pages: [pageWith({ type: 'list-view', ...node, ...(properties === undefined ? {} : { properties }) })], + }); + expect(findings).toEqual([]); + }); + + it.each(['objectName', 'viewType'] as const)('`%s` is flagged by name, with the react tier\'s prescription', (alias) => { + const findings = judge({ ...BOUND, [alias]: alias === 'objectName' ? 'deal' : 'kanban' }); + expect(shape(findings)).toEqual([[COMPONENT_PROPS_UNKNOWN_KEY, 'error', `${AT}.properties.${alias}`]]); + expect(findings[0]!.message).toContain(REACT_RETIRED_OVERLAY_PROPS.ListView![alias]!.note); + }); + + it('reaches the shared pipeline — the run `os validate` / `os build` / `os lint` read', () => { + const findings = runAuthoringRules('validate', { + normalized: { pages: [pageWith({ type: 'list-view', properties: { objectName: 'deal', options: { grid: {} } } })] } as never, + }); + const mine = findings.filter((f) => f.path.startsWith(`${AT}.properties`)); + expect(mine.map((f) => [f.rule, f.path]).sort()).toEqual( + [ + [COMPONENT_PROPS_UNKNOWN_KEY, `${AT}.properties.objectName`], + [COMPONENT_PROPS_UNKNOWN_KEY, `${AT}.properties.options.grid`], + ].sort(), + ); + }); +}); diff --git a/packages/spec/src/conversions/page-list-view-object-name-view-type-to-data-type.test.ts b/packages/spec/src/conversions/page-list-view-object-name-view-type-to-data-type.test.ts new file mode 100644 index 00000000000..b277962e130 --- /dev/null +++ b/packages/spec/src/conversions/page-list-view-object-name-view-type-to-data-type.test.ts @@ -0,0 +1,143 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, expect, it } from 'vitest'; + +import { ComponentPropsMap } from '../ui/component.zod.js'; +import { applyConversions, collectConversionNotices } from './apply.js'; +import { ALL_CONVERSIONS } from './registry.js'; +import { applyConversionsToStoredItem } from './stored.js'; +import type { ConversionNotice, ConversionTodoNotice } from './types.js'; + +/** + * `page-list-view-object-name-view-type-to-data-type` — the data-at-rest half + * of ruling C on #22444: a stored page `list-view` node in the page carrier's + * old spelling loads in the list view's own spelling. + * + * The fixture pair in `conversions.test.ts` proves before → after over the + * whole table. What this file pins on top: + * + * - a stored node in the old spelling converts to the spelling its props row + * (`ComponentPropsMap['list-view']`) accepts, where the stored node is + * refused there by name; + * - a conflict between an alias and the spec spelling beside it is left as + * stored and reported as a TODO, never resolved by the walker; + * - the authoring funnel never replays it (retired from the load path); + * - IDEMPOTENCE, and the copy-on-write control. + */ +describe('page-list-view-object-name-view-type-to-data-type (ADR-0087 D2)', () => { + const ID = 'page-list-view-object-name-view-type-to-data-type'; + const row = ComponentPropsMap['list-view']; + const page = (properties: Record, node: Record = {}) => ({ + name: 'sales_home', + label: 'Sales Home', + regions: [{ name: 'main', components: [{ type: 'list-view', ...node, properties }] }], + }); + const propsOf = (stack: unknown) => + (stack as { pages: { regions: { components: { properties: Record }[] }[] }[] }) + .pages[0]!.regions[0]!.components[0]!.properties; + + it('is registered for protocol 18 and retired from the authoring load path', () => { + const entry = ALL_CONVERSIONS.find((c) => c.id === ID); + expect(entry, 'the conversion is registered').toBeDefined(); + expect(entry!.toMajor).toBe(18); + expect(entry!.retiredFromLoadPath).toBe(true); + expect(entry!.retiredAfter).toBe('17.7.0'); + }); + + it('a stored node in the old spelling loads in the spelling its props row accepts', () => { + const stored = { + objectName: 'crm_lead', + viewType: 'kanban', + columns: ['full_name', 'status'], + kanban: { groupByField: 'status', columns: ['full_name'] }, + }; + // Before: the row refuses both aliases by name. + const before = row.safeParse(stored); + expect(before.success).toBe(false); + expect(before.success ? [] : before.error.issues.flatMap((i) => ('keys' in i ? i.keys : []))).toEqual([ + 'objectName', + 'viewType', + ]); + + const converted = applyConversionsToStoredItem('page', page(stored)); + const props = (converted as ReturnType).regions[0]!.components[0]!.properties; + expect(props).toEqual({ + columns: ['full_name', 'status'], + kanban: { groupByField: 'status', columns: ['full_name'] }, + data: { provider: 'object', object: 'crm_lead' }, + type: 'kanban', + }); + expect(row.safeParse(props).success).toBe(true); + }); + + it('one notice per converted alias, each naming the spec key it now sits at', () => { + const { notices } = collectConversionNotices( + { pages: [page({ objectName: 'deal', viewType: 'grid' })] }, + { includeRetired: true }, + ); + const mine = notices.filter((n) => n.conversionId === ID); + expect(mine.map((n) => [n.path, n.from, n.to])).toEqual([ + ['pages[0].regions[0].components[0].properties.data', '"deal"', '{"provider":"object","object":"deal"}'], + ['pages[0].regions[0].components[0].properties.type', '"grid"', '"grid"'], + ]); + }); + + it('a conflict is left as stored and reported as a TODO, naming the key', () => { + const conflicts = [ + // A `data` source naming another object. + page({ objectName: 'deal', data: { provider: 'object', object: 'account' } }), + // A node binding naming another object. + page({ objectName: 'deal' }, { dataSource: { object: 'account' } }), + // A different kind beside the alias. + page({ viewType: 'kanban', type: 'gallery' }), + // Not a name at all. + page({ objectName: '' }), + ]; + const todos: ConversionTodoNotice[] = []; + const notices: ConversionNotice[] = []; + const out = applyConversions( + { pages: conflicts }, + { includeRetired: true, onNotice: (n) => notices.push(n), onTodo: (t) => todos.push(t) }, + ); + expect(notices.filter((n) => n.conversionId === ID)).toEqual([]); + const mine = todos.filter((t) => t.conversionId === ID); + expect(mine.map((t) => t.path)).toEqual([ + 'pages[0].regions[0].components[0].properties.objectName', + 'pages[1].regions[0].components[0].properties.objectName', + 'pages[2].regions[0].components[0].properties.viewType', + 'pages[3].regions[0].components[0].properties.objectName', + ]); + // Byte-identical: every conflicting page is the same reference. + const outPages = (out as { pages: unknown[] }).pages; + conflicts.forEach((p, i) => expect(outPages[i]).toBe(p)); + }); + + it('another component\'s `objectName` is not this entry\'s surface', () => { + const grid = { type: 'object-grid', properties: { objectName: 'deal' } }; + const stack = { pages: [{ name: 'p', label: 'P', regions: [{ name: 'main', components: [grid] }] }] }; + const out = applyConversions(stack, { includeRetired: true }); + expect((out as typeof stack).pages[0]!.regions[0]!.components[0]).toBe(grid); + }); + + it('control: a node already in the spec spelling is returned as the same reference, with no notice', () => { + const clean = { pages: [page({ data: { provider: 'object', object: 'deal' }, type: 'kanban' })] }; + const notices: ConversionNotice[] = []; + const out = applyConversions(clean, { includeRetired: true, onNotice: (n) => notices.push(n) }); + expect(out).toBe(clean); + expect(notices.filter((n) => n.conversionId === ID)).toEqual([]); + }); + + it('⛔ never replays on the authoring funnel — an author meets the row\'s refusal instead', () => { + const { stack, notices } = collectConversionNotices({ pages: [page({ objectName: 'deal', viewType: 'grid' })] }); + expect(propsOf(stack)).toEqual({ objectName: 'deal', viewType: 'grid' }); + expect(notices.filter((n) => n.conversionId === ID)).toEqual([]); + }); + + it('is idempotent — the converted result replays to itself with no second notice', () => { + const once = applyConversions({ pages: [page({ objectName: 'deal', viewType: 'grid' })] }, { includeRetired: true }); + const notices: ConversionNotice[] = []; + const twice = applyConversions(once, { includeRetired: true, onNotice: (n) => notices.push(n) }); + expect(twice).toBe(once); + expect(notices).toEqual([]); + }); +}); diff --git a/packages/spec/src/conversions/refused-shapes.test.ts b/packages/spec/src/conversions/refused-shapes.test.ts index f7e108223de..94698679698 100644 --- a/packages/spec/src/conversions/refused-shapes.test.ts +++ b/packages/spec/src/conversions/refused-shapes.test.ts @@ -340,8 +340,11 @@ describe('#23269 — a stored flow whose CRUD filter operand is a {…} token is describe('#23317 — a stored page whose component binds the retired API source in its `data` prop is listed by page and component', () => { const PRESCRIPTION = /`view\.data\.provider: 'api'` was removed in @objectstack\/spec 18/; /** The components whose `data` prop is a view data source, as the D3 entry names them. */ - const VIEW_DATA_COMPONENTS = ['object-grid', 'object-map', 'object-gantt', 'object-tree'] as const; - const component = (type: string, data: unknown, id = 'block') => ({ id, type, properties: { objectName: 'crm_case', data } }); + const VIEW_DATA_COMPONENTS = ['object-grid', 'object-map', 'object-gantt', 'object-tree', 'list-view'] as const; + // A `list-view` names its object in `data` alone: its `objectName` is a retired + // alias, which a stored node carries forward through its own conversion (#22444). + const component = (type: string, data: unknown, id = 'block') => + ({ id, type, properties: type === 'list-view' ? { data } : { objectName: 'crm_case', data } }); const pageRow = (components: unknown[], extra: Record = {}) => ({ name: 'partner_page', label: 'Partner', diff --git a/packages/spec/src/conversions/refused-shapes.ts b/packages/spec/src/conversions/refused-shapes.ts index 3ece73f2a8a..91f11d9e613 100644 --- a/packages/spec/src/conversions/refused-shapes.ts +++ b/packages/spec/src/conversions/refused-shapes.ts @@ -254,7 +254,7 @@ const viewDataApiProvider: RefusedStoredShape = { id: 'view-data-api-provider-retired', toMajor: 18, surface: 'view.data — a list or form view\'s data source, in a view body or an object\'s listViews, ' - + 'and the `data` prop of a page component that takes one (object-grid, object-map, object-gantt, object-tree)', + + 'and the `data` prop of a page component that takes one (object-grid, object-map, object-gantt, object-tree, list-view)', detect(stack, report) { const judge = (payload: Dict, path: string, where: string): void => { const prescription = apiBindingRefusal(payload.data); diff --git a/packages/spec/src/conversions/registry.ts b/packages/spec/src/conversions/registry.ts index 982ae6a2d28..f8c9af2fa52 100644 --- a/packages/spec/src/conversions/registry.ts +++ b/packages/spec/src/conversions/registry.ts @@ -11344,6 +11344,247 @@ const pageIsDefaultOffRecordPageRemoved: MetadataConversion = { }, }; +/** + * A page `list-view` node's `properties.objectName` → `properties.data`, and its + * `properties.viewType` → `properties.type` (protocol 18, #22444; ruling C, the + * maintainer's 「同意」: the page carrier converges on the spec spelling, and + * stored nodes in the old spelling are carried by this conversion, ⛔ never by a + * fallback read in objectui). + * + * The page carrier spelled a list view's binding and kind its own way — + * `objectName` and `viewType`, the two words the react tier retired on + * `ListView` with no window — while every other list view door spells them + * `data` and `type`. The pinned console reads only the spec spelling off a page + * `list-view` node (objectui#12668), and the node's props row + * (`ComponentPropsMap['list-view']`) refuses both aliases by name. + * + * ## What converts + * + * - `objectName: 'deal'` (a non-empty string) → `data: { provider: 'object', + * object: 'deal' }`, the list view's own object source — the meaning the + * console's old fold gave the alias. When `data` already names the same + * object through the `object` provider, the alias is a redundant twin and is + * deleted. + * - `viewType: 'kanban'` → `type: 'kanban'` (the value carried verbatim; the + * row judges it against the list view's kind enum). When `type` already + * holds the same value, the alias is a redundant twin and is deleted. + * + * ## What is left exactly as stored — reported as a TODO + * + * An alias that is not a non-empty string, an `objectName` beside a `data` + * source that names something else, an `objectName` beside a node-level + * `dataSource.object` that names a different object, and a `viewType` beside a + * different `type`. Each is two answers to one question, and which one the + * author meant is not a walker's call; the row refuses the alias by name, so + * the next save asks the author. + * + * ## Reach + * + * Every page component {@link mapPageComponents} visits (regions, slots, + * nested containers such as `page:tabs` items) whose `type` is `list-view`. + * The same keys on any other component are not this entry's surface: an + * `object-grid`'s `objectName` is that row's own declared binding. + * + * ## Why `retiredFromLoadPath` + * + * An author writing either alias meets the row's refusal, which names the + * spelling, instead of having it quietly rewritten at `defineStack` / + * `validate`. Data at rest has no author to teach: stored `sys_metadata` rows + * (`applyConversionsToStoredItem`), the artifact-ingestion door and the + * package registration door replay this, and `os migrate meta --write` + * rewrites a source where it can prove the edit. The paired D3 entry is + * `page-list-view-object-name-view-type-retired`. + */ +const pageListViewObjectNameViewTypeToDataType: MetadataConversion = { + id: 'page-list-view-object-name-view-type-to-data-type', + toMajor: 18, + retiredFromLoadPath: true, + retiredAfter: '17.7.0', + surface: 'page.component(list-view).properties.objectName / .viewType', + summary: + "a page `list-view` node's `properties.objectName` becomes `properties.data: { provider: 'object', " + + "object }` and its `properties.viewType` becomes `properties.type` — the list view's own spelling of " + + 'its object source and its kind, which every other list view door uses (one list view, one set of ' + + 'property names wherever it is embedded); an alias that disagrees with the spec spelling beside it is ' + + 'left as stored and reported as a TODO', + apply(stack, emit, context) { + return mapPageComponents(stack, (component, path) => { + if (component.type !== 'list-view' || !isDict(component.properties)) return component; + const base = `${path}.properties`; + const leave = (key: string, value: unknown, why: string) => { + context?.reportTodo?.({ + path: `${base}.${key}`, + from: JSON.stringify(value), + reason: `On this page \`list-view\`, \`${key}\` ${why} Left as stored; the node's props refuse \`${key}\` ` + + 'by name, so rewrite it by hand.', + }); + }; + + let props = component.properties; + + if ('objectName' in props) { + const objectName = props.objectName; + const data = props.data; + const bound = isDict(component.dataSource) ? component.dataSource.object : undefined; + if (typeof objectName !== 'string' || objectName === '') { + leave('objectName', objectName, 'is not an object name.'); + } else if (typeof bound === 'string' && bound !== '' && bound !== objectName) { + leave('objectName', objectName, `names a different object than the node's \`dataSource.object\` (\`${bound}\`).`); + } else if (data === undefined) { + const source = { provider: 'object', object: objectName }; + const { objectName: _alias, ...rest } = props; + props = { ...rest, data: source }; + emit({ from: JSON.stringify(objectName), to: JSON.stringify(source), path: `${base}.data` }); + } else if (isDict(data) && data.provider === 'object' && data.object === objectName) { + const { objectName: _twin, ...rest } = props; + props = rest; + emit({ from: JSON.stringify(objectName), to: JSON.stringify(data), path: `${base}.data` }); + } else { + leave('objectName', objectName, 'sits beside a `data` source that does not name the same object.'); + } + } + + if ('viewType' in props) { + const viewType = props.viewType; + const renamed = typeof viewType === 'string' && viewType !== '' ? renameKey(props, 'viewType', 'type') : null; + if (renamed) { + props = renamed; + emit({ from: JSON.stringify(viewType), to: JSON.stringify(viewType), path: `${base}.type` }); + } else { + leave( + 'viewType', + viewType, + typeof viewType === 'string' && viewType !== '' + ? `sits beside a different \`type\` (${JSON.stringify(props.type)}).` + : 'is not a list kind.', + ); + } + } + + return props === component.properties ? component : { ...component, properties: props }; + }); + }, + fixture: { + before: { + pages: [ + { + name: 'sales_home', + label: 'Sales Home', + regions: [ + { + name: 'main', + components: [ + // The shipped shape: both aliases on one node. + { + type: 'list-view', + id: 'open_deals', + properties: { + objectName: 'deal', + viewType: 'kanban', + columns: ['name', 'amount'], + kanban: { groupByField: 'stage', columns: ['name'] }, + }, + }, + // Nested in a tab: reached, and a redundant twin is deleted. + { + type: 'page:tabs', + properties: { + items: [ + { + label: 'Leads', + children: [ + { + type: 'list-view', + properties: { + objectName: 'lead', + data: { provider: 'object', object: 'lead' }, + viewType: 'grid', + type: 'grid', + }, + }, + ], + }, + ], + }, + }, + // Two answers to one question: left as stored, reported. + { + type: 'list-view', + properties: { + objectName: 'deal', + data: { provider: 'object', object: 'account' }, + viewType: 'kanban', + type: 'gallery', + }, + }, + // Another component's `objectName` is its own binding: untouched. + { type: 'object-grid', properties: { objectName: 'deal' } }, + ], + }, + ], + }, + ], + }, + after: { + pages: [ + { + name: 'sales_home', + label: 'Sales Home', + regions: [ + { + name: 'main', + components: [ + { + type: 'list-view', + id: 'open_deals', + properties: { + columns: ['name', 'amount'], + kanban: { groupByField: 'stage', columns: ['name'] }, + data: { provider: 'object', object: 'deal' }, + type: 'kanban', + }, + }, + { + type: 'page:tabs', + properties: { + items: [ + { + label: 'Leads', + children: [ + { + type: 'list-view', + properties: { + data: { provider: 'object', object: 'lead' }, + type: 'grid', + }, + }, + ], + }, + ], + }, + }, + { + type: 'list-view', + properties: { + objectName: 'deal', + data: { provider: 'object', object: 'account' }, + viewType: 'kanban', + type: 'gallery', + }, + }, + { type: 'object-grid', properties: { objectName: 'deal' } }, + ], + }, + ], + }, + ], + }, + // Two per converted node — its `objectName` and its `viewType` — on the + // region node and the nested one. The conflicting node and the grid emit none. + expectedNotices: 4, + }, +}; + /** * `page.requires` removed from the page kinds whose source is never compiled at * save — `react`, `full` and `slotted`, a page that omits `kind` included @@ -17047,6 +17288,7 @@ const MAJOR_18_CONVERSIONS: readonly OrderedConversion[] = [ { conversion: pageHeaderBreadcrumbRemoved, order: 49 }, { conversion: pageHeaderShowStarRemoved, order: 66 }, { conversion: pageIsDefaultOffRecordPageRemoved, order: 67 }, + { conversion: pageListViewObjectNameViewTypeToDataType, order: 68 }, { conversion: pageRequiresNonCompiledKindRemoved, order: 58 }, { conversion: pageTemplateFullWidthToDefault, order: 67 }, { conversion: permissionAdminScopeBindingAuthoringRemoved, order: 65 }, diff --git a/packages/spec/src/migrations/entries/semantic/18.page-list-view-object-name-view-type-retired.ts b/packages/spec/src/migrations/entries/semantic/18.page-list-view-object-name-view-type-retired.ts new file mode 100644 index 00000000000..1531cb03818 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.page-list-view-object-name-view-type-retired.ts @@ -0,0 +1,49 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// #22444, ruling C (record 6100463199, the maintainer's 「同意」): the page +// carrier converges on the list view's own spelling — one list view, one set of +// property names wherever it is embedded. `ComponentPropsMap['list-view']` is +// the list view's members by reference, and refuses the page carrier's +// `objectName` / `viewType` by name with the react tier's prescription for the +// same two aliases. Neither key was ever declared on the spec's page carrier +// (it had no `list-view` row), so there is no tombstone and no +// RETIRED_KEYS_BY_MAJOR row: the refusal is the row's guidance. Data at rest +// replays the retired D2 entry `page-list-view-object-name-view-type-to-data-type` +// on read. The judgment left to the author is the TODO that entry reports: an +// alias beside a spec key that answers the same question differently. +// +// No backticks and no pipes in `surface` — build-upgrade-guide.ts renders it +// inside a code span and a table cell. +export const entry: SemanticMigration = { + id: 'page-list-view-object-name-view-type-retired', + surface: + 'pages[] — a page component of type list-view (in a region, a slot, or nested in a container such as ' + + 'page:tabs) whose properties carry objectName or viewType', + replacement: + 'Write the list view\'s own spelling in `properties`: `data: { provider: \'object\', object: \'…\' }` ' + + 'for the object (or name it on the node\'s `dataSource.object` binding) and `type: \'kanban\'` for the ' + + 'list kind. The node\'s own `type` stays `list-view`. `os migrate meta --from 17 --write` rewrites the ' + + 'nodes it can prove.', + reason: + 'A page list-view node spelled its object and its kind objectName and viewType while every other list ' + + 'view door spells them data and type, and its properties were judged by nothing: a key every list view ' + + 'door refuses saved green on a page. The props row now judges the bag as a list view and refuses both ' + + 'aliases by name; the pinned console reads only the spec spelling off a page list-view node, so a node ' + + 'that still carries the alias queries no object or draws a grid. ⚠️ Data at rest converts instead: a ' + + 'stored page row, a compiled artifact inside the window its manifest opens and an installed package ' + + 'replay the retired conversion page-list-view-object-name-view-type-to-data-type on read, which moves ' + + 'each alias to its spec key with an OS_METADATA_CONVERTED notice. An alias beside a spec key that says ' + + 'something different (an objectName beside a data source or a dataSource.object naming another object, ' + + 'a viewType beside a different type) is left as stored and reported as an OS_METADATA_CONVERSION_TODO, ' + + 'because which one the author meant is not a walker\'s call. ADR-0087.', + acceptanceCriteria: + 'Grep every page in defineStack pages sources, exported stacks and every page row in sys_metadata for a ' + + 'component of type list-view whose properties carry objectName or viewType. Move each to data and type ' + + '(os migrate meta --from 17 --write does it where it can prove the edit), and resolve every TODO it ' + + 'reports by keeping the one value the list should show. Then objectstack validate reports no ' + + 'component-props-unknown-key finding at a list-view node\'s properties.objectName or properties.viewType, ' + + 'and each list draws the same object and kind as before.', + conversionIds: ['page-list-view-object-name-view-type-to-data-type'], +}; diff --git a/packages/spec/src/migrations/entries/semantic/18.view-data-api-provider-retired.ts b/packages/spec/src/migrations/entries/semantic/18.view-data-api-provider-retired.ts index 261d8b4473f..35c51a672c7 100644 --- a/packages/spec/src/migrations/entries/semantic/18.view-data-api-provider-retired.ts +++ b/packages/spec/src/migrations/entries/semantic/18.view-data-api-provider-retired.ts @@ -19,7 +19,7 @@ export const entry: SemanticMigration = { id: 'view-data-api-provider-retired', surface: "view.data (`provider: 'api'`, with its `read` / `write` request blocks) — the view-level API data source, " + "in a view and in the `data` prop of each page component that takes a view data source (`object-grid`, " - + '`object-map`, `object-gantt`, `object-tree`)', + + '`object-map`, `object-gantt`, `object-tree`, `list-view`)', replacement: "A view binds its object (`provider: 'object'`) or static rows (`provider: 'value'`), and so does a " + "page component's `data` prop; the API read " + "moves onto a page component's `dataSource` binding — `{ provider: 'api', read: { url }, path? }` under a " diff --git a/packages/spec/src/migrations/registry.ts.template b/packages/spec/src/migrations/registry.ts.template index d8040946d3c..bbdccf98155 100644 --- a/packages/spec/src/migrations/registry.ts.template +++ b/packages/spec/src/migrations/registry.ts.template @@ -2511,6 +2511,21 @@ const STEP18_RATIONALE: readonly RationaleFragment[] = [ + '`page-header-show-star-retired`. The host hook is not part of it: a host that wires the ' + 'favorites toggle still draws its star.', }, + { + id: 'page-list-view-object-name-view-type-retired', + order: 101, + text: + 'It also judges a list view embedded on a page as a list view (maintainer ruling 2026-10-10: one ' + + 'list view has one set of property names wherever it is embedded). `ComponentPropsMap` gains a ' + + '`list-view` row built from the list view\'s own members, with `columns` optional and the legacy ' + + '`options` bag judged per kind as the list overlay member judges them, so a key every list view door ' + + 'refuses (a kanban block\'s `groupField`, `options.grid`) is no longer saved green on a page. The page ' + + 'carrier\'s own spelling of the binding and the kind, `objectName` and `viewType`, is refused by name ' + + 'with the prescription the react tier gives the same two aliases on `ListView`. The D2 conversion ' + + '`page-list-view-object-name-view-type-to-data-type` moves them to `data` and `type` on stored nodes, ' + + 'retired from the load path, and leaves an alias that disagrees with the spec key beside it as stored, ' + + 'reported as a TODO. Its D3 record is the semantic entry `page-list-view-object-name-view-type-retired`.', + }, { id: 'page-requires-non-compiled-kind-refused', order: 66, diff --git a/packages/spec/src/shared/retired-key-removal-version.pin.test.ts b/packages/spec/src/shared/retired-key-removal-version.pin.test.ts index 57b7f0cd579..fcefc90fe47 100644 --- a/packages/spec/src/shared/retired-key-removal-version.pin.test.ts +++ b/packages/spec/src/shared/retired-key-removal-version.pin.test.ts @@ -98,6 +98,7 @@ import { EmailTemplateDefinitionSchema } from '../system/email-template.zod'; import { ActionButtonPropsSchema, ActionGroupPropsSchema, + ComponentPropsMap, ActionIconPropsSchema, ActionMenuPropsSchema, ElementNumberPropsSchema, @@ -350,6 +351,14 @@ const JOIN: Readonly> = { 'isDefault', ), ], + // #22444: the page carrier's two aliases, undeclared on the `list-view` row + // and answered by name with the row's guidance. + 'page-list-view-object-name-view-type-to-data-type': (['objectName', 'viewType'] as const).map((alias) => + refusal( + `a page list-view node's properties.${alias}`, + () => ComponentPropsMap['list-view'].safeParse({ [alias]: alias === 'objectName' ? 'deal' : 'kanban' }), + ), + ), 'page-template-full-width-to-default': [ refusal( "page template 'full-width'", diff --git a/packages/spec/src/ui/component-list-view-row.test.ts b/packages/spec/src/ui/component-list-view-row.test.ts new file mode 100644 index 00000000000..bc72d3ac917 --- /dev/null +++ b/packages/spec/src/ui/component-list-view-row.test.ts @@ -0,0 +1,126 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, expect, it } from 'vitest'; + +import { ComponentPropsMap } from './component.zod'; +import { ListViewSchema, VIEW_METADATA_MEMBERS } from './view.zod'; +import { PageSchema } from './page.zod'; +import { REACT_RETIRED_OVERLAY_PROPS } from './react-blocks'; +import { LIST_VIEW_RETIRED_ALIASES } from './list-view-retired-aliases'; + +/** + * #22444, ruling C — `ComponentPropsMap['list-view']`: a list view embedded on + * a page is judged as a list view. + * + * Pins the ruling's own list: a page `list-view` carrying `kanban.groupField` + * or `options.grid` is flagged naming the key; one with contract keys only + * passes; the old spellings `objectName` / `viewType` are flagged with the + * react tier's prescription. Plus what the row is built from — the list view's + * members and checks by reference, and the list overlay member's two + * differences — and the half that does NOT move: `PageSchema`'s accept set. + * The lint finding these issues become is pinned in `@objectstack/lint` + * (`validate-component-props-list-view-22444.test.ts`). + */ +const row = ComponentPropsMap['list-view']; + +const issuesOf = (value: unknown) => { + const r = row.safeParse(value); + return r.success + ? [] + : r.error.issues.map((i) => ({ code: i.code, path: i.path.join('.'), keys: 'keys' in i ? i.keys : undefined })); +}; + +describe("ComponentPropsMap['list-view'] (#22444)", () => { + describe('the ruling\'s pins', () => { + it('`kanban.groupField` is flagged, naming the key on the kanban block', () => { + const issues = issuesOf({ data: { provider: 'object', object: 'deal' }, kanban: { groupByField: 'stage', columns: ['name'], groupField: 'owner' } }); + expect(issues).toEqual([{ code: 'unrecognized_keys', path: 'kanban', keys: ['groupField'] }]); + }); + + it('`options.grid` is flagged, naming the key on the `options` bag', () => { + expect(issuesOf({ options: { grid: { pageSize: 10 } } })).toEqual([ + { code: 'unrecognized_keys', path: 'options', keys: ['grid'] }, + ]); + }); + + it('`options.KIND` is judged by the kind\'s own block: an unknown key under it is flagged by name', () => { + expect(issuesOf({ options: { kanban: { groupField: 'owner' } } })).toEqual([ + { code: 'unrecognized_keys', path: 'options.kanban', keys: ['groupField'] }, + ]); + }); + + it.each([ + ['an object-bound kanban', { data: { provider: 'object', object: 'deal' }, type: 'kanban', columns: ['name'], kanban: { groupByField: 'stage', columns: ['name'] } }], + ['a column-less node (bound by a saved view, or drawing the object\'s fields)', { data: { provider: 'object', object: 'deal' } }], + ['an empty bag (the node binds through `dataSource`)', {}], + ['the legacy per-kind underlay, partial', { type: 'kanban', options: { kanban: { titleField: 'name' } } }], + ['the shipped page node shape in the spec spelling', { + data: { provider: 'object', object: 'crm_lead' }, + type: 'grid', + columns: ['full_name', 'company'], + filter: [{ field: 'owner_id', operator: 'equals', value: '{current_user_id}' }], + sort: [{ field: 'rating', order: 'desc' }], + }], + ])('contract keys only pass — %s', (_label, props) => { + expect(issuesOf(props)).toEqual([]); + }); + + it.each(['objectName', 'viewType'] as const)('`%s` is flagged by name, with the react tier\'s prescription for the same alias', (alias) => { + const value = alias === 'objectName' ? 'deal' : 'kanban'; + const r = row.safeParse({ [alias]: value }); + expect(r.success).toBe(false); + const issues = r.success ? [] : r.error.issues; + expect(issues.map((i) => ({ code: i.code, path: i.path.join('.'), keys: 'keys' in i ? i.keys : undefined }))).toEqual([ + { code: 'unrecognized_keys', path: '', keys: [alias] }, + ]); + // One prescription, two tiers: the refusal carries the react tier's note + // for this alias verbatim, and names the list view member that replaces it. + expect(issues[0]!.message).toContain(LIST_VIEW_RETIRED_ALIASES[alias].note); + expect(issues[0]!.message).toContain(`\`${LIST_VIEW_RETIRED_ALIASES[alias].replacedBy}\``); + }); + }); + + describe('built from the list view, by reference', () => { + const shape = (row as unknown as { shape: Record }).shape; + + it('every list view member is the list view\'s own schema object, except `columns`', () => { + for (const [key, member] of Object.entries(ListViewSchema.shape)) { + if (key === 'columns') continue; + expect(shape[key], key).toBe(member); + } + }); + + it('the two overlay-member differences: `columns` optional, `options` the overlay member\'s own judged bag', () => { + expect((shape.columns as { unwrap: () => unknown }).unwrap()).toBe(ListViewSchema.shape.columns); + expect(shape.options).toBe(VIEW_METADATA_MEMBERS.listOverlay.shape.options); + // And nothing else: the row's keys are the list view's plus `options`. + expect(Object.keys(shape).sort()).toEqual([...Object.keys(ListViewSchema.shape), 'options'].sort()); + }); + + it('the list view\'s object-level checks are attached: a chart with no dataset binding is refused as on the authoring door', () => { + const chart = { type: 'chart', columns: ['name'] }; + const authoring = ListViewSchema.safeParse(chart); + expect(authoring.success).toBe(false); + const here = row.safeParse(chart); + expect(here.success).toBe(false); + const codes = (r: typeof here) => (r.success ? [] : r.error.issues.map((i) => `${i.code}@${i.path.join('.')}`)); + expect(codes(here)).toEqual(codes(authoring)); + }); + + it('the react tier refuses the same two aliases from the same declaration', () => { + expect(REACT_RETIRED_OVERLAY_PROPS.ListView).toBe(LIST_VIEW_RETIRED_ALIASES); + }); + }); + + it('`PageSchema`\'s accept set does not move: the carrier stays an open record, and the row is the lint\'s judge', () => { + const page = { + name: 'sales_home', + label: 'Sales Home', + regions: [{ + name: 'main', + components: [{ type: 'list-view', properties: { objectName: 'deal', viewType: 'kanban', kanban: { groupField: 'owner' }, options: { grid: {} } } }], + }], + }; + expect(PageSchema.safeParse(page).success).toBe(true); + }); +}); diff --git a/packages/spec/src/ui/component-props-unknown-members.pin.test.ts b/packages/spec/src/ui/component-props-unknown-members.pin.test.ts index 6ceac58f24a..4bbd1beb83c 100644 --- a/packages/spec/src/ui/component-props-unknown-members.pin.test.ts +++ b/packages/spec/src/ui/component-props-unknown-members.pin.test.ts @@ -247,7 +247,9 @@ const on = (types: readonly string[], paths: readonly string[], reason: Reason): // whose values are a `records` line below // (`component-action-row-declarative-update.test.ts`). const ACTION_RUNNER_PATHS = ['params', 'bodyExtra', 'bodyShape', 'toast', 'resultDialog']; -const VIEW_DATA_TYPES = ['object-grid', 'object-map', 'object-gantt', 'object-tree']; +// [#22444] `list-view` is the list view itself, so its `data` member is the +// same view data source by reference. +const VIEW_DATA_TYPES = ['object-grid', 'object-map', 'object-gantt', 'object-tree', 'list-view']; // Composition slots. on(['page:tabs', 'page:accordion'], ['items[].children[]'], SLOT); @@ -263,7 +265,7 @@ on(['action:group', 'action:menu'], ['actions[].visible.ast', 'actions[].disable on(['action:button', 'action:icon'], ['visible.ast', 'disabled.ast'], EXPRESSION_AST); on(['record:line_items'], ['columns[].readonlyWhen.ast', 'columns[].requiredWhen.ast'], EXPRESSION_AST); on(['object-master-detail-form'], ['details[].columns[].readonlyWhen.ast', 'details[].columns[].requiredWhen.ast'], EXPRESSION_AST); -on(['object-grid'], ['conditionalFormatting[].condition.ast', 'bulkActionDefs[].visible.ast'], EXPRESSION_AST); +on(['object-grid', 'list-view'], ['conditionalFormatting[].condition.ast', 'bulkActionDefs[].visible.ast'], EXPRESSION_AST); on(['object-kanban'], ['conditionalFormatting[].condition.ast'], EXPRESSION_AST); // The S-forms stage: an inline form field (`customFields[]`, and a section's // inline entry) and the form view's `{ field }` entry carry the three `*When` @@ -293,7 +295,7 @@ on(['object-grid', 'object-map', 'object-gantt', 'object-tree'], ['staticData[]' on(['object-kanban', 'object-timeline'], ['data[]'], RECORDS); on(['object-calendar'], ['data[]', 'staticData[]'], RECORDS); on(['object-form', 'object-master-detail-form'], ['initialValues{}', 'initialData{}'], RECORDS); -on(['object-grid'], ['bulkActionDefs[].patch{}', 'bulkActionDefs[].params[].default'], RECORDS); +on(['object-grid', 'list-view'], ['bulkActionDefs[].patch{}', 'bulkActionDefs[].params[].default'], RECORDS); // A page action row's `patch` is `ActionSchema`'s record of static field // values, by reference: each value is one field's. on(['action:button', 'action:icon'], ['patch{}'], RECORDS); @@ -301,7 +303,7 @@ on(['action:group', 'action:menu'], ['actions[].patch{}'], RECORDS); // A static board's card is a record row: `id` and `title` are typed, the rest // is the row's own values (the kanban's lane bucketing keeps them verbatim). on(['object-kanban'], ['columns[].cards[].*'], RECORDS); -on(['object-grid'], ['bulkActionDefs[].params[].options[].*'], BULK_OPTION_ENTRY); +on(['object-grid', 'list-view'], ['bulkActionDefs[].params[].options[].*'], BULK_OPTION_ENTRY); on(['object-form'], ['customFields[].summaryOperations.filter{}'], FILTER_CONDITION); on(['object-form', 'object-master-detail-form'], ['sections[].fields[].summaryOperations.filter{}'], FILTER_CONDITION); // The S-final stage: the drill-down's `report` is `ReportSchema`, by reference, diff --git a/packages/spec/src/ui/component-record-block-field-security.test.ts b/packages/spec/src/ui/component-record-block-field-security.test.ts index f7e9a8d0b19..7d583c8c75d 100644 --- a/packages/spec/src/ui/component-record-block-field-security.test.ts +++ b/packages/spec/src/ui/component-record-block-field-security.test.ts @@ -192,12 +192,15 @@ describe('`requiredPermissions` is declared on the three blocks as a capability expect(keys).toContain('requiredPermissions'); }); - it('B · the census: exactly the three blocks and `record:quick_actions` declare it — lit controls `aria` and `fields` fire on the same instrument', () => { + it('B · the census: exactly the three blocks, `record:quick_actions` and `list-view` declare it — lit controls `aria` and `fields` fire on the same instrument', () => { // `record:json` (#23745) declared the same NAME with a different meaning — // required and fail-closed — until it was retired (#24162); its row now // refuses every bag, so it declares no key at all // (`component-record-json-retirement.test.ts`). - expect(declaring('requiredPermissions')).toEqual([...BLOCKS, 'record:quick_actions'].sort()); + // `list-view` (#22444) is the list view's own members by reference, so it + // carries the list view's audience `requiredPermissions`, the same + // capability-set member. + expect(declaring('requiredPermissions')).toEqual([...BLOCKS, 'record:quick_actions', 'list-view'].sort()); // Controls: the census can find a key at all, and finds the blocks under // test when it should. `fields` is declared on two of the three (the related // list's column key is `columns`), which is itself a discriminating reading. diff --git a/packages/spec/src/ui/component.zod.ts b/packages/spec/src/ui/component.zod.ts index d9bb43d8bc5..c19607a6582 100644 --- a/packages/spec/src/ui/component.zod.ts +++ b/packages/spec/src/ui/component.zod.ts @@ -45,7 +45,17 @@ import { FormFieldSchema, type FormField, type FormFieldInput, + // [#22444] The `list-view` row is the list view itself: its members by + // reference, its two object-level checks, and the list overlay member's + // judged `options` bag (`VIEW_METADATA_MEMBERS.listOverlay`) — see + // `ListViewPageProps`. + checkListViewCalendarVisualization, + checkListViewChartBinding, + VIEW_METADATA_MEMBERS, } from './view.zod'; +// [#22444] The one prescription both tiers refuse a list view's retired +// `objectName` / `viewType` with (the react tier's `REACT_RETIRED_OVERLAY_PROPS.ListView`). +import { LIST_VIEW_RETIRED_ALIASES } from './list-view-retired-aliases'; // [#21445] `object-grid.bulkActionDefs` is the list view's bulk-action def, // by identity — the element `ListViewSchema.bulkActionDefs` declares. import { BulkActionDefSchema } from './bulk-action.zod'; @@ -8176,6 +8186,91 @@ export type ObjectTimelineProps = z.input; */ export type ObjectTimelinePropsParsed = z.infer; +/** + * The guidance a page `list-view` node's retired binding alias is refused + * with: what to write in the bag, then the react tier's own note for the same + * alias, verbatim ({@link LIST_VIEW_RETIRED_ALIASES}), so the two tiers carry + * one prescription. + */ +const LIST_VIEW_PAGE_ALIAS_GUIDANCE: Readonly> = { + objectName: + '`objectName` was retired on a page `list-view` node in @objectstack/spec 18: the list view names its object in its own ' + + `\`${LIST_VIEW_RETIRED_ALIASES.objectName.replacedBy}\` source — write \`data: { provider: 'object', object: '…' }\` ` + + 'in `properties`, or name the object on the node\'s `dataSource` binding (`dataSource.object`). The ' + + 'console reads no `objectName` off this node, so the list queries nothing. ' + + `The react tier retired the same alias on \`ListView\`: ${LIST_VIEW_RETIRED_ALIASES.objectName.note} ` + + 'Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.', + viewType: + '`viewType` was retired on a page `list-view` node in @objectstack/spec 18: the list kind is the list view\'s own ' + + `\`${LIST_VIEW_RETIRED_ALIASES.viewType.replacedBy}\`, written in \`properties\` (\`type: 'kanban'\`) — the ` + + 'node\'s own `type` stays `list-view`, the registry key. The console reads no `viewType` off this node, ' + + 'so the list draws as a grid. The react tier retired the same alias on `ListView`: ' + + `${LIST_VIEW_RETIRED_ALIASES.viewType.note} ` + + 'Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.', +}; + +/** + * `list-view` — a list view embedded on a page (#22444, ruling C, the + * maintainer's 「同意」: the page carrier converges on the spec spelling; one + * list view has one set of property names wherever it is embedded). + * + * ## Built from the list view, by reference + * + * Every member is {@link ListViewSchema}'s own schema object (its `shape` + * spread), and both of its object-level checks are attached + * ({@link checkListViewCalendarVisualization}, {@link checkListViewChartBinding}), + * so a key or value the authoring door refuses on a list view is refused here + * with the same issue. So `kanban.groupField` is an unknown key of the kanban + * block, and the list kind is the bag's own `type` (the node's `type` is the + * registry key `list-view`, and objectui's page renderer reads the bag's + * `type` as the list kind). + * + * ## The two members the list OVERLAY member declares instead + * + * The ruling takes the overlay layer's two differences, measured on the seat's + * probes, and nothing else: + * + * - `columns` OPTIONAL — the overlay member's own spelling + * (`shape.columns.optional()`). A page node bound to a saved view through + * its `dataSource.view` draws that view's columns, and the list renderer + * draws a column-less list from the object; the authoring + * {@link ListViewSchema} keeps `columns` required, because an authored list + * view is a full config. The row judges `properties` only, so it cannot see + * the node's binding: `columns` is optional whatever the binding. + * - `options` judged PER KIND — the overlay member's own bag, by reference + * (`VIEW_METADATA_MEMBERS.listOverlay.shape.options`): each `options.KIND` is + * judged by that kind's block, an unknown key refused by name, and + * `options.grid` refused with its own guidance (a grid has no per-kind + * block). + * + * ## The retired aliases + * + * `objectName` and `viewType` were the page carrier's own spelling of `data` + * and `type`. The pinned console reads neither off a page `list-view` node, + * and the react tier retired both on `ListView` with no window. They are + * undeclared here and answered by name ({@link LIST_VIEW_PAGE_ALIAS_GUIDANCE}); + * stored pages carry them forward through the D2 conversion + * `page-list-view-object-name-view-type-to-data-type`, never a fallback read. + * + * Not exported: a top-level exported schema mints a protocol def and a full + * set of authorable-surface keys, which would duplicate every `ui/ListView` + * key under a second name (the reason `ListViewShapeSchema` is not exported). + * The row reaches readers through {@link ComponentPropsMap}. + */ +const ListViewPageProps = lazySchema(() => strictObject({ + surface: 'this page `list-view`', + history: + 'Until this row a page `list-view` node\'s `properties` were judged by nothing: a key every list view ' + + 'door refuses (a kanban block\'s `groupField`, an `options.grid` bag) saved green on a page and reached ' + + 'the renderer.', + guidance: LIST_VIEW_PAGE_ALIAS_GUIDANCE, + guidanceSets: [COMPONENT_NODE_VISIBILITY_GUIDANCE], +}, { + ...ListViewSchema.shape, + columns: ListViewSchema.shape.columns.optional(), + options: VIEW_METADATA_MEMBERS.listOverlay.shape.options, +}).superRefine(checkListViewCalendarVisualization).superRefine(checkListViewChartBinding)); + /** * ---------------------------------------------------------------------- * Component Props Map @@ -8430,6 +8525,15 @@ export const ComponentPropsMap = { // own header carries the per-key reading and what it deliberately does NOT // declare. 'object-timeline': ObjectTimelinePropsSchema, + // #22444, ruling C (the maintainer's 「同意」): a list view embedded on a + // page is judged as a list view. Until this row the node's `properties` were + // judged by nothing, so `kanban.groupField` and `options.grid`, refused at + // every list view door, saved green on a page. The row is the list view's + // members by reference plus the list overlay member's two differences + // (`columns` optional, `options` judged per kind), and the page carrier's + // retired `objectName` / `viewType` are refused by name — see the schema's + // own header. + 'list-view': ListViewPageProps, } as const; /** diff --git a/packages/spec/src/ui/list-view-retired-aliases.ts b/packages/spec/src/ui/list-view-retired-aliases.ts new file mode 100644 index 00000000000..cdb650ea0ba --- /dev/null +++ b/packages/spec/src/ui/list-view-retired-aliases.ts @@ -0,0 +1,37 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The list view's two retired binding aliases, `objectName` and `viewType`, + * with the prescription each is refused with — declared ONCE, read by both + * tiers that refuse them (#22444, ruling C: one list view has one set of + * property names wherever it is embedded): + * + * - the react tier: `REACT_RETIRED_OVERLAY_PROPS.ListView` (`./react-blocks.ts`, + * #14791), which `validate-react-page-props` quotes when a `kind: 'react'` + * page writes either prop on ``; + * - the metadata page tier: `ComponentPropsMap['list-view']` + * (`./component.zod.ts`), whose guidance quotes the same note when a page + * `list-view` node writes either key in its `properties` bag. + * + * A module of its own, outside the `ui` barrel, for one reason: the props row + * cannot import `./react-blocks.ts`, which reads `ComponentPropsMap` at module + * load, so the one prescription lives below both (the + * `./list-view-export-options.ts` precedent). The value is published through + * `REACT_RETIRED_OVERLAY_PROPS`, as it was. + * + * `replacedBy` names the list view member that carries the binding now; + * `note` is the one-line fix. + */ +export const LIST_VIEW_RETIRED_ALIASES: Readonly> = { + // Commit 5383fa670 published these as deprecated aliases of ListViewSchema's own + // `data` / `type`; #14791 retires them outright, the consumer fold having + // landed (objectui `normalizeListViewSchema`, console pin a472b071). + objectName: { + replacedBy: 'data', + note: "Write data={{ provider: 'object', object: '…' }} — the metadata-tier data source, the same spelling a metadata list view authors.", + }, + viewType: { + replacedBy: 'type', + note: 'Write type="kanban" — ListViewSchema\'s own `type`, the metadata-tier view kind.', + }, +}; diff --git a/packages/spec/src/ui/page.zod.ts b/packages/spec/src/ui/page.zod.ts index e3f3674b323..caf5b84481f 100644 --- a/packages/spec/src/ui/page.zod.ts +++ b/packages/spec/src/ui/page.zod.ts @@ -1293,6 +1293,7 @@ export const PRINT_REFUSED_PAGE_COMPONENT_TYPES: ReadonlyMap = n ['record:history', 'draws a window of the history feed (`limit`, 50 unless set), so a printed copy carries only part of it'], ['record:activity', 'draws a window of the activity feed (`limit`) with comment and reaction controls, so a printed copy carries only part of it'], ['object-grid', 'pages its rows (`pagination` / `pageSize`), so a printed copy carries only the page on screen'], + ['list-view', 'pages its rows (`pagination`), so a printed copy carries only the page on screen'], ['object-timeline', 'draws a window of its items (`limit`) along a time axis scrolled to the visible range'], ['object-tree', 'draws only the nodes a viewer has expanded, so a printed copy depends on who expanded what'], // Lays itself out to the screen. diff --git a/packages/spec/src/ui/react-blocks.ts b/packages/spec/src/ui/react-blocks.ts index 3db542897a4..55f63c0f5d7 100644 --- a/packages/spec/src/ui/react-blocks.ts +++ b/packages/spec/src/ui/react-blocks.ts @@ -16,6 +16,7 @@ import type { ZodTypeAny } from 'zod'; import { ListViewSchema, FormViewSchema } from './view.zod'; import { ComponentPropsMap } from './component.zod'; import { ChartConfigSchema } from './chart.zod'; +import { LIST_VIEW_RETIRED_ALIASES } from './list-view-retired-aliases'; export type ReactPropKind = 'data' | 'binding' | 'controlled' | 'callback'; @@ -105,19 +106,11 @@ export const REACT_OVERLAY_SHADOWS: Readonly> export const REACT_RETIRED_OVERLAY_PROPS: Readonly< Record>> > = { - ListView: { - // Commit 5383fa670 published these as deprecated aliases of ListViewSchema's own - // `data` / `type`; #14791 retires them outright, the consumer fold having - // landed (objectui `normalizeListViewSchema`, console pin a472b071). - objectName: { - replacedBy: 'data', - note: "Write data={{ provider: 'object', object: '…' }} — the metadata-tier data source, the same spelling a metadata list view authors.", - }, - viewType: { - replacedBy: 'type', - note: 'Write type="kanban" — ListViewSchema\'s own `type`, the metadata-tier view kind.', - }, - }, + // [#22444] Declared once in `./list-view-retired-aliases.ts`: a page + // `list-view` node's `properties` refuses the same two aliases with the same + // notes (`ComponentPropsMap['list-view']`), and that row cannot import this + // module. + ListView: LIST_VIEW_RETIRED_ALIASES, }; /**