From ce3bee7fd4bb3eaaab7e7a0d9bceb9624771144f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 13:46:44 +0000 Subject: [PATCH 1/5] feat(types): a page list-view node is judged in its properties bag, data / type, with objectName / viewType refused by name (objectui#12670) The authored `list-view` arm of `AnyComponentSchema` is now `ListViewBlockSchema`, built on the `ObjectChartBlockSchema` construct: its `properties` bag is the flat `ListViewSchema` mirror's own members by reference (the spec has no `ComponentPropsMap['list-view']` row), with the list kind as the spec's own `type` and `objectName` / `viewType` refused by name, flat or in the bag, naming `properties.data` and `properties.type`. The record source is `properties.data` or the node's `dataSource.object`. `ListViewSchema` leaves the zod ObjectQL union and stays the `ListView` component's props. Claude-Session: https://claude.ai/code/session_01Vk15bz8xHtKGUfXfCm16h4 Co-authored-by: Claude --- packages/types/src/authoring-nodes.ts | 20 +- packages/types/src/index.ts | 1 + packages/types/src/zod/index.zod.ts | 5 + packages/types/src/zod/objectql.zod.ts | 259 ++++++++++++++++++++++++- 4 files changed, 280 insertions(+), 5 deletions(-) diff --git a/packages/types/src/authoring-nodes.ts b/packages/types/src/authoring-nodes.ts index 9756c120f3..3ae7d90cd1 100644 --- a/packages/types/src/authoring-nodes.ts +++ b/packages/types/src/authoring-nodes.ts @@ -191,16 +191,19 @@ export type PublicBlockNodeOf = Extract; @@ -238,6 +241,15 @@ export type ObjectPivotBlockNode = ObjectQLPublicBlockNodeOf<'object-pivot'>; /** An authored `embeddable-form` node: the `EmbeddableFormBlockSchema` arm's input (objectui#11440). */ export type EmbeddableFormBlockNode = ObjectQLPublicBlockNodeOf<'embeddable-form'>; +/** + * An authored page `list-view` node: the `ListViewBlockSchema` arm's input + * (objectui#12670). Its props are its `properties` bag, the `ListViewSchema` + * mirror's own members, closed ({@link ClosedBag}), with the list kind as + * `type` and `objectName` / `viewType` refused. ⚠️ Not `ListViewSchema` + * (`./objectql.ts`), which stays the `ListView` component's props. + */ +export type ListViewBlockNode = ObjectQLPublicBlockNodeOf<'list-view'>; + /** `FlexBlockSchema`'s shape with its `properties` member closed ({@link ClosedArmShape}). */ type FlexBlockArmShape = ClosedArmShape<(typeof FlexBlockSchema)['shape']>; diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 6c55625c78..d99d41664c 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -162,6 +162,7 @@ export type { ObjectGridBlockNode, ObjectPivotBlockNode, EmbeddableFormBlockNode, + ListViewBlockNode, FlexBlockNode, ElementTextInputNode, ElementRecordPickerNode, diff --git a/packages/types/src/zod/index.zod.ts b/packages/types/src/zod/index.zod.ts index cd2ce5093e..0cfad0f426 100644 --- a/packages/types/src/zod/index.zod.ts +++ b/packages/types/src/zod/index.zod.ts @@ -358,6 +358,11 @@ export { // from the block's registration inputs. ObjectPivotBlockSchema, EmbeddableFormBlockSchema, + // objectui#12670 — the authored page `list-view` arm, its bag the flat + // mirror's own members with the list kind as `type` and `objectName` / + // `viewType` refused by name; `ListViewSchema` above stays the `ListView` + // component's props. + ListViewBlockSchema, ObjectQLPublicBlockComponentSchema, } from './objectql.zod.js'; diff --git a/packages/types/src/zod/objectql.zod.ts b/packages/types/src/zod/objectql.zod.ts index 976dd1a5fd..c7c4a11b2a 100644 --- a/packages/types/src/zod/objectql.zod.ts +++ b/packages/types/src/zod/objectql.zod.ts @@ -81,6 +81,11 @@ import { // below, by reference). The bag's own schema is module-private in the spec. VIEW_METADATA_MEMBERS as SpecViewMetadataMembers, checkListViewCalendarVisualization, + // objectui#12670 — the spec list view's chart-binding check. On the flat + // `ListViewSchema` the view kind is objectui's `viewType`, so it is not + // attachable there; in the authored `list-view` node's bag the kind is the + // spec's own `type`, so `ListViewPropsBag` below attaches it. + checkListViewChartBinding, } from '@objectstack/spec/ui'; // objectui#11266 — one inline master-detail grid column, the closed shape // `@objectstack/spec` 17.6.0 judges `FormViewSchema.subforms[].columns` by @@ -1495,6 +1500,17 @@ const UserFiltersSchema = z.object({ * * Migrating the remaining legacy vocabulary to the spec-canonical keys (`type`/`columns`/ * `filter`/`userActions`) is deferred — see #2231. + * + * ## The `ListView` component's props, not the authored page node (objectui#12670) + * + * This mirror is the node as the `ListView` component reads it — its `schema` + * prop, as `ObjectView` and the app-shell list pages compose it, and as + * `ListViewBlock` hands it over. It left `ObjectQLComponentSchema`, and so + * `AnyComponentSchema`: the AUTHORED page `list-view` node is armed by + * `ListViewBlockSchema`, whose `properties` bag is this mirror's own members + * with the list kind as `type` and `objectName` / `viewType` refused by name + * (ruling C on objectstack-ai/objectstack#22444). It stays exported, and its + * TypeScript twin stays `ListViewInferred & ListViewRuntimeProps`. */ // Spec view-config keys objectui overrides locally: the component envelope // (name/label/description → BaseSchema), the discriminator/renamed/relaxed keys @@ -2638,6 +2654,11 @@ export const ObjectMapConfigSchema = z.object({ * batch 6 moved the authored `object-gantt` arm the same way * (`ObjectGanttBlockSchema` below), with the same three rungs and the same * binding: its registration is `elementDataSourceBlock`-wrapped too. + * objectui#12670 armed the authored page `list-view` node the same way + * (`ListViewBlockSchema` below) with ONE rung, `data`, the spec list view's own + * data source: `properties.objectName` is refused there by name, not counted. + * The flat `ListViewSchema` keeps its `objectName` rung as the `ListView` + * component's props. * * The two sections met in one merge (objectui#11117 merging objectui#10859 * batch 5). Batch 5 counted the bag arm's binding by PRESENCE (`dataSource !== @@ -4366,6 +4387,13 @@ export const ObjectDataTableSchema = BaseSchema.extend({ * the mirror still builds the `object-view` `table` slot, and the authored * `object-grid` node is armed by `ObjectGridBlockSchema` below, from its spec * row. + * + * ⚠️ SIX since objectui#12670: `ListViewSchema` left it the same way. It stays + * the `ListView` component's props, and its TypeScript twin stays in the + * TypeScript union; the authored page `list-view` node is armed by + * `ListViewBlockSchema` below, its bag the mirror's own members (the spec has no + * row for this type), with `objectName` / `viewType` refused by name (ruling C on + * objectstack-ai/objectstack#22444). */ const ObjectQLComponentSchemaInferred = z.discriminatedUnion('type', [ ObjectViewSchema, @@ -4374,7 +4402,6 @@ const ObjectQLComponentSchemaInferred = z.discriminatedUnion('type', [ ObjectKanbanSchema, ObjectGallerySchema, ObjectDataTableSchema, - ListViewSchema, ]); /** @@ -5313,6 +5340,230 @@ export const EmbeddableFormBlockSchema = BaseSchema.extend({ children: retirementTombstone(EMBEDDABLE_FORM_NEITHER_CHANNEL), }).superRefine(...requireRecordSource('embeddable-form', ['objectName'], 'properties')); +/* ── The authored `list-view` node: its props in the `properties` bag ────── */ + +/** + * The keys of `ListViewSchema` above that stay on the NODE of an authored + * `list-view` (objectui#12670): every key `BaseSchema` declares that + * `@objectstack/spec`'s `ListViewSchema` does not (`id`, `className`, `visible`, + * the content channels and the rest of the node base), and the node's + * `dataSource` binding, which the spec's `PageComponentSchema` declares beside + * `properties` (objectui#11070). Read off the two declarations, not + * transcribed: a key the node base gains stays on the node the day it lands, + * and a key the spec's list view shares with the node base (`type`, `name`, + * `label`, `description`, `data`) stays a list view member, in the bag. + */ +const LIST_VIEW_NODE_LEVEL_KEYS = Object.fromEntries( + [ + ...Object.keys(BaseSchema.shape).filter((key) => !(key in stripImportedDefaults(SpecListViewSchema).shape)), + 'dataSource', + ].map((key) => [key, true]), +) as { + [K in Exclude> | 'dataSource']: true; +}; + +/** + * The ONE refusal detail a page `list-view` node's `objectName` gets, flat or in + * `properties` (objectui#12670). `aliasKeyRefusal` puts the key and what to + * write instead in front of it: "Did you mean `objectName` → `properties.data`?". + */ +const LIST_VIEW_PAGE_OBJECT_NAME_RETIRED = + 'A page `list-view` node names its object the way `@objectstack/spec`\'s list view does, in its `data` ' + + 'source: write `{ "type": "list-view", "properties": { "data": { "provider": "object", "object": "…" } } }`, ' + + 'or name the object in the node\'s `dataSource` binding (`dataSource.object`). `objectName` is retired on a ' + + 'page `list-view` node, flat or in `properties`, with no second spelling (ruling C on ' + + 'objectstack-ai/objectstack#22444), as the react layer retired it on `ListView` ' + + '(objectstack-ai/objectstack#14791).'; + +/** The same for a page `list-view` node's `viewType`: "Did you mean `viewType` → `properties.type`?". */ +const LIST_VIEW_PAGE_VIEW_TYPE_RETIRED = + 'A page `list-view` node states its list kind the way `@objectstack/spec`\'s list view does, as `type`, and ' + + 'in `properties`, because the node\'s own `type` is the registry key `list-view`: write `{ "type": ' + + '"list-view", "properties": { "type": "kanban", … } }`. `viewType` is retired on a page `list-view` node, ' + + 'flat or in `properties`, with no second spelling (ruling C on objectstack-ai/objectstack#22444), as the ' + + 'react layer retired it on `ListView` (objectstack-ai/objectstack#14791).'; + +/** + * objectui#10327's dataset-chart filter refusal, read in the bag: there the + * view kind is the spec's own `type`, which `checkListViewDatasetChartFilter` + * reads as `viewType` on the flat mirror. The same check, the same messages. + */ +function checkListViewBagDatasetChartFilter( + bag: { type?: unknown; chart?: unknown; options?: unknown; filter?: unknown; filters?: unknown }, + ctx: z.RefinementCtx, +): void { + checkListViewDatasetChartFilter({ ...bag, viewType: bag.type }, ctx); +} + +/** + * The `list-view` props bag (objectui#12670): the list view's members, BY + * REFERENCE — every member `ListViewSchema` above declares beyond the + * node-level keys, each the SAME schema object the mirror holds. + * + * ⛔ `@objectstack/spec` has no `ComponentPropsMap['list-view']` row at the + * resolved version, and none is invented here (ruling C puts that row in the + * spec, after this face moves). So the bag is built the way `object-chart`'s is + * ({@link ObjectChartPropsBag}): from the flat mirror's shape. The mirror's + * members ARE the spec's list view members by reference (`specFieldsExcept` + * over the spec's `ListViewSchema`), with objectui's local overrides on top — + * `columns` optional, the `kanban` / `calendar` / `gallery` / `timeline` blocks + * with their named refusals, the legacy `options` bag, the toolbar flags. One + * list view therefore has one set of property names wherever it is embedded, + * as ruling C says: the bag accepts what the `ListView` component's + * `ListViewSchema` props accept, except the two spellings the ruling renames. + * ⛔ Not the spec's `ListViewSchema` verbatim: it requires `columns` (and a + * kanban block's `columns`), which the ruling's option A, declined, would have + * reported on every real page node. + * + * Three members differ from the mirror, each the ruling's: + * + * - `type` is the list kind, the spec's own view-kind enum (grid, kanban, + * calendar, …) by reference. On the node, `type` is the registry key; in + * the bag it means what it means on the spec's list view. + * - `objectName` and `viewType` are refused by name, with what to write + * instead: `properties.data` and `properties.type`. ⛔ No fallback + * acceptance and no second spelling. + * + * The spec's two object-level list view checks are attached, both by + * reference: `checkListViewCalendarVisualization`, as on the flat mirror, and + * `checkListViewChartBinding`, which the flat mirror cannot attach (its view + * kind is `viewType`) and the bag can, because its `type` is the spec's. So is + * objectui's own dataset-chart filter refusal, read in the bag + * ({@link checkListViewBagDatasetChartFilter}). + * + * The bag keeps the mirror's posture, `.passthrough()` (`BaseSchema`'s), so a + * key the list view does not declare is judged in the bag exactly as it was on + * the flat node: unjudged by the tolerant face, refused by name by the strict + * authoring face, which closes every object it walks. + */ +const ListViewPropsBag = z + .looseObject(ListViewSchema.shape) + .omit(LIST_VIEW_NODE_LEVEL_KEYS) + .extend({ + type: ViewKindEnum.optional().describe( + 'The list kind — `@objectstack/spec`\'s list view `type` (grid, kanban, gallery, calendar, timeline, gantt, ' + + 'map, chart, tree), by reference. Absent, the list is a grid, or the kind of the saved view the node\'s ' + + '`dataSource.view` names.', + ), + objectName: aliasKeyRefusal('objectName', 'properties.data', 'a page `list-view` node', LIST_VIEW_PAGE_OBJECT_NAME_RETIRED), + viewType: aliasKeyRefusal('viewType', 'properties.type', 'a page `list-view` node', LIST_VIEW_PAGE_VIEW_TYPE_RETIRED), + }) + .superRefine(checkListViewCalendarVisualization) + .superRefine(checkListViewChartBinding) + .superRefine(checkListViewBagDatasetChartFilter); + +/** + * The ONE refusal detail every list view member written flat on a page + * `list-view` node gets (objectui#12670). `aliasKeyRefusal` puts the key and + * its bag member in front of it: "Did you mean `columns` → `properties.columns`?". + */ +const LIST_VIEW_FLAT_PROP = + 'A page `list-view` node takes its props in its `properties` bag: write `{ "type": "list-view", ' + + '"properties": { "data": { "provider": "object", "object": "…" }, "type": "grid", "columns": ["…"] } }` ' + + '(objectui#12670). `@objectstack/spec`\'s own page component refuses a prop written on the node as ' + + 'mis-layered (ADR-0089 D3a), so this face and `os validate` agree. The spec has no ' + + '`ComponentPropsMap[\'list-view\']` row, so the bag\'s members are `ListViewSchema`\'s own, the list view\'s ' + + 'members by reference. Moving it changes nothing at render time: `SchemaRenderer` hoists each `properties` ' + + 'key but `type` and `id` onto the node before the list renderer reads it.'; + +/** objectui#12670 / objectui#9256: ONE refusal string for both content channels of `ListViewBlockSchema`. */ +const LIST_VIEW_BLOCK_NEITHER_CHANNEL = neitherContentChannelGuidance( + 'list-view', + 'its registration (`plugin-list:list-view`) hands the node, its `properties` hoisted, to `ListViewBlock`, ' + + 'which renders `ListView`', + 'the records of `properties.data` in the list kind `properties.type` names, shaped by `columns`, `filter`, ' + + '`sort` and `options`', +); + +/** + * `list-view` — the AUTHORED page node: its props in the `properties` bag + * (objectui#12670, ruling C on objectstack-ai/objectstack#22444, triage's + * option A). + * + * ## The defect this closes + * + * Ruling C converges the page carrier on the spec spelling: a page `list-view` + * node names its object with `data: { provider: 'object', object }` (or the + * node's `dataSource` binding) and its kind with `type`, in `properties` + * because the node's own `type` is the registry key. This union used to arm + * the node with the flat `ListViewSchema` above, the `ListView` component's + * props, so `objectui validate` refused the ruled node (its record-source + * refinement found no `objectName`, and the strict face named `properties` as + * an unrecognized key) and accepted the retired flat `objectName` / + * `viewType` one. The authoring door steered an author, human or AI, from the + * ruled spelling back to the retired one. + * + * ## The construct + * + * The one `ObjectMapBlockSchema` and `ObjectChartBlockSchema` use: `BaseSchema` + * + the `type` literal + `NODE_ENVELOPE` + `properties` through `propsBag` + + * `flatPropRefusals` over the bag, so a list view member written FLAT on the + * node is refused by name toward `properties.KEY`. The bag is + * {@link ListViewPropsBag}, the flat mirror's own members by reference, as on + * `object-chart`, because the spec has no row for this type. A member the + * spec's page component also declares on the node (`label`, `aria`, + * `responsive`) is not refused flat: `flatPropRefusals` leaves those alone, so + * `label` keeps `BaseSchema`'s member, and the other two are left as every arm + * leaves them. `objectName` and `viewType` are refused by name in both positions with the + * same message, which names `properties.data` and `properties.type`: in the bag + * they are retired members, so the flat refusal takes the bag's own member + * (`flatPropRefusals` hands a retired member through). + * + * The five handler keys `ListView` reads are declared as the flat mirror + * declares them: RUNTIME SLOTS, refused by name, so a flat one is not pointed + * at a bag member. Spelled as the helper calls, as `ObjectGridBlockSchema` + * spells its `onNavigate`, so `check:handler-key-reads` reads the disposition + * off this arm. + * + * ## The record source, `dataSource` and the content channels + * + * `requireRecordSource(…, 'properties')` with one rung, `data`: the node needs + * `properties.data` (an `object` provider names the object, a `value` provider + * carries the rows), or the node's `dataSource` binding naming its object (a + * non-empty `dataSource.object`, the predicate every gate-wrapped arm shares + * since objectui#11117): the registration is `elementDataSourceBlock`-wrapped, + * and `ElementDataSourceGate` lands the binding's object before `ListView` + * runs. `properties.objectName` is NOT a rung: it is refused. Presence is + * `!== undefined`, as on every arm (the maintainer ruling recorded + * 2026-09-02). `dataSource` is the flat mirror's own member, the spec's + * `ElementDataSourceSchema` by reference. Neither content channel is read, so + * both are refused by name (objectui#9256). + * + * ## What did not move + * + * The flat `ListViewSchema` above and its TypeScript `ListViewSchema` stay + * published as the `ListView` component's props: the node as the component + * reads it, and as `ObjectView` and the app-shell list pages compose it. It is + * no longer an arm of `AnyComponentSchema`. A composed node never passes + * through `safeValidateSchema`. + */ +export const ListViewBlockSchema = BaseSchema.extend({ + type: z.literal('list-view'), + ...NODE_ENVELOPE, + // A list view member written flat on the node is refused by name, toward `properties.KEY`. + ...flatPropRefusals('list-view', ListViewPropsBag, LIST_VIEW_FLAT_PROP), + properties: propsBag( + 'list-view', + ListViewPropsBag, + 'The `list-view` props bag — the members `ListViewSchema` declares beyond the node-level keys, by reference, ' + + 'with `type` as the list kind (`@objectstack/spec`\'s list view `type`) and `objectName` / `viewType` ' + + 'refused by name. `@objectstack/spec` has no `ComponentPropsMap[\'list-view\']` row (objectui#12670).', + ), + // objectui#11070 — the node's binding, the flat mirror's own member by + // reference (the spec's `ElementDataSourceSchema`). + dataSource: ListViewSchema.shape.dataSource, + // The runtime slots `ListView` reads, declared as the flat mirror declares them. + onAddRecord: handlerKeyRefusal('onAddRecord', 'runtime-slot', 'Add-record handler'), + onBulkAction: handlerKeyRefusal('onBulkAction', 'runtime-slot', 'Bulk action handler'), + onDensityChange: handlerKeyRefusal('onDensityChange', 'runtime-slot', 'Row density change handler'), + onNavigate: handlerKeyRefusal('onNavigate', 'runtime-slot', 'Record navigation handler'), + onPageSizeChange: handlerKeyRefusal('onPageSizeChange', 'runtime-slot', 'Page size change handler'), + // objectui#9256: the renderer reads NEITHER content channel, so both are refused by name, each + // kept a MEMBER. + body: retirementTombstone(LIST_VIEW_BLOCK_NEITHER_CHANNEL), + children: retirementTombstone(LIST_VIEW_BLOCK_NEITHER_CHANNEL), +}).superRefine(...requireRecordSource('list-view', ['data'], 'properties')); + /** * The public blocks above, as one arm of `AnyComponentSchema` * (objectui#10859, batches 2 to 6). @@ -5339,6 +5590,10 @@ export const EmbeddableFormBlockSchema = BaseSchema.extend({ * is built from the block's registration `inputs`, and no TypeScript * declaration in this package restates the node. * + * `list-view` joined in objectui#12670, a no-row arm like `object-chart`: its + * bag is the flat `ListViewSchema` mirror's own members by reference, and that + * mirror, with its TypeScript twin, stays the `ListView` component's props. + * * Each arm also spreads `NODE_ENVELOPE` from `./public-blocks.zod.ts`, * the node-level `responsiveStyles` every public block declares by reference to * the spec's `PageComponentSchema` (objectui#10872 batch 8) — the same one @@ -5357,6 +5612,8 @@ const ObjectQLPublicBlockComponentSchemaInferred = z.discriminatedUnion('type', // objectui#11440 — two Tier A public blocks with no spec row; bags from their registration inputs. ObjectPivotBlockSchema, EmbeddableFormBlockSchema, + // objectui#12670 — the authored page `list-view` node; its bag is the flat mirror's own members. + ListViewBlockSchema, ]); /** From 23afb33a7c9c23bcb6bf58fee89a3d57f20d18ea Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 14:01:14 +0000 Subject: [PATCH 2/5] test(types): the page list-view arm's pins, and the published-door legs re-spelled onto the page node (objectui#12670) A new pin, `list-view-properties-bag-12670.test.ts`, holds the four acceptance cases on both faces (the ruled node accepted; `objectName` / `viewType` refused by name, flat or in the bag, FROM -> TO; the record source refused at `properties.data`; `dataSource.object` alone accepted), the bag's members by reference, the spec's two list view checks in the bag, and that `ListViewSchema` stays the `ListView` component's props. The pins that handed the component spelling to `safeValidateSchema` / `StrictAnyComponentSchema` now hand those faces the page spelling, through a test-only helper (`list-view-page-node.ts`); their mirror legs are unchanged. The census pins (union arms, flat-prop population, responsive styles, grouping, mirror parity, import boundary, bag carriers) count the new arm. Claude-Session: https://claude.ai/code/session_01Vk15bz8xHtKGUfXfCm16h4 Co-authored-by: Claude --- ...authoring-nodes-bag-carriers-11468.test.ts | 21 +- .../content-channel-remeasure-9256.test.ts | 19 +- .../flat-props-refusal-10872.test.ts | 43 +- ...-view-conditional-formatting-11533.test.ts | 12 +- ...grouping-field-padded-refused-7347.test.ts | 21 +- .../__tests__/imported-defaults-8317.test.ts | 5 + ...list-view-blocks-by-reference-6152.test.ts | 13 +- ...dataset-chart-filter-refusal-10327.test.ts | 60 ++- .../list-view-options-bag-6152.test.ts | 13 +- .../src/__tests__/list-view-page-node.ts | 77 +++ .../list-view-properties-bag-12670.test.ts | 470 ++++++++++++++++++ ...art-legacy-axis-keys-retired-10608.test.ts | 8 +- ...ct-grid-show-filters-retired-11068.test.ts | 11 +- .../objectql-union-arms-7363.test.ts | 7 +- ...blic-block-responsive-styles-10872.test.ts | 8 +- .../registered-type-arms-10859-b2.test.ts | 4 +- .../spec-object-refinements-7715.test.ts | 14 +- .../strict-face-read-keys-11070.test.ts | 4 +- .../src/__tests__/zod-mirror-parity.test.ts | 6 + packages/types/src/objectql.ts | 5 +- 20 files changed, 775 insertions(+), 46 deletions(-) create mode 100644 packages/types/src/__tests__/list-view-page-node.ts create mode 100644 packages/types/src/__tests__/list-view-properties-bag-12670.test.ts diff --git a/packages/types/src/__tests__/authoring-nodes-bag-carriers-11468.test.ts b/packages/types/src/__tests__/authoring-nodes-bag-carriers-11468.test.ts index 9f5fee5d5e..86eecf7bf3 100644 --- a/packages/types/src/__tests__/authoring-nodes-bag-carriers-11468.test.ts +++ b/packages/types/src/__tests__/authoring-nodes-bag-carriers-11468.test.ts @@ -35,6 +35,7 @@ import type { ObjectMetricBlockNode, ObjectPivotBlockNode, EmbeddableFormBlockNode, + ListViewBlockNode, ObjectQLPublicBlockNode, ObjectTimelineBlockNode, PublicBlockNode, @@ -93,6 +94,7 @@ type _EachAliasIsItsArm = Assert< ObjectGridBlockNode['type'], ObjectPivotBlockNode['type'], EmbeddableFormBlockNode['type'], + ListViewBlockNode['type'], ], [ 'object-metric', @@ -105,6 +107,7 @@ type _EachAliasIsItsArm = Assert< 'object-grid', 'object-pivot', 'embeddable-form', + 'list-view', ] > >; @@ -118,12 +121,14 @@ type _JoinedIntoAuthoringNode = Assert< // leaves each bag with `[k: string]: unknown`. `object-pivot` and // `embeddable-form` (objectui#11440) have no row either: their required bags // are built from the registration inputs, kept `.passthrough()` the same way. +// The page `list-view` (objectui#12670) has no row either: its bag is the +// `ListViewSchema` mirror's own members, kept `.passthrough()` like `object-chart`'s. // The control shows that, which is why the derivation reads the bag's shape too. type _NoAuthoringNodeBagIsOpen = Assert, never>>; type _ControlShapeOnlyLeavesTheOwnMemberBagsOpen = Assert< Equal< OpenBagTypes>, - 'object-chart' | 'flex' | 'object-pivot' | 'embeddable-form' + 'object-chart' | 'flex' | 'object-pivot' | 'embeddable-form' | 'list-view' > >; @@ -176,6 +181,20 @@ describe('objectui#11468: the authored properties-bag carriers have a TypeScript expect([chart, misspelled]).toHaveLength(2); }); + it('list-view (objectui#12670): the bag is closed, spelled `data` / `type`, and the retired pair is refused in both positions', () => { + const ruled: ListViewBlockNode = { + type: 'list-view', + properties: { data: { provider: 'object', object: 'deal' }, type: 'kanban', kanban: { groupByField: 'stage' } }, + }; + // @ts-expect-error `objectName` is refused in the bag: the object is named in `data` + const bagObjectName: ListViewBlockNode = { type: 'list-view', properties: { objectName: 'deal' } }; + // @ts-expect-error `viewType` is refused flat on the node: the kind is `properties.type` + const flatViewType: ListViewBlockNode = { type: 'list-view', viewType: 'kanban' }; + // @ts-expect-error `viewTyp` is not a member of the list-view bag (the mirror's members, closed) + const misspelled: ListViewBlockNode = { type: 'list-view', properties: { viewTyp: 'kanban' } }; + expect([ruled, bagObjectName, flatViewType, misspelled]).toHaveLength(4); + }); + it('every carrier arm is a zod object, and so are the two mirror bags: the premises the derivation reads', () => { const arms: z.ZodType[] = [...ObjectQLPublicBlockComponentSchema.options, FlexBlockSchema]; expect(arms.filter((arm) => arm.def.type !== 'object')).toEqual([]); diff --git a/packages/types/src/__tests__/content-channel-remeasure-9256.test.ts b/packages/types/src/__tests__/content-channel-remeasure-9256.test.ts index b577a72808..620af7cebc 100644 --- a/packages/types/src/__tests__/content-channel-remeasure-9256.test.ts +++ b/packages/types/src/__tests__/content-channel-remeasure-9256.test.ts @@ -109,6 +109,17 @@ const ROWS: ReadonlyArray>> = { + 'list-view': { properties: { data: { provider: 'object', object: 'account' } } }, +}; + const CHANNELS = ['body', 'children'] as const; const CONTENT = [{ type: 'text', content: 'measured' }]; const issues = (m: Mirror, doc: unknown) => { @@ -155,8 +166,12 @@ describe('objectui#9256 re-measure — both content channels are refused where t }); it.each(CASES)('%s — the refusal reaches the node through `AnyComponentSchema`, not only its own arm', (_label, type, _mirror, key, required) => { - expect(AnyComponentSchema.safeParse({ ...required, type }).success).toBe(true); - expect(AnyComponentSchema.safeParse({ ...required, type, [key]: CONTENT }).success).toBe(false); + const published = PUBLISHED_REQUIRED[type] ?? required; + expect(AnyComponentSchema.safeParse({ ...published, type }).success).toBe(true); + const refused = AnyComponentSchema.safeParse({ ...published, type, [key]: CONTENT }); + expect(refused.success).toBe(false); + const issue = refused.error!.issues.find((i) => i.path.join('.') === key); + expect(issue?.message).toContain(`\`${type}\` reads NEITHER content channel`); }); }); diff --git a/packages/types/src/__tests__/flat-props-refusal-10872.test.ts b/packages/types/src/__tests__/flat-props-refusal-10872.test.ts index f2c4d6bcb2..fa8f470e5f 100644 --- a/packages/types/src/__tests__/flat-props-refusal-10872.test.ts +++ b/packages/types/src/__tests__/flat-props-refusal-10872.test.ts @@ -186,9 +186,32 @@ const VALID_BAG: Readonly>> = { // `description` are the keys a flat node shares with the node base. 'object-pivot': { objectName: 'deal', title: 'Pipeline', rowField: 'stage', columnField: 'owner', valueField: 'amount' }, 'embeddable-form': { formId: 'contact-us', objectName: 'lead', title: 'Contact us', description: 'We reply within a day' }, + // objectui#12670: the authored page `list-view` arm spreads this helper over its bag, the + // `ListViewSchema` mirror's own members; `data`, `name` and `description` are the keys a flat + // node shares with the node base. The bag's `type` (the list kind) is left out here: written + // flat it is the node's own `type`, the registry key, so `flattened` below could not carry it. + 'list-view': { + data: { provider: 'object', object: 'deal' }, + name: 'open_deals', + label: 'Open deals', + description: 'Deals not yet closed', + columns: ['name', 'amount'], + kanban: { groupByField: 'stage' }, + }, }; const COVERED = Object.keys(VALID_BAG); +/** + * Bag members an arm refuses flat with its OWN refusal, declared after the spread — the + * helper's "whatever the arm declares AFTER the spread" — rather than the generated one: + * the page `list-view`'s five runtime slots (objectui#12670). `ListView` reads each of them, + * so a flat one is refused as the slot it is, never pointed at a bag member. + */ +const OWN_FLAT_REFUSALS: Readonly> = { + 'list-view': ['onAddRecord', 'onBulkAction', 'onDensityChange', 'onNavigate', 'onPageSizeChange'], +}; +const ownFlatRefusal = (type: string, key: string): boolean => (OWN_FLAT_REFUSALS[type] ?? []).includes(key); + /** The arms whose bag is REQUIRED — its registration-required members live in it (objectui#11440). */ const REQUIRES_BAG: ReadonlySet = new Set(['object-pivot', 'embeddable-form']); @@ -219,7 +242,9 @@ describe('objectui#10872 batch 10 — the population is read off the unions', () describe('objectui#10872 batch 10 — each row member written flat is refused by name, on both faces', () => { const CASES = COVERED.flatMap((type) => - owedRefusals(Object.keys(rowOf(ARMS.get(type)!).shape)).map((key) => [`${type}.${key}`, type, key] as const), + owedRefusals(Object.keys(rowOf(ARMS.get(type)!).shape)) + .filter((key) => !ownFlatRefusal(type, key)) + .map((key) => [`${type}.${key}`, type, key] as const), ); it('the population of row members is not empty (non-vacuity)', () => { @@ -326,6 +351,22 @@ describe('objectui#10872 batch 10 — what the refusal leaves alone', () => { }, ); + it.each(Object.entries(OWN_FLAT_REFUSALS).flatMap(([type, keys]) => keys.map((key) => [`${type}.${key}`, type, key] as const)))( + '%s — a bag member the arm refuses flat with its own refusal keeps it, and is not pointed at the bag', + (_label, type, key) => { + const arm = ARMS.get(type)!; + // A member of the bag, so the generated refusal would have applied without the arm's own. + expect(Object.keys(rowOf(arm).shape)).toContain(key); + expect(messageOf(arm.shape[key])).toContain('RUNTIME SLOT'); + expect(messageOf(arm.shape[key])).not.toContain(`properties.${key}`); + for (const [face, judge] of FACES) { + const issue = issuesOf(judge({ type, [key]: { action: 'toast' } })).find((i) => i.path.join('.') === key); + expect(issue?.code, face).toBe('custom'); + expect(issue?.message, face).toContain('RUNTIME SLOT'); + } + }, + ); + it('an arm\'s own refusal of a row key outranks the generated one', () => { // `record:alert`'s `body` (objectui#10872 batch 3) says why `children` is no remedy here. expect(messageOf(ARMS.get('record:alert')!.shape.body)).toContain('objectui#9256'); diff --git a/packages/types/src/__tests__/grid-list-view-conditional-formatting-11533.test.ts b/packages/types/src/__tests__/grid-list-view-conditional-formatting-11533.test.ts index eafa11e3ba..2a7a300bfa 100644 --- a/packages/types/src/__tests__/grid-list-view-conditional-formatting-11533.test.ts +++ b/packages/types/src/__tests__/grid-list-view-conditional-formatting-11533.test.ts @@ -24,7 +24,8 @@ * Each refusal is read on every zod face an author meets: the two mirrors * (`ObjectGridSchema`, `ListViewSchema`), the tolerant document face * (`safeValidateSchema`) and the strict authoring face - * (`StrictAnyComponentSchema`), the last two on a `list-view` node and on an + * (`StrictAnyComponentSchema`), the last two on a page `list-view` node (its + * props in the `properties` bag since objectui#12670) and on an * `object-view` `table` slot. A bare `success: false` would not be a refusal pin * (a document with an unrelated mistake fails too), so each assertion reads the * issue at the retired key's path and its message. The accepted @@ -94,12 +95,17 @@ type Face = readonly [ const grid = (rules: unknown[]) => ({ type: 'object-grid', objectName: 'task', conditionalFormatting: rules }); const listView = (rules: unknown[]) => ({ type: 'list-view', objectName: 'task', conditionalFormatting: rules }); const objectView = (rules: unknown[]) => ({ type: 'object-view', objectName: 'task', table: { conditionalFormatting: rules } }); +/** objectui#12670: the authored page `list-view` the published faces judge — its props in the bag, its object in `data`. */ +const listViewPage = (rules: unknown[]) => ({ + type: 'list-view', + properties: { data: { provider: 'object', object: 'task' }, conditionalFormatting: rules }, +}); const FACES: readonly Face[] = [ ['ObjectGridSchema (the grid mirror)', grid, 'conditionalFormatting', (d) => ObjectGridSchema.safeParse(d) as never], ['ListViewSchema (the list-view mirror)', listView, 'conditionalFormatting', (d) => ListViewSchema.safeParse(d) as never], - ['safeValidateSchema on a `list-view` node', listView, 'conditionalFormatting', (d) => safeValidateSchema(d) as never], - ['StrictAnyComponentSchema on a `list-view` node', listView, 'conditionalFormatting', (d) => StrictAnyComponentSchema.safeParse(d) as never], + ['safeValidateSchema on a `list-view` node', listViewPage, 'properties.conditionalFormatting', (d) => safeValidateSchema(d) as never], + ['StrictAnyComponentSchema on a `list-view` node', listViewPage, 'properties.conditionalFormatting', (d) => StrictAnyComponentSchema.safeParse(d) as never], ['safeValidateSchema on an `object-view` `table` slot', objectView, 'table.conditionalFormatting', (d) => safeValidateSchema(d) as never], ['StrictAnyComponentSchema on an `object-view` `table` slot', objectView, 'table.conditionalFormatting', (d) => StrictAnyComponentSchema.safeParse(d) as never], ]; diff --git a/packages/types/src/__tests__/grouping-field-padded-refused-7347.test.ts b/packages/types/src/__tests__/grouping-field-padded-refused-7347.test.ts index 97eb27d745..5f6024b5af 100644 --- a/packages/types/src/__tests__/grouping-field-padded-refused-7347.test.ts +++ b/packages/types/src/__tests__/grouping-field-padded-refused-7347.test.ts @@ -65,7 +65,7 @@ import type { z } from 'zod'; import { GroupingFieldSchema as SpecGroupingFieldSchema } from '@objectstack/spec/ui'; import { AnyComponentSchema, - ListViewSchema, + ListViewBlockSchema, ObjectGridBlockSchema, ObjectGallerySchema, ObjectKanbanSchema, @@ -104,11 +104,15 @@ interface DeclaringArm { } const DECLARING: Readonly> = { - 'list-view': { - declaredAt: 'grouping', - arm: ListViewSchema, - doc: (fields) => ({ type: 'list-view', objectName: 'account', grouping: { fields } }), - fieldPath: (i) => ['grouping', 'fields', i, 'field'], + // objectui#12670: the authored page `list-view` is armed by `ListViewBlockSchema`, + // whose bag holds the flat `ListViewSchema` mirror's own `grouping` member by + // reference; the mirror stays the `ListView` component's props, off the union. + 'list-view properties': { + type: 'list-view', + declaredAt: 'properties.grouping', + arm: ListViewBlockSchema, + doc: (fields) => ({ type: 'list-view', properties: { data: { provider: 'object', object: 'account' }, grouping: { fields } } }), + fieldPath: (i) => ['properties', 'grouping', 'fields', i, 'field'], }, 'object-gallery': { declaredAt: 'grouping', @@ -170,10 +174,13 @@ const DECLARING: Readonly> = { * (`flatPropRefusals`), pointing at `properties.grouping`. The bag member * `properties.grouping` stood here too while the spec row typed it `z.unknown()`; * since `@objectstack/spec` 17.6.0 the row judges it, so it is a `DECLARING` row - * above (objectui#11438). + * above (objectui#11438). The page `list-view` (objectui#12670) is the same shape. */ const NOT_COVERED: Readonly> = { 'object-grid': ['grouping'], + // objectui#12670: the same on the page `list-view` — the node-level `grouping` is + // the flat spelling's by-name refusal, pointing at `properties.grouping`. + 'list-view': ['grouping'], }; /** The faces an authored document meets. `objectui validate` runs the strict one. */ diff --git a/packages/types/src/__tests__/imported-defaults-8317.test.ts b/packages/types/src/__tests__/imported-defaults-8317.test.ts index 83a9bc30ea..93e2a00f8f 100644 --- a/packages/types/src/__tests__/imported-defaults-8317.test.ts +++ b/packages/types/src/__tests__/imported-defaults-8317.test.ts @@ -136,6 +136,7 @@ import { ResponsiveStylesSchema as SpecResponsiveStylesSchema, objectNavTargetExclusivity, checkListViewCalendarVisualization, + checkListViewChartBinding, checkPageSourceCompleteness, checkDashboardWidgetStageOrder, checkDashboardWidgetMetricMeasureArity, @@ -713,6 +714,10 @@ describe('the import boundary strips every imported default (objectui#8317)', () // `PageKindNodeSchema`, and `isDefault` placement, on `PageKindNodeSchema` only. ['checkPageRegionPlacement', checkPageRegionPlacement], ['checkPageDefaultPlacement', checkPageDefaultPlacement], + // objectui#12670: the list view chart-binding check `@objectstack/spec` 18 carries on + // `ListViewSchema`, attached on the authored page `list-view` node's bag + // (`ListViewPropsBag`), whose `type` is the spec's view kind. + ['checkListViewChartBinding', checkListViewChartBinding], ]); /** diff --git a/packages/types/src/__tests__/list-view-blocks-by-reference-6152.test.ts b/packages/types/src/__tests__/list-view-blocks-by-reference-6152.test.ts index 46e0a724dd..768d3e6e25 100644 --- a/packages/types/src/__tests__/list-view-blocks-by-reference-6152.test.ts +++ b/packages/types/src/__tests__/list-view-blocks-by-reference-6152.test.ts @@ -36,6 +36,7 @@ import { ListViewSchema as SpecListViewSchema } from '@objectstack/spec/ui'; import type { ListViewSchema as TsListViewSchema } from '../objectql'; import { ListViewSchema } from '../zod/objectql.zod.js'; import { AnyComponentSchema, StrictAnyComponentSchema, safeValidateSchema } from '../zod/index.zod.js'; +import { onPageSpelling } from './list-view-page-node'; /* ── Instruments ─────────────────────────────────────────────────────────────── */ @@ -96,11 +97,17 @@ const BASE: Record> = { }; const NODE = { type: 'list-view', objectName: 'deal' }; +/** + * The mirror is the `ListView` component's props; the three published faces + * judge the authored PAGE node since objectui#12670, its props in the + * `properties` bag, so each re-spells the node and reads its paths relative to + * the bag (`onPageSpelling`). The bag's blocks are the mirror's, by reference. + */ const DOORS: ReadonlyArray = [ ['the list-view mirror', (d) => ListViewSchema.safeParse(d)], - ['the tolerant face', (d) => AnyComponentSchema.safeParse(d)], - ['the strict face', (d) => StrictAnyComponentSchema.safeParse(d)], - ['safeValidateSchema', (d) => safeValidateSchema(d)], + ['the tolerant face', onPageSpelling((d) => AnyComponentSchema.safeParse(d) as Parsed)], + ['the strict face', onPageSpelling((d) => StrictAnyComponentSchema.safeParse(d) as Parsed)], + ['safeValidateSchema', onPageSpelling((d) => safeValidateSchema(d) as Parsed)], ]; /* ── 1. The slots, on the installed protocol (the precondition) ──────────────── */ diff --git a/packages/types/src/__tests__/list-view-dataset-chart-filter-refusal-10327.test.ts b/packages/types/src/__tests__/list-view-dataset-chart-filter-refusal-10327.test.ts index 389795e4de..76b7a61ca8 100644 --- a/packages/types/src/__tests__/list-view-dataset-chart-filter-refusal-10327.test.ts +++ b/packages/types/src/__tests__/list-view-dataset-chart-filter-refusal-10327.test.ts @@ -20,9 +20,20 @@ * Each refused case asserts it is the ONLY issue, so the refusal cannot ride on * some other failure of the same document; each control asserts a full parse, * on both doors (the schema and the published `safeValidateSchema`). + * + * Since objectui#12670 the two doors take two spellings of the same list view: + * `ListViewSchema` is the `ListView` component's props (`viewType` the kind), + * and the published door judges the authored PAGE node, its props in the + * `properties` bag with the kind as the bag's `type` (`asPageListView`). The + * bag also carries `@objectstack/spec`'s own `checkListViewChartBinding`, which + * the component's props cannot (their kind is `viewType`), so the two controls + * that author a `chart` view with no dataset binding are refused there by the + * spec's check, never by this refusal. */ import { describe, it, expect } from 'vitest'; +import { ListViewSchema as SpecListViewSchema } from '@objectstack/spec/ui'; import { ListViewSchema, safeValidateSchema } from '../zod/index.zod'; +import { asPageListView, bagPath } from './list-view-page-node'; const REMEDY = "a dataset chart's scope is written in the dataset."; @@ -43,10 +54,16 @@ const node = (extra: Record) => ({ ...extra, }); -const bothDoors = (doc: unknown) => [ListViewSchema.safeParse(doc), safeValidateSchema(doc)]; +type Parsed = { success: boolean; error?: { issues: Array<{ code: string; path: PropertyKey[]; message: string }> } }; + +/** The component's props, and the page node the published door judges, each issue path read relative to the bag. */ +const bothDoors = (doc: Record): Parsed[] => [ + ListViewSchema.safeParse(doc), + safeValidateSchema(asPageListView(doc)), +]; -const issuesOf = (r: { success: boolean; error?: { issues: Array<{ code: string; path: PropertyKey[]; message: string }> } }) => - (r.error?.issues ?? []).map((i) => ({ code: i.code, path: i.path.join('.'), message: i.message })); +const issuesOf = (r: Parsed) => + (r.error?.issues ?? []).map((i) => ({ code: i.code, path: bagPath(i.path), message: i.message })); describe('objectui#10327 — a view filter on a dataset-bound chart view is refused', () => { it.each([ @@ -75,19 +92,40 @@ describe('objectui#10327 — a view filter on a dataset-bound chart view is refu }); describe('objectui#10327 — controls: every neighbour of the refused shape parses', () => { + // `unbound`: a `chart` view whose effective block binds no dataset. The page + // door refuses it with the spec's own `checkListViewChartBinding` (its issue at + // `chart`, or at the bag block's missing binding keys), the same document the + // spec's list view refuses — ⛔ never at the view filter, which is this card's. it.each([ - ['a chart whose legacy `options.chart` bag binds no dataset, with a view filter', { viewType: 'chart', options: { chart: OBJECT_BAG }, filter: VIEW_FILTER }], - ['a chart view declaring no block, with a view filter', { viewType: 'chart', filter: VIEW_FILTER }], - ['a dataset-bound chart with no view filter', { viewType: 'chart', chart: DATASET_BLOCK }], - ['a dataset-bound chart with an EMPTY view filter', { viewType: 'chart', chart: DATASET_BLOCK, filter: [] }], + ['a chart whose legacy `options.chart` bag binds no dataset, with a view filter', { viewType: 'chart', options: { chart: OBJECT_BAG }, filter: VIEW_FILTER }, 'unbound'], + ['a chart view declaring no block, with a view filter', { viewType: 'chart', filter: VIEW_FILTER }, 'unbound'], + ['a dataset-bound chart with no view filter', { viewType: 'chart', chart: DATASET_BLOCK }, 'parses'], + ['a dataset-bound chart with an EMPTY view filter', { viewType: 'chart', chart: DATASET_BLOCK, filter: [] }, 'parses'], [ 'a grid that offers a switch to a dataset chart, with a view filter (the filter scopes the grid)', { viewType: 'grid', chart: DATASET_BLOCK, appearance: { allowedVisualizations: ['grid', 'chart'] }, filter: VIEW_FILTER }, + 'parses', ], - ])('%s', (_label, extra) => { - for (const r of bothDoors(node(extra))) { - expect(issuesOf(r)).toEqual([]); - expect(r.success).toBe(true); + ] as const)('%s', (_label, extra, pageDoor) => { + const [component, page] = bothDoors(node(extra)); + expect(issuesOf(component)).toEqual([]); + expect(component.success).toBe(true); + if (pageDoor === 'parses') { + expect(issuesOf(page)).toEqual([]); + expect(page.success).toBe(true); + return; } + const issues = issuesOf(page); + expect(issues.length).toBeGreaterThan(0); + expect(issues.every(({ code, path }) => code === 'custom' && (path === 'chart' || path.startsWith('options.chart.')))).toBe(true); + expect(issues.some(({ path }) => path === 'filter' || path === 'filters')).toBe(false); + // Lit control: the spec's own list view refuses the same document (the spec requires + // `columns`). Read only where the document is spec-shaped: the spec's authoring list + // view declares no legacy `options` bag at all. + const { viewType, ...rest } = extra as Record; + if ('options' in rest) return; + const spec = SpecListViewSchema.safeParse({ ...rest, type: viewType, columns: ['name'] }); + expect(spec.success).toBe(false); + expect(spec.error!.issues.map((i) => i.path.join('.')).sort()).toEqual(issues.map(({ path }) => path).sort()); }); }); diff --git a/packages/types/src/__tests__/list-view-options-bag-6152.test.ts b/packages/types/src/__tests__/list-view-options-bag-6152.test.ts index 7d5057ea16..e5c6531ebd 100644 --- a/packages/types/src/__tests__/list-view-options-bag-6152.test.ts +++ b/packages/types/src/__tests__/list-view-options-bag-6152.test.ts @@ -41,6 +41,7 @@ import { ListViewSchema as SpecListViewSchema, VIEW_METADATA_MEMBERS } from '@ob import type { ListViewSchema as TsListViewSchema, ListViewTimelineConfig } from '../objectql'; import { ListViewSchema } from '../zod/objectql.zod.js'; import { AnyComponentSchema, StrictAnyComponentSchema, safeValidateSchema } from '../zod/index.zod.js'; +import { onPageSpelling } from './list-view-page-node'; // @ts-expect-error — `ListViewGalleryConfig` is retired (objectui#6152 round 12): write the spec's `GalleryConfig`. import type { ListViewGalleryConfig } from '../index'; @@ -92,11 +93,17 @@ const ARMS: Record = { }; const NODE = { type: 'list-view', objectName: 'deal' }; +/** + * The mirror is the `ListView` component's props; the three published faces + * judge the authored PAGE node since objectui#12670, its props in the + * `properties` bag, so each re-spells the node and reads its paths relative to + * the bag (`onPageSpelling`). The bag's `options` is the mirror's, by reference. + */ const DOORS: ReadonlyArray = [ ['the list-view mirror', (d) => ListViewSchema.safeParse(d)], - ['the tolerant face', (d) => AnyComponentSchema.safeParse(d)], - ['the strict face', (d) => StrictAnyComponentSchema.safeParse(d)], - ['safeValidateSchema', (d) => safeValidateSchema(d)], + ['the tolerant face', onPageSpelling((d) => AnyComponentSchema.safeParse(d) as Parsed)], + ['the strict face', onPageSpelling((d) => StrictAnyComponentSchema.safeParse(d) as Parsed)], + ['safeValidateSchema', onPageSpelling((d) => safeValidateSchema(d) as Parsed)], ]; /* ── 1. The overlay's bag, on the installed protocol (the precondition) ──────── */ diff --git a/packages/types/src/__tests__/list-view-page-node.ts b/packages/types/src/__tests__/list-view-page-node.ts new file mode 100644 index 0000000000..e12f6dc3f1 --- /dev/null +++ b/packages/types/src/__tests__/list-view-page-node.ts @@ -0,0 +1,77 @@ +/** + * 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. + */ + +/** + * The PAGE spelling of a `list-view` node, spelled ONCE for the pins that judge + * a list view at the published door (objectui#12670). Test-only: `__tests__/` + * is excluded from this package's build, and nothing re-exports this module. + * + * Since objectui#12670 two schemas judge a list view, and they take two + * spellings of it: + * + * - `ListViewSchema`, the `ListView` component's props: `objectName` names the + * object, `viewType` the kind, every prop on the node itself. + * - `ListViewBlockSchema`, the arm `AnyComponentSchema` (and so + * `safeValidateSchema`, the strict authoring face and `objectui validate`) + * judges an authored page node with: its props in the `properties` bag, the + * object named in `data: { provider: 'object', object }`, the kind as the + * bag's `type` (ruling C on objectstack-ai/objectstack#22444). `objectName` + * and `viewType` are refused there by name. + * + * The pins that used to hand the component spelling to the published door + * re-spell it here, so each one keeps judging the same list view on both + * doors. Every key but the node-level ones moves into the bag, and a page + * issue's path is read relative to the bag ({@link bagPath}), so an assertion + * written against the component spelling's paths reads the same. + */ + +/** The keys that stay on the page node: the node base's own and the node's binding. */ +const NODE_LEVEL = new Set(['id', 'className', 'style', 'dataSource', 'responsiveStyles']); + +/** + * `view`, in the component spelling (`{ type: 'list-view', objectName, viewType, … }`), + * re-spelled as the page node `ListViewBlockSchema` judges. + */ +export function asPageListView(view: Readonly>): Record { + const node: Record = { type: 'list-view' }; + const bag: Record = {}; + for (const [key, value] of Object.entries(view)) { + if (key === 'type') continue; + if (key === 'objectName') bag.data = { provider: 'object', object: value }; + else if (key === 'viewType') bag.type = value; + else if (NODE_LEVEL.has(key)) node[key] = value; + else bag[key] = value; + } + node.properties = bag; + return node; +} + +/** An issue path read relative to the page node's bag: `properties.options.chart` → `options.chart`. */ +export function bagPath(path: readonly PropertyKey[]): string { + return (path[0] === 'properties' ? path.slice(1) : path).map(String).join('.'); +} + +type Issued = { success: boolean; error?: { issues: readonly { path: PropertyKey[] }[] } }; + +/** + * A published door, judging the page spelling of what it is handed: `parse` + * receives {@link asPageListView} of the document, and every issue comes back + * with its path relative to the bag, so a table of component-spelling paths + * reads the same on the mirror and on the door. + */ +export function onPageSpelling(parse: (doc: unknown) => R): (doc: unknown) => R { + return (doc) => { + const r = parse(asPageListView(doc as Record)); + if (r.success || !r.error) return r; + const issues = r.error.issues.map((issue) => ({ + ...issue, + path: issue.path[0] === 'properties' ? issue.path.slice(1) : issue.path, + })); + return { ...r, error: { issues } } as R; + }; +} diff --git a/packages/types/src/__tests__/list-view-properties-bag-12670.test.ts b/packages/types/src/__tests__/list-view-properties-bag-12670.test.ts new file mode 100644 index 0000000000..50195b5c6a --- /dev/null +++ b/packages/types/src/__tests__/list-view-properties-bag-12670.test.ts @@ -0,0 +1,470 @@ +/** + * 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. + */ + +/** + * A page `list-view` node is judged in its `properties` bag, in the spec + * spelling — `data` names the object, `type` the list kind — and the retired + * `objectName` / `viewType` are refused by name (objectui#12670; ruling C on + * objectstack-ai/objectstack#22444, triage's option A). + * + * ## The defect this pins + * + * The `list-view` arm of `AnyComponentSchema` was the flat `ListViewSchema` + * mirror, the `ListView` component's props, closed by a record-source rule + * whose one rung was `objectName`. So `safeValidateSchema`, the strict + * authoring face and `objectui validate`: + * + * - refused the ruled page node, `{ type: 'list-view', properties: { data: + * { provider: 'object', object }, type: 'kanban', kanban } }` (no + * `objectName` on the node; the strict face also named `properties` as an + * unrecognized key); + * - accepted the retired flat `{ type: 'list-view', objectName, viewType }`. + * + * The door an author, human or AI, runs steered it from the ruled spelling + * back to the retired one. + * + * ## What is particular to this node + * + * - `@objectstack/spec` has no `ComponentPropsMap['list-view']` row at the + * resolved version, so the bag is built as `object-chart`'s is: the flat + * mirror's own members, BY REFERENCE (each the same schema object), with + * three ruled differences — `type` is the spec's view kind, `objectName` + * and `viewType` are refused by name. + * - In the bag the kind is the spec's own `type`, so the spec's two + * object-level list view checks attach there by reference, the chart + * binding check included, which the flat mirror cannot attach. + * - The record source is `properties.data`, or the node's `dataSource` + * binding naming the object. + * + * ## What did NOT move + * + * `ListViewSchema` (zod) and its TypeScript `ListViewSchema` stay published as + * the `ListView` component's props, `objectName` / `viewType` and all; the + * zod mirror is no longer an arm of `AnyComponentSchema`. The last describe + * block holds that split. + * + * ## Two instruments + * + * The `Expect` / `Equal` block is TYPE-level (`tsc -p tsconfig.test.json`, the + * third leg of this package's `type-check`); vitest strips it. The `describe` + * blocks are RUNTIME. + */ + +import { describe, expect, it } from 'vitest'; +import type { z } from 'zod'; +import { + ListViewSchema as SpecListViewSchema, + PageComponentSchema as SpecPageComponentSchema, +} from '@objectstack/spec/ui'; + +import type { ListViewSchema as TsListViewSchema, ObjectQLComponentSchema as TsObjectQLComponentSchema } from '../objectql'; +import type { ListViewBlockNode } from '../index'; +import { + ListViewBlockSchema, + ListViewSchema, + ObjectQLComponentSchema, + ObjectQLPublicBlockComponentSchema, + StrictAnyComponentSchema, + safeValidateSchema, +} from '../zod/index.zod.js'; + +/* ── Type-level: the `tsc` channel ───────────────────────────────────────── */ + +/** Invariant equality — `extends` both ways would accept a narrowing. */ +type Equal = + (() => T extends A ? 1 : 2) extends (() => T extends B ? 1 : 2) ? true : false; +type Expect = T; + +type ShapeOf = M extends { shape: infer S } ? S : never; +/** What a shape entry ACCEPTS (input side, so `.optional()` shows). */ +type InputOf = T extends z.ZodType ? z.input : never; +type Arm = ShapeOf; +type Bag = NonNullable; +type SpecListView = z.input; + +/** + * On the node: the retired pair and a list view member written flat accept + * nothing. In the bag: `type` is the spec's own view kind, `data` the spec's + * own data source, and the retired pair accepts nothing. + */ +export type assertionThePageSpelling = [ + Expect>, + Expect, undefined>>, + Expect, undefined>>, + Expect, undefined>>, + Expect, undefined>>, + Expect>, + Expect>, + Expect>, + Expect>, +]; + +/** + * The TypeScript `ListViewSchema` is RE-DECLARED, not retired: still the + * `ListView` component's props, `objectName` / `viewType` still its members, + * still in the TypeScript ObjectQL union. + */ +export type assertionComponentPropsStay = [ + Expect>, + Expect>, + Expect, false>>, + Expect, TsListViewSchema>>, +]; + +/** Non-vacuity: a narrower kind is not Equal to the spec's. */ +export type assertionInstrumentFires = [ + Expect, false>>, +]; + +/* ── Runtime fixtures ────────────────────────────────────────────────────── */ + +/** The node the ruling and the card name: an object-bound kanban, in the bag. */ +const RULED = { + type: 'list-view', + properties: { + data: { provider: 'object', object: 'deal' }, + type: 'kanban', + kanban: { groupByField: 'stage', columns: ['name', 'amount'] }, + }, +} as const; + +/** The object named by the node's binding alone, the spec's per-element `dataSource`. */ +const BOUND = { type: 'list-view', dataSource: { object: 'deal', view: 'open' } } as const; + +/** Inline rows: a `value` provider is a record source too. */ +const INLINE = { + type: 'list-view', + properties: { data: { provider: 'value', items: [{ id: 1, name: 'Acme' }] }, columns: ['name'] }, +} as const; + +type Issue = { code: string; path: PropertyKey[]; message: string; keys?: string[]; params?: { code?: string } }; +type Result = { success: boolean; error?: { issues: readonly unknown[] } }; + +const FACES: ReadonlyArray Result]> = [ + ['safeValidateSchema', (doc) => safeValidateSchema(doc)], + ['the strict authoring face', (doc) => StrictAnyComponentSchema.safeParse(doc)], +]; + +function issuesOf(result: Result): Issue[] { + if (result.success) throw new Error('expected a refusal, the document parsed'); + return result.error!.issues as Issue[]; +} +const at = (issues: Issue[], path: string) => issues.filter((i) => i.path.join('.') === path); +const recordSourceIssues = (issues: Issue[]) => issues.filter((i) => i.params?.code === 'RECORD_SOURCE_REQUIRED'); + +/* ── 1. The ruled node validates ─────────────────────────────────────────── */ + +describe('a page `list-view` validates in the spec spelling, in its `properties` bag (objectui#12670)', () => { + it.each([ + ['the ruled node: `data` names the object, `properties.type` the kind', RULED], + ['a node bound only through the node\'s `dataSource`', BOUND], + ['a node on inline rows (a `value` provider)', INLINE], + ['a node with a display `label` and `responsiveStyles` on the node', { ...RULED, id: 'deals', label: 'Deals', responsiveStyles: { large: { minWidth: '0' } } }], + ] as const)('%s is accepted on both faces', (_label, doc) => { + for (const [face, parse] of FACES) { + const result = parse(doc); + expect(result.success, `${face}: ${JSON.stringify(result.error?.issues)}`).toBe(true); + } + }); + + it('the fixtures are spec-valid by the spec\'s own page component (lit control)', () => { + for (const doc of [RULED, BOUND, INLINE]) { + const page = SpecPageComponentSchema.safeParse(doc); + expect(page.success, JSON.stringify(page.error?.issues)).toBe(true); + } + }); + + it('reaches the arm at a child slot too — a page holding the ruled node', () => { + for (const [face, parse] of FACES) { + const result = parse({ type: 'page', children: [RULED] }); + expect(result.success, `${face}: ${JSON.stringify(result.error?.issues)}`).toBe(true); + } + }); + + it.each(SpecListViewSchema.shape.type.removeDefault().options.map((kind) => [kind] as const))( + 'the bag\'s `type` takes the spec\'s list kind `%s`', + (kind) => { + // A `chart` list names its dataset binding: the spec's own check (section 5) requires one. + const chart = kind === 'chart' ? { chart: { chartType: 'bar', dataset: 'deal_ds', values: ['total'] } } : {}; + for (const [face, parse] of FACES) { + const result = parse({ ...RULED, properties: { ...RULED.properties, type: kind, kanban: undefined, ...chart } }); + expect(result.success, `${face}: ${JSON.stringify(result.error?.issues)}`).toBe(true); + } + }, + ); + + it.each([['the registry key', 'list-view'], ['a retired kind', 'page'], ['a misspelt kind', 'kanbn']] as const)( + 'the bag\'s `type` refuses %s, at `properties.type`', + (_label, kind) => { + for (const [face, parse] of FACES) { + const found = at(issuesOf(parse({ ...RULED, properties: { ...RULED.properties, type: kind } })), 'properties.type'); + expect(found.map((i) => i.code), face).toEqual(['invalid_value']); + } + }, + ); + + it('the parse authors no default into the document (objectui#8317): an absent kind stays absent', () => { + // Control: the spec's own list view writes `type: 'grid'` into its output. + expect((SpecListViewSchema.parse({ columns: ['name'] }) as { type?: string }).type).toBe('grid'); + const parsed = safeValidateSchema({ type: 'list-view', properties: { data: { provider: 'object', object: 'deal' } } }); + expect(parsed.success).toBe(true); + expect('type' in (parsed.data as unknown as { properties: Record }).properties).toBe(false); + }); +}); + +/* ── 2. The retired spelling is refused by name, FROM → TO ────────────────── */ + +describe('`objectName` / `viewType` are refused by name on a page `list-view`, flat or in the bag (objectui#12670)', () => { + it.each([ + ['a flat `objectName`', { type: 'list-view', objectName: 'deal' }, 'objectName', '`objectName` → `properties.data`'], + ['`properties.objectName`', { type: 'list-view', properties: { objectName: 'deal' } }, 'properties.objectName', '`objectName` → `properties.data`'], + ['a flat `viewType`', { ...RULED, viewType: 'kanban' }, 'viewType', '`viewType` → `properties.type`'], + ['`properties.viewType`', { ...RULED, properties: { ...RULED.properties, viewType: 'kanban' } }, 'properties.viewType', '`viewType` → `properties.type`'], + ] as const)('%s is refused at its own path, naming the key and what to write instead', (_label, doc, path, fromTo) => { + for (const [face, parse] of FACES) { + const found = at(issuesOf(parse(doc)), path); + expect(found.map((i) => i.code), face).toEqual(['invalid_type']); + expect(found[0].message, face).toContain(`Did you mean ${fromTo}?`); + // The remedy is the whole document shape, not only the path. + expect(found[0].message, face).toContain('"properties": {'); + } + }); + + it('a node that names its object ONLY as `objectName` has no record source: it is refused for that too', () => { + for (const doc of [{ type: 'list-view', objectName: 'deal' }, { type: 'list-view', properties: { objectName: 'deal' } }]) { + for (const [face, parse] of FACES) { + expect(recordSourceIssues(issuesOf(parse(doc))), face).toHaveLength(1); + } + } + }); + + it('the retired flat node is refused whole: each key at its own path, and the record source', () => { + const issues = issuesOf(safeValidateSchema({ type: 'list-view', objectName: 'deal', viewType: 'kanban' })); + expect(issues.map((i) => i.path.join('.')).sort()).toEqual(['objectName', 'properties.data', 'viewType']); + }); + + it('⛔ no fallback acceptance: `objectName` beside a working `data` is refused, not ignored', () => { + for (const [face, parse] of FACES) { + const found = at(issuesOf(parse({ ...RULED, properties: { ...RULED.properties, objectName: 'deal' } })), 'properties.objectName'); + expect(found, face).toHaveLength(1); + } + }); + + it('the spec\'s own page component refuses the flat pair as unrecognized keys (lit control: the reason holds)', () => { + const page = SpecPageComponentSchema.safeParse({ type: 'list-view', objectName: 'deal', viewType: 'kanban' }); + expect(page.success).toBe(false); + const issue = (page.error!.issues as Issue[]).find((i) => i.code === 'unrecognized_keys'); + expect(issue?.keys?.sort()).toEqual(['objectName', 'viewType']); + }); + + it('the spec\'s own list view refuses both in the bag position too', () => { + const spec = SpecListViewSchema.safeParse({ columns: ['name'], objectName: 'deal', viewType: 'kanban' }); + expect(spec.success).toBe(false); + const issue = (spec.error!.issues as Issue[]).find((i) => i.code === 'unrecognized_keys'); + expect(issue?.keys?.sort()).toEqual(['objectName', 'viewType']); + }); +}); + +/* ── 3. The record source ─────────────────────────────────────────────────── */ + +describe('the record source is `properties.data` or the node\'s `dataSource` binding (objectui#12670)', () => { + it.each([ + ['the bare node', { type: 'list-view' }], + ['an empty bag', { type: 'list-view', properties: {} }], + ['a bag with columns and no source', { type: 'list-view', properties: { columns: ['name'] } }], + ['a binding that names no object', { type: 'list-view', dataSource: { object: '' }, properties: { columns: ['name'] } }], + ] as const)('%s is refused on both faces, at `properties.data`, naming the bag rung and the binding', (_label, doc) => { + for (const [face, parse] of FACES) { + const found = recordSourceIssues(issuesOf(parse(doc))); + expect(found, face).toHaveLength(1); + expect(found[0].code).toBe('custom'); + expect(found[0].path).toEqual(['properties', 'data']); + expect(found[0].message).toContain('`properties.data`'); + expect(found[0].message).toContain('`dataSource.object`'); + } + }); + + it('`properties.data` alone is a source; the node\'s `dataSource.object` alone is a source', () => { + for (const [face, parse] of FACES) { + expect(parse({ type: 'list-view', properties: { data: { provider: 'object', object: 'deal' } } }).success, face).toBe(true); + expect(parse({ type: 'list-view', dataSource: { object: 'deal' } }).success, face).toBe(true); + } + }); + + it('a `data` written flat is not a source: it is refused by name and pointed at the bag', () => { + const issues = issuesOf(safeValidateSchema({ type: 'list-view', data: { provider: 'object', object: 'deal' } })); + expect(at(issues, 'data')[0]?.message).toContain('`data` → `properties.data`'); + expect(recordSourceIssues(issues)).toHaveLength(1); + }); + + it('the spec has no such rule: its page component accepts the bare node (the recorded divergence)', () => { + expect(SpecPageComponentSchema.safeParse({ type: 'list-view' }).success).toBe(true); + }); +}); + +/* ── 4. The bag is the mirror's members, by reference ─────────────────────── */ + +const BAG = ListViewBlockSchema.shape.properties.unwrap(); +const BAG_SHAPE = BAG.shape as Record; +const MIRROR_SHAPE = ListViewSchema.shape as Record; +/** The bag members the ruling changes: the kind, and the retired pair. */ +const RULED_MEMBERS = new Set(['type', 'objectName', 'viewType']); + +describe('the bag is the `ListViewSchema` mirror\'s own members, by reference (objectui#12670)', () => { + it('every mirror member but the node-level ones is a bag member, the same schema object', () => { + const bagKeys = Object.keys(BAG_SHAPE).filter((key) => !RULED_MEMBERS.has(key)); + expect(bagKeys.length).toBeGreaterThan(60); + for (const key of bagKeys) expect(BAG_SHAPE[key], key).toBe(MIRROR_SHAPE[key]); + // Every spec list view member is one of them, read off the installed spec. + for (const key of Object.keys(SpecListViewSchema.shape)) expect(Object.keys(BAG_SHAPE), key).toContain(key); + }); + + it('the node-level keys stay on the node: the node base\'s own and the binding', () => { + for (const key of ['id', 'className', 'visible', 'hidden', 'dataSource', 'body', 'children']) { + expect(Object.keys(BAG_SHAPE), key).not.toContain(key); + } + // The keys the spec's list view shares with the node base are list view members, in the bag. + for (const key of ['name', 'label', 'description', 'data']) expect(BAG_SHAPE[key], key).toBe(MIRROR_SHAPE[key]); + }); + + it('the bag\'s description says it is no spec row', () => { + expect(ListViewBlockSchema.shape.properties.description).toContain('no `ComponentPropsMap[\'list-view\']` row'); + }); + + it.each([ + ['an invented key', 'inventedKey12670'], + ] as const)('%s in the bag stays unjudged on the tolerant face and is refused on the strict face', (_label, key) => { + // The mirror's posture, `.passthrough()`, kept: the strict face closes it. + const doc = { ...RULED, properties: { ...RULED.properties, [key]: 1 } }; + expect(safeValidateSchema(doc).success).toBe(true); + const issue = issuesOf(StrictAnyComponentSchema.safeParse(doc)).find((i) => i.code === 'unrecognized_keys'); + expect(issue?.path).toEqual(['properties']); + expect(issue?.keys).toEqual([key]); + }); + + it.each([ + ['a kanban block alias (objectui#6152)', { kanban: { groupField: 'stage' } }, 'properties.kanban.groupField'], + ['a retired conditional-formatting dialect (objectui#11533)', { conditionalFormatting: [{ field: 'stage', operator: 'equals', value: 'won' }] }, 'properties.conditionalFormatting.0.field'], + ['a handler key (objectui#6124 runtime slot)', { onAddRecord: { action: 'toast' } }, 'properties.onAddRecord'], + ] as const)('the mirror\'s refusals reach the bag: %s', (_label, props, path) => { + for (const [face, parse] of FACES) { + expect(at(issuesOf(parse({ ...RULED, properties: { ...RULED.properties, ...props } })), path), face).toHaveLength(1); + } + }); +}); + +/* ── 5. The spec's object-level checks, attached in the bag ───────────────── */ + +describe('the spec\'s list view checks run in the bag, with the spec\'s own issue (objectui#12670)', () => { + /** The spec's issue for the same list view — the spec requires `columns`. */ + const specIssue = (props: Record, path: string) => + (SpecListViewSchema.safeParse({ columns: ['name'], ...props }).error?.issues as Issue[] | undefined) + ?.find((i) => i.path.join('.') === path); + const page = (props: Record) => ({ + type: 'list-view', + properties: { data: { provider: 'object', object: 'deal' }, ...props }, + }); + + it.each([ + ['`checkListViewCalendarVisualization`: a calendar offered with no `calendar` block', { appearance: { allowedVisualizations: ['calendar'] } }, 'calendar'], + ['`checkListViewChartBinding`: a `chart` list with no dataset binding', { type: 'chart' }, 'chart'], + ] as const)('%s', (_label, props, path) => { + const expected = specIssue(props, path); + expect(expected?.code, 'the spec refuses the same list view (lit control)').toBe('custom'); + for (const [face, parse] of FACES) { + const found = at(issuesOf(parse(page(props))), `properties.${path}`); + expect(found.map((i) => ({ code: i.code, message: i.message })), face).toEqual([{ code: 'custom', message: expected!.message }]); + } + }); + + it('controls: a declared calendar block, and a dataset-bound chart, pass on both faces', () => { + for (const props of [ + { appearance: { allowedVisualizations: ['calendar'] }, calendar: { startDateField: 'starts_at' } }, + { type: 'chart', chart: { chartType: 'bar', dataset: 'deal_ds', dimensions: ['stage'], values: ['total'] } }, + ]) { + expect(SpecListViewSchema.safeParse({ columns: ['name'], ...props }).success).toBe(true); + for (const [face, parse] of FACES) expect(parse(page(props)).success, face).toBe(true); + } + }); + + it('objectui\'s dataset-chart filter refusal (objectui#10327) reads the bag\'s `type` as the kind', () => { + const chart = { chartType: 'bar', dataset: 'deal_ds', dimensions: ['stage'], values: ['total'] }; + const filter = [{ field: 'stage', operator: 'equals', value: 'won' }]; + for (const [face, parse] of FACES) { + expect(at(issuesOf(parse(page({ type: 'chart', chart, filter }))), 'properties.filter'), face).toHaveLength(1); + // Control: the same filter on a grid scopes the grid. + expect(parse(page({ type: 'grid', chart, filter })).success, face).toBe(true); + } + }); +}); + +/* ── 6. The node: flat members, runtime slots, content channels ───────────── */ + +describe('the node around the bag (objectui#12670)', () => { + it.each(['columns', 'filter', 'kanban', 'options', 'showSearch'] as const)( + 'a flat `%s` is refused on both faces, by name, toward the bag', + (key) => { + for (const [face, parse] of FACES) { + const found = at(issuesOf(parse({ ...RULED, [key]: [] })), key); + expect(found.map((i) => i.code), face).toEqual(['invalid_type']); + expect(found[0].message, face).toContain(`\`${key}\` → \`properties.${key}\``); + } + }, + ); + + it('a node-level `label` is kept on both faces, as the spec\'s page component keeps it', () => { + for (const [face, parse] of FACES) { + expect(parse({ ...RULED, label: 'Deals' }).success, face).toBe(true); + expect(parse({ ...RULED, label: 7 }).success, face).toBe(false); + } + }); + + it.each(['onAddRecord', 'onBulkAction', 'onDensityChange', 'onNavigate', 'onPageSizeChange'] as const)( + 'a flat `%s` is refused as the runtime slot `ListView` reads, not pointed at the bag', + (key) => { + expect(ListViewBlockSchema.shape[key].description).toBe(ListViewSchema.shape[key].description); + for (const [face, parse] of FACES) { + const found = at(issuesOf(parse({ ...RULED, [key]: { action: 'toast' } })), key); + expect(found.map((i) => i.code), face).toEqual(['custom']); + expect(found[0].message, face).toContain('RUNTIME SLOT'); + } + }, + ); + + it.each(['body', 'children'] as const)('refuses the `%s` content channel by name (objectui#9256)', (key) => { + const found = at(issuesOf(safeValidateSchema({ ...RULED, [key]: [{ type: 'text', content: 'x' }] })), key); + expect(found[0]?.message).toContain('`list-view` reads NEITHER content channel'); + expect(found[0]?.message).toContain('`properties.data`'); + }); + + it('`dataSource` is judged as the spec binding on both faces', () => { + for (const [face, parse] of FACES) expect(parse({ ...RULED, dataSource: 'objectstack' }).success, face).toBe(false); + }); +}); + +/* ── 7. The arm moved; the component props stayed ─────────────────────────── */ + +describe('the arm moved; `ListViewSchema` stayed the `ListView` component\'s props (objectui#12670)', () => { + const literalsOf = (union: { options: readonly unknown[] }) => + union.options.map((arm) => (arm as { shape: { type: z.ZodLiteral } }).shape.type.value); + + it('`list-view` is armed by the page arm, not by the component props', () => { + expect(literalsOf(ObjectQLPublicBlockComponentSchema)).toContain('list-view'); + expect(literalsOf(ObjectQLComponentSchema)).not.toContain('list-view'); + expect(ObjectQLPublicBlockComponentSchema.options).toContain(ListViewBlockSchema); + }); + + it('`ListViewSchema` still judges the component props, `objectName` / `viewType` and all', () => { + const props = { type: 'list-view', objectName: 'deal', viewType: 'kanban', kanban: { groupByField: 'stage' } }; + expect(ListViewSchema.safeParse(props).success).toBe(true); + // Its own record-source rule is unchanged: `objectName`, or the binding. + const bare = ListViewSchema.safeParse({ type: 'list-view' }); + expect(bare.success).toBe(false); + expect(recordSourceIssues(issuesOf(bare))[0]?.path).toEqual(['objectName']); + }); +}); diff --git a/packages/types/src/__tests__/object-chart-legacy-axis-keys-retired-10608.test.ts b/packages/types/src/__tests__/object-chart-legacy-axis-keys-retired-10608.test.ts index a6f157214f..14ab77c655 100644 --- a/packages/types/src/__tests__/object-chart-legacy-axis-keys-retired-10608.test.ts +++ b/packages/types/src/__tests__/object-chart-legacy-axis-keys-retired-10608.test.ts @@ -64,6 +64,7 @@ import { ChartAggregateSchema as SpecChartAggregateSchema } from '@objectstack/s import type { ObjectChartSchema } from '../objectql'; import { ObjectChartSchema as ObjectChartMirror } from '../zod/objectql.zod'; import { ListViewSchema, safeValidateSchema } from '../zod/index.zod'; +import { asPageListView, bagPath } from './list-view-page-node'; const CHART = { type: 'object-chart', chartType: 'bar' } as const; const chart = (extra: Record) => ({ ...CHART, ...extra }); @@ -208,9 +209,10 @@ describe('objectui#10608 (e) — the list-view carrier refuses these names too ( viewType: 'chart', options: { chart: { chartType: 'bar', xAxisField: 'status', yAxisFields: ['estimate'], aggregation: 'sum' } }, }; - for (const r of [ListViewSchema.safeParse(view), safeValidateSchema(view)]) { + // The component's props, and the page node the published door judges (objectui#12670). + for (const r of [ListViewSchema.safeParse(view), safeValidateSchema(asPageListView(view))]) { expect(r.success).toBe(false); - const issue = (r.error?.issues ?? []).find((i) => i.path.join('.') === 'options.chart') as + const issue = (r.error?.issues ?? []).find((i) => bagPath(i.path) === 'options.chart') as | { code?: string; keys?: string[] } | undefined; expect(issue?.code).toBe('unrecognized_keys'); @@ -227,7 +229,7 @@ describe('objectui#10608 (e) — the list-view carrier refuses these names too ( options: { chart: { chartType: 'bar', dataset: 'task_ds', dimensions: ['status'], values: ['total_estimate'] } }, }; expect(issuesOf(ListViewSchema.safeParse(view))).toEqual([]); - expect(issuesOf(safeValidateSchema(view))).toEqual([]); + expect(issuesOf(safeValidateSchema(asPageListView(view)))).toEqual([]); }); }); diff --git a/packages/types/src/__tests__/object-grid-show-filters-retired-11068.test.ts b/packages/types/src/__tests__/object-grid-show-filters-retired-11068.test.ts index 3e1a20b92a..12c7263eb4 100644 --- a/packages/types/src/__tests__/object-grid-show-filters-retired-11068.test.ts +++ b/packages/types/src/__tests__/object-grid-show-filters-retired-11068.test.ts @@ -133,15 +133,20 @@ describe('an authored `showFilters` on `object-grid` is refused BY NAME on both }); describe('`showFilters` stays live where a renderer reads it (the controls the retirement must not touch)', () => { - const LIST = { type: 'list-view' as const, objectName: 'probe', columns: ['name'] }; + // objectui#12670: a page `list-view` takes its props in its `properties` bag and names its + // object in `data`, so the published faces judge the list view in that spelling. + const list = (props: Record) => ({ + type: 'list-view' as const, + properties: { data: { provider: 'object', object: 'probe' }, columns: ['name'], ...props }, + }); it.each(FACES)('%s accepts the list view’s `userActions.filter` — the spelling the refusal names', (_face, parse) => { - const result = parse({ ...LIST, userActions: { filter: true } }); + const result = parse(list({ userActions: { filter: true } })); expect(result.success, result.success ? '' : JSON.stringify(result.error.issues)).toBe(true); }); it.each(FACES)('%s accepts a list view’s own `showFilters`', (_face, parse) => { - const result = parse({ ...LIST, showFilters: true }); + const result = parse(list({ showFilters: true })); expect(result.success, result.success ? '' : JSON.stringify(result.error.issues)).toBe(true); }); diff --git a/packages/types/src/__tests__/objectql-union-arms-7363.test.ts b/packages/types/src/__tests__/objectql-union-arms-7363.test.ts index 0b616708cd..a8f933076f 100644 --- a/packages/types/src/__tests__/objectql-union-arms-7363.test.ts +++ b/packages/types/src/__tests__/objectql-union-arms-7363.test.ts @@ -90,8 +90,11 @@ describe('objectui#7363 — the two objectui#6576 schemas are arms of the Object // authored arm left in objectui#10859 batch 6 // (`object-gantt-properties-bag-10859-b6.test.ts`), and `object-grid`, whose // authored arm left in objectui#11276's `object-grid` batch - // (`object-grid-properties-bag-11276.test.ts`). - expect(literals).toHaveLength(7); + // (`object-grid-properties-bag-11276.test.ts`), and `list-view`, whose + // authored page arm left in objectui#12670 + // (`list-view-properties-bag-12670.test.ts`). + expect(literals).toHaveLength(6); + expect(literals).not.toContain('list-view'); expect(literals).not.toContain('object-grid'); expect(literals).not.toContain('object-form'); expect(literals).not.toContain('object-map'); diff --git a/packages/types/src/__tests__/public-block-responsive-styles-10872.test.ts b/packages/types/src/__tests__/public-block-responsive-styles-10872.test.ts index 2fdb78c59b..aaa74d797c 100644 --- a/packages/types/src/__tests__/public-block-responsive-styles-10872.test.ts +++ b/packages/types/src/__tests__/public-block-responsive-styles-10872.test.ts @@ -145,6 +145,9 @@ const DECLARES_DATA_SOURCE: ReadonlySet = new Set([ // binding as its CHILD rows (objectstack#6953), so its arm declares it at // node level, beside its bag. 'record:related_list', + // objectui#12670: the authored page `list-view` arm, armed with the binding its + // flat mirror has declared since objectui#11070, at node level beside its bag. + 'list-view', ]); /** @@ -157,7 +160,8 @@ const DECLARES_DATA_SOURCE: ReadonlySet = new Set([ * record-source rule the same way (objectui#11276's `object-grid` batch). * `object-pivot` and `embeddable-form` (objectui#11440) REQUIRE their bag: its * registration-required members (the three cross-tab fields; `formId`) and a - * record source. Each node carries the smallest bag that satisfies its rule; + * record source. The page `list-view` (objectui#12670) keeps a record-source + * rule read in its bag, `properties.data`. Each node carries the smallest bag that satisfies its rule; * every other arm takes the bare node. */ const RECORD_SOURCE: Readonly>> = { @@ -167,6 +171,8 @@ const RECORD_SOURCE: Readonly>> = { 'object-grid': { properties: { objectName: 'task' } }, 'object-pivot': { properties: { objectName: 'deal', rowField: 'stage', columnField: 'owner', valueField: 'amount' } }, 'embeddable-form': { properties: { formId: 'contact-us', objectName: 'lead' } }, + // objectui#12670: the page `list-view` keeps a record-source rule, read in the bag: `data`. + 'list-view': { properties: { data: { provider: 'object', object: 'deal' } } }, }; /** The five envelope keys the spec declares and this batch leaves undeclared. */ diff --git a/packages/types/src/__tests__/registered-type-arms-10859-b2.test.ts b/packages/types/src/__tests__/registered-type-arms-10859-b2.test.ts index 57fa9eb69d..3951e2e211 100644 --- a/packages/types/src/__tests__/registered-type-arms-10859-b2.test.ts +++ b/packages/types/src/__tests__/registered-type-arms-10859-b2.test.ts @@ -364,7 +364,8 @@ describe('the spec-row arms read the row by reference (objectui#10859)', () => { // `object-chart` in objectui#11276 (`object-chart-properties-bag-11276.test.ts`), // `object-gantt` in batch 6 (`object-gantt-properties-bag-10859-b6.test.ts`), // `object-grid` in objectui#11276 (`object-grid-properties-bag-11276.test.ts`), - // `object-pivot` and `embeddable-form` in objectui#11440 (`passing-keys-arms-11440.test.ts`). + // `object-pivot` and `embeddable-form` in objectui#11440 (`passing-keys-arms-11440.test.ts`), + // the page `list-view` in objectui#12670 (`list-view-properties-bag-12670.test.ts`). expect(literals(ObjectQLPublicBlockComponentSchema)).toEqual([ 'object-metric', 'object-master-detail-form', @@ -376,6 +377,7 @@ describe('the spec-row arms read the row by reference (objectui#10859)', () => { 'object-grid', 'object-pivot', 'embeddable-form', + 'list-view', ]); expect(literals(DataDisplaySchema)).toContain('pivot'); }); diff --git a/packages/types/src/__tests__/spec-object-refinements-7715.test.ts b/packages/types/src/__tests__/spec-object-refinements-7715.test.ts index 2948bef76d..22fea364d1 100644 --- a/packages/types/src/__tests__/spec-object-refinements-7715.test.ts +++ b/packages/types/src/__tests__/spec-object-refinements-7715.test.ts @@ -42,6 +42,7 @@ import { PageSchema as SpecPageSchema, } from '@objectstack/spec/ui'; import { ListViewSchema, PageNodeSchema, safeValidateSchema } from '../zod/index.zod'; +import { asPageListView, bagPath } from './list-view-page-node'; import { DashboardWidgetSchema, GlobalFilterSchema } from '../zod/complex.zod'; /** The spec-shaped view: the spec requires `columns`, objectui does not. */ @@ -63,9 +64,12 @@ describe('objectui#7715 — tripwire: the ListView calendar-visualization refusa const spec = SpecListViewSchema.safeParse(specView(CALENDAR_OFFERED_NO_BLOCK)); const specIssue = spec.error!.issues.find((i) => i.path.join('.') === 'calendar')!; - for (const r of [ListViewSchema.safeParse(node(CALENDAR_OFFERED_NO_BLOCK)), safeValidateSchema(node(CALENDAR_OFFERED_NO_BLOCK))]) { + // The `ListView` component's props, and the page node the published door judges + // since objectui#12670 (its props in the `properties` bag, paths read relative to it). + const page = asPageListView(node(CALENDAR_OFFERED_NO_BLOCK)); + for (const r of [ListViewSchema.safeParse(node(CALENDAR_OFFERED_NO_BLOCK)), safeValidateSchema(page)]) { expect(r.success).toBe(false); - const issues = r.error!.issues.map((i) => ({ code: i.code, path: i.path.join('.'), message: i.message })); + const issues = r.error!.issues.map((i) => ({ code: i.code, path: bagPath(i.path), message: i.message })); // The spec's issue, derived from the spec's own parse — not a restated // message — so this proves the spec's check runs here, not a copy of it. expect(issues).toContainEqual({ code: 'custom', path: 'calendar', message: specIssue.message }); @@ -78,7 +82,7 @@ describe('objectui#7715 — tripwire: the ListView calendar-visualization refusa for (const extra of [withBlock, noCalendar]) { expect(SpecListViewSchema.safeParse(specView(extra)).success).toBe(true); expect(ListViewSchema.safeParse(node(extra)).success).toBe(true); - expect(safeValidateSchema(node(extra)).success).toBe(true); + expect(safeValidateSchema(asPageListView(node(extra))).success).toBe(true); } }); }); @@ -350,6 +354,10 @@ const SITES = [ // `type` is `'chart'`, and on this node `type` is the component discriminator // (`'list-view'`; the view kind is the local `viewType`), so attached it would never // fire: not attachable, for the reason `checkListViewPageMount` was (objectui#12030). + // ⚠️ This row is the flat mirror, the `ListView` component's props. The authored page + // `list-view` node's bag (`ListViewPropsBag`, objectui#12670), whose `type` IS the spec's + // view kind, attaches both checks by reference; `list-view-properties-bag-12670.test.ts` + // measures each against the spec's own issue. notAttachable: ['checkListViewChartBinding'], }, ] as const; diff --git a/packages/types/src/__tests__/strict-face-read-keys-11070.test.ts b/packages/types/src/__tests__/strict-face-read-keys-11070.test.ts index f6292fce2f..0f49f4fbbd 100644 --- a/packages/types/src/__tests__/strict-face-read-keys-11070.test.ts +++ b/packages/types/src/__tests__/strict-face-read-keys-11070.test.ts @@ -229,7 +229,9 @@ describe('objectui#11070 — the declared read keys parse on the strict face', ( // props gate lets it on `element:number`) is a separate question from // whether the key is declared, and this block measures only the second. ['object-kanban', { objectName: 'task' }], - ['list-view', { objectName: 'task' }], + // objectui#12670: a page `list-view` takes its props in the bag and names its + // object in `data`; `objectName` is refused by name there. + ['list-view', { properties: { data: { provider: 'object', object: 'task' } } }], // objectui#10859 batch 6: `object-gantt` takes its props in the bag too. ['object-gantt', { properties: { objectName: 'task' } }], // objectui#10859 batch 5: `object-map` takes its props in the bag too. diff --git a/packages/types/src/__tests__/zod-mirror-parity.test.ts b/packages/types/src/__tests__/zod-mirror-parity.test.ts index 3e049d2334..9012800fdf 100644 --- a/packages/types/src/__tests__/zod-mirror-parity.test.ts +++ b/packages/types/src/__tests__/zod-mirror-parity.test.ts @@ -5035,6 +5035,12 @@ const EXCLUSIONS: Readonly> = { "registration-owned — `BaseSchema` + the `object-pivot` literal + `NODE_ENVELOPE` + a required `properties` bag of the registration's `inputs` (its cross-tab members ARE the `data-display.zod.ts#PivotTableSchema` mirror's own, by reference; `drillDown` is `DrillDownConfigSchema` with `mode` refused) + `flatPropRefusals` over that bag + the node's `dataSource` binding, which IS the spec's `ElementDataSourceSchema`, + the objectui#9256 `body` / `children` refusals; no TS declaration in this package restates the node (`@object-ui/plugin-dashboard`'s `ObjectPivotTableProps` is the component's prop type)", 'objectql.zod.ts#EmbeddableFormBlockSchema': "registration-owned — `BaseSchema` + the `embeddable-form` literal + `NODE_ENVELOPE` + a required `properties` bag of exactly the registration's `inputs` + `flatPropRefusals` over that bag + the node's `dataSource` binding, which IS the spec's `ElementDataSourceSchema`, + the objectui#9256 `body` / `children` refusals; no TS declaration in this package restates the node (`@object-ui/plugin-form`'s `EmbeddableFormConfig` is the component's config type)", + // objectui#12670 — the authored page `list-view` arm, built as `object-chart`'s + // above with NO spec row: its bag is the flat mirror's own members. Its TS twin + // is paired with that FLAT mirror (`ListViewSchema`), which stays the `ListView` + // component's props. + 'objectql.zod.ts#ListViewBlockSchema': + "mirror-owned BY REFERENCE — `BaseSchema` + the `list-view` literal + `NODE_ENVELOPE` + `properties`, whose members ARE the flat `objectql.zod.ts#ListViewSchema` mirror's own members (the same schema objects; `@objectstack/spec` has no `ComponentPropsMap['list-view']` row) with `type` as the spec's view kind and `objectName` / `viewType` refused by name, + `flatPropRefusals` over that bag (one `aliasKeyRefusal` per member but the spec page component's node-level keys, pointed at `properties.KEY`; the key set is read off the bag), + the mirror's `dataSource` binding by reference, + its five objectui#6124 runtime slots, declared as the mirror declares them, + the objectui#9256 `body` / `children` refusals; the TS declaration of the node's props, `ListViewSchema` (`../objectql.ts`), is the `ListView` component's props and is derived FROM the flat mirror, not paired with this arm", 'objectql.zod.ts#ObjectQLPublicBlockComponentSchema': "a union OVER the public-block arms above, not an object of its own — its members are checked individually", 'overlay.zod.ts#MenuItemSchema': diff --git a/packages/types/src/objectql.ts b/packages/types/src/objectql.ts index 389bc78bfa..18d0e1f38a 100644 --- a/packages/types/src/objectql.ts +++ b/packages/types/src/objectql.ts @@ -6324,7 +6324,10 @@ export interface ObjectDataTableSchema extends BaseSchema { * by `ObjectGanttBlockSchema`, and `ObjectGanttSchema` here is the node as * `ObjectGantt` reads it. It carries seven since objectui#11276's `object-grid` * batch: the authored `object-grid` node is armed by `ObjectGridBlockSchema`, - * and `ObjectGridSchema` here is the node as `ObjectGrid` reads it. + * and `ObjectGridSchema` here is the node as `ObjectGrid` reads it. It carries + * six since objectui#12670: the authored page `list-view` node is armed by + * `ListViewBlockSchema`, and `ListViewSchema` here is the `ListView` + * component's props. */ export type ObjectQLComponentSchema = | ObjectGridSchema From fadb698a7682070d8bcb1b10105f15b0e5d741a5 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 14:04:53 +0000 Subject: [PATCH 3/5] docs(types): the zod README and the changeset for the page list-view arm; plugin-list's read-door pins re-spelled (objectui#12670) The two `plugin-list` pins that judged a list view at the read door (`safeValidateSchema`) now hand it the page spelling of the same fixture; the render halves still render the `ListView` component's props. Claude-Session: https://claude.ai/code/session_01Vk15bz8xHtKGUfXfCm16h4 Co-authored-by: Claude --- .changeset/12670-list-view-page-face.md | 27 ++++++++++++++ ...istView.calendarAliasRefused-8355.test.tsx | 18 ++++++++-- ...ListView.strayGroupByRefused-8365.test.tsx | 35 ++++++++++++++----- packages/types/src/zod/README.md | 26 ++++++++++++-- 4 files changed, 93 insertions(+), 13 deletions(-) create mode 100644 .changeset/12670-list-view-page-face.md diff --git a/.changeset/12670-list-view-page-face.md b/.changeset/12670-list-view-page-face.md new file mode 100644 index 0000000000..5220c9ae3d --- /dev/null +++ b/.changeset/12670-list-view-page-face.md @@ -0,0 +1,27 @@ +--- +'@object-ui/types': minor +--- + +A page `list-view` node is judged in its `properties` bag, in `@objectstack/spec`'s list view spelling, and the retired `objectName` / `viewType` are refused by name (objectui#12670; ruling C on objectstack-ai/objectstack#22444). + +```json +{ + "type": "list-view", + "properties": { + "data": { "provider": "object", "object": "deal" }, + "type": "kanban", + "kanban": { "groupByField": "stage" } + } +} +``` + +- **zod (`@object-ui/types/zod`).** The `list-view` arm of `AnyComponentSchema` is the new `ListViewBlockSchema`, so `safeValidateSchema`, `StrictAnyComponentSchema` and `objectui validate` judge a page node by its bag. The bag's members are `ListViewSchema`'s own, by reference (the spec has no `ComponentPropsMap['list-view']` row), with the list kind as the bag's `type`, the spec's view-kind enum. The record source is `properties.data` or the node's `dataSource.object`; with neither, the node is refused with `RECORD_SOURCE_REQUIRED` at `properties.data`. The spec's `checkListViewCalendarVisualization` and `checkListViewChartBinding` run in the bag, so a `chart` list with no dataset binding is refused at `properties.chart`, as the spec's list view refuses it. +- **TypeScript.** `ListViewBlockNode` is exported: the authored page node, its bag closed. `ListViewSchema` (zod and TypeScript) is unchanged and stays the `ListView` component's props, `objectName` / `viewType` included; it is no longer an arm of the zod union. + +BREAKING (`@object-ui/types`), for a page `list-view` node in the old spelling: + +- `objectName`, flat or in `properties`, is refused by name. Write `properties.data: { "provider": "object", "object": "…" }`, or bind the object with the node's `dataSource.object`. +- `viewType`, flat or in `properties`, is refused by name. Write `properties.type`. +- Any other list view prop written flat on the node (`columns`, `filter`, `kanban`, …) is refused by name. Move it into `properties`, as `@objectstack/spec`'s own page component already requires. + +The ruling carries stored page nodes in the old spelling by an ADR-0087 conversion in `@objectstack/spec` (objectstack-ai/objectstack#22444), never by a fallback read in objectui. (The bump is `minor` by this repo's release model: objectui's major follows the `@objectstack` family major, and its own breaking changes ship as `minor` with the breaking semantics stated here.) diff --git a/packages/plugin-list/src/__tests__/ListView.calendarAliasRefused-8355.test.tsx b/packages/plugin-list/src/__tests__/ListView.calendarAliasRefused-8355.test.tsx index 3e3db17036..12605bea55 100644 --- a/packages/plugin-list/src/__tests__/ListView.calendarAliasRefused-8355.test.tsx +++ b/packages/plugin-list/src/__tests__/ListView.calendarAliasRefused-8355.test.tsx @@ -124,8 +124,20 @@ const CANONICAL_VIEW = { calendar: { startDateField: 'kickoff', endDateField: 'wrapup', titleField: 'name' }, } as const; -const issuesAt = (doc: unknown, suffix: string) => { - const result = safeValidateSchema(doc); +/** + * The read door judges the AUTHORED page node (objectui#12670, ruling C on + * objectstack-ai/objectstack#22444): its props in the `properties` bag, its + * object named in `data`, its kind as the bag's `type`. The fixtures above are + * the `ListView` component's props, which half 1 renders; half 2 hands the + * door the same list view in the page spelling. + */ +const asPageNode = ({ type, objectName, viewType, ...props }: Record) => ({ + type, + properties: { data: { provider: 'object', object: objectName }, type: viewType, ...props }, +}); + +const issuesAt = (doc: Record, suffix: string) => { + const result = safeValidateSchema(asPageNode(doc)); if (result.success) return []; return result.error.issues.filter((i) => i.path.join('.').endsWith(suffix)); }; @@ -208,7 +220,7 @@ describe('objectui#8355 · half 2 — the retired spellings are REFUSED at the r }); it('DARK CONTROL: the canonical fixture parses GREEN through the same door', () => { - const result = safeValidateSchema(CANONICAL_VIEW); + const result = safeValidateSchema(asPageNode(CANONICAL_VIEW)); expect(result.success, result.success ? '' : JSON.stringify(result.error.issues)).toBe(true); }); }); diff --git a/packages/plugin-list/src/__tests__/ListView.strayGroupByRefused-8365.test.tsx b/packages/plugin-list/src/__tests__/ListView.strayGroupByRefused-8365.test.tsx index bc98a58685..19f3afc5b2 100644 --- a/packages/plugin-list/src/__tests__/ListView.strayGroupByRefused-8365.test.tsx +++ b/packages/plugin-list/src/__tests__/ListView.strayGroupByRefused-8365.test.tsx @@ -172,11 +172,30 @@ const DISTINGUISHING_VIEW = { kanban: { groupByField: CANONICAL }, } as const; +/** + * The read door judges the AUTHORED page node (objectui#12670, ruling C on + * objectstack-ai/objectstack#22444): its props in the `properties` bag, its + * object named in `data`, its kind as the bag's `type`. The fixture above is + * the `ListView` component's props, which the lane half renders; the refusal + * half hands the door the same list view in the page spelling, and reads each + * issue path relative to the bag. + */ +const asPageNode = ({ type, objectName, viewType, ...props }: Record) => ({ + type, + properties: { + data: { provider: 'object', object: objectName }, + ...(viewType === undefined ? {} : { type: viewType }), + ...props, + }, +}); +const readDoor = (doc: Record) => safeValidateSchema(asPageNode(doc)); +const bagPath = (path: readonly PropertyKey[]) => (path[0] === 'properties' ? path.slice(1) : path).join('.'); + /** Issues the read door raises at `kanban.groupBy`, if any. */ -const refusalIssuesFor = (doc: unknown) => { - const result = safeValidateSchema(doc); +const refusalIssuesFor = (doc: Record) => { + const result = readDoor(doc); if (result.success) return []; - return result.error.issues.filter((i) => i.path.join('.').endsWith('kanban.groupBy')); + return result.error.issues.filter((i) => bagPath(i.path).endsWith('kanban.groupBy')); }; beforeEach(() => { @@ -231,7 +250,7 @@ describe('objectui#8365 · half 2 — the stray key is REFUSED at the read door' // nests the declared slot's own block, so the arm is a member there too: // `invalid_type`, the same code and ONE message in both nestings. expect(issues[0].code).toBe('invalid_type'); - expect(issues[0].path.join('.')).toBe('options.kanban.groupBy'); + expect(bagPath(issues[0].path)).toBe('options.kanban.groupBy'); // The lead sentence is the one the protocol's own `strictObject({ aliases })` // answers with, so an author meets ONE remedy on both faces. expect(issues[0].message).toContain('Unrecognized key(s) on this kanban configuration: `groupBy`.'); @@ -248,7 +267,7 @@ describe('objectui#8365 · half 2 — the stray key is REFUSED at the read door' // `aliasKeyRefusal` is a `z.never()` ARM here, so the envelope is // `invalid_type` at the key's own path. expect(declared[0].code).toBe('invalid_type'); - expect(declared[0].path.join('.')).toBe('kanban.groupBy'); + expect(bagPath(declared[0].path)).toBe('kanban.groupBy'); // ⭐ ONE STRING, BOTH NESTINGS — the assertion that keeps the check and the // arm from drifting into two dialects of one remedy. const legacy = refusalIssuesFor(DISTINGUISHING_VIEW); @@ -256,7 +275,7 @@ describe('objectui#8365 · half 2 — the stray key is REFUSED at the read door' }); it('DARK CONTROL: the canonical config alone parses GREEN through the same door', () => { - const result = safeValidateSchema({ + const result = readDoor({ type: 'list-view', objectName: OBJECT, kanban: { groupByField: CANONICAL }, @@ -269,7 +288,7 @@ describe('objectui#8365 · half 2 — the stray key is REFUSED at the read door' // own path with `invalid_type`; a key no arm names is refused at `kanban` // with the spec slot's `unrecognized_keys` — two codes, so the arm cannot be // read as a plain strictness refusal. - const result = safeValidateSchema({ + const result = readDoor({ type: 'list-view', objectName: OBJECT, kanban: { groupByField: CANONICAL, zzzBogusKey: 'refused now' }, @@ -277,7 +296,7 @@ describe('objectui#8365 · half 2 — the stray key is REFUSED at the read door' expect(result.success).toBe(false); const issue = result.success ? undefined - : (result.error.issues.find((i) => i.path.join('.') === 'kanban') as { code?: string; keys?: string[] } | undefined); + : (result.error.issues.find((i) => bagPath(i.path) === 'kanban') as { code?: string; keys?: string[] } | undefined); expect(issue?.code).toBe('unrecognized_keys'); expect(issue?.keys).toEqual(['zzzBogusKey']); }); diff --git a/packages/types/src/zod/README.md b/packages/types/src/zod/README.md index 564fb81004..2da6def629 100644 --- a/packages/types/src/zod/README.md +++ b/packages/types/src/zod/README.md @@ -31,6 +31,27 @@ is `z.infer & ListViewRuntimeProps`. A drift-guard test (`__tests__/list-view-spec-parity.test.ts`) fails if the spec grows a field objectui hasn't triaged. **Do not hand-add spec-owned fields here** — import them from the spec. +**`ListViewSchema` is the `ListView` component's props; the authored page node is +`ListViewBlockSchema` (objectui#12670).** Ruling C on objectstack-ai/objectstack#22444 +converges the page carrier on the spec spelling, so `AnyComponentSchema` (and +`safeValidateSchema`, `StrictAnyComponentSchema` and `objectui validate` with it) judges a +page `list-view` node by its `properties` bag: the object named in `data` +(`{ "provider": "object", "object": "…" }`), or in the node's `dataSource.object`, and the +list kind as the bag's `type`. `objectName` and `viewType` are refused by name there, flat +or in the bag, with what to write instead. `ListViewSchema` itself, `objectName` / +`viewType` and all, is unchanged, and is no longer an arm of the union. + +```json +{ + "type": "list-view", + "properties": { + "data": { "provider": "object", "object": "deal" }, + "type": "kanban", + "kanban": { "groupByField": "stage" } + } +} +``` + ### Per-view-type configs are the spec's list-view slots, by reference — #2231, objectui#6152 `kanban` / `calendar` / `gallery` / `timeline` on `ListViewSchema` are each the spec @@ -262,7 +283,7 @@ function validateComponent(config: unknown) { - `KanbanSchema`, `CalendarViewSchema` - `FilterBuilderSchema`, `CarouselSchema`, `ChatbotSchema` -### ObjectQL Public Blocks (10) +### ObjectQL Public Blocks (11) The ADR-0080 public blocks `object-metric`, `object-master-detail-form`, `object-timeline`, `object-form`, `object-map`, `object-chart`, `object-gantt` and `object-grid`. Every arm's `properties` but `object-chart`'s is the block's `@objectstack/spec` `ComponentPropsMap` row, by reference; `object-chart` has no row (below). Each of those seven arms also declares the node's `dataSource` binding, the spec's `ElementDataSourceSchema`, which all seven registrations read through `ElementDataSourceGate`. None of the eight renderers reads a content channel, so every arm refuses `children` and `body` by name (objectui#9256). On all eight, a member of the row written flat on the node instead of in `properties` is refused by name, naming `properties.KEY`: `object-metric`, `object-master-detail-form`, `object-timeline` and `object-grid` through the public blocks' shared helper since objectui#10872 batch 10 (below), the other four through their own maps. `ObjectFormBlockSchema` is the authored `object-form` node (objectui#10859 batch 4): its props go in the `properties` bag, and each member of the row written flat on the node is refused by name, with a message naming `properties.KEY`. `ObjectFormSchema`, the flat mirror of the TypeScript `ObjectFormSchema`, stays exported as the node as `ObjectForm` reads it after `SchemaRenderer` hoists the bag; it is no longer an arm of `AnyComponentSchema`. `ObjectMapBlockSchema` is the authored `object-map` node, built the same way (objectui#10859 batch 5). The flat mirror's `locationField` and `titleField` are refused by name too, pointing at `properties.map.KEY`. The flat mirror's record-source rule stays, read in the bag: the node needs one of `properties.data`, `properties.staticData` or `properties.objectName`, or the node's `dataSource` binding. `ObjectMapSchema`, the flat mirror of the TypeScript `ObjectMapSchema`, stays exported as the node as `ObjectMap` reads it; it is no longer an arm of `AnyComponentSchema`. @@ -270,8 +291,9 @@ The ADR-0080 public blocks `object-metric`, `object-master-detail-form`, `object `ObjectGanttBlockSchema` is the authored `object-gantt` node, built the way `ObjectMapBlockSchema` is (objectui#10859 batch 6). Each member of the row written flat on the node is refused by name, naming `properties.KEY`, except `label`, which `@objectstack/spec`'s page component also declares on the node itself. The flat mirror's `GanttConfig` keys (`startDateField`, `viewMode`, …) and its `dependencyField` alias are refused by name too, pointing at the bag's `gantt` block, `properties.gantt.KEY`. The record-source rule stays, read in the bag, as on `object-map`. `ObjectGanttSchema`, the flat mirror of the TypeScript `ObjectGanttSchema`, stays exported as the node as `ObjectGantt` reads it, and as the list and object views compose it; it is no longer an arm of `AnyComponentSchema`. `ObjectGridBlockSchema` is the authored `object-grid` node (objectui#11276's `object-grid` batch), built the way `ObjectGanttBlockSchema` is from the spec's `ComponentPropsMap['object-grid']` row. `label` stays on the node, as on `object-gantt`, and a flat `defaultSort` keeps the row's own retirement. The flat mirror's retirements of keys the row does not declare (`operators`, `rowSpecActions`, `bulkSpecActions`, `name`, `placeholder`, `showFilters`) and its content-channel refusals ride onto the arm by reference, and its `onNavigate` runtime slot is declared as the mirror declares it. The record-source rule stays, read in the bag: the node needs `properties.objectName`, or the node's `dataSource` binding. `ObjectGridSchema`, the flat mirror of the TypeScript `ObjectGridSchema`, stays exported as the node as `ObjectGrid` reads it, and still builds the `object-view` `table` slot; it is no longer an arm of `AnyComponentSchema`. `ObjectPivotBlockSchema` and `EmbeddableFormBlockSchema` (objectui#11440) are the authored `object-pivot` and `embeddable-form` nodes, two more blocks with no `ComponentPropsMap` row. Each bag is built from the block's registration `inputs`, and it is required, because those inputs include required members: `rowField`, `columnField` and `valueField` on the pivot, `formId` on the form. `objectName` is required unless the node's `dataSource` binding names the object. The pivot's cross-tab members are the `PivotTableSchema` mirror's own, by reference; its `drillDown` refuses `mode` by name (a pivot always drills through), and its retired `dataProvider` is refused by name. A member written flat is refused by name toward `properties.KEY`, the node declares the `dataSource` binding, and both content channels are refused. +`ListViewBlockSchema` is the authored page `list-view` node (objectui#12670, ruling C on objectstack-ai/objectstack#22444), built the way `ObjectChartBlockSchema` is: `@objectstack/spec` has no `ComponentPropsMap['list-view']` row, so the bag's members are the flat `ListViewSchema` mirror's own, by reference, with three differences the ruling makes: the bag's `type` is the list kind (the spec's own view-kind enum), and `objectName` and `viewType` are refused by name, pointing at `properties.data` and `properties.type`, in the bag and written flat alike. The spec's list view checks run in the bag, by reference: `checkListViewCalendarVisualization`, and `checkListViewChartBinding`, which the flat mirror cannot attach because its kind is `viewType`; so does objectui's dataset-chart filter refusal. The record source is `properties.data` or the node's `dataSource` binding naming the object (`RECORD_SOURCE_REQUIRED` at `properties.data` otherwise); `properties.objectName` is not one. Every other list view member written flat is refused by name toward `properties.KEY`; the five runtime slots `ListView` reads (`onAddRecord`, `onBulkAction`, `onDensityChange`, `onNavigate`, `onPageSizeChange`) keep their own refusal, and both content channels are refused. `ListViewSchema` stays exported as the `ListView` component's props; it is no longer an arm of `AnyComponentSchema`. - `ObjectMetricBlockSchema`, `ObjectMasterDetailFormBlockSchema`, `ObjectTimelineBlockSchema`, `ObjectFormBlockSchema`, `ObjectMapBlockSchema`, `ObjectChartBlockSchema`, `ObjectGanttBlockSchema`, `ObjectGridBlockSchema` -- `ObjectPivotBlockSchema`, `EmbeddableFormBlockSchema` +- `ObjectPivotBlockSchema`, `EmbeddableFormBlockSchema`, `ListViewBlockSchema` ### AI Components (3) - `AIFormAssistSchema`, `AIRecommendationsSchema`, `NLQuerySchema` From ddc1ca994475e57a138c0e3ff81f0d0b89e82b54 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 15:08:34 +0000 Subject: [PATCH 4/5] docs(plugin-list): the page list-view fence is a whole document now that the face judges it (objectui#12670) The strict authoring face accepts the page spelling of a `list-view` node, so `check:doc-json` reported the fence's `doc-json: fragment` declaration as stale. The marker is gone and the fence is judged whole; the paragraph that said `objectui validate` does not judge the page spelling now says what it judges. The kind stays in prose. Claude-Session: https://claude.ai/code/session_01Vk15bz8xHtKGUfXfCm16h4 Co-authored-by: Claude --- packages/plugin-list/README.md | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/plugin-list/README.md b/packages/plugin-list/README.md index 2d41a4269b..89897c7031 100644 --- a/packages/plugin-list/README.md +++ b/packages/plugin-list/README.md @@ -356,7 +356,6 @@ list it is. On a metadata page both sit in the node's `properties`. The node's own `type` stays `list-view`, because it is the key the page renderer looks the component up by: - ```json { "type": "list-view", @@ -384,8 +383,13 @@ the old spelling is the platform's job (an ADR-0087 conversion in React props (the `ListViewSchema` examples above) are a separate surface and still spell the two `objectName` and `viewType`. -`objectui validate` does not judge the page spelling yet: its `list-view` face -still reads `objectName` and counts no `data` as a record source. +`objectui validate` judges a page `list-view` node in this spelling. Its record +source is `properties.data` or a bound `dataSource.object`, and a node with +neither is refused at `properties.data`. `objectName` and `viewType` are refused +by name, on the node or in `properties`, each naming what to write instead +(`properties.data`, `properties.type`). A `properties.type` that names no kind a +list view draws is refused, and so is a list view prop such as `columns` written +on the node itself rather than in `properties`. ## Page binding — `dataSource` (referencing a saved view by name) From 5bb1b1d1594f2a3e70e019a417130a8823b46a0f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 15:10:16 +0000 Subject: [PATCH 5/5] docs(types): name ObjectQLComponentSchema's lost list-view arm in the changeset, and list every exported block node type (objectui#12670) The changeset now spells what the precedent entries spelled for the grid, form, map, gantt and chart arms: `ListViewBlockSchema` reaches `AnyComponentSchema` through `ObjectQLPublicBlockComponentSchema`, and `ListViewSchema` leaves `ObjectQLComponentSchema`, so a direct `ObjectQLComponentSchema.safeParse` of a `list-view` node fails on the discriminator. The BREAKING list names that consumer and where to parse instead. Frontmatter unchanged. The types README's by-name list of ObjectQL block node types gains `ListViewBlockNode`, and the two it already omitted, `ObjectPivotBlockNode` and `EmbeddableFormBlockNode`. Claude-Session: https://claude.ai/code/session_01Vk15bz8xHtKGUfXfCm16h4 Co-authored-by: Claude --- .changeset/12670-list-view-page-face.md | 4 +++- packages/types/README.md | 5 +++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/.changeset/12670-list-view-page-face.md b/.changeset/12670-list-view-page-face.md index 5220c9ae3d..bebd8d0727 100644 --- a/.changeset/12670-list-view-page-face.md +++ b/.changeset/12670-list-view-page-face.md @@ -16,12 +16,14 @@ A page `list-view` node is judged in its `properties` bag, in `@objectstack/spec ``` - **zod (`@object-ui/types/zod`).** The `list-view` arm of `AnyComponentSchema` is the new `ListViewBlockSchema`, so `safeValidateSchema`, `StrictAnyComponentSchema` and `objectui validate` judge a page node by its bag. The bag's members are `ListViewSchema`'s own, by reference (the spec has no `ComponentPropsMap['list-view']` row), with the list kind as the bag's `type`, the spec's view-kind enum. The record source is `properties.data` or the node's `dataSource.object`; with neither, the node is refused with `RECORD_SOURCE_REQUIRED` at `properties.data`. The spec's `checkListViewCalendarVisualization` and `checkListViewChartBinding` run in the bag, so a `chart` list with no dataset binding is refused at `properties.chart`, as the spec's list view refuses it. -- **TypeScript.** `ListViewBlockNode` is exported: the authored page node, its bag closed. `ListViewSchema` (zod and TypeScript) is unchanged and stays the `ListView` component's props, `objectName` / `viewType` included; it is no longer an arm of the zod union. +- `ListViewBlockSchema` reaches `AnyComponentSchema` through `ObjectQLPublicBlockComponentSchema`, the public block union. `ListViewSchema` leaves `ObjectQLComponentSchema`, so `ObjectQLComponentSchema.safeParse` of a `list-view` node now fails on the discriminator. +- **TypeScript.** `ListViewBlockNode` is exported: the authored page node, its bag closed. `ListViewSchema` (zod and TypeScript) is unchanged and stays the `ListView` component's props, `objectName` / `viewType` included; it is no longer an arm of the zod `ObjectQLComponentSchema` union. BREAKING (`@object-ui/types`), for a page `list-view` node in the old spelling: - `objectName`, flat or in `properties`, is refused by name. Write `properties.data: { "provider": "object", "object": "…" }`, or bind the object with the node's `dataSource.object`. - `viewType`, flat or in `properties`, is refused by name. Write `properties.type`. - Any other list view prop written flat on the node (`columns`, `filter`, `kanban`, …) is refused by name. Move it into `properties`, as `@objectstack/spec`'s own page component already requires. +- Code that parses with `ObjectQLComponentSchema` directly no longer accepts any `list-view` node. Parse a page node with `AnyComponentSchema`, whose `list-view` arm is `ListViewBlockSchema`, and the `ListView` component's props with `ListViewSchema`, which is still exported. The ruling carries stored page nodes in the old spelling by an ADR-0087 conversion in `@objectstack/spec` (objectstack-ai/objectstack#22444), never by a fallback read in objectui. (The bump is `minor` by this repo's release model: objectui's major follows the `@objectstack` family major, and its own breaking changes ship as `minor` with the breaking semantics stated here.) diff --git a/packages/types/README.md b/packages/types/README.md index 68d40c9057..fa6e9ec56e 100644 --- a/packages/types/README.md +++ b/packages/types/README.md @@ -136,8 +136,9 @@ restated by hand: `ObjectQLPublicBlockComponentSchema`, each the arm's own input. Each is also exported by name: `ObjectGridBlockNode`, `ObjectFormBlockNode`, `ObjectMapBlockNode`, `ObjectGanttBlockNode`, `ObjectChartBlockNode`, - `ObjectMetricBlockNode`, `ObjectTimelineBlockNode` and - `ObjectMasterDetailFormBlockNode`. + `ObjectMetricBlockNode`, `ObjectTimelineBlockNode`, + `ObjectMasterDetailFormBlockNode`, `ObjectPivotBlockNode`, + `EmbeddableFormBlockNode` and `ListViewBlockNode`. - `FlexBlockNode`: an authored `flex` node, its layout props and its child list in the bag. `flex` and `object-chart` have no `ComponentPropsMap` row, so each bag is the flat type's own members (`FlexSchema`,