diff --git a/.changeset/10770-object-chart-react-tier-node.md b/.changeset/10770-object-chart-react-tier-node.md new file mode 100644 index 0000000000..cc0dba5cad --- /dev/null +++ b/.changeset/10770-object-chart-react-tier-node.md @@ -0,0 +1,33 @@ +--- +'@object-ui/types': minor +--- + +`ObjectChartSchema` (and its TS twin) accepts the `object-chart` node that the react tier's `` block produces: the spec's `{ name }` series arm, with the chart family carried as `specType` (objectui#10770, ruling 5617465269 principle 1). + +The react tier's `` authors `@objectstack/spec`'s `ChartConfigSchema`: its react-blocks entry has +`schemaType: 'object-chart'` and `schema: ChartConfigSchema`, and publishes `type` and `series` among its data +props. The objectstack showcase renewals-pipeline page writes +``. +The react-page wrapper spreads those props into an `object-chart` node and parks the author's `type` as `specType`, +because `type` is the node's discriminator. Both faces refused that node: `chartType` was required, and every +series entry needed `dataKey`. + +- **`series` is two arms.** An entry is either the spec's `ChartSeriesSchema` (TS: `ChartSeries`), by reference, + which is the AUTHOR arm, or the unchanged INTERNAL `{ dataKey }` arm the relays compose. The author arm is the + spec's own closed shape, so an unknown key on it is refused by name, and the spec's `.default()`s are not written + into the parse output. An entry with neither `name` nor `dataKey` is still refused. +- **`specType` is declared**, as the spec's `ChartTypeSchema` (TS: `ChartType`), by reference. +- **`chartType` is optional.** The zod mirror requires one of `chartType` or `specType`, so a node that names no + chart family is still refused, at `chartType`. An interface cannot express "one of two", so the TS face does not + carry that floor. + +Measured before the change: no render, publish or `objectui validate` path ran `ObjectChartSchema` on the wrapper's +node, so no user met the refusal. It sat on the contract face: `objectui validate` over a hand-written copy of +that node, and a TS author typing it as `ObjectChartSchema`. + +⚠️ Shipped as `minor`. The runtime accept set only widens, but two TS types move in a way that can stop a consumer +compiling: + +- `ObjectChartSchema['chartType']` now includes `undefined`. +- An element of `ObjectChartSchema['series']` is a union, so reading `.dataKey` off it needs narrowing first, for + example `'dataKey' in entry` or `Extract<…, { dataKey: string }>`. diff --git a/packages/plugin-charts/src/__tests__/ObjectChart.schemaAnchor-7946.test.ts b/packages/plugin-charts/src/__tests__/ObjectChart.schemaAnchor-7946.test.ts index cd6484dd93..a5fec351dc 100644 --- a/packages/plugin-charts/src/__tests__/ObjectChart.schemaAnchor-7946.test.ts +++ b/packages/plugin-charts/src/__tests__/ObjectChart.schemaAnchor-7946.test.ts @@ -89,7 +89,8 @@ describe('ObjectChartProps.schema — anchored to ObjectChartSchema (objectui#79 series: vals.map((v) => ({ dataKey: v, label: v })), className: 'h-[400px] w-full', }; - expect(node.series?.[0]?.dataKey).toBe('amount'); + const first = node.series?.[0]; + expect(first && 'dataKey' in first ? first.dataKey : undefined).toBe('amount'); }); it('accepts the legacy inline-aggregate shape, both filter arms', () => { @@ -123,8 +124,8 @@ describe('ObjectChartProps.schema — anchored to ObjectChartSchema (objectui#79 const badX: ObjectChartProps['schema'] = { type: 'object-chart', chartType: 'bar', xAxisKey: 0 }; // @ts-expect-error — `series` is an array of `{ dataKey }` entries, not a bare column name. const badSeries: ObjectChartProps['schema'] = { type: 'object-chart', chartType: 'bar', series: 'amount' }; - // @ts-expect-error — the series entry's binding key is `dataKey` (the renderer's internal arm); the spec's author-facing `name` arm is a different shape and is translated by `normalizeChartSchema`. - const badSeriesEntry: ObjectChartProps['schema'] = { type: 'object-chart', chartType: 'bar', series: [{ name: 'amount' }] }; + // @ts-expect-error — a series entry names its column: `dataKey` (the renderer's internal arm) or, since objectui#10770, the spec's author-facing `name` arm. An entry with neither matches no arm. + const badSeriesEntry: ObjectChartProps['schema'] = { type: 'object-chart', chartType: 'bar', series: [{ label: 'amount' }] }; // @ts-expect-error — `aggregate.function` is the declared vocabulary; `avg` is spelled `avg`. const badFn: ObjectChartProps['schema'] = { type: 'object-chart', chartType: 'bar', aggregate: { function: 'average', groupBy: 'stage' } }; // @ts-expect-error — `dateGranularity` lives INSIDE the structured `groupBy` node, not beside it (the spec's own guidance for this shape). diff --git a/packages/types/src/__tests__/imported-defaults-8317.test.ts b/packages/types/src/__tests__/imported-defaults-8317.test.ts index 09c2f22f5f..5e8ead99e7 100644 --- a/packages/types/src/__tests__/imported-defaults-8317.test.ts +++ b/packages/types/src/__tests__/imported-defaults-8317.test.ts @@ -51,6 +51,7 @@ import { NavigationAreaSchema as SpecNavigationAreaSchema, ChartTypeSchema as SpecChartTypeSchema, ChartAxisSchema as SpecChartAxisSchema, + ChartSeriesSchema as SpecChartSeriesSchema, DashboardSchema as SpecDashboardSchema, DashboardWidgetSchema as SpecDashboardWidgetSchema, GlobalFilterSchema as SpecGlobalFilterSchema, @@ -200,6 +201,10 @@ const IMPORTED: Array = [ // cross the same boundary with the same symbol, so this one entry measures // all four crossings. ['ChartAxisSchema', SpecChartAxisSchema], + // objectui#10770: the author arm of `ObjectChartSchema.series` is the spec's + // series schema, whose `yAxis` / `variant` defaults are exactly what this + // boundary exists to keep out of a parse output. + ['ChartSeriesSchema', SpecChartSeriesSchema], ] as const; /** The subset that actually carries an imported default — where the strip does work. */ 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 new file mode 100644 index 0000000000..efdbfe2a7f --- /dev/null +++ b/packages/types/src/__tests__/object-chart-react-tier-node-10770.test.ts @@ -0,0 +1,211 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectui#10770 — the react tier's `` node, on the + * `ObjectChartSchema` face. + * + * The objectstack showcase `renewals-pipeline` page authors + * ``. The react-page wrapper + * (`components/src/renderers/layout/react-page.tsx`) spreads those props into + * an `object-chart` node, and parks the author's `type` as `specType` because + * `type` is the node's discriminator. Both faces of `ObjectChartSchema` + * refused that node: `chartType` was required, and every series entry needed + * `dataKey`. + * + * Ruling `5617465269` principle 1 (objectui#7759): the spec's shape is the + * authority. So the face now takes the spec's own `ChartSeries` / + * `ChartSeriesSchema` as the AUTHOR arm of `series`, by reference, beside the + * unchanged internal `{ dataKey }` arm, and the spec's `ChartType` as + * `specType`. `chartType` is optional, and the mirror requires one of the two. + * + * What this file pins: + * (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); + * (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; + * (e) the TS twin carries the same contract. + */ + +import { describe, it, expect } from 'vitest'; +import { + ChartConfigSchema as SpecChartConfigSchema, + ChartTypeSchema as SpecChartTypeSchema, + REACT_BLOCKS, + type ChartSeries as SpecChartSeries, + type ChartType as SpecChartType, +} from '@objectstack/spec/ui'; +import type { ObjectChartSchema } from '../objectql'; +import { ObjectChartSchema as ObjectChartMirror } from '../zod/objectql.zod'; +import { safeValidateSchema } from '../zod/index.zod'; + +/** + * The node the react-page wrapper builds from the showcase `renewals-pipeline` + * page's ``: `{ dataSource, ...props, specType, type: tag }`. + * `dataSource` is the page adapter, `null` before the host connects one + * (objectui#7912). + */ +const SHOWCASE_NODE = { + dataSource: null, + objectName: 'showcase_invoice', + aggregate: { field: 'total', function: 'sum', groupBy: 'status' }, + xAxis: { field: 'status' }, + yAxis: [{ field: 'total', format: '$0,0' }], + series: [{ name: 'total', label: 'Invoice value' }], + title: 'Invoice value by status', + showLegend: true, + specType: 'bar', + type: 'object-chart', +} as const; + +const issuesOf = (doc: unknown) => { + const r = ObjectChartMirror.safeParse(doc); + if (r.success) throw new Error('expected REFUSE, got ACCEPT'); + return r.error.issues; +}; + +/* ── Type-level pins (compiled by `tsc -p tsconfig.test.json`) ─────────────── */ + +type Equal = + (() => T extends A ? 1 : 2) extends (() => T extends B ? 1 : 2) ? true : false; +type Expect = T; +type SeriesEntry = NonNullable[number]; + +/** The author arm IS the spec's `ChartSeries`, read off the union by its binding key. */ +export type assertionAuthorArmIsTheSpecSeries = Expect, SpecChartSeries>>; +/** The internal arm is still there, and still binds through a required `dataKey`. */ +export type assertionInternalArmKeepsDataKey = Expect['dataKey'], string>>; +/** `specType` is the spec's `ChartType`, by reference. */ +export type assertionSpecTypeIsTheSpecChartType = Expect>; +/** `chartType` is optional on this face; the mirror carries the one-of floor. */ +export type assertionChartTypeIsOptional = Expect>; +/** The helper can FAIL — synthetic control (an undeclared key reads `any`). */ +export type assertionEqualCanFail = Expect, false>>; + +/* ── (a) the spec side ──────────────────────────────────────────────────── */ + +describe('objectui#10770 (a) — the spec publishes `type` and `series: [{ name }]` on the react block', () => { + it('the `object-chart` react block lists both keys over `ChartConfigSchema`', () => { + const block = REACT_BLOCKS.find((b) => b.schemaType === 'object-chart'); + expect(block, 'no REACT_BLOCKS entry for object-chart in the installed spec').toBeDefined(); + expect(block!.schema).toBe(SpecChartConfigSchema); + expect(block!.dataProps).toEqual(expect.arrayContaining(['type', 'series'])); + }); + + it('`ChartConfigSchema` takes the `{ name }` arm and refuses `dataKey` there', () => { + expect(SpecChartConfigSchema.safeParse({ type: 'bar', series: [{ name: 'total', label: 'Invoice value' }] }).success).toBe(true); + const internal = SpecChartConfigSchema.safeParse({ type: 'bar', series: [{ dataKey: 'total' }] }); + expect(internal.success).toBe(false); + }); +}); + +/* ── (b) the showcase node parses ─────────────────────────────────────────── */ + +describe('objectui#10770 (b) — the react tier\'s showcase node parses', () => { + it('on the mirror, with zero issues', () => { + const r = ObjectChartMirror.safeParse(SHOWCASE_NODE); + expect(r.error?.issues ?? []).toEqual([]); + expect(r.success).toBe(true); + }); + + it('through `safeValidateSchema`, the parse `objectui validate` runs', () => { + const r = safeValidateSchema(SHOWCASE_NODE); + expect(r.error?.issues ?? []).toEqual([]); + expect(r.success).toBe(true); + }); + + it('keeps the authored series as written: the spec\'s defaults are not injected', () => { + const r = ObjectChartMirror.safeParse(SHOWCASE_NODE); + expect(r.data?.series).toEqual(SHOWCASE_NODE.series); + }); +}); + +/* ── (c) the controls ────────────────────────────────────────────────────── */ + +describe('objectui#10770 (c) — what stays refused', () => { + it('a series entry that names no column, neither `name` nor `dataKey`', () => { + const issues = issuesOf({ ...SHOWCASE_NODE, series: [{ label: 'Invoice value' }] }); + expect(issues.map((i) => [i.code, i.path])).toEqual([['invalid_union', ['series', 0]]]); + }); + + it('a node that names no chart family, neither `chartType` nor `specType`', () => { + const { specType: _family, ...noFamily } = SHOWCASE_NODE; + const issues = issuesOf(noFamily); + expect(issues.map((i) => [i.code, i.path])).toEqual([['custom', ['chartType']]]); + }); + + it('either family key alone is enough (the control for the control above)', () => { + const { specType: _family, ...noFamily } = SHOWCASE_NODE; + expect(ObjectChartMirror.safeParse({ ...noFamily, chartType: 'bar' }).success).toBe(true); + expect(ObjectChartMirror.safeParse({ type: 'object-chart', specType: 'line' }).success).toBe(true); + }); + + it('the internal `{ dataKey }` arm is unchanged', () => { + const r = ObjectChartMirror.safeParse({ type: 'object-chart', chartType: 'bar', series: [{ dataKey: 'amount', chartType: 'line' }] }); + expect(r.error?.issues ?? []).toEqual([]); + }); +}); + +/* ── (d) both new crossings are the spec's, not look-alikes ────────────────── */ + +describe('objectui#10770 (d) — the author arm and `specType` are the spec\'s, by reference', () => { + it('the author arm is STRICT: an unknown key is refused by name, not stripped', () => { + // `field` is the AXIS spelling of a series' `name`. A strip-postured local + // copy of the arm would drop it and parse; the spec's closed shape refuses + // it by name. The internal arm refuses the entry too (no `dataKey`), so the + // union issue carries each arm's own verdict. + const [issue, ...rest] = issuesOf({ ...SHOWCASE_NODE, series: [{ name: 'total', field: 'total' }] }); + expect(rest).toEqual([]); + expect(issue!.code).toBe('invalid_union'); + const [authorArm] = (issue as unknown as { errors: Array> }).errors; + expect(authorArm).toEqual(expect.arrayContaining([expect.objectContaining({ code: 'unrecognized_keys', keys: ['field'] })])); + }); + + it('`specType` takes every value of the spec\'s `ChartTypeSchema`, and nothing else', () => { + for (const family of SpecChartTypeSchema.options) { + expect(ObjectChartMirror.safeParse({ type: 'object-chart', specType: family }).success, family).toBe(true); + } + const bogus = issuesOf({ type: 'object-chart', specType: 'bar-chart' }); + expect(bogus.map((i) => [i.code, i.path])).toEqual([['invalid_value', ['specType']]]); + }); +}); + +/* ── (e) the TS twin ─────────────────────────────────────────────────────── */ + +describe('objectui#10770 (e) — the TS twin types the react tier\'s node', () => { + it('accepts the showcase node at the authoring site', () => { + const node: ObjectChartSchema = { + type: 'object-chart', + objectName: 'showcase_invoice', + specType: 'bar', + aggregate: { field: 'total', function: 'sum', groupBy: 'status' }, + xAxis: { field: 'status' }, + yAxis: [{ field: 'total', format: '$0,0' }], + series: [{ name: 'total', label: 'Invoice value' }], + title: 'Invoice value by status', + }; + expect(node.series).toHaveLength(1); + }); + + it('refuses what the mirror refuses, checked by `tsc -p tsconfig.test.json`', () => { + // Not `as const`: a readonly literal would fail every line below for a + // reason of its own, and each `@ts-expect-error` would pass without testing. + const base = { type: 'object-chart' as const, specType: 'bar' as const }; + const refused: ObjectChartSchema[] = [ + // @ts-expect-error — a series entry names its column with `name` or `dataKey` + { ...base, series: [{ label: 'Invoice value' }] }, + // @ts-expect-error — `specType` is the spec's `ChartType` + { ...base, specType: 'bar-chart' }, + ]; + expect(refused).toHaveLength(2); + }); +}); diff --git a/packages/types/src/__tests__/widget-schema-anchors-7946.test.ts b/packages/types/src/__tests__/widget-schema-anchors-7946.test.ts index 4b9e9f0217..36632c22a0 100644 --- a/packages/types/src/__tests__/widget-schema-anchors-7946.test.ts +++ b/packages/types/src/__tests__/widget-schema-anchors-7946.test.ts @@ -62,6 +62,10 @@ * registry `inputs`. They are declared anyway because they are already * passed through `.passthrough()` unvalidated; declaring buys the VALUE * check without minting authorable vocabulary. + * ⚠️ `series` is TWO arms since objectui#10770: the internal `{ dataKey }` + * arm this file pins, plus the spec's `{ name }` author arm, by reference, + * which the react tier's `` writes. That arm's pins live in + * `object-chart-react-tier-node-10770.test.ts` beside this file. * * ## The ceiling, stated rather than assumed (objectui#5155) * @@ -111,12 +115,13 @@ export type assertionXAxisKeyDeclared = Expect | undefined>>; export type assertionFilterAdmitsBothArms = Expect | undefined>>; /** - * `series`' element type is `ChartRendererProps.schema.series`' INTERNAL arm. + * `series`' INTERNAL arm is `ChartRendererProps.schema.series`' internal arm. * Spelled here as the two facts a reader needs — the binding key is `dataKey` * and it is required — rather than as a copy of the whole arm, which would pin - * the shape twice and drift. + * the shape twice and drift. Read through `Extract` because the element is a + * union since objectui#10770 (the spec's `{ name }` arm is the other member). */ -export type assertionSeriesBindsDataKey = Expect[number]['dataKey'], string>>; +export type assertionSeriesBindsDataKey = Expect[number], { dataKey: string }>['dataKey'], string>>; /** * ⭐ `aggregate` is the SPEC's symbol, by reference — not a local shape that * happens to look like it. `Equal` against `ChartAggregate` itself is the only @@ -169,7 +174,8 @@ describe('ObjectChartSchema — compile-time pins (objectui#7946)', () => { series: [{ dataKey: 'amount', label: 'Amount' }], colors: { won: '#10B981' }, }; - expect([chart.aggregate?.groupBy, chart.xAxisKey, chart.series?.[0].dataKey]).toEqual(['stage', 'stage', 'amount']); + const first = chart.series?.[0]; + expect([chart.aggregate?.groupBy, chart.xAxisKey, first && 'dataKey' in first ? first.dataKey : undefined]).toEqual(['stage', 'stage', 'amount']); }); it('still accepts a MISSPELLING — the ceiling, pinned honestly (objectui#5155)', () => { @@ -208,7 +214,7 @@ describe('the zod mirror declares the same keys (objectui#7946)', () => { const refusals: Array = [ ['xAxisKey is a column NAME, not an index', { xAxisKey: 0 }], - ['a series entry binds through `dataKey`', { series: [{ label: 'no binding key' }] }], + ['a series entry binds through `dataKey` (or the spec arm\'s `name`)', { series: [{ label: 'no binding key' }] }], ['`average` is not the declared function vocabulary', { aggregate: { function: 'average', groupBy: 'stage' } }], // F6 — the two keys the first cut declared but never probed on this face. ['filter is a FilterArray or an ObjectQL $filter object, never a query STRING', { filter: 'stage=won' }], diff --git a/packages/types/src/objectql.ts b/packages/types/src/objectql.ts index b511ab98fc..130c2dc05a 100644 --- a/packages/types/src/objectql.ts +++ b/packages/types/src/objectql.ts @@ -143,6 +143,11 @@ import type { // `ObjectChartSchema.xAxis` and the element type of `ObjectChartSchema.yAxis`. // Aliased for the reason `SpecGanttConfig` above is. ChartAxis as SpecChartAxis, + // objectui#10770 — the react tier's `` author arm: the spec's + // `{ name }` series entry (the author arm of `ObjectChartSchema.series`) and + // its chart family (`ObjectChartSchema.specType`). Aliased for the same reason. + ChartSeries as SpecChartSeries, + ChartType as SpecChartType, } from '@objectstack/spec/ui'; /** @@ -4207,16 +4212,24 @@ export type KanbanConditionalFormattingRule = * spelling ON THIS NODE. All five producers COMPUTE it (`dims[0]`, * `chartCategoryKey(...)`), none forwards an authored value, and it is * absent from the registry `inputs`. - * - `series` — INTERNAL (relay-composed). The `{ dataKey }` shape below is - * the renderer's internal contract; the spec's author-facing - * `ChartSeriesSchema` REFUSES `dataKey` by name (`dataKey` → `name` - * rename). All five producers compose it from something else. + * - `series` — TWO ARMS, one verdict each (objectui#10770 corrected the + * INTERNAL-only verdict this bullet carried). The `{ dataKey }` arm is + * INTERNAL: the renderer's own contract, which the five relays compose + * from something else. The `{ name }` arm is AUTHORABLE, and it is the + * spec's `ChartSeries` by reference. The react tier's `` + * publishes `series` in that shape (its react-blocks entry's `schema` is + * `ChartConfigSchema`), and the react-page wrapper forwards the authored + * array onto this node untouched. That makes the wrapper a sixth producer, + * and the one the earlier verdict missed. The spec's `ChartSeriesSchema` + * refuses `dataKey` by name (`dataKey` → `name`), so the two arms cannot + * be mistaken for each other. * - * Both internal keys are still declared HERE and on the mirror: they are read - * and written today, `BaseSchema` is `.passthrough()`, so leaving them - * undeclared does not make them unauthorable — it only means an `xAxisKey: 42` - * rides through unchecked. Declaring buys the VALUE check without minting new - * authorable vocabulary, and the descriptions say which is which. + * `xAxisKey` and the `{ dataKey }` arm are still declared HERE and on the + * mirror: they are read and written today, and `BaseSchema` is + * `.passthrough()`, so leaving them undeclared does not make them + * unauthorable. It only means an `xAxisKey: 42` rides through unchecked. + * Declaring buys the VALUE check without minting new authorable vocabulary, + * and the descriptions say which is which. * * ## The three keys objectui#8885 declared, and why each is bound to the spec * @@ -4289,14 +4302,55 @@ export type KanbanConditionalFormattingRule = * * ⛔ The LIST-VIEW carriers keep these names: they are a different node, they * read them, and nothing here touches them. + * + * ## The react tier's node, and the two chart-family keys (objectui#10770) + * + * The react tier's `` block becomes this node too. Its spec + * react-blocks entry has `schemaType: 'object-chart'` and + * `schema: ChartConfigSchema`, so its author writes the spec's `type` and + * `series: [{ name }]`. `type` is also the SDUI envelope's discriminator, so + * the react-page wrapper parks the author's value beside it as + * {@link specType} (objectui#2880), and `normalizeChartSchema` reads it back. + * Until objectui#10770 this face refused that node twice: `chartType` was + * required, and every `series` entry needed `dataKey`. + * + * The metadata tier still writes the family as {@link chartType}. So both keys + * are optional here, and the zod mirror's refinement requires ONE of them. + * That is the floor `chartType`'s required flag used to carry, so a node that + * names no family is still refused. An interface cannot say "one of two", so + * this TS face does not express that floor: a ceiling, stated rather than + * assumed. + * + * Whether a user ever met the old refusal was measured once, on + * 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. */ export interface ObjectChartSchema extends BaseSchema { type: 'object-chart'; /** ObjectQL object name (legacy inline path; optional under ADR-0021 dataset binding) */ objectName?: string; /** Chart type. Includes donut / horizontal-bar / column — all rendered by - * AdvancedChartImpl (previously only reachable by passing an untyped string). */ - chartType: 'bar' | 'column' | 'horizontal-bar' | 'line' | 'area' | 'pie' | 'donut' | 'scatter'; + * AdvancedChartImpl (previously only reachable by passing an untyped string). + * + * The metadata tier's spelling of the chart family. OPTIONAL since + * objectui#10770, because a react-tier node carries the family as + * {@link specType} instead. The zod mirror requires one of the two, and + * `normalizeChartSchema` reads this one first when a node writes both. */ + chartType?: 'bar' | 'column' | 'horizontal-bar' | 'line' | 'area' | 'pie' | 'donut' | 'scatter'; + /** + * The react tier's chart family: the author's `type` on ``, + * parked here by the react-page wrapper because `type` is this node's + * discriminator (objectui#2880). The spec's react-blocks index describes + * that parking in its own comments, and `normalizeChartSchema` reads the + * value after {@link chartType}. + * + * ⛔ `ChartType` from `@objectstack/spec/ui` BY REFERENCE: it is the value + * domain of `ChartConfigSchema.type`, the shape the react-blocks entry + * publishes for the author's `type`. A local enum would be a second dialect + * of one key. + */ + specType?: SpecChartType; /** * RETIRED (objectui#10608, ADR-0049) — the list-view chart block's spelling, * read by no `object-chart` reader. Write the spec's {@link xAxis} object, @@ -4442,31 +4496,43 @@ export interface ObjectChartSchema extends BaseSchema { */ xAxisKey?: string; /** - * INTERNAL (relay-composed) — the plotted series, in the renderer's internal - * `{ dataKey }` contract. - * - * The element type is `ChartRendererProps.schema.series`' internal arm — - * that is the read this value ends at, and the ruling on objectui#7946 asked - * for the reads rather than a copy of any producer's literal — with every - * member of it EXCEPT the per-series `type` that arm gained under + * The plotted series. Each entry is ONE of two arms (objectui#10770), and + * `normalizeChartSchema` is the one translation between them. + * + * **The `{ name }` arm: AUTHORABLE, the spec's `ChartSeries` BY REFERENCE.** + * The react tier's `` publishes this shape (its + * react-blocks entry's `schema` is `ChartConfigSchema`), and the react-page + * wrapper forwards the authored array onto this node untouched. The named + * producer is the objectstack showcase `renewals-pipeline` page, which + * writes `series={[{ name: 'total', label: 'Invoice value' }]}`. ⛔ Never a + * local near-copy: the key set, the value domains and the strict refusal + * are the spec's, and a spec bump moves them here with it. The spec refuses + * `dataKey` on this arm by name (`dataKey` → `name`), and a per-series + * family override is its `type`. + * + * **The `{ dataKey }` arm: INTERNAL (relay-composed).** This is the + * renderer's own contract, which the five relays compose from something + * else. Its type is `ChartRendererProps.schema.series`' internal arm, which + * is the read this value ends at (the ruling on objectui#7946 asked for the + * reads rather than a copy of any producer's literal). It carries every + * member of that arm EXCEPT the per-series `type` the arm gained under * objectui#8086. * - * ⛔ The omission is deliberate, not a lag (objectui#10584). Taking `type` + * ⛔ That omission is deliberate, not a lag (objectui#10584). Taking `type` * up widens a published accept set, and that waits for a named producer - * that writes `type` on an `object-chart` node; none was named. Until one - * is, the per-series family override on this node is `chartType` — the - * renderer-internal spelling of `type`, which wins when an entry writes - * both. A `type` written here anyway is an excess property on a literal - * typed by this interface, and the zod mirror's element is a plain - * `z.object`, which strips it: it parses clean and is dropped. The mirror's - * `.describe()` says the same, because that string is what an author-facing - * tool renders. - * - * The spec's AUTHOR-facing `ChartSeriesSchema` is the other arm - * (`{ name }`), and it refuses `dataKey` by name; `normalizeChartSchema` is - * the one translation between them. - */ - series?: Array<{ + * that writes `type` on a `{ dataKey }` entry of an `object-chart` node; + * none was named. Until one is, the per-series family override on this arm + * is `chartType`, the renderer-internal spelling of `type`, which wins when + * an entry writes both. A `type` written on this arm anyway is an excess + * property on a literal typed by this interface, and the zod mirror's + * element is a plain `z.object`, which strips it: it parses clean and is + * dropped. The mirror's `.describe()` says the same, because that string is + * what an author-facing tool renders. + * + * An entry with neither `name` nor `dataKey` matches no arm and is refused; + * `normalizeChartSchema` would drop it from the chart. + */ + series?: Array` author writes the spec's `type`, which the react-page + * wrapper parks as `specType`, because `type` is this node's discriminator. + * Either one is enough, so both members are optional. This refinement keeps + * the floor that `chartType`'s required flag used to carry: a node that names + * no family is refused, as it was before. Only which key may carry the family + * widened. The path stays on `chartType`, so the diagnostic lands where it + * always did. + */ +function requireObjectChartFamily( + schema: { chartType?: unknown; specType?: unknown }, + ctx: z.core.$RefinementCtx, +): void { + if (schema.chartType !== undefined || schema.specType !== undefined) return; + ctx.addIssue({ + code: 'custom', + path: ['chartType'], + message: + '`object-chart` names no chart family: write `chartType` on a metadata node. On the react tier, ' + + '`` arrives here as `specType`.', + }); +} + /** * ObjectChart Schema */ @@ -2532,7 +2560,18 @@ export const ObjectChartSchema = BaseSchema.extend({ // Legacy inline path (objectName + aggregate). Optional now that a chart may // instead bind to a semantic-layer dataset (ADR-0021, #1890). objectName: z.string().optional().describe('ObjectQL object name (legacy inline path)'), - chartType: z.enum(['bar', 'column', 'horizontal-bar', 'line', 'area', 'pie', 'donut', 'scatter']).describe('Chart type'), + // ── objectui#10770: the chart family, on either tier's key ── + // + // `chartType` is the metadata tier's spelling. `specType` is where the + // react-page wrapper parks a react-tier author's `type`, and its value domain + // is the spec's own `ChartTypeSchema`, by reference, because that is what + // `ChartConfigSchema.type` declares. Both are optional, and + // `requireObjectChartFamily` (the `.superRefine` at the end) requires one of + // them. The TS twin in `../objectql.ts` carries the ground. + chartType: z.enum(['bar', 'column', 'horizontal-bar', 'line', 'area', 'pie', 'donut', 'scatter']).optional() + .describe('Chart type — the metadata tier\'s spelling of the chart family. One of chartType or specType is required'), + specType: stripImportedDefaults(SpecChartTypeSchema).optional() + .describe('The react tier\'s chart family: the author\'s `type` on , parked here by the react-page wrapper because `type` is this node\'s discriminator. @objectstack/spec ChartType, by reference. chartType wins when a node writes both'), // ── objectui#10608: three list-view spellings, RETIRED on this node ── // // `xAxisField` / `yAxisFields` / `aggregation` are the LIST-VIEW chart @@ -2585,7 +2624,8 @@ export const ObjectChartSchema = BaseSchema.extend({ // mirror unexamined; what changes is that their VALUES are checked. Leaving // them undeclared would instead have put them in `zod-mirror-parity`'s // `UnmirroredDeclared` ledger — which that file calls a real defect in the - // pair, not a neutral state. + // pair, not a neutral state. (`series` is only half internal since + // objectui#10770: see its own comment below.) // BOTH `filter` arms are live and both are measured — see the twin docblock in // `../objectql.ts`. The array arm is the spec's published `FilterArray` and // the registry `inputs` spelling; the record arm is the ObjectQL `$filter` @@ -2608,24 +2648,36 @@ export const ObjectChartSchema = BaseSchema.extend({ aggregate: stripImportedDefaults(SpecChartAggregateSchema).optional() .describe('AUTHORABLE — inline aggregation for the legacy objectName path. @objectstack/spec ChartAggregateSchema ({ field?, function, groupBy }), the same schema the react-page publish gate parses: function and groupBy are REQUIRED, field is optional because only count counts rows rather than a column, and unknown keys are refused rather than dropped'), xAxisKey: z.string().optional().describe('INTERNAL (relay-composed) — the category column the renderer binds the x axis to. Authors write the spec xAxis: { field }; all five producers compute this key'), - // `series` copies the `{ dataKey }` arm of `ChartRendererProps.schema.series` - // WITHOUT that arm's per-series `type` (objectui#8086), deliberately - // (objectui#10584): widening a published accept set waits for a named - // producer that writes `type` on this node. The `.describe()` says so and - // names `chartType` as this copy's override, because that string is what an - // author-facing tool renders. The TS twin in `../objectql.ts` carries the - // ground. - series: z.array(z.object({ - dataKey: z.string().describe('Result column this series plots'), - label: z.string().optional().describe('Series display label'), - variant: z.enum(['current', 'comparison']).optional().describe('Comparison overlays render muted'), - opacity: z.number().optional().describe('Series opacity override (0-1)'), - dashArray: z.string().optional().describe('SVG stroke-dasharray override'), - chartType: z.enum(['bar', 'line', 'area']).optional().describe('Per-series family override (combo charts)'), - stack: z.string().optional().describe('Stack identifier to group series'), - yAxis: z.enum(['left', 'right']).optional().describe('Bind to a specific Y axis'), - color: z.string().optional().describe('Series color (hex/rgb/token)'), - })).optional().describe("INTERNAL (relay-composed) — plotted series in the renderer's internal { dataKey } contract: the { dataKey } arm ChartRendererProps declares, minus that arm's per-series type, which this copy does not declare. The per-series family override here is chartType (bar | line | area). The spec's author-facing ChartSeriesSchema is the { name } arm and refuses dataKey by name; normalizeChartSchema is the one translation"), + // ── objectui#10770: `series` is TWO arms, one verdict each ── + // + // Arm 1 is the spec's `ChartSeriesSchema` BY REFERENCE: the AUTHOR arm + // (`{ name }`). The react tier's `` publishes it, the + // react-page wrapper forwards it onto this node untouched, and the showcase + // `renewals-pipeline` page writes it. The strict refusal is the spec's too, + // so `dataKey` on this arm is refused by name. + // + // Arm 2 is the INTERNAL arm (`{ dataKey }`), unchanged: the `{ dataKey }` arm + // of `ChartRendererProps.schema.series`, WITHOUT that arm's per-series `type` + // (objectui#8086), deliberately (objectui#10584). Widening it waits for a + // named producer that writes `type` on a `{ dataKey }` entry of this node. + // + // An entry matching neither arm (no `name` and no `dataKey`) is refused; + // `normalizeChartSchema` would drop it from the chart. Arm order puts the + // author arm first. The TS twin in `../objectql.ts` carries the ground. + series: z.array(z.union([ + stripImportedDefaults(SpecChartSeriesSchema), + z.object({ + dataKey: z.string().describe('Result column this series plots'), + label: z.string().optional().describe('Series display label'), + variant: z.enum(['current', 'comparison']).optional().describe('Comparison overlays render muted'), + opacity: z.number().optional().describe('Series opacity override (0-1)'), + dashArray: z.string().optional().describe('SVG stroke-dasharray override'), + chartType: z.enum(['bar', 'line', 'area']).optional().describe('Per-series family override (combo charts)'), + stack: z.string().optional().describe('Stack identifier to group series'), + yAxis: z.enum(['left', 'right']).optional().describe('Bind to a specific Y axis'), + color: z.string().optional().describe('Series color (hex/rgb/token)'), + }), + ])).optional().describe("Plotted series, each entry ONE of two arms. AUTHORABLE: @objectstack/spec ChartSeriesSchema, by reference (the { name } arm the react tier's publishes; strict, refuses dataKey by name; per-series family override is type). INTERNAL (relay-composed): the renderer's { dataKey } arm, as ChartRendererProps declares it minus that arm's per-series type, which this copy does not declare; its per-series family override is chartType (bar | line | area). An entry with neither name nor dataKey is refused. normalizeChartSchema is the one translation"), // Colors are overloaded kanban-style: a string[] is the positional palette // (applied per category in order; fallback only), while a Record // is an explicit value→color map. A select/lookup dimension's option colors — @@ -2710,7 +2762,7 @@ export const ObjectChartSchema = BaseSchema.extend({ 'AUTHORABLE — value (y) axes: an ARRAY of @objectstack/spec ChartAxis objects, by reference (field required, strict). ' + 'The first entry is the primary axis; a second entry declares the right-hand axis.', ), -}); +}).superRefine(requireObjectChartFamily); /** * ObjectGallery Schema (objectui#6576)