Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .changeset/22444-list-view-component-props-row.md
Original file line number Diff line number Diff line change
@@ -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.
10 changes: 5 additions & 5 deletions docs/audits/2026-07-unknown-key-strictness-ledger.counts/ui.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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 |
Expand All @@ -46,23 +46,23 @@ 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

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 |
|---|---|---|
| `action-params.zod.ts` | 1 | 1 |
| `app.zod.ts` | 1 | 19 |
| `view.zod.ts` | 4 | 59 |
| `widget.zod.ts` | 1 | 1 |
| **total** | **7** | **213** |
| **total** | **7** | **214** |

| Bucket | Sites |
|---|---|
Expand Down
82 changes: 82 additions & 0 deletions packages/lint/src/validate-component-props-list-view-22444.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;

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<typeof validateComponentProps>) =>
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(),
);
});
});
Original file line number Diff line number Diff line change
@@ -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<string, unknown>, node: Record<string, unknown> = {}) => ({
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<string, unknown> }[] }[] }[] })
.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<typeof page>).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([]);
});
});
7 changes: 5 additions & 2 deletions packages/spec/src/conversions/refused-shapes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> = {}) => ({
name: 'partner_page',
label: 'Partner',
Expand Down
2 changes: 1 addition & 1 deletion packages/spec/src/conversions/refused-shapes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading
Loading