diff --git a/.changeset/11276-object-chart-properties-bag.md b/.changeset/11276-object-chart-properties-bag.md new file mode 100644 index 0000000000..7de0a8b9c3 --- /dev/null +++ b/.changeset/11276-object-chart-properties-bag.md @@ -0,0 +1,29 @@ +--- +'@object-ui/types': minor +--- + +feat(types): an authored `object-chart` takes its props in the `properties` bag; the flat spelling is refused by name (objectui#11276) + +**BREAKING (authoring):** an `object-chart` node that writes its props on the node itself is now refused by `safeValidateSchema`, `validateSchema` and the strict authoring face, and so by `objectui validate`. Move each prop into the node's `properties` bag: + +```json +{ "type": "object-chart", "properties": { "chartType": "bar", "dataset": "task_metrics", "dimensions": ["status"], "values": ["task_count"] } } +``` + +Nothing changes at render time: `SchemaRenderer` hoists every `properties` key onto the node before `ObjectChart` runs, so the bag node draws what the flat one drew, and a node built in code (`ObjectView`, `ListView`, the dashboard renderers and the react-page wrapper all build a flat `object-chart` node) keeps working with its flat keys. + +**Clause-②: yes (narrowing)** — the accept set narrows (a flat `object-chart` node that parsed is refused) and widens (a bag node that was refused parses). Released as `minor` under the objectui narrowing rule, with this banner. + +**Why.** `@objectstack/spec`'s strict `PageComponentSchema` refuses a prop written on any page component itself as mis-layered (ADR-0089 D3a): `properties` is the only home of a component's props. The arm here was the flat mirror of the TypeScript `ObjectChartSchema`, so the two validators disagreed both ways: the objectstack showcase's command-center charts, written in the bag, were refused (the flat mirror found no chart family on the node), and the flat node `os validate` refuses was accepted. + +**What changed, in observable terms.** + +- `@object-ui/types/zod` exports `ObjectChartBlockSchema`, built the way `ObjectFormBlockSchema` and `ObjectMapBlockSchema` are: `BaseSchema`, the `object-chart` literal, the node-level `responsiveStyles`, and `properties`. One difference: `@objectstack/spec` has no `ComponentPropsMap['object-chart']` row, so the spec's page component takes any bag on this type, and no row is invented here. The bag's members are the members `ObjectChartSchema` already declares (beyond the node base), the same schema objects, so their value checks and spec bindings (`aggregate`, `series`, `drillDown`, `xAxis`, `yAxis`) judge the bag exactly as they judged the flat node. +- Each of those members written flat on the node is refused on both faces, with a message that names its bag member (`Did you mean \`chartType\` → \`properties.chartType\`?`). The refusal set is read off the bag. The three list-view spellings retired by objectui#10608 (`xAxisField`, `yAxisFields`, `aggregation`) keep their retirement message, on the node and in the bag. +- The chart-family floor stays, read in the bag: the node needs `properties.chartType` (or `properties.specType`). A node with neither is refused at `properties.chartType`, as the flat node was refused at `chartType`. The spec has no such rule. +- A key the chart does not declare stays as before, one layer down: unjudged by the tolerant face, refused by name by the strict face. `BaseSchema`'s keys (`className`, `hidden`, …) stay on the node. `dataSource` stays undeclared on this node, as objectui#11070 left it. `body` / `children` stay refused (objectui#9256). +- `ObjectChartBlockSchema` reaches `AnyComponentSchema` through `ObjectQLPublicBlockComponentSchema`. `ObjectChartSchema` leaves `ObjectQLComponentSchema`. + +**The same release's `.changeset/10518-object-chart-y-axis-declared.md`, `.changeset/10608-object-chart-legacy-axis-keys-retired.md`, `.changeset/10770-object-chart-react-tier-node.md` and `.changeset/10872-flat-arm-responsive-styles.md`** describe what the validators say about an `object-chart` written flat. On an authored node those readings now happen in the bag: a malformed `xAxis` is refused at `properties.xAxis`, a retired key at `properties.KEY`, a missing family at `properties.chartType`; `responsiveStyles` stays node-level and is judged there as they say. The flat mirror they name still behaves as they say. + +**What did not move.** The TypeScript `ObjectChartSchema` and its zod mirror `ObjectChartSchema` stay published and unchanged in shape. They are the node as `ObjectChart` reads it after the hoist, and as code composes it or hands it to `` directly. diff --git a/packages/types/src/__tests__/content-channel-e3-residual-9256.test.ts b/packages/types/src/__tests__/content-channel-e3-residual-9256.test.ts index a653c62db7..66e33aadf7 100644 --- a/packages/types/src/__tests__/content-channel-e3-residual-9256.test.ts +++ b/packages/types/src/__tests__/content-channel-e3-residual-9256.test.ts @@ -126,6 +126,8 @@ const ROWS: ReadonlyArray>> = { 'object-form': { properties: { objectName: 'account', mode: 'create' } }, 'object-map': { properties: { objectName: 'account' } }, + 'object-chart': { properties: { objectName: 'account', chartType: 'bar' } }, }; const CHANNELS = ['body', 'children'] as const; diff --git a/packages/types/src/__tests__/flat-arm-responsive-styles-10872.test.ts b/packages/types/src/__tests__/flat-arm-responsive-styles-10872.test.ts index 613dc4e5d7..749155dd65 100644 --- a/packages/types/src/__tests__/flat-arm-responsive-styles-10872.test.ts +++ b/packages/types/src/__tests__/flat-arm-responsive-styles-10872.test.ts @@ -31,6 +31,13 @@ * * The three arms spread the ONE shared fragment the public-block arms spread, * `PAGE_COMPONENT_ENVELOPE`, so the member is the same object on all 33 arms. + * + * ⚠️ objectui#11276 moved `object-chart`'s AUTHORED arm into the public-block + * set (`ObjectChartBlockSchema`, its props in the `properties` bag), so the + * population below is the two arms the node union still selects a flat mirror + * for. The flat `ObjectChartSchema` keeps the shared member as the post-hoist + * reading, and the authored chart arm is pinned with the other public blocks in + * `./public-block-responsive-styles-10872.test.ts`. * The key is NOT declared on `BaseSchema` and NOT on any arm without a measured * producer: `grid`, `stack` and `container` below are the control, and the * whole arm list is read at run time so an arm that gains the member silently @@ -122,14 +129,16 @@ function armsByLiteral(): Map { } /** - * The three arms this batch widens: the arms outside the public-block set that - * a producer writes `responsiveStyles` on (objectui#10872's batch-9 pull). NAMED, - * not derived, because the population is a measurement of producers. + * The arms this batch widens that the node union still selects: the arms + * outside the public-block set that a producer writes `responsiveStyles` on + * (objectui#10872's batch-9 pull). NAMED, not derived, because the population + * is a measurement of producers. `object-chart` was the third until + * objectui#11276 moved its authored arm into the public-block set; its flat + * mirror keeps the member (the post-hoist row below). */ const FLAT_ARMS = [ ['flex', FlexSchema], ['object-grid', ObjectGridSchema], - ['object-chart', ObjectChartSchema], ] as const; /** Arms with no producer: the strict face still refuses the key on them. */ @@ -161,15 +170,6 @@ const VALID_NODES = { columns: ['title', 'status'], responsiveStyles: { large: { minWidth: '0', display: 'block' } }, }, - 'object-chart': { - type: 'object-chart', - id: 'cc_status_c', - dataset: 'showcase_task_metrics', - dimensions: ['status'], - values: ['task_count'], - chartType: 'bar', - responsiveStyles: { large: { width: '100%', minWidth: '0' }, xsmall: { display: 'none' } }, - }, } as const; /** Values the spec refuses: a breakpoint it does not have, a non-object, a non-CSS value. */ @@ -220,13 +220,20 @@ describe('the population (objectui#10872 batch 9)', () => { }); }); -describe('`responsiveStyles` is declared on the three arms, from the public blocks\' fragment (objectui#10872 batch 9)', () => { +describe('`responsiveStyles` is declared on the batch-9 arms, from the public blocks\' fragment (objectui#10872 batch 9)', () => { it.each(FLAT_ARMS)('%s carries the member, and it is the SAME object the public-block arms carry', (_type, arm) => { const shared = PageSectionBlockSchema.shape.responsiveStyles; expect(shared).toBeDefined(); expect(arm.shape.responsiveStyles).toBe(shared); }); + it('the flat `ObjectChartSchema` mirror keeps the SAME member as the post-hoist reading (objectui#11276)', () => { + // Its authored arm left the node union for the public-block set; the + // mirror is the node as `ObjectChart` reads it, and it still declares the key. + expect(ObjectChartSchema.shape.responsiveStyles).toBe(PageSectionBlockSchema.shape.responsiveStyles); + expect(armsByLiteral().get('object-chart')).not.toBe(ObjectChartSchema); + }); + it('the member is the spec\'s own map: the same breakpoints, each the spec\'s own value schema', () => { const member = (FlexSchema.shape.responsiveStyles as z.ZodOptional).unwrap() as z.ZodObject; const spec = SpecResponsiveStylesSchema as unknown as z.ZodObject; @@ -234,7 +241,7 @@ describe('`responsiveStyles` is declared on the three arms, from the public bloc for (const key of Object.keys(spec.shape)) expect(member.shape[key]).toBe(spec.shape[key]); }); - it('exactly the public-block arms and these three declare it: no other arm, and not `BaseSchema`', () => { + it('exactly the public-block arms and these flat arms declare it: no other arm, and not `BaseSchema`', () => { expect(Object.keys(BaseSchema.shape)).not.toContain('responsiveStyles'); const publicBlocks = [ ...optionsOf(PublicBlockComponentSchema), @@ -282,7 +289,7 @@ describe('an invalid `responsiveStyles` is refused on BOTH faces, with the spec\ }); }); -describe('the other five envelope keys stay undeclared on the three arms (objectui#10872 batch 9)', () => { +describe('the other five envelope keys stay undeclared on the batch-9 arms (objectui#10872 batch 9)', () => { it('no arm gained any of them: `dataSource` only where objectui#11070 declared it', () => { const declared = FLAT_ARMS.flatMap(([type, arm]) => UNDECLARED_ENVELOPE.filter((key) => key in arm.shape).map((key) => `${type}#${key}`), diff --git a/packages/types/src/__tests__/object-chart-axis-config-10518.test.ts b/packages/types/src/__tests__/object-chart-axis-config-10518.test.ts index 64c570cfa7..f7b075c34f 100644 --- a/packages/types/src/__tests__/object-chart-axis-config-10518.test.ts +++ b/packages/types/src/__tests__/object-chart-axis-config-10518.test.ts @@ -73,6 +73,13 @@ import { safeValidateSchema } from '../zod/index.zod'; const CHART = { type: 'object-chart', chartType: 'bar' } as const; const chart = (extra: Record) => ({ ...CHART, ...extra }); +/** + * The AUTHORED spelling of `chart(extra)` (objectui#11276): an authored + * `object-chart` takes its props in the `properties` bag, and the public door + * judges that arm, so every `safeValidateSchema` row below hands it this. The + * mirror rows keep `chart(extra)`, the node as `ObjectChart` reads it. + */ +const authored = (extra: Record) => ({ type: 'object-chart', properties: { chartType: 'bar', ...extra } }); const parsed = (doc: unknown) => { const r = ObjectChartMirror.safeParse(doc); @@ -124,6 +131,12 @@ const SHOWCASE_NODE = { yAxis: [{ field: 'task_count', stepSize: 1 }], } as const; +/** The same producer node as the producer WRITES it — its props in the bag (objectui#11276). */ +const SHOWCASE_AUTHORED = (() => { + const { id, type, responsiveStyles, ...props } = SHOWCASE_NODE; + return { id, type, responsiveStyles, properties: props }; +})(); + type Shape = Record; const mirrorShape = (ObjectChartMirror as unknown as { shape: Shape }).shape; @@ -200,7 +213,7 @@ describe('objectui#10518 (b) — LIT CONTROL: the showcase producer\'s node pars }); it('through `safeValidateSchema`, the door `objectui validate` / `objectui check` run', () => { - expect(safeValidateSchema(SHOWCASE_NODE).success).toBe(true); + expect(safeValidateSchema(SHOWCASE_AUTHORED).success).toBe(true); }); }); @@ -220,14 +233,14 @@ describe('objectui#10518 (c) — a malformed `yAxis` entry is refused at its own }); it('the refusal reaches the public door — `safeValidateSchema` refuses the node', () => { - expect(safeValidateSchema(chart({ yAxis: [{ field: 'task_count', stepSize: 'big' }] })).success).toBe(false); + expect(safeValidateSchema(authored({ yAxis: [{ field: 'task_count', stepSize: 'big' }] })).success).toBe(false); }); /* CONTROL for every refusal above: the same documents minus the defect parse. */ it('CONTROL — the same entries, minus the defect, are accepted', () => { expect(() => parsed(chart({ yAxis: [{ field: 'task_count', stepSize: 2 }] }))).not.toThrow(); expect(() => parsed(chart({ yAxis: [{ field: 'task_count', position: 'right', logarithmic: true }] }))).not.toThrow(); - expect(safeValidateSchema(chart({ yAxis: [{ field: 'task_count', stepSize: 2 }] })).success).toBe(true); + expect(safeValidateSchema(authored({ yAxis: [{ field: 'task_count', stepSize: 2 }] })).success).toBe(true); }); }); @@ -343,7 +356,7 @@ describe('objectui#10518 (g) — LIT CONTROL: the showcase `renewals-pipeline` a }); it('through `safeValidateSchema`, the door `objectui validate` / `objectui check` run', () => { - expect(safeValidateSchema(chart(RENEWALS_AXES)).success).toBe(true); + expect(safeValidateSchema(authored(RENEWALS_AXES)).success).toBe(true); }); }); @@ -363,7 +376,7 @@ describe('objectui#10518 (h) — a malformed `xAxis` object is refused at `xAxis }); it('the refusal reaches the public door — `safeValidateSchema` refuses the node', () => { - expect(safeValidateSchema(chart({ xAxis: { field: 'status', min: 'zero' } })).success).toBe(false); + expect(safeValidateSchema(authored({ xAxis: { field: 'status', min: 'zero' } })).success).toBe(false); }); /* CONTROL for every refusal above: the same objects, minus the defect, parse. */ @@ -388,7 +401,7 @@ describe('objectui#10518 (i) — `xAxis` is the spec object, and only that', () }); it('the bare string is refused at the public door too', () => { - expect(safeValidateSchema(chart({ xAxis: 'status' })).success).toBe(false); + expect(safeValidateSchema(authored({ xAxis: 'status' })).success).toBe(false); }); it('CONTROL — the same column as the spec object parses, and a malformed OBJECT is not told the remedy', () => { 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 0ba5fa7d55..b485159fce 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 @@ -65,6 +65,13 @@ import { ListViewSchema, safeValidateSchema } from '../zod/index.zod'; const CHART = { type: 'object-chart', chartType: 'bar' } as const; const chart = (extra: Record) => ({ ...CHART, ...extra }); +/** + * The AUTHORED node (objectui#11276): its props in the `properties` bag, the + * arm the public door judges. `authoredFlat` writes `extra` on that node + * itself. The mirror rows keep `chart(extra)`, the node as `ObjectChart` reads it. + */ +const authored = (extra: Record) => ({ type: 'object-chart', properties: { chartType: 'bar', ...extra } }); +const authoredFlat = (extra: Record) => ({ type: 'object-chart', properties: { chartType: 'bar' }, ...extra }); /** * The retired keys, each with the values an author would plausibly have written @@ -102,10 +109,17 @@ describe.each(RETIRED_KEYS)('objectui#10608 (a) — `%s` is RETIRED on `object-c it.each(values.map((v) => [JSON.stringify(v), v] as const))( 'refuses `%s` at the public door and on the mirror: ONE `invalid_type` issue at the key, naming the remedy', (_label, value) => { - for (const r of [safeValidateSchema(chart({ [key]: value })), ObjectChartMirror.safeParse(chart({ [key]: value }))]) { + // objectui#11276: at the door the key is retired in the authored node's + // bag AND written flat on it (the flat mirror's own tombstone, by + // reference, not a pointer at a bag member that is itself retired). + for (const [r, path] of [ + [safeValidateSchema(authored({ [key]: value })), ['properties', key]], + [safeValidateSchema(authoredFlat({ [key]: value })), [key]], + [ObjectChartMirror.safeParse(chart({ [key]: value })), [key]], + ] as const) { expect(r.success, `an authored \`${key}: ${JSON.stringify(value)}\` was ACCEPTED`).toBe(false); const issues = issuesOf(r); - expect(issues.map(({ code, path }) => ({ code, path }))).toEqual([{ code: 'invalid_type', path: [key] }]); + expect(issues.map(({ code, path }) => ({ code, path }))).toEqual([{ code: 'invalid_type', path }]); expect(issues[0]!.message).toContain(`\`${key}\``); expect(issues[0]!.message).toContain(remedy); } @@ -130,12 +144,12 @@ describe('objectui#10608 (b) — LIT CONTROL: each remedy parses on the same nod ['`yAxis: [{ field }]` for `yAxisFields`', { yAxis: [{ field: 'amount' }] }], ['`aggregate: { field, function, groupBy }` for `aggregation`', { objectName: 'task', aggregate: { field: 'amount', function: 'sum', groupBy: 'status' } }], ])('%s', (_label, extra) => { - expect(issuesOf(safeValidateSchema(chart(extra)))).toEqual([]); + expect(issuesOf(safeValidateSchema(authored(extra)))).toEqual([]); expect(issuesOf(ObjectChartMirror.safeParse(chart(extra)))).toEqual([]); }); it('all three remedies together, on one node', () => { - const node = chart({ + const node = authored({ objectName: 'task', aggregate: { field: 'amount', function: 'sum', groupBy: 'status' }, xAxis: { field: 'status' }, diff --git a/packages/types/src/__tests__/object-chart-properties-bag-11276.test.ts b/packages/types/src/__tests__/object-chart-properties-bag-11276.test.ts new file mode 100644 index 0000000000..f752d85091 --- /dev/null +++ b/packages/types/src/__tests__/object-chart-properties-bag-11276.test.ts @@ -0,0 +1,420 @@ +/** + * 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. + */ + +/** + * `object-chart` takes its props in the `properties` bag, and the flat + * spelling retires from both authoring faces (objectui#11276, the + * `object-chart` batch of triage's routing call A). + * + * ## The defect this pins + * + * `@objectstack/spec`'s strict `PageComponentSchema` refuses a prop written on + * a page component itself as mis-layered (ADR-0089 D3a), for every component + * type, and accepts the same props in `properties`. objectui's arm was the flat + * mirror of the TypeScript twin instead, so the two validators disagreed in + * both directions: + * + * - the objectstack showcase's command-center charts, + * `{ type: 'object-chart', responsiveStyles, properties: { … } }`, were + * refused by `safeValidateSchema` (the flat mirror's chart-family floor + * found no family on the node) and by the strict face, which added + * `properties` as an unrecognized key; + * - the flat node `os validate` refuses parsed green on both faces. + * + * ## The one difference from batches 4 and 5: there is no spec row + * + * `ComponentPropsMap` carries no `object-chart` row, so the spec's page + * component takes ANY bag for this type, and no row is invented here. The bag's + * members are the flat `ObjectChartSchema` mirror's own members, BY REFERENCE — + * the same schema objects — and everything else is the construct batches 4 + * and 5 used: `propsBag`, one `aliasKeyRefusal` per member written flat, the + * mirror's tombstones and content-channel refusals. + * + * ## What the arm keeps that the spec does not say + * + * The flat mirror's chart-family floor (objectui#10770) stays, read in the bag. + * The spec has no such rule, so a node naming no family passes the spec's page + * component and is refused here, as the flat mirror refused it. + * + * ## What did NOT move + * + * The TypeScript `ObjectChartSchema` (`../objectql.ts`) and its zod mirror stay + * published: they are the node as `ObjectChart` reads it AFTER `SchemaRenderer` + * hoists `properties`, and as `ObjectView` / `ListView` / the dashboard + * renderers / the react-page wrapper compose it. The mirror is no longer an arm + * of `AnyComponentSchema`; `ObjectChartBlockSchema` is. + * + * ## Two instruments, and which half each one reads + * + * The `Expect` / `Equal` block below is TYPE-level: `tsc -p tsconfig.test.json` + * (the third leg of this package's `type-check` script) reads it, and vitest — + * which strips types — does not. The `describe` blocks are RUNTIME. + */ + +import { describe, expect, it } from 'vitest'; +import { z } from 'zod'; +import { + ComponentPropsMap as SpecComponentPropsMap, + PageComponentSchema as SpecPageComponentSchema, +} from '@objectstack/spec/ui'; + +import type { ObjectChartSchema as TsObjectChartSchema, ObjectQLComponentSchema as TsObjectQLComponentSchema } from '../objectql'; +import { + BaseSchema, + ObjectChartBlockSchema, + ObjectChartSchema, + ObjectMapBlockSchema, + ObjectQLComponentSchema, + ObjectQLPublicBlockComponentSchema, + StrictAnyComponentSchema, + safeValidateSchema, +} from '../zod/index.zod.js'; + +/* ── Type-level parity: 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 Mirror = ShapeOf; +type Bag = ShapeOf>>; +/** The node-level keys: the node base's and the envelope's. */ +type NodeLevel = keyof ShapeOf | 'responsiveStyles'; + +/** + * The bag's key set is exactly the mirror's own members (its shape less the + * node-level keys), and each member IS the mirror's schema type. + */ +export type assertionBagIsTheMirrorsOwnMembers = [ + Expect>>, + Expect>, + Expect>, + Expect>, + Expect>, +]; + +/** Every bag member written flat on the arm accepts nothing, and so do both content channels. */ +export type assertionEveryBagKeyIsRefusedFlat = [ + Expect }[keyof Bag], undefined>>, + Expect, undefined>>, + Expect, undefined>>, +]; + +/** + * The TypeScript twin is RE-DECLARED, not retired: still published, still the + * post-hoist reading with its flat members, still a member of the TypeScript + * ObjectQL union. A narrowing or a removal here reddens `tsc`. + */ +export type assertionTwinStaysPublished = [ + Expect>, + Expect>, + Expect>, + Expect, TsObjectChartSchema>>, +]; + +/** Non-vacuity: a key set narrower than the mirror's own is not Equal to it. */ +export type assertionInstrumentFires = [ + Expect>, false>>, +]; + +/* ── Runtime fixtures ────────────────────────────────────────────────────── */ + +const PALETTE = ['hsl(192 86% 46%)', 'hsl(256 72% 62%)']; + +/** + * The objectstack showcase command-center producer's `chart()` helper, as it + * WRITES the node — `integerYAxis` on — and the same helper without it. + */ +const SHOWCASE_BAR = { + id: 'cc_status_c', + type: 'object-chart', + responsiveStyles: { large: { width: '100%', minWidth: '0' } }, + properties: { + dataset: 'showcase_task_metrics', + dimensions: ['status'], + values: ['task_count'], + chartType: 'bar', + colors: PALETTE, + yAxis: [{ field: 'task_count', stepSize: 1 }], + }, +} as const; +const SHOWCASE_DONUT = { + id: 'cc_health_c', + type: 'object-chart', + responsiveStyles: { large: { width: '100%', minWidth: '0' } }, + properties: { dataset: 'showcase_project_metrics', dimensions: ['health'], values: ['project_count'], chartType: 'donut', colors: PALETTE }, +} as const; + +/** The inline object path: `objectName` + the spec's `aggregate`, a `filter` and a drill. */ +const INLINE = { + type: 'object-chart', + properties: { + objectName: 'opportunity', + chartType: 'bar', + aggregate: { field: 'amount', function: 'sum', groupBy: 'stage' }, + filter: [['stage', '!=', 'lost']], + xAxis: { field: 'stage' }, + drillDown: { target: 'drawer' }, + title: 'Pipeline by stage', + }, +} as const; + +/** The family named by `specType` alone, with the spec's `{ name }` series arm. */ +const SPEC_TYPE = { + type: 'object-chart', + properties: { objectName: 'invoice', specType: 'line', series: [{ name: 'total', label: 'Invoice value' }] }, +} as const; + +/** The flat spelling this batch retires. */ +const FLAT = { type: 'object-chart', objectName: 'opportunity', chartType: 'bar' } as const; + +type Issues = z.core.$ZodIssue[]; + +function issuesOf(result: { success: boolean; error?: { issues: Issues } }): Issues { + if (result.success) throw new Error('expected a refusal, the document parsed'); + return result.error!.issues; +} + +const FACES = [ + ['safeValidateSchema', (doc: unknown) => safeValidateSchema(doc)], + ['the strict authoring face', (doc: unknown) => StrictAnyComponentSchema.safeParse(doc)], +] as const; + +const bagOf = () => ObjectChartBlockSchema.shape.properties.unwrap(); +const bagKeys = () => Object.keys(bagOf().shape); +/** The three list-view spellings objectui#10608 retired — tombstones in the mirror. */ +const RETIRED = ['xAxisField', 'yAxisFields', 'aggregation'] as const; + +describe('object-chart validates in the `properties` bag (objectui#11276)', () => { + it.each([ + ['the showcase command-center bar chart (dataset-bound, integer y axis)', SHOWCASE_BAR], + ['the showcase command-center donut', SHOWCASE_DONUT], + ['an object-bound chart with its aggregate, filter, axis and drill', INLINE], + ['a chart naming its family by `specType`, with the spec series arm', SPEC_TYPE], + ] as const)('%s is accepted by safeValidateSchema and by the strict authoring face', (_label, doc) => { + const tolerant = safeValidateSchema(doc); + expect(tolerant.success, JSON.stringify(tolerant.success ? null : tolerant.error.issues)).toBe(true); + const strict = StrictAnyComponentSchema.safeParse(doc); + expect(strict.success, JSON.stringify(strict.success ? null : strict.error.issues)).toBe(true); + }); + + it('the fixtures are spec-valid by the spec\'s own page component (lit control)', () => { + for (const doc of [SHOWCASE_BAR, SHOWCASE_DONUT, INLINE, SPEC_TYPE]) { + const r = SpecPageComponentSchema.safeParse(doc); + expect(r.success, JSON.stringify(r.success ? null : r.error.issues)).toBe(true); + } + }); + + it('the bag survives the parse unchanged on both faces', () => { + expect((safeValidateSchema(SHOWCASE_BAR).data as { properties?: unknown }).properties).toEqual(SHOWCASE_BAR.properties); + expect((StrictAnyComponentSchema.safeParse(SHOWCASE_BAR).data as { properties?: unknown }).properties).toEqual( + SHOWCASE_BAR.properties, + ); + }); + + it('reaches the arm at a child slot and at a page container\'s bag child list', () => { + expect(safeValidateSchema({ type: 'page', children: [SHOWCASE_BAR, INLINE] }).success).toBe(true); + const section = { type: 'page:section', properties: { children: [SHOWCASE_BAR, SHOWCASE_DONUT] } }; + for (const [, parse] of FACES) expect(parse(section).success).toBe(true); + // Control: the walk does judge a chart nested in the bag — a flat one is refused there. + const flatInSection = { type: 'page:section', properties: { children: [FLAT] } }; + const nested = issuesOf(safeValidateSchema(flatInSection)).find( + (i) => i.path.join('.') === 'properties.children.0.objectName', + ); + expect(nested?.message).toContain('`objectName` → `properties.objectName`'); + }); +}); + +describe('the spec has no `object-chart` row, and none is invented (objectui#11276)', () => { + it('`ComponentPropsMap` carries no `object-chart` row (lit control, read off the installed spec)', () => { + expect(Object.prototype.hasOwnProperty.call(SpecComponentPropsMap, 'object-chart')).toBe(false); + // Control: the read finds a row where one exists. + expect(Object.prototype.hasOwnProperty.call(SpecComponentPropsMap, 'object-map')).toBe(true); + }); + + it('so the spec\'s page component takes any bag on this type', () => { + const r = SpecPageComponentSchema.safeParse({ type: 'object-chart', properties: { inventedKey11276: 1, chartType: 7 } }); + expect(r.success).toBe(true); + }); + + it('the bag\'s description names the mirror, not a row; a row-backed arm\'s still names its row', () => { + const own = ObjectChartBlockSchema.shape.properties.description ?? ''; + expect(own).toContain('`ObjectChartSchema`'); + expect(own).toContain('no `ComponentPropsMap[\'object-chart\']` row'); + const control = ObjectMapBlockSchema.shape.properties.description ?? ''; + expect(control).toContain('`ComponentPropsMap[\'object-map\']`, by reference'); + }); +}); + +describe('the bag is the flat mirror\'s own members, by reference (objectui#11276)', () => { + it('its key set is the mirror\'s shape less the node-level keys, read on every run', () => { + const nodeLevel = new Set([...Object.keys(BaseSchema.shape), 'responsiveStyles']); + const expected = Object.keys(ObjectChartSchema.shape).filter((key) => !nodeLevel.has(key)).sort(); + expect(bagKeys().sort()).toEqual(expected); + // Non-vacuity: the chart's own vocabulary is in it. + expect(expected.length).toBeGreaterThan(10); + expect(expected).toEqual(expect.arrayContaining(['chartType', 'dataset', 'objectName', 'aggregate', 'xAxis', 'yAxis'])); + }); + + it('each member IS the mirror\'s schema object, not a copy', () => { + const mirror = ObjectChartSchema.shape as Record; + for (const [key, member] of Object.entries(bagOf().shape)) expect(member, key).toBe(mirror[key]); + }); + + it.each([ + ['a chart family outside the vocabulary', { chartType: 'radar' }, ['properties', 'chartType'], 'invalid_value'], + ['a bare column name as `xAxis` (objectui#10518)', { chartType: 'bar', xAxis: 'stage' }, ['properties', 'xAxis'], 'invalid_union'], + ['a single axis object as `yAxis` (objectui#10518)', { chartType: 'bar', yAxis: { field: 'n' } }, ['properties', 'yAxis'], 'invalid_type'], + ['an `aggregate` with no `groupBy` (the spec\'s strict schema)', { chartType: 'bar', aggregate: { function: 'count' } }, ['properties', 'aggregate', 'groupBy'], 'invalid_union'], + ['a series entry naming no column (objectui#10770)', { chartType: 'bar', series: [{ label: 'x' }] }, ['properties', 'series', 0], 'invalid_union'], + ] as const)('%s is refused at that member on both faces', (_label, props, path, code) => { + for (const [, parse] of FACES) { + const issue = issuesOf(parse({ type: 'object-chart', properties: props })).find((i) => i.code === code); + expect(issue?.path).toEqual(path); + } + }); + + it.each(RETIRED.map((key) => [key] as const))('the retired `%s` is refused in the bag with its retirement (objectui#10608)', (key) => { + const [issue] = issuesOf(safeValidateSchema({ type: 'object-chart', properties: { chartType: 'bar', [key]: 'x' } })); + expect(issue.code).toBe('invalid_type'); + expect(issue.path).toEqual(['properties', key]); + expect(issue.message).toContain('RETIRED (objectui#10608'); + }); + + it('a key the chart does not declare, in the bag, stays unjudged by the tolerant face and is refused by the strict face', () => { + // The mirror's own posture (`BaseSchema`'s `.passthrough()`), kept one layer down. + const doc = { type: 'object-chart', properties: { chartType: 'bar', inventedKey11276: 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 as { keys?: string[] } | undefined)?.keys).toEqual(['inventedKey11276']); + }); +}); + +describe('the flat spelling is refused by name, with the bag member as the remedy (objectui#11276)', () => { + it('the spec\'s own page component refuses the flat node (lit control: the reason holds for this type)', () => { + const issues = issuesOf(SpecPageComponentSchema.safeParse(FLAT)); + expect(issues.map((i) => i.code)).toEqual(['unrecognized_keys']); + expect((issues[0] as { keys?: string[] }).keys).toEqual(['objectName', 'chartType']); + }); + + it.each(FACES)('%s refuses the flat node at each flat key, naming `properties.KEY`', (_face, parse) => { + const issues = issuesOf(parse(FLAT)); + const byPath = new Map(issues.map((i) => [i.path.join('.'), i.message])); + expect([...byPath.keys()].sort()).toEqual(['chartType', 'objectName']); + expect(byPath.get('chartType')).toContain('`chartType` → `properties.chartType`'); + expect(byPath.get('objectName')).toContain('`objectName` → `properties.objectName`'); + // The prescription is the whole document shape, not only the path. + expect(byPath.get('chartType')).toContain('"properties": {'); + }); + + it('the bag is non-trivial, so the per-key rows below are not vacuous', () => { + expect(bagKeys().length).toBeGreaterThan(10); + }); + + // Every member of the bag but the three retired ones, read off the arm on + // each run — not a transcribed list that could drift from it. + const LIVE_KEYS = Object.keys(ObjectChartBlockSchema.shape.properties.unwrap().shape).filter( + (key) => !(RETIRED as readonly string[]).includes(key), + ); + + it.each(LIVE_KEYS.map((key) => [key] as const))('a flat `%s` is refused on the tolerant face, by name', (key) => { + const issues = issuesOf(safeValidateSchema({ ...SHOWCASE_BAR, [key]: true })); + const issue = issues.find((i) => i.path.join('.') === key); + expect(issue, JSON.stringify(issues)).toBeDefined(); + expect(issue!.message).toContain(`\`${key}\` → \`properties.${key}\``); + }); + + it.each(RETIRED.map((key) => [key] as const))( + 'a flat `%s` keeps its retirement, not a pointer at a retired bag member', + (key) => { + for (const [, parse] of FACES) { + const issue = issuesOf(parse({ ...SHOWCASE_BAR, [key]: 'x' })).find((i) => i.path.join('.') === key); + expect(issue?.message).toContain('RETIRED (objectui#10608'); + expect(issue?.message).not.toContain('Did you mean'); + } + }, + ); + + it.each([ + ['an invented key', 'inventedKey11276'], + ['the node-level `dataSource` this arm leaves undeclared (objectui#11070)', 'dataSource'], + ] as const)('%s written flat stays unjudged on the tolerant face and is refused on the strict face', (_label, key) => { + const doc = { ...SHOWCASE_BAR, [key]: { object: 'x' } }; + expect(safeValidateSchema(doc).success).toBe(true); + const issue = issuesOf(StrictAnyComponentSchema.safeParse(doc)).find((i) => i.code === 'unrecognized_keys'); + expect((issue as { keys?: string[] } | undefined)?.keys).toEqual([key]); + }); + + it('a `BaseSchema` key stays on the node, as on every arm (control)', () => { + for (const [, parse] of FACES) expect(parse({ ...SHOWCASE_BAR, className: 'min-h-0' }).success).toBe(true); + }); + + it.each(['body', 'children'] as const)('refuses the `%s` content channel by name (objectui#9256)', (key) => { + const issue = issuesOf(safeValidateSchema({ ...SHOWCASE_BAR, [key]: [{ type: 'text', content: 'x' }] }))[0]; + expect(issue.path).toEqual([key]); + expect(issue.message).toContain('objectui#9256'); + expect(issue.message).toContain('`object-chart`'); + }); +}); + +describe('the chart family is required, read in the bag (objectui#11276)', () => { + it.each([ + ['the bare node', { type: 'object-chart' }], + ['an empty bag', { type: 'object-chart', properties: {} }], + ['a bag with a dataset and no family', { type: 'object-chart', properties: { dataset: 'm', values: ['n'] } }], + ] as const)('%s is refused on both faces at `properties.chartType`', (_label, doc) => { + for (const [, parse] of FACES) { + const found = issuesOf(parse(doc)).filter((i) => i.code === 'custom'); + expect(found.map((i) => i.path)).toEqual([['properties', 'chartType']]); + expect(found[0].message).toContain('`properties.chartType`'); + } + }); + + it.each([ + ['`properties.chartType`', { chartType: 'pie' }], + ['`properties.specType`', { specType: 'pie' }], + ] as const)('%s alone names the family', (_label, props) => { + expect(safeValidateSchema({ type: 'object-chart', properties: props }).success).toBe(true); + }); + + it('a family written flat is not a family: it is refused by name and pointed at the bag', () => { + const issue = issuesOf(safeValidateSchema({ type: 'object-chart', chartType: 'bar' })).find( + (i) => i.path.join('.') === 'chartType', + ); + expect(issue?.message).toContain('`chartType` → `properties.chartType`'); + }); + + it('the spec has no such rule: its page component accepts the bare node (the recorded divergence)', () => { + expect(SpecPageComponentSchema.safeParse({ type: 'object-chart' }).success).toBe(true); + }); +}); + +describe('the arm moved; the post-hoist mirror stayed (objectui#11276)', () => { + const literalsOf = (union: { options: readonly unknown[] }) => + union.options.map((arm) => (arm as { shape: { type: z.ZodLiteral } }).shape.type.value); + + it('`object-chart` is armed by the bag arm, not by the flat mirror', () => { + expect(literalsOf(ObjectQLPublicBlockComponentSchema)).toContain('object-chart'); + expect(literalsOf(ObjectQLComponentSchema)).not.toContain('object-chart'); + expect(ObjectQLPublicBlockComponentSchema.options).toContain(ObjectChartBlockSchema); + }); + + it('the flat mirror is still published and still judges the post-hoist node', () => { + // It is what `ObjectChart` reads after the hoist, and what the composers + // build — neither is an authored `object-chart` node. + expect(ObjectChartSchema.safeParse(FLAT).success).toBe(true); + expect(ObjectChartSchema.safeParse({ ...FLAT, xAxisKey: 'stage', series: [{ dataKey: 'amount' }] }).success).toBe(true); + expect(ObjectChartSchema.safeParse({ ...FLAT, chartType: 'radar' }).success).toBe(false); + }); +}); diff --git a/packages/types/src/__tests__/object-chart-react-tier-node-10770.test.ts b/packages/types/src/__tests__/object-chart-react-tier-node-10770.test.ts index efdbfe2a7f..53e36a04d6 100644 --- a/packages/types/src/__tests__/object-chart-react-tier-node-10770.test.ts +++ b/packages/types/src/__tests__/object-chart-react-tier-node-10770.test.ts @@ -29,7 +29,10 @@ * (a) the spec side: the react block publishes `type` and `series` over * `ChartConfigSchema`, whose series arm is `{ name }`; * (b) the showcase node parses, on the mirror and through - * `safeValidateSchema` (the `objectui validate` door); + * `safeValidateSchema` (the `objectui validate` door) — at the door in + * the authored spelling, its props in the `properties` bag, since + * objectui#11276 armed the authored node with `ObjectChartBlockSchema`; + * the wrapper's flat node is the mirror's (post-hoist) reading; * (c) the controls: a series entry with neither `name` nor `dataKey`, and a * node that names no chart family, are refused; * (d) both new crossings are the spec's by reference, not look-alikes; @@ -67,6 +70,16 @@ const SHOWCASE_NODE = { type: 'object-chart', } as const; +/** + * The same props as an AUTHORED node writes them (objectui#11276): in the + * `properties` bag, the binding beside it at node level. The door judges this + * spelling; the wrapper's flat node above is what the mirror judges. + */ +const SHOWCASE_AUTHORED = (() => { + const { type, dataSource, ...props } = SHOWCASE_NODE; + return { type, dataSource, properties: props }; +})(); + const issuesOf = (doc: unknown) => { const r = ObjectChartMirror.safeParse(doc); if (r.success) throw new Error('expected REFUSE, got ACCEPT'); @@ -118,7 +131,7 @@ describe('objectui#10770 (b) — the react tier\'s showcase node parses', () => }); it('through `safeValidateSchema`, the parse `objectui validate` runs', () => { - const r = safeValidateSchema(SHOWCASE_NODE); + const r = safeValidateSchema(SHOWCASE_AUTHORED); expect(r.error?.issues ?? []).toEqual([]); expect(r.success).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 81531e371f..4d8d013480 100644 --- a/packages/types/src/__tests__/objectql-union-arms-7363.test.ts +++ b/packages/types/src/__tests__/objectql-union-arms-7363.test.ts @@ -84,10 +84,13 @@ describe('objectui#7363 — the two objectui#6576 schemas are arms of the Object // addition, not a reshuffle — less `object-form` and `object-map`, whose // authored arms left this union for `ObjectQLPublicBlockComponentSchema` in // objectui#10859 batches 4 and 5 (`object-form-properties-bag-10859-b4.test.ts`, - // `object-map-properties-bag-10859-b5.test.ts`). - expect(literals).toHaveLength(10); + // `object-map-properties-bag-10859-b5.test.ts`), and `object-chart`, whose + // authored arm left the same way in objectui#11276 + // (`object-chart-properties-bag-11276.test.ts`). + expect(literals).toHaveLength(9); expect(literals).not.toContain('object-form'); expect(literals).not.toContain('object-map'); + expect(literals).not.toContain('object-chart'); }); it.each([ 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 ffd10ae3c5..7d617ab156 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 @@ -113,14 +113,17 @@ const DECLARES_DATA_SOURCE: ReadonlySet = new Set([ ]); /** - * The one arm that refuses a node with nothing but `responsiveStyles` for a - * reason that is not about the envelope: `object-map` keeps the flat mirror's + * The arms that refuse a node with nothing but `responsiveStyles` for a reason + * that is not about the envelope: `object-map` keeps the flat mirror's * record-source rule, read in its bag (objectui#10859 batch 5, - * `requireRecordSource`). Its node carries the smallest source; every other arm - * takes the bare node. + * `requireRecordSource`), and `object-chart` keeps the flat mirror's + * chart-family floor, read in its bag (objectui#11276, + * `requireObjectChartFamilyInBag`). Each node carries the smallest bag that + * satisfies its rule; every other arm takes the bare node. */ const RECORD_SOURCE: Readonly>> = { 'object-map': { properties: { objectName: 'store' } }, + 'object-chart': { properties: { chartType: 'bar' } }, }; /** 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 3b8585b389..6b1d927f97 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 @@ -352,13 +352,15 @@ describe('the spec-row arms read the row by reference (objectui#10859)', () => { union.options.map((arm) => arm.shape.type.value); // `object-timeline` joined the union in batch 3 (`object-timeline-arm-10859-b3.test.ts`), // `object-form` in batch 4 (`object-form-properties-bag-10859-b4.test.ts`), - // `object-map` in batch 5 (`object-map-properties-bag-10859-b5.test.ts`). + // `object-map` in batch 5 (`object-map-properties-bag-10859-b5.test.ts`), + // `object-chart` in objectui#11276 (`object-chart-properties-bag-11276.test.ts`). expect(literals(ObjectQLPublicBlockComponentSchema)).toEqual([ 'object-metric', 'object-master-detail-form', 'object-timeline', 'object-form', 'object-map', + 'object-chart', ]); expect(literals(DataDisplaySchema)).toContain('pivot'); }); 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 c05212f17d..6b3078bb77 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 @@ -235,7 +235,9 @@ describe('objectui#11070 — the read keys left undeclared pending a ruling stay }); it('`object-chart.dataSource` is refused by name until it is declared (the react-page wrapper no longer puns the adapter into that key; the declaration and the objectui#10770 node pin move together)', () => { - const doc = { type: 'object-chart', objectName: 'task', chartType: 'bar', dataSource: { object: 'task' } }; + // objectui#11276: the authored node takes its props in the `properties` bag; + // `dataSource` stays node-level, and stays undeclared on that arm too. + const doc = { type: 'object-chart', properties: { objectName: 'task', chartType: 'bar' }, dataSource: { object: 'task' } }; expect(undeclared(issuesOf(StrictAnyComponentSchema, doc))).toEqual(['dataSource']); expect(issuesOf(AnyComponentSchema, doc)).toBeNull(); }); diff --git a/packages/types/src/__tests__/zod-mirror-parity.test.ts b/packages/types/src/__tests__/zod-mirror-parity.test.ts index 24c287508e..346b69b166 100644 --- a/packages/types/src/__tests__/zod-mirror-parity.test.ts +++ b/packages/types/src/__tests__/zod-mirror-parity.test.ts @@ -4585,6 +4585,12 @@ const EXCLUSIONS: Readonly> = { // (`ObjectMapSchema`), which stays the renderer's post-hoist reading. 'objectql.zod.ts#ObjectMapBlockSchema': "spec-owned BY REFERENCE — `BaseSchema` + the `object-map` literal + `properties`, which IS `ComponentPropsMap['object-map']`, + the node's `dataSource` binding, which IS the spec's `ElementDataSourceSchema`, + one `aliasKeyRefusal` per member of the row (the flat spelling, refused by name and pointed at `properties.KEY`; the key set is read off the row) plus two for the flat mirror's `locationField` / `titleField` (pointed at `properties.map.KEY`), + the objectui#9256 `body` / `children` refusals; the TS declaration of the node, `ObjectMapSchema` (`../objectql.ts`), is the renderer's post-hoist reading and is paired with the flat `objectql.zod.ts#ObjectMapSchema` mirror, not with this arm", + // objectui#11276 — the authored `object-chart` arm, built as batches 4 and 5's + // above but with NO spec row: its bag is the flat mirror's own members. Its TS + // twin is paired with that FLAT mirror (`ObjectChartSchema`), which stays the + // renderer's post-hoist reading. + 'objectql.zod.ts#ObjectChartBlockSchema': + "mirror-owned BY REFERENCE — `BaseSchema` + the `object-chart` literal + `NODE_ENVELOPE` + `properties`, whose members ARE the flat `objectql.zod.ts#ObjectChartSchema` mirror's own members (the same schema objects; `@objectstack/spec` has no `ComponentPropsMap['object-chart']` row), + one `aliasKeyRefusal` per bag member (the flat spelling, refused by name and pointed at `properties.KEY`; the key set is read off the bag), + the mirror's own objectui#10608 tombstones and objectui#9256 `body` / `children` refusals; the TS declaration of the node, `ObjectChartSchema` (`../objectql.ts`), is the renderer's post-hoist reading and is paired with the flat mirror, not 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 cc273eb01b..0eb683bbdc 100644 --- a/packages/types/src/objectql.ts +++ b/packages/types/src/objectql.ts @@ -5107,6 +5107,25 @@ export type KanbanConditionalFormattingRule = * objectui#10770's PR, and nothing re-derives it: no render, publish or * `objectui validate` path ran this schema on the wrapper's node. The defect * was on the contract face, not on a door. + * + * ## What this type describes (objectui#11276) + * + * The `object-chart` node as `ObjectChart` READS it: after `SchemaRenderer` + * has hoisted the node's `properties` bag onto it, or as code composes it — + * `ObjectView` and `ListView` building a chart view, the dashboard renderers + * building a widget, the react-page wrapper building the `` + * block, a host mounting ``. That is why its members + * sit flat on the node. + * + * It is NOT the shape of an AUTHORED `object-chart` document. An authored node + * takes its props in the `properties` bag: `{ type: 'object-chart', + * properties: { chartType, dataset, dimensions, values, … } }`, because + * `@objectstack/spec`'s page component refuses a prop written on the node + * itself (ADR-0089 D3a). `ObjectChartBlockSchema` (`./zod/objectql.zod.ts`) + * judges it and refuses a prop written flat on the node by name. The spec has + * no `ComponentPropsMap['object-chart']` row, so that bag's members are this + * type's own members, read off its zod mirror by reference. `SchemaRenderer` + * reads both spellings, so a node built in code keeps its flat keys. */ export interface ObjectChartSchema extends BaseSchema { type: 'object-chart'; @@ -5839,7 +5858,9 @@ export interface ObjectDataTableSchema extends BaseSchema { * and `ObjectFormSchema` here is the node as `ObjectForm` reads it. It carries * ten since batch 5, for the same reason: the authored `object-map` node is * armed by `ObjectMapBlockSchema`, and `ObjectMapSchema` here is the node as - * `ObjectMap` reads it. + * `ObjectMap` reads it. It carries nine since objectui#11276: the authored + * `object-chart` node is armed by `ObjectChartBlockSchema`, and + * `ObjectChartSchema` here is the node as `ObjectChart` reads it. */ export type ObjectQLComponentSchema = | ObjectGridSchema diff --git a/packages/types/src/zod/README.md b/packages/types/src/zod/README.md index dc1b76a3f6..7677945bcb 100644 --- a/packages/types/src/zod/README.md +++ b/packages/types/src/zod/README.md @@ -231,19 +231,20 @@ function validateComponent(config: unknown) { - `KanbanSchema`, `CalendarViewSchema` - `FilterBuilderSchema`, `CarouselSchema`, `ChatbotSchema` -### ObjectQL Public Blocks (5) -The ADR-0080 public blocks `object-metric`, `object-master-detail-form`, `object-timeline`, `object-form` and `object-map`: each arm's `properties` is the block's `@objectstack/spec` `ComponentPropsMap` row, by reference. Each arm also declares the node's `dataSource` binding, the spec's `ElementDataSourceSchema`, which all five registrations read through `ElementDataSourceGate`. None of the five renderers reads a content channel, so every arm refuses `children` and `body` by name (objectui#9256). +### ObjectQL Public Blocks (6) +The ADR-0080 public blocks `object-metric`, `object-master-detail-form`, `object-timeline`, `object-form`, `object-map` and `object-chart`. The first five arms' `properties` is the block's `@objectstack/spec` `ComponentPropsMap` row, by reference; `object-chart` has no row (below). Each of those five arms also declares the node's `dataSource` binding, the spec's `ElementDataSourceSchema`, which all five registrations read through `ElementDataSourceGate`. None of the six renderers reads a content channel, so every arm refuses `children` and `body` by name (objectui#9256). `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`. -- `ObjectMetricBlockSchema`, `ObjectMasterDetailFormBlockSchema`, `ObjectTimelineBlockSchema`, `ObjectFormBlockSchema`, `ObjectMapBlockSchema` +`ObjectChartBlockSchema` is the authored `object-chart` node (objectui#11276), built the same way with one difference: `@objectstack/spec` has no `ComponentPropsMap['object-chart']` row, so the bag's members are the flat `ObjectChartSchema` mirror's own members, by reference, and no row is invented. Each of them written flat on the node is refused by name, naming `properties.KEY`; the three list-view spellings objectui#10608 retired keep their retirement message instead. The flat mirror's chart-family floor stays, read in the bag: the node needs `properties.chartType` (or `properties.specType`). It does not declare `dataSource` (objectui#11070 left that binding undeclared on this node) and refuses both content channels. `ObjectChartSchema` stays exported as the node as `ObjectChart` reads it, and as the composers build it; it is no longer an arm of `AnyComponentSchema`. +- `ObjectMetricBlockSchema`, `ObjectMasterDetailFormBlockSchema`, `ObjectTimelineBlockSchema`, `ObjectFormBlockSchema`, `ObjectMapBlockSchema`, `ObjectChartBlockSchema` ### AI Components (3) - `AIFormAssistSchema`, `AIRecommendationsSchema`, `NLQuerySchema` ### ADR-0080 Public Blocks (27) Each arm's `properties` is the block's `@objectstack/spec` `ComponentPropsMap` row, by reference. `ElementNumberBlockSchema` also declares the spec's `dataSource` binding, and mirrors the spec gate's one waiver: its bag may omit `object` when `dataSource.object` names the object. -Every arm here, and each of the five ObjectQL public blocks above, declares the node-level `responsiveStyles` (ADR-0065 per-breakpoint style maps, which `SchemaRenderer` compiles to scoped CSS) as the spec's `ResponsiveStylesSchema`, by reference, from one shared fragment, `NODE_ENVELOPE` (objectui#10872 batch 8; named `PUBLIC_BLOCK_ENVELOPE` until batch 9). The other node-level keys the spec's `PageComponentSchema` declares (`events`, `aria`, `visibility`, `responsive`, and `dataSource` outside the arms that read it) stay undeclared, so the strict face refuses them by name. -Outside this set, `FlexSchema`, `ObjectGridSchema` and `ObjectChartSchema` spread the same fragment (objectui#10872 batch 9), and their TypeScript twins declare the key as the spec's `ResponsiveStyles`: they are the arms outside this set that objectui#10872's producer reading found `responsiveStyles` written on (a dated reading, not re-derived here). `BaseSchema` and every other arm leave it undeclared, so the strict face refuses it there by name. +Every arm here, and each of the six ObjectQL public blocks above, declares the node-level `responsiveStyles` (ADR-0065 per-breakpoint style maps, which `SchemaRenderer` compiles to scoped CSS) as the spec's `ResponsiveStylesSchema`, by reference, from one shared fragment, `NODE_ENVELOPE` (objectui#10872 batch 8; named `PUBLIC_BLOCK_ENVELOPE` until batch 9). The other node-level keys the spec's `PageComponentSchema` declares (`events`, `aria`, `visibility`, `responsive`, and `dataSource` outside the arms that read it) stay undeclared, so the strict face refuses them by name. +Outside this set, `FlexSchema`, `ObjectGridSchema` and `ObjectChartSchema` spread the same fragment (objectui#10872 batch 9; `ObjectChartSchema` is the post-hoist mirror since objectui#11276, the authored arm being `ObjectChartBlockSchema` above), and their TypeScript twins declare the key as the spec's `ResponsiveStyles`: they are the arms outside this set that objectui#10872's producer reading found `responsiveStyles` written on (a dated reading, not re-derived here). `BaseSchema` and every other arm leave it undeclared, so the strict face refuses it there by name. Every arm except the four `page:` containers (`page:card`, `page:section`, `page:footer`, `page:sidebar`) refuses `children` and `body` by name, because its renderer reads neither content channel (objectui#9256; the last six below since objectui#10872 batch 5). The four containers do render a child list, and its home is `properties.children`, the member their spec row declares; they refuse the node-level `children` and `body` with a message naming it, as `@objectstack/spec`'s `PageComponentSchema` refuses a node-level `children` (objectui#10872 batch 6). Two carve-outs: `record:alert` refuses `children` with its own message, while its `body` is the message text and belongs in `properties`, so a flat `body` is refused with a pointer to `properties.body` rather than to `children` (objectui#10872); and `page:tabs` / `page:accordion` refuse the node's own `children` while each item's `children` in `items` stays live. - `PageHeaderBlockSchema`, `PageTabsBlockSchema`, `PageCardBlockSchema`, `PageAccordionBlockSchema` - `PageSectionBlockSchema`, `PageFooterBlockSchema`, `PageSidebarBlockSchema` diff --git a/packages/types/src/zod/index.zod.ts b/packages/types/src/zod/index.zod.ts index 108acf8703..f2b07a5f4d 100644 --- a/packages/types/src/zod/index.zod.ts +++ b/packages/types/src/zod/index.zod.ts @@ -331,11 +331,15 @@ export { // Batch 4's `ObjectFormBlockSchema` and batch 5's `ObjectMapBlockSchema` are // the authored `object-form` / `object-map` arms; `ObjectFormSchema` and // `ObjectMapSchema` above stay the nodes as their renderers read them. + // objectui#11276's `ObjectChartBlockSchema` is the authored `object-chart` + // arm, its bag the flat mirror's own members (the spec has no row for it); + // `ObjectChartSchema` above stays the node as `ObjectChart` reads it. ObjectMetricBlockSchema, ObjectMasterDetailFormBlockSchema, ObjectTimelineBlockSchema, ObjectFormBlockSchema, ObjectMapBlockSchema, + ObjectChartBlockSchema, ObjectQLPublicBlockComponentSchema, } from './objectql.zod.js'; diff --git a/packages/types/src/zod/objectql.zod.ts b/packages/types/src/zod/objectql.zod.ts index 55add962a8..ec019f610c 100644 --- a/packages/types/src/zod/objectql.zod.ts +++ b/packages/types/src/zod/objectql.zod.ts @@ -3276,6 +3276,179 @@ export const ObjectChartSchema = BaseSchema.extend({ children: retirementTombstone(OBJECT_CHART_NEITHER_CHANNEL), }).superRefine(requireObjectChartFamily); +/* ── The authored `object-chart` node: its props in the `properties` bag ─── */ + +/** + * The node-level keys of `ObjectChartSchema` above: everything the node base + * declares (`BaseSchema`, `type` and the two content channels among them) and + * the node envelope (`NODE_ENVELOPE`). The chart's OWN members are the rest of + * the mirror's shape, and they are what the bag below holds (objectui#11276). + * Read off the two declarations, not transcribed, so a key either one gains + * stays at node level the day it lands. + */ +const OBJECT_CHART_NODE_LEVEL_KEYS = Object.fromEntries( + [...Object.keys(BaseSchema.shape), ...Object.keys(NODE_ENVELOPE)].map((key) => [key, true]), +) as { [K in keyof typeof BaseSchema.shape | keyof typeof NODE_ENVELOPE]: true }; + +/** + * The `object-chart` props bag (objectui#11276): the flat mirror's own members, + * BY REFERENCE. + * + * ⛔ `@objectstack/spec` has no `ComponentPropsMap['object-chart']` row, and none + * is invented here. The spec's `PageComponentSchema` types every `properties` + * bag as an open record and judges a bag only through a row, so for this type + * the spec accepts any bag at all. The bag's members are therefore objectui's + * own: every member `ObjectChartSchema` declares beyond the node-level keys + * above, each the SAME schema object the mirror holds (its value checks, its + * spec bindings — `aggregate`, `series`, `drillDown`, `xAxis`, `yAxis` — and + * its objectui#10608 retirement tombstones). Nothing is restated, so the bag + * and the post-hoist mirror cannot drift apart. + * + * ⭐ Built from the mirror's SHAPE, not with `.omit()` on the mirror: zod + * refuses `.pick()` / `.omit()` on an object carrying a refinement, and the + * mirror carries the chart-family floor. That floor is a NODE rule, so the bag + * object deliberately carries no check of its own; the arm below re-applies the + * same floor one layer down (`requireObjectChartFamilyInBag`). + * + * The bag keeps the mirror's posture, `.passthrough()` (`BaseSchema`'s), so a + * key the chart does not declare is judged in the bag exactly as it was judged + * on the flat node: unjudged by the tolerant face, refused by name by the + * strict authoring face, which closes every object it walks. + */ +const ObjectChartPropsBag = z.looseObject(ObjectChartSchema.shape).omit(OBJECT_CHART_NODE_LEVEL_KEYS); + +/** + * The chart-family floor of {@link requireObjectChartFamily}, read in the bag + * (objectui#11276): an authored `object-chart` names its family as + * `properties.chartType` (or `properties.specType`). A node with no bag, or a + * bag naming neither, is refused at `properties.chartType`, as the flat mirror + * refuses a node naming neither key. + */ +function requireObjectChartFamilyInBag( + node: { properties?: unknown }, + ctx: z.core.$RefinementCtx, +): void { + const bag = node.properties; + const family = bag !== null && typeof bag === 'object' && !Array.isArray(bag) + ? (bag as { chartType?: unknown; specType?: unknown }) + : {}; + if (family.chartType !== undefined || family.specType !== undefined) return; + ctx.addIssue({ + code: 'custom', + path: ['properties', 'chartType'], + message: + '`object-chart` names no chart family: write `properties.chartType` — ' + + '`{ "type": "object-chart", "properties": { "chartType": "bar", … } }` (objectui#11276).', + }); +} + +/** + * The ONE refusal detail every `object-chart` prop written flat on the node + * gets (objectui#11276). `aliasKeyRefusal` puts the key and its bag member in + * front of it: "Did you mean `chartType` → `properties.chartType`?". + */ +const OBJECT_CHART_FLAT_PROP = + 'An `object-chart` node takes its props in its `properties` bag: write `{ "type": "object-chart", ' + + '"properties": { "chartType": "bar", "dataset": "…", "dimensions": ["…"], "values": ["…"] } }` ' + + '(objectui#11276). `@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[\'object-chart\']` row, so the bag\'s members are `ObjectChartSchema`\'s own. Moving it ' + + 'changes nothing at render time: `SchemaRenderer` hoists every `properties` key onto the node before ' + + '`ObjectChart` reads it.'; + +/** A member of the `object-chart` bag — a TYPE position. */ +type ObjectChartBagKey = keyof typeof ObjectChartPropsBag.shape; + +/** + * One by-name refusal per member of the bag, keyed by the bag's own key set — + * read off the bag, not transcribed, so a member the mirror gains is refused + * flat the day it lands and the list cannot fall behind. + */ +const OBJECT_CHART_FLAT_PROP_REFUSALS = Object.fromEntries( + Object.keys(ObjectChartPropsBag.shape).map((key) => [ + key, + aliasKeyRefusal(key, `properties.${key}`, 'this `object-chart` node', OBJECT_CHART_FLAT_PROP), + ]), +) as { [K in ObjectChartBagKey]-?: ReturnType }; + +/** + * `object-chart` — the AUTHORED node: its props in the `properties` bag + * (objectui#11276, the `object-chart` batch of triage's routing call A). + * + * ## Why the arm moved to the bag + * + * `@objectstack/spec`'s strict `PageComponentSchema` refuses a prop written on + * a page component itself as mis-layered (ADR-0089 D3a), for every component + * type: `properties` is the only home of a component's own props. This union + * used to arm the node with the flat `ObjectChartSchema` above, so `objectui + * validate` refused the spec-shaped document — the objectstack showcase's + * command-center charts, `{ type: 'object-chart', responsiveStyles, + * properties: { dataset, dimensions, values, chartType, colors, yAxis } }` — + * and accepted the flat one `os validate` refuses. Triage's answer on + * objectui#11276 is this arm: "the `properties` bag is the contract on these + * three arms too", released for `object-chart` first. + * + * It is the construct `ObjectFormBlockSchema` and `ObjectMapBlockSchema` use + * (objectui#10859, batches 4 and 5) — `BaseSchema` + the `type` literal + + * `NODE_ENVELOPE` + `properties` through `propsBag` + one `aliasKeyRefusal` + * per bag member — with ONE difference, stated rather than hidden: ⛔ there is + * no spec row to read. `ComponentPropsMap` carries no `object-chart` row, so + * the bag is `ObjectChartPropsBag` above, the flat mirror's own members by + * reference, and its description says so instead of naming a row. + * + * ## The flat spelling is refused by name + * + * Every member of the bag written FLAT on the node is refused on both faces, + * with a message naming its bag member (`OBJECT_CHART_FLAT_PROP_REFUSALS` + * above). The three list-view spellings objectui#10608 retired + * (`xAxisField`, `yAxisFields`, `aggregation`) are the exception: written flat + * they keep the flat mirror's own retirement tombstones (the same objects, by + * reference), whose remedy is the spec spelling rather than a bag member that + * is itself retired. In the bag they are refused by the same tombstones. + * `BaseSchema`'s keys stay on the node, as on every arm. A key the chart does + * not declare is left as every arm leaves an undeclared key: unjudged by the + * tolerant face, refused by the strict one. + * + * ## The chart family, `dataSource` and the content channels + * + * The flat mirror's chart-family floor (objectui#10770) stays on the authored + * node, read in the bag (`requireObjectChartFamilyInBag`). `dataSource` stays + * undeclared, as on the flat mirror: objectui#11070 left `object-chart`'s + * binding refused by name on the strict face until it is declared, and this + * arm does not decide that. Neither content channel is read, so both are + * refused with the objectui#9256 string the flat mirror uses. + * + * ## What did not move + * + * The TypeScript `ObjectChartSchema` and its zod mirror above stay published: + * they are the node as `ObjectChart` reads it after `SchemaRenderer` hoists the + * bag, and as code composes it — `ObjectView`, `ListView`, the dashboard + * renderers and the react-page wrapper all build a flat `object-chart` node. + * Those keep working, because `SchemaRenderer` reads both spellings and no + * composed node passes through `safeValidateSchema`. + */ +export const ObjectChartBlockSchema = BaseSchema.extend({ + type: z.literal('object-chart'), + ...NODE_ENVELOPE, + properties: propsBag( + 'object-chart', + ObjectChartPropsBag, + 'The `object-chart` props bag — the members `ObjectChartSchema` declares beyond the node-level keys, ' + + 'by reference. `@objectstack/spec` has no `ComponentPropsMap[\'object-chart\']` row, so these are ' + + 'objectui\'s own members (objectui#11276). The chart family is required here: `chartType` (or `specType`).', + ), + ...OBJECT_CHART_FLAT_PROP_REFUSALS, + // objectui#10608: the three retired list-view spellings keep their retirement + // when written flat — the flat mirror's own tombstones, by reference. + xAxisField: ObjectChartSchema.shape.xAxisField, + yAxisFields: ObjectChartSchema.shape.yAxisFields, + aggregation: ObjectChartSchema.shape.aggregation, + // objectui#9256: the renderer reads NEITHER content channel, so both are + // refused by name — the flat mirror's own members, by reference. + body: ObjectChartSchema.shape.body, + children: ObjectChartSchema.shape.children, +}).superRefine(requireObjectChartFamilyInBag); + /** * ObjectGallery Schema (objectui#6576) * @@ -3484,6 +3657,11 @@ export const ObjectDataTableSchema = BaseSchema.extend({ * ⚠️ TEN since objectui#10859 batch 5: `ObjectMapSchema` left it the same way, * for the same reason. Its TypeScript twin is the node as `ObjectMap` reads it, * and the authored `object-map` node is armed by `ObjectMapBlockSchema` below. + * + * ⚠️ NINE since objectui#11276: `ObjectChartSchema` left it the same way. Its + * TypeScript twin is the node as `ObjectChart` reads it, and the authored + * `object-chart` node is armed by `ObjectChartBlockSchema` above, whose bag is + * the mirror's own members (the spec has no row for this type). */ export const ObjectQLComponentSchema = z.discriminatedUnion('type', [ ObjectGridSchema, @@ -3492,7 +3670,6 @@ export const ObjectQLComponentSchema = z.discriminatedUnion('type', [ ObjectGanttSchema, ObjectCalendarSchema, ObjectKanbanSchema, - ObjectChartSchema, ObjectGallerySchema, ObjectDataTableSchema, ListViewSchema, @@ -3939,6 +4116,11 @@ export const ObjectMapBlockSchema = BaseSchema.extend({ * node as its renderer reads it after the hoist, not the authored document * (batches 4 and 5). * + * `object-chart` joined in objectui#11276, the one arm here with NO spec row: + * `ComponentPropsMap` carries no `object-chart` entry, so its bag is the flat + * `ObjectChartSchema` mirror's own members by reference, and its TypeScript + * twin is likewise the node as `ObjectChart` reads it after the hoist. + * * 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 @@ -3951,4 +4133,5 @@ export const ObjectQLPublicBlockComponentSchema = z.discriminatedUnion('type', [ ObjectTimelineBlockSchema, ObjectFormBlockSchema, ObjectMapBlockSchema, + ObjectChartBlockSchema, ]); diff --git a/packages/types/src/zod/public-blocks.zod.ts b/packages/types/src/zod/public-blocks.zod.ts index 311e4c1587..224b8ec18d 100644 --- a/packages/types/src/zod/public-blocks.zod.ts +++ b/packages/types/src/zod/public-blocks.zod.ts @@ -235,13 +235,20 @@ import { * The ONE copy: `./objectql.zod.ts`'s public-block arms import it rather than * restate it (objectui#10872). Internal to this package's zod modules — deliberately NOT * re-exported from `index.zod.ts`, like the helpers in `./tombstone.zod.ts`. + * + * `description` replaces the provenance text for the one bag that is NOT a spec + * row: `object-chart` (objectui#11276) has no `ComponentPropsMap` row, so its + * bag is the flat mirror's own members, and its description says that rather + * than naming a row that does not exist. Every other caller omits it, and its + * description is unchanged. */ -export function propsBag(type: string, row: T) { +export function propsBag(type: string, row: T, description?: string) { return row .optional() .describe( - `The \`${type}\` props bag — \`@objectstack/spec\` \`ComponentPropsMap['${type}']\`, by reference. ` - + 'Judged only when present, as the spec\'s props gate judges it.', + description + ?? `The \`${type}\` props bag — \`@objectstack/spec\` \`ComponentPropsMap['${type}']\`, by reference. ` + + 'Judged only when present, as the spec\'s props gate judges it.', ); }