diff --git a/.changeset/10518-object-chart-y-axis-declared.md b/.changeset/10518-object-chart-y-axis-declared.md new file mode 100644 index 0000000000..f612d96bd8 --- /dev/null +++ b/.changeset/10518-object-chart-y-axis-declared.md @@ -0,0 +1,51 @@ +--- +'@object-ui/types': minor +--- + +`ObjectChartSchema` (and its TS twin) declares `xAxis` and `yAxis` as `@objectstack/spec`'s axis +config: `xAxis` is ONE `ChartAxisSchema` object and `yAxis` a list of them. The Zod mirror references +the spec schema, and the TS side is typed by the spec's `ChartAxis`. Until now both keys rode +`BaseSchema`'s passthrough unchecked (objectui#10518, ruling 5809510046, branch 2, declare). This is +the `object-chart` sibling of the objectui#7690 declaration on `ChartSchema`. + +- The spec declares both keys on this node. The `REACT_BLOCKS` entry for the `ObjectChart` react + block has `schemaType: 'object-chart'` and `schema: ChartConfigSchema`, and lists `xAxis` and + `yAxis` in its `dataProps`. +- Real producers write them. The objectstack showcase command-center page's dataset-bound charts + author `yAxis: [{ field, stepSize: 1 }]`, and its renewals-pipeline page writes + `xAxis: { field }` with `yAxis: [{ field, format }]`. Both still parse, unchanged. +- The spec's `.default()`s are not written into the parse output. An omitted `showGridLines` or + `logarithmic` stays omitted. +- `xAxis` is the object ONLY. There is no string arm and no fold onto `xAxisKey`: the bare-string + alias of objectui#7113 belongs to the `chart` node alone, and the spec's `ChartConfigSchema.xAxis` + has no string arm (seat decision on objectui#10518, option A). + +⚠️ Shipped as `minor`, not `patch`: documents that validated before now **refuse** (the accept set +narrows), and a narrowed accept set is breaking semantics, which this repo's version policy ships as +`minor`. Several of the refused documents draw a chart today; what the rest draw depends on the +chart type, on the rows, and on the node's other category and series sources: + +- An `xAxis` object with a malformed value (`min: 'zero'`, `position: 'middle'`), an undeclared + key (`grid`, `logScale`) or no `field`. It is refused as one issue at `xAxis` whose message carries + the spec's own diagnostic, including its alias hint. The renderer drops a malformed `min` or + `position` and never reads `grid` or `logScale`, so an object carrying them draws exactly what it + draws without them. An object with no `field` names no category of its own. +- An `xAxis` written as a bare column name (`xAxis: 'status'`), a list of axis objects, or any other + non-object value. It is refused with the remedy `xAxis: { field }`. The renderer's normalizer still + honours a bare string as a tolerance, and that is unchanged, but no producer on this node writes + one. A bare string names the category column exactly as `{ field }` does, so it draws what that + object draws. A list or any other non-object names no category, like an object with no `field`. +- A `yAxis` entry with a malformed value (`stepSize: 'big'`, `position: 'middle'`), an undeclared + key (`logScale`, `grid`) or no `field`. It is refused at the entry's own path. The renderer ignores + what it cannot read and uses the rest of the entry. An entry with no `field` names no column, so it + adds no series. +- A `yAxis` written as a single object or a bare column name. The renderer's normalizer honours + both as a tolerance. Neither is a member of the spec's list, and no producer on this node writes + either, so they are refused with a remedy (the reading objectui#7690 applied to + `ChartSchema.yAxis`). The normalizer is unchanged. It reads a single object as a one-entry list, so + a single object with no `field` adds no series either. + +A TS consumer that typed a bare-string `xAxis` onto an `ObjectChartSchema` value no longer compiles. +The one in-repo instance, a renderer-tolerance test fixture, now carries an explicit cast. + +No renderer changed. diff --git a/packages/plugin-charts/src/ObjectChart.absentCategoryAxisRefusal-8168.test.tsx b/packages/plugin-charts/src/ObjectChart.absentCategoryAxisRefusal-8168.test.tsx index 2b790979d6..7b935f8957 100644 --- a/packages/plugin-charts/src/ObjectChart.absentCategoryAxisRefusal-8168.test.tsx +++ b/packages/plugin-charts/src/ObjectChart.absentCategoryAxisRefusal-8168.test.tsx @@ -328,7 +328,7 @@ describe('objectui#8168 — what the refusal deliberately does not touch', () => it('a bare string `xAxis`, the report surface spelling', async () => { renderChart({ objectName: 'crm_opportunity', - xAxis: 'stage', + xAxis: 'stage' as unknown as ObjectChartSchema['xAxis'], // unauthorable since objectui#10518 (object only): the renderer's tolerance, cast as `UNAUTHORABLE` casts `aggregate` series: [{ dataKey: 'amount' }], }); await expectNoRefusal(); diff --git a/packages/types/src/__tests__/imported-defaults-8317.test.ts b/packages/types/src/__tests__/imported-defaults-8317.test.ts index 25bd06e287..09c2f22f5f 100644 --- a/packages/types/src/__tests__/imported-defaults-8317.test.ts +++ b/packages/types/src/__tests__/imported-defaults-8317.test.ts @@ -196,6 +196,9 @@ const IMPORTED: Array = [ // objectui#7690: `ChartSchema.xAxis` / `ChartSchema.yAxis` reference the // spec's axis config object, whose `showGridLines` / `logarithmic` defaults // are exactly what this boundary exists to keep out of a parse output. + // objectui#10518: `ObjectChartSchema.xAxis` / `ObjectChartSchema.yAxis` + // cross the same boundary with the same symbol, so this one entry measures + // all four crossings. ['ChartAxisSchema', SpecChartAxisSchema], ] as const; 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 new file mode 100644 index 0000000000..64c570cfa7 --- /dev/null +++ b/packages/types/src/__tests__/object-chart-axis-config-10518.test.ts @@ -0,0 +1,431 @@ +/** + * 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#10518 — `ObjectChartSchema.xAxis` / `ObjectChartSchema.yAxis` + * DECLARE the spec's axis config — ONE object on `xAxis`, a LIST on `yAxis` + * (ruling 5809510046, branch 2 — declare), the `object-chart` sibling of + * objectui#7690, whose pin for the `chart` node is + * `chart-axis-config-7690.test.ts`. + * + * ## The protocol answered first + * + * `@objectstack/spec/ui` declares both keys on this very node: the + * `REACT_BLOCKS` entry for the `ObjectChart` react block carries + * `schemaType: 'object-chart'` and `schema: ChartConfigSchema`, and lists + * `xAxis` / `yAxis` among its `dataProps`; `ChartConfigSchema.xAxis` is ONE + * `ChartAxisSchema` and `ChartConfigSchema.yAxis` an ARRAY of them. Blocks (a) + * and (f) read that off the installed spec rather than writing it down here. + * + * ## `xAxis` is the object ONLY (seat decision on objectui#10518, option A) + * + * No string arm and no fold onto `xAxisKey`: objectui#7113's bare-string alias + * is `ChartSchema`'s alone, and the spec's `ChartConfigSchema.xAxis` has no + * string arm. A bare column name and a list are refused with the `{ field }` + * remedy. The renderer's own tolerance of a bare string is untouched and stays + * pinned where it lives (`ObjectChart.absentCategoryAxisRefusal-8168.test.tsx`). + * + * ## The defect (measured on the branch point before the change) + * + * Neither key was declared on either face, so both rode `BaseSchema`'s + * `.passthrough()` — KEPT, read by the renderer, and UNCHECKED: + * `yAxis: [{ field: 'n', stepSize: 'big' }]`, `xAxis: { field: 'n', min: 'zero' }`, + * keys the spec only names as aliases, a single `yAxis` object and a bare + * `xAxis` string all parsed green. The shape pins in (a) and (f) and every + * refusal in (c), (d), (h) and (i) are red on that tree; the PR records the run. + * + * ## The lit controls are the real producers + * + * `yAxis`: the objectstack showcase command-center page builds its + * dataset-bound charts through one helper that writes + * `yAxis: [{ field: values[0], stepSize: 1 }]` inside the node's `properties` + * bag. `SchemaRenderer` hoists that bag onto the node, so the node below is the + * shape the mirror judges — flattened, with the helper's own keys. + * `xAxis`: the showcase `renewals-pipeline` page writes `xAxis={{ field }}` and + * `yAxis={[{ field, format }]}` on the `ObjectChart` react block. Both parse on + * the branch point and on this change alike: the declarations narrow nothing + * those producers write. + * + * ## How refusals are asserted + * + * By issue `code` and `path`. Where a message is asserted, it is compared + * against the SPEC's own message for the same axis, read off the spec schema in + * the same test — never against a string written here. The one exception is + * the `xAxis` remedy, whose wording (`xAxis: { field`) IS the contract the seat + * decision asked for. + */ + +import { describe, it, expect } from 'vitest'; +import { + ChartAxisSchema as SpecChartAxisSchema, + ChartConfigSchema as SpecChartConfigSchema, + REACT_BLOCKS, + type ChartAxis as SpecChartAxis, +} from '@objectstack/spec/ui'; +import type { ObjectChartSchema } from '../objectql'; +import { ObjectChartSchema as ObjectChartMirror } from '../zod/objectql.zod'; +import { safeValidateSchema } from '../zod/index.zod'; + +const CHART = { type: 'object-chart', chartType: 'bar' } as const; +const chart = (extra: Record) => ({ ...CHART, ...extra }); + +const parsed = (doc: unknown) => { + const r = ObjectChartMirror.safeParse(doc); + if (!r.success) throw new Error(`expected ACCEPT, got: ${JSON.stringify(r.error.issues)}`); + return r.data as Record; +}; + +const issues = (doc: unknown) => { + const r = ObjectChartMirror.safeParse(doc); + if (r.success) throw new Error('expected REFUSE, got ACCEPT'); + return r.error.issues; +}; + +/** The spec's own first message for an axis value, read live off the spec schema. */ +const specMessage = (axis: unknown): string => { + const r = SpecChartAxisSchema.safeParse(axis); + if (r.success) throw new Error('control: the spec accepts this axis'); + return r.error.issues[0]!.message; +}; + +const SPEC_AXIS_KEYS = Object.keys(SpecChartAxisSchema.shape).sort(); + +/** One valid value per spec axis key — a key the spec adds without a sample here fails (a) loudly. */ +const FULL_AXIS: Record = { + field: 'task_count', + title: { en: 'Tasks', 'zh-CN': '任务' }, + format: '0,0', + min: 0, + max: 100, + stepSize: 1, + showGridLines: false, + position: 'left', + logarithmic: false, +}; + +/** + * The showcase command-center producer's node, flattened as `SchemaRenderer` + * hoists `properties` — the `chart()` helper with its `integerYAxis` flag on. + */ +const SHOWCASE_NODE = { + id: 'cc_status_c', + type: 'object-chart', + responsiveStyles: { large: { width: '100%', minWidth: '0' } }, + dataset: 'showcase_task_metrics', + dimensions: ['status'], + values: ['task_count'], + chartType: 'bar', + colors: ['hsl(192 86% 46%)', 'hsl(256 72% 62%)'], + yAxis: [{ field: 'task_count', stepSize: 1 }], +} as const; + +type Shape = Record; +const mirrorShape = (ObjectChartMirror as unknown as { shape: Shape }).shape; + +/* ── Type-level pin (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; + +/** + * `Equal`, not `extends`: an UNDECLARED member reads `any` through + * `BaseSchema`'s index signature, and a one-way check accepts `any` — which is + * precisely the before-state. + */ +export type assertionYAxisIsTheSpecList = Expect>; +/** `xAxis` is the spec's ONE object — not a string, not a list, not `any`. */ +export type assertionXAxisIsTheSpecObject = Expect>; +/** The helper can FAIL — synthetic control (an undeclared key reads `any`). */ +export type assertionEqualCanFail = Expect, false>>; + +/* ── (a) the declaration IS the spec's ───────────────────────────────────── */ + +describe('objectui#10518 (a) — `yAxis` is declared, with the spec axis list', () => { + it('the spec declares `yAxis` on the `object-chart` react block, as `ChartConfigSchema`\'s list', () => { + 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).toContain('yAxis'); + // The list arm, read off the spec: one entry parses, a bare object is + // refused AT `yAxis` (the spec's config needs its own `type`, so it is given). + expect(SpecChartConfigSchema.safeParse({ type: 'bar', yAxis: [{ field: 'n' }] }).success).toBe(true); + const single = SpecChartConfigSchema.safeParse({ type: 'bar', yAxis: { field: 'n' } }); + expect(single.success).toBe(false); + expect(single.error!.issues.map((i) => i.path)).toEqual([['yAxis']]); + }); + + it('`yAxis` is in the mirror\'s own shape — declared, not passed through', () => { + expect(Object.keys(mirrorShape)).toContain('yAxis'); + }); + + it('the sample below covers exactly the spec\'s axis keys (read off the installed spec)', () => { + expect(Object.keys(FULL_AXIS).sort()).toEqual(SPEC_AXIS_KEYS); + }); + + it('every spec axis key is accepted on a `yAxis` entry, and the value survives unchanged', () => { + const second = { ...FULL_AXIS, position: 'right' }; + expect(parsed(chart({ yAxis: [FULL_AXIS, second] })).yAxis).toEqual([FULL_AXIS, second]); + }); + + /* + * The spec gives `showGridLines` and `logarithmic` a `.default()`. Referenced + * raw, a parse would WRITE both into the output. The CONTROL is the spec + * schema itself, which does inject — so the equality is a reading of + * `stripImportedDefaults`, not an accident. + */ + it('omitted keys stay omitted — the spec defaults are not written into the output', () => { + expect(parsed(chart({ yAxis: [{ field: 'task_count' }] })).yAxis).toEqual([{ field: 'task_count' }]); + }); + + it('CONTROL — the spec schema referenced raw DOES inject its defaults', () => { + expect(SpecChartAxisSchema.parse({ field: 'task_count' })).toEqual({ + field: 'task_count', + showGridLines: true, + logarithmic: false, + }); + }); +}); + +/* ── (b) the lit control: the real producer ──────────────────────────────── */ + +describe('objectui#10518 (b) — LIT CONTROL: the showcase producer\'s node parses, unchanged', () => { + it('on the mirror, with `yAxis` exactly as written', () => { + expect(parsed(SHOWCASE_NODE).yAxis).toEqual([{ field: 'task_count', stepSize: 1 }]); + }); + + it('through `safeValidateSchema`, the door `objectui validate` / `objectui check` run', () => { + expect(safeValidateSchema(SHOWCASE_NODE).success).toBe(true); + }); +}); + +/* ── (c) values and keys are CHECKED now ─────────────────────────────────── */ + +describe('objectui#10518 (c) — a malformed `yAxis` entry is refused at its own path', () => { + it.each([ + ['a non-number `stepSize`', { field: 'task_count', stepSize: 'big' }, ['yAxis', 0, 'stepSize'], 'invalid_type'], + ['a `position` outside the four', { field: 'task_count', position: 'middle' }, ['yAxis', 0, 'position'], 'invalid_value'], + ['an undeclared key the spec names an alias of (`logScale`)', { field: 'task_count', logScale: true }, ['yAxis', 0], 'unrecognized_keys'], + ['a missing `field` — the spec requires it', { stepSize: 1 }, ['yAxis', 0, 'field'], 'invalid_type'], + ])('%s', (_label, axis, path, code) => { + const [issue] = issues(chart({ yAxis: [axis] })); + expect(issue!.code).toBe(code); + expect(issue!.path).toEqual(path); + expect(issue!.message).toBe(specMessage(axis)); + }); + + it('the refusal reaches the public door — `safeValidateSchema` refuses the node', () => { + expect(safeValidateSchema(chart({ 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); + }); +}); + +/* ── (d) `yAxis` is the spec's LIST — the two other shapes the normalizer tolerates are not members ── */ + +describe('objectui#10518 (d) — `yAxis` is the spec array, and only that', () => { + it.each([ + ['a single axis object', { field: 'task_count', stepSize: 1 }], + ['a bare column name', 'task_count'], + ])('%s is refused at `yAxis` with `invalid_type`', (_label, yAxis) => { + const [issue] = issues(chart({ yAxis })); + expect(issue!.code).toBe('invalid_type'); + expect(issue!.path).toEqual(['yAxis']); + }); + + it('CONTROL — the same axis as a one-entry list parses', () => { + expect(parsed(chart({ yAxis: [{ field: 'task_count', stepSize: 1 }] })).yAxis).toEqual([ + { field: 'task_count', stepSize: 1 }, + ]); + }); +}); + +/* ── (e) the TS twin carries the same contract ───────────────────────────── */ + +describe('objectui#10518 (e) — the TS twin types `yAxis` with the spec\'s `ChartAxis[]`', () => { + it('accepts the spec shape at the authoring site', () => { + const axis: SpecChartAxis = { field: 'task_count', stepSize: 1, title: { en: 'Tasks', 'zh-CN': '任务' } }; + const node: ObjectChartSchema = { + type: 'object-chart', + chartType: 'bar', + dataset: 'showcase_task_metrics', + dimensions: ['status'], + values: ['task_count'], + yAxis: [axis, { field: 'task_count', position: 'right' }], + }; + expect(node.yAxis).toHaveLength(2); + }); + + 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, chartType: 'bar' as const }; + const refused: ObjectChartSchema[] = [ + // @ts-expect-error — `yAxis` is the spec's LIST, not a single axis object + { ...base, yAxis: { field: 'task_count' } }, + // @ts-expect-error — `yAxis` is the spec's LIST, not a bare column name + { ...base, yAxis: 'task_count' }, + // @ts-expect-error — `stepSize` is a number + { ...base, yAxis: [{ field: 'task_count', stepSize: 'big' }] }, + // @ts-expect-error — `field` is required on a spec axis object + { ...base, yAxis: [{ stepSize: 1 }] }, + ]; + expect(refused).toHaveLength(4); + }); +}); + +/* ══ `xAxis` — the spec's ONE axis object, and only that ══════════════════ */ + +/** The showcase `renewals-pipeline` producer's axis props and binding, on this node. */ +const RENEWALS_AXES = { + objectName: 'showcase_invoice', + aggregate: { field: 'total', function: 'sum', groupBy: 'status' }, + xAxis: { field: 'status' }, + yAxis: [{ field: 'total', format: '$0,0' }], +} as const; + +/** The remedy's own spelling — the contract the seat decision named. */ +const X_AXIS_REMEDY = 'xAxis: { field'; + +/* ── (f) the declaration IS the spec's ───────────────────────────────────── */ + +describe('objectui#10518 (f) — `xAxis` is declared, with the spec axis object', () => { + it('the spec declares `xAxis` on the `object-chart` react block, as `ChartConfigSchema`\'s ONE object', () => { + const block = REACT_BLOCKS.find((b) => b.schemaType === 'object-chart'); + expect(block!.dataProps).toContain('xAxis'); + // The object arm, read off the spec: an object parses, a bare string and a + // list are refused AT `xAxis` — the spec's config has no string arm. + expect(SpecChartConfigSchema.safeParse({ type: 'bar', xAxis: { field: 'n' } }).success).toBe(true); + for (const xAxis of ['n', [{ field: 'n' }]]) { + const r = SpecChartConfigSchema.safeParse({ type: 'bar', xAxis }); + expect(r.success).toBe(false); + expect(r.error!.issues.map((i) => i.path)).toEqual([['xAxis']]); + } + }); + + it('`xAxis` is in the mirror\'s own shape — declared, not passed through', () => { + expect(Object.keys(mirrorShape)).toContain('xAxis'); + }); + + it('every spec axis key is accepted on `xAxis`, and the value survives unchanged', () => { + const axis = { ...FULL_AXIS, field: 'status', position: 'bottom' }; + expect(parsed(chart({ xAxis: axis })).xAxis).toEqual(axis); + }); + + it('omitted keys stay omitted — the spec defaults are not written into the output', () => { + expect(parsed(chart({ xAxis: { field: 'status' } })).xAxis).toEqual({ field: 'status' }); + }); + + it('NOT folded — the object survives the parse, and no `xAxisKey` is minted from it', () => { + const out = parsed(chart({ xAxis: { field: 'status', title: 'Status' } })); + expect(out.xAxis).toEqual({ field: 'status', title: 'Status' }); + expect(out).not.toHaveProperty('xAxisKey'); + }); +}); + +/* ── (g) the lit control: the real producer ──────────────────────────────── */ + +describe('objectui#10518 (g) — LIT CONTROL: the showcase `renewals-pipeline` axes parse, unchanged', () => { + it('on the mirror, with `xAxis` / `yAxis` exactly as written', () => { + const out = parsed(chart(RENEWALS_AXES)); + expect(out.xAxis).toEqual({ field: 'status' }); + expect(out.yAxis).toEqual([{ field: 'total', format: '$0,0' }]); + }); + + it('through `safeValidateSchema`, the door `objectui validate` / `objectui check` run', () => { + expect(safeValidateSchema(chart(RENEWALS_AXES)).success).toBe(true); + }); +}); + +/* ── (h) values and keys inside the object are CHECKED now ───────────────── */ + +describe('objectui#10518 (h) — a malformed `xAxis` object is refused at `xAxis`, carrying the spec\'s own diagnostic', () => { + it.each([ + ['a non-number `min`', { field: 'status', min: 'zero' }], + ['a `position` outside the four', { field: 'status', position: 'middle' }], + ['an undeclared key the spec names an alias of (`grid`)', { field: 'status', grid: true }], + ['a missing `field` — the spec requires it', { title: 'Status' }], + ])('%s', (_label, axis) => { + const [issue] = issues(chart({ xAxis: axis })); + expect(issue!.code).toBe('invalid_union'); + expect(issue!.path).toEqual(['xAxis']); + expect(issue!.message).toContain(specMessage(axis)); + }); + + it('the refusal reaches the public door — `safeValidateSchema` refuses the node', () => { + expect(safeValidateSchema(chart({ xAxis: { field: 'status', min: 'zero' } })).success).toBe(false); + }); + + /* CONTROL for every refusal above: the same objects, minus the defect, parse. */ + it('CONTROL — the same objects, minus the defect, are accepted', () => { + expect(() => parsed(chart({ xAxis: { field: 'status', min: 0, position: 'bottom', showGridLines: true } }))).not.toThrow(); + expect(() => parsed(chart({ xAxis: { field: 'status', title: 'Status' } }))).not.toThrow(); + }); +}); + +/* ── (i) a bare string and a list are refused, with the remedy ───────────── */ + +describe('objectui#10518 (i) — `xAxis` is the spec object, and only that', () => { + it.each([ + ['a bare column name', 'status'], + ['a list of axis objects', [{ field: 'status' }]], + ['a number', 42], + ])('%s is refused at `xAxis`, naming the `{ field }` remedy', (_label, xAxis) => { + const [issue] = issues(chart({ xAxis })); + expect(issue!.code).toBe('invalid_union'); + expect(issue!.path).toEqual(['xAxis']); + expect(issue!.message).toContain(X_AXIS_REMEDY); + }); + + it('the bare string is refused at the public door too', () => { + expect(safeValidateSchema(chart({ xAxis: 'status' })).success).toBe(false); + }); + + it('CONTROL — the same column as the spec object parses, and a malformed OBJECT is not told the remedy', () => { + expect(parsed(chart({ xAxis: { field: 'status' } })).xAxis).toEqual({ field: 'status' }); + const [issue] = issues(chart({ xAxis: { field: 'status', min: 'zero' } })); + expect(issue!.message).not.toContain(X_AXIS_REMEDY); + }); +}); + +/* ── (j) the TS twin carries the same contract ───────────────────────────── */ + +describe('objectui#10518 (j) — the TS twin types `xAxis` with the spec\'s ONE `ChartAxis`', () => { + it('accepts the spec shape at the authoring site', () => { + const node: ObjectChartSchema = { + type: 'object-chart', + chartType: 'bar', + objectName: 'showcase_invoice', + aggregate: { field: 'total', function: 'sum', groupBy: 'status' }, + xAxis: { field: 'status', title: { en: 'Status', 'zh-CN': '状态' } }, + yAxis: [{ field: 'total', format: '$0,0' }], + }; + expect(node.xAxis?.field).toBe('status'); + }); + + it('refuses what the mirror refuses — checked by `tsc -p tsconfig.test.json`', () => { + // Not `as const`, for the reason block (e) gives. + const base = { type: 'object-chart' as const, chartType: 'bar' as const }; + const refused: ObjectChartSchema[] = [ + // @ts-expect-error — `xAxis` is the spec's axis OBJECT, not a bare column name + { ...base, xAxis: 'status' }, + // @ts-expect-error — `xAxis` is ONE axis object, not a list + { ...base, xAxis: [{ field: 'status' }] }, + // @ts-expect-error — `field` is required on a spec axis object + { ...base, xAxis: { title: 'Status' } }, + // @ts-expect-error — `min` is a number + { ...base, xAxis: { field: 'status', min: 'zero' } }, + ]; + expect(refused).toHaveLength(4); + }); +}); diff --git a/packages/types/src/__tests__/zod-mirror-parity.test.ts b/packages/types/src/__tests__/zod-mirror-parity.test.ts index e480cc1903..3bae0a91fa 100644 --- a/packages/types/src/__tests__/zod-mirror-parity.test.ts +++ b/packages/types/src/__tests__/zod-mirror-parity.test.ts @@ -4148,9 +4148,9 @@ const SPEC_DERIVED_PAIRS: readonly string[] = [ 'data-display.zod.ts#ChartSchema', 'form.zod.ts#SelectOptionSchema', 'layout.zod.ts#PageNodeSchema', - // ⭐ ONE entry, FOUR spec crossings — two cards put them there and both grounds - // are recorded, because either one alone is enough to keep this membership and - // deleting the entry needs both to be gone. + // ⭐ ONE entry, SIX spec crossings over FIVE spec symbols — three cards put + // them there and every ground is recorded, because any one alone is enough to + // keep this membership and deleting the entry needs all of them to be gone. // - objectui#7946 (rework round): `aggregate` is `SpecChartAggregateSchema` by // reference rather than the local near-copy the first cut declared, so a // spec bump that widens or narrows the object-bound aggregation vocabulary @@ -4162,6 +4162,11 @@ const SPEC_DERIVED_PAIRS: readonly string[] = [ // own declaration: `DashboardRenderer` forwards `widget.compareTo` // verbatim). So a spec bump that moves the chart drill vocabulary, the // i18n label union, or the widget's comparison directive moves ONE side too. + // - objectui#10518: `xAxis` / `yAxis` are the spec's `ChartAxisSchema` by + // reference — one object and a LIST, as `ChartConfigSchema` declares them + // and the spec's `ObjectChart` react block (`schemaType: 'object-chart'`) + // publishes them — so a spec bump that moves the axis vocabulary moves ONE + // side of this pair, as it does for `data-display.zod.ts#ChartSchema` above. // Either way it is exactly what this list exists to make legible rather than // mysterious. // objectui#8651: the `calendar` CONTAINER is the spec's own diff --git a/packages/types/src/objectql.ts b/packages/types/src/objectql.ts index dba0fbefd1..551c007426 100644 --- a/packages/types/src/objectql.ts +++ b/packages/types/src/objectql.ts @@ -139,6 +139,10 @@ import type { ChartDrillDown, I18nLabel, DashboardWidget as SpecDashboardWidget, + // objectui#10518 — the spec's axis config object: the type of + // `ObjectChartSchema.xAxis` and the element type of `ObjectChartSchema.yAxis`. + // Aliased for the reason `SpecGanttConfig` above is. + ChartAxis as SpecChartAxis, } from '@objectstack/spec/ui'; /** @@ -4247,6 +4251,16 @@ export type KanbanConditionalFormattingRule = * did not matter: each ledgers the OTHER card's keys by name and asserts only * that each is STILL READ, never that it is still undeclared. So declaring a * ledgered key does not redden its ledger — dropping the READ does. + * + * ## A third card, and a channel neither census sees (objectui#10518) + * + * `xAxis` and `yAxis` (see the members) are read by NO `schema.KEY` + * expression in `ObjectChart.tsx`: the component spreads the node into the + * schema it hands `ChartRenderer`, and `normalizeChartSchema` reads them there. + * Both census pins above read `schema.KEY` off that one file, so neither lists + * them — as reads or as exceptions — and neither was moved. Their pin is + * `__tests__/object-chart-axis-config-10518.test.ts`; the render path was + * measured once with a real registry probe, recorded on the card's PR. */ export interface ObjectChartSchema extends BaseSchema { type: 'object-chart'; @@ -4362,8 +4376,8 @@ export interface ObjectChartSchema extends BaseSchema { aggregate?: ChartAggregate; /** * INTERNAL (relay-composed) — the category column the renderer binds the x - * axis to. Authors write `xAxisField` above (or, one layer down, the spec's - * `xAxis: { field }`, which `normalizeChartSchema` resolves); the five + * axis to. Authors write `xAxisField` above (or the spec's {@link xAxis} + * object below, whose `field` `normalizeChartSchema` resolves); the five * producers of an `object-chart` node compute this key. * * Typed `string` from `ChartRendererProps.schema.xAxisKey`, the read this @@ -4491,6 +4505,57 @@ export interface ObjectChartSchema extends BaseSchema { * value check that `.passthrough()` was skipping. */ compareTo?: SpecDashboardWidget['compareTo']; + /** + * AUTHORABLE — the category (x) axis: ONE `@objectstack/spec` axis CONFIG + * object, the type of `ChartConfigSchema.xAxis`. Its `field` names the + * category column when neither `aggregate.groupBy` nor {@link xAxisKey} does + * (`resolveChartCategoryField`'s order); its other keys are presentation. + * + * Declared by objectui#10518 beside {@link yAxis}, for the same three reasons + * (see that member): the spec's `ObjectChart` react block lists `xAxis` in its + * `dataProps` with `ChartConfigSchema` as its schema; `normalizeChartSchema` + * reads every spec axis key off the object; and a real producer writes it — + * the objectstack showcase `renewals-pipeline` page's `xAxis: { field }`. + * + * ⛔ The object ONLY. There is no `string` arm and no fold onto + * {@link xAxisKey} (seat decision on objectui#10518, option A): the bare + * string is objectui#7113's alias on `ChartSchema` alone, the spec's + * `ChartConfigSchema.xAxis` has no string arm, and no producer on this node + * writes one. The renderer still TOLERATES a bare string — that is + * `normalizeChartSchema`'s, untouched — but it does not compile here and the + * mirror refuses it, as it refuses a list, with the `{ field }` remedy. + */ + xAxis?: SpecChartAxis; + /** + * AUTHORABLE — the value (y) axes: an ARRAY of `@objectstack/spec`'s axis + * CONFIG objects, the type of `ChartConfigSchema.yAxis`. The first entry is + * the primary axis; a second entry declares the right-hand axis a series + * binds to. + * + * Declared by objectui#10518, the `object-chart` sibling of objectui#7690 + * (ruling 5809510046, branch 2 — declare), for three measured reasons: + * + * - the spec declares the key ON THIS NODE: its `REACT_BLOCKS` entry for + * `` carries `schemaType: 'object-chart'` and + * `schema: ChartConfigSchema`, and lists `yAxis` among its `dataProps`; + * - the node renders it: `ObjectChart` spreads the node into the schema it + * hands `ChartRenderer`, and `normalizeChartSchema` reads every spec axis + * key off each entry — so the type IS the spec's `ChartAxis`, not a + * restatement of it; + * - a real producer writes it: the objectstack showcase command-center + * page's dataset-bound `object-chart` authors + * `yAxis: [{ field, stepSize: 1 }]` to pin integer ticks on a count axis. + * + * Until then the list survived only on `BaseSchema`'s index signature: read + * by the renderer, checked by nothing. Only the spec's list is a member — a + * single axis object or a bare column name, which the normalizer tolerates, + * is refused by the mirror and does not compile here. + * + * ⚠️ Declared beside {@link yAxisFields} above, a separate legacy + * vocabulary. Whether that key is live on this node is reported on + * objectui#10518 for its own ruling; this member settles nothing about it. + */ + yAxis?: SpecChartAxis[]; } /** diff --git a/packages/types/src/zod/objectql.zod.ts b/packages/types/src/zod/objectql.zod.ts index 2176166f03..9d5386b47d 100644 --- a/packages/types/src/zod/objectql.zod.ts +++ b/packages/types/src/zod/objectql.zod.ts @@ -38,6 +38,7 @@ import { ChartDrillDownSchema as SpecChartDrillDownSchema, I18nLabelSchema as SpecI18nLabelSchema, DashboardWidgetSchema as SpecDashboardWidgetSchema, + ChartAxisSchema as SpecChartAxisSchema, UserFilterFieldSchema as SpecUserFilterFieldSchema, checkListViewCalendarVisualization, } from '@objectstack/spec/ui'; @@ -2393,6 +2394,65 @@ export const ObjectKanbanSchema = BaseSchema.extend({ onQuickAdd: handlerKeyRefusal('onQuickAdd', 'runtime-slot', 'Quick Add handler'), }).superRefine(requireKanbanRecordSource); +/** + * The message for an `ObjectChartSchema.yAxis` that is not a list + * (objectui#10518) — the remedy names the spec's own spelling, the one-entry + * list the showcase producer writes. Applied to the array's own `invalid_type` + * only, so an issue INSIDE an entry keeps the spec's wording (including its + * alias hint, e.g. `logScale` → `logarithmic`). + */ +const OBJECT_CHART_Y_AXIS_IS_A_LIST_GUIDANCE = + '`yAxis` on an `object-chart` is `@objectstack/spec`\'s ARRAY of axis objects — write ' + + '`yAxis: [{ field: \'task_count\', stepSize: 1 }]`, one entry per value axis (a second entry declares ' + + 'the right-hand axis). A single axis object or a bare column name is not a member of the protocol.'; + +/** + * The remedy for an `ObjectChartSchema.xAxis` that is not an object + * (objectui#10518) — the spec's own spelling, the `{ field }` form the + * showcase `renewals-pipeline` page writes on this node. + */ +const OBJECT_CHART_X_AXIS_IS_AN_OBJECT_GUIDANCE = + '`xAxis` on an `object-chart` is `@objectstack/spec`\'s axis OBJECT — write `xAxis: { field: \'status\' }` ' + + '(plus `title`, `format`, … as the spec declares them). A bare column name or a list is not a member of ' + + 'the protocol on this node: the bare-string spelling is an alias of `xAxisKey` on the `chart` node only.'; + +/** + * The message for a refused `ObjectChartSchema.xAxis` (objectui#10518). + * + * `xAxis` here is ONLY the spec's axis object: no string arm and no fold. The + * objectui#7113 bare-string alias of `xAxisKey` is a ruling scoped to + * `ChartSchema` (seat decision on objectui#10518, option A), and the spec's + * `ChartConfigSchema.xAxis` — the `ObjectChart` react block's schema — has no + * string arm either. + * + * Why the member below is a two-arm union whose second arm is `z.never()`: the + * spec's object answers a string with a bare "expected object", and zod runs a + * ONE-option union as its option, so a single-arm union's error hook never + * fires. `z.never()` admits nothing and adds nothing to the inferred type; what + * it buys is that every refusal becomes one `invalid_union` at `xAxis`, worded + * here — the shape `ChartSchema.xAxis` already reports (`chartXAxisUnionError` + * in `data-display.zod.ts`): + * + * - an OBJECT can only have meant the axis object, and that arm's issues are + * surfaced with their paths (`xAxis.min: …`), in the spec's own words — the + * alias hint included; + * - anything else — a bare column name, a list, a number — gets the remedy. + * + * It is a MESSAGE, not an accept: the issue stays `invalid_union` at `xAxis`, + * and nothing that was refused is admitted. + */ +function objectChartXAxisError(issue: z.core.$ZodRawIssue): string | undefined { + if (issue.code !== 'invalid_union') return undefined; + const input = issue.input; + if (!input || typeof input !== 'object' || Array.isArray(input)) return OBJECT_CHART_X_AXIS_IS_AN_OBJECT_GUIDANCE; + // Arm order is the union's: [axis object, never]. + const axisArm = issue.errors[0] ?? []; + const detail = axisArm + .map((i) => `${['xAxis', ...i.path.map(String)].join('.')}: ${i.message}`) + .join('; '); + return `\`xAxis\` is not a valid \`@objectstack/spec\` axis object — ${detail || 'Invalid input'}`; +} + /** * ObjectChart Schema */ @@ -2496,6 +2556,58 @@ export const ObjectChartSchema = BaseSchema.extend({ // the position it reads (`stripImportedDefaults()`). compareTo: stripImportedDefaults(SpecDashboardWidgetSchema).shape.compareTo .describe('Period-over-period comparison directive, forwarded verbatim from the dashboard widget key of the same name — bound BY REFERENCE to `DashboardWidgetSchema.shape.compareTo` so the producer and this consumer cannot drift into two dialects.'), + // ── objectui#10518: the two axes, as `@objectstack/spec`'s axis config — + // ONE object for `xAxis`, a LIST for `yAxis` — the `object-chart` sibling of + // objectui#7690, under the same ruling (5809510046, branch 2 — declare), + // which triage reused for this node. + // + // Three readings, each taken rather than assumed (the TS twin in + // `../objectql.ts` carries them in full): + // - the spec DECLARES both keys on this node: its `REACT_BLOCKS` entry for + // the `ObjectChart` react block has `schemaType: 'object-chart'` and + // `schema: ChartConfigSchema`, and lists `xAxis` / `yAxis` among its + // `dataProps`; `ChartConfigSchema.xAxis` is ONE `ChartAxisSchema` and + // `ChartConfigSchema.yAxis` an ARRAY of them; + // - the node RENDERS them: `ObjectChart` spreads the node into the schema + // it hands `ChartRenderer`, whose `normalizeChartSchema` reads the spec's + // axis keys (tied to them from the renderer's side by + // `normalizeChartSchema.specAxisKeys-7690.test.ts`); + // - real producers WRITE them: the objectstack showcase command-center + // page's dataset-bound `object-chart` authors `yAxis: [{ field, stepSize: 1 }]` + // to pin integer ticks on a count axis, and its `renewals-pipeline` page + // writes `xAxis: { field }` on the `ObjectChart` react block. + // + // Until here both keys rode `BaseSchema`'s `.passthrough()` — kept, read and + // UNCHECKED — so `yAxis: [{ field: 'n', stepSize: 'big' }]` and + // `xAxis: { field: 'n', min: 'zero' }` parsed green. + // + // BY REFERENCE, not restated: the key set, the value domains and the strict + // refusal are the spec's. `stripImportedDefaults` keeps the spec's two + // `.default()`s out of the parse output, exactly as on `ChartSchema`. + // Only the spec's shapes are members: a bare column name, a list or any + // other non-object on `xAxis`, and a single object or a bare column name on + // `yAxis`, are refused, each with its remedy (the liveness read on + // objectui#10518, a one-time reading, found no producer on this node writing + // any of them). Of those, `normalizeChartSchema` honours the bare string on + // `xAxis` and both `yAxis` shapes as tolerances; a list on `xAxis` names no + // category there. ⛔ No string arm and no fold on `xAxis`: see + // `objectChartXAxisError` above. + xAxis: z + .union([stripImportedDefaults(SpecChartAxisSchema), z.never()], { error: objectChartXAxisError }) + .optional() + .describe( + 'AUTHORABLE — category (x) axis: ONE @objectstack/spec ChartAxis object, by reference (field required, strict). ' + + 'Its field names the category column when neither aggregate.groupBy nor xAxisKey does. A bare column name or a list is refused.', + ), + yAxis: z + .array(stripImportedDefaults(SpecChartAxisSchema), { + error: (issue) => (issue.code === 'invalid_type' ? OBJECT_CHART_Y_AXIS_IS_A_LIST_GUIDANCE : undefined), + }) + .optional() + .describe( + '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.', + ), }); /**