From 3f3a877b1263403447da19c9eaadda0f6992c950 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 22:49:55 +0000 Subject: [PATCH 1/3] feat(types): ObjectChartSchema.chartType declares the spec's chart families plugin-charts draws (objectui#11513) Both faces declared eight families. The zod mirror now picks the thirteen families plugin-charts draws as a chart out of @objectstack/spec's ChartTypeSchema (.extract); the TS twin Extracts the same set from the spec's ChartType. The single-value and tabular spec families stay undeclared, refused with the set named. The 11276 and 7946 fixtures that used `radar` as the family outside the vocabulary move to `gauge`, which is still outside it. Claude-Session: https://claude.ai/code/session_01XvhGmGAP79ZB8swnkapxPC Co-authored-by: Claude --- .../ObjectChart.schemaAnchor-7946.test.ts | 6 +- .../object-chart-families-11513.test.ts | 164 ++++++++++++++++++ .../object-chart-properties-bag-11276.test.ts | 9 +- packages/types/src/objectql.ts | 21 ++- packages/types/src/zod/objectql.zod.ts | 49 +++++- 5 files changed, 238 insertions(+), 11 deletions(-) create mode 100644 packages/types/src/__tests__/object-chart-families-11513.test.ts 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 a5fec351dc..66c1eedf33 100644 --- a/packages/plugin-charts/src/__tests__/ObjectChart.schemaAnchor-7946.test.ts +++ b/packages/plugin-charts/src/__tests__/ObjectChart.schemaAnchor-7946.test.ts @@ -188,9 +188,9 @@ describe('ObjectChartProps.schema — anchored to ObjectChartSchema (objectui#79 const missing: ObjectChartProps['schema'] = { objectName: 'account', chartType: 'bar' }; // @ts-expect-error — the only spelling is the key `ObjectChart.tsx` registers. const wrong: ObjectChartProps['schema'] = { type: 'chart', objectName: 'account', chartType: 'bar' }; - // @ts-expect-error — `radar` is rendered by AdvancedChartImpl but is not on THIS node's declared union; widening it is a contract change, not a cast. - const family: ObjectChartProps['schema'] = { type: 'object-chart', chartType: 'radar' }; - expect([missing.objectName, wrong.objectName, family.chartType]).toEqual(['account', 'account', 'radar']); + // @ts-expect-error — `gauge` is a spec chart family this block draws no chart of (one row's number), so it is not on THIS node's declared union (objectui#11513); widening it is a contract change, not a cast. + const family: ObjectChartProps['schema'] = { type: 'object-chart', chartType: 'gauge' }; + expect([missing.objectName, wrong.objectName, family.chartType]).toEqual(['account', 'account', 'gauge']); }); it('refuses a wrong-typed inherited base member for the DECLARED reason', () => { diff --git a/packages/types/src/__tests__/object-chart-families-11513.test.ts b/packages/types/src/__tests__/object-chart-families-11513.test.ts new file mode 100644 index 0000000000..8a9d343821 --- /dev/null +++ b/packages/types/src/__tests__/object-chart-families-11513.test.ts @@ -0,0 +1,164 @@ +/** + * 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`'s `chartType` declares the installed `@objectstack/spec`'s + * chart families that plugin-charts draws, on both faces (objectui#11513). + * + * ## The defect this pins + * + * Both faces declared eight families (`bar`, `column`, `horizontal-bar`, + * `line`, `area`, `pie`, `donut`, `scatter`). The spec's `ChartTypeSchema` + * declares more, and plugin-charts draws five of them as charts too + * (`funnel`, `treemap`, `sankey`, `combo`, `radar`). The dashboard renderers + * compose an `object-chart` node with every series family they route, so the + * node they build named families the face refused, and objectui#11466's V1 + * could not type that producer without a cast. + * + * ## The set, and where it comes from + * + * The seat's ruling A: the face declares the spec's families the renderer + * draws, by reference where the sets coincide. They do not coincide here, so + * the zod mirror picks the drawn families out of the spec's enum (`.extract`) + * and the TS twin `Extract`s them from the spec's `ChartType`. A spec family + * that draws no chart on this block stays undeclared: the single-value + * families render one row's number, the tabular ones a notice. Which families + * draw a chart is re-derived by rendering, in plugin-charts' + * `object-chart-declared-families-11513.test.tsx`. This file holds the two + * faces to the set, and the set to the spec. + * + * ## Two instruments + * + * The `Expect` / `Equal` block is TYPE-level: `tsc -p tsconfig.test.json` (the + * third leg of this package's `type-check` script) reads it, and vitest does + * not. The `describe` blocks are RUNTIME. + */ + +import { describe, expect, it } from 'vitest'; +import type { z } from 'zod'; +import { ChartTypeSchema as SpecChartTypeSchema, type ChartType as SpecChartType } from '@objectstack/spec/ui'; +import { enumOptions } from '@object-ui/test-support'; + +import type { ObjectChartSchema as TsObjectChartSchema } from '../objectql'; +import { ObjectChartSchema, StrictAnyComponentSchema, safeValidateSchema } from '../zod/index.zod.js'; + +/* ── The set this card declares, written out once ───────────────────────── */ + +/** The spec families plugin-charts draws as a chart, measured by rendering (objectui#11513). */ +const DECLARED = [ + 'bar', 'horizontal-bar', 'column', 'line', 'area', 'pie', 'donut', 'funnel', + 'scatter', 'treemap', 'sankey', 'combo', 'radar', +] as const; +type Declared = (typeof DECLARED)[number]; + +/** + * The installed spec's families this block draws no chart of: the single-value + * ones render one row's number and the tabular ones a notice. + */ +const UNDECLARED_SPEC_FAMILIES = ['gauge', 'solid-gauge', 'metric', 'kpi', 'bullet', 'table', 'pivot'] as const; + +/* ── Type-level: the `tsc` channel ───────────────────────────────────────── */ + +/** Invariant equality — `extends` both ways would accept a narrowing. */ +type Equal = + (() => T extends A ? 1 : 2) extends (() => T extends B ? 1 : 2) ? true : false; +type Expect = T; + +type TsFamily = NonNullable; +type ZodFamily = NonNullable['chartType']>; + +export type assertionBothFacesDeclareTheSet = [ + Expect>, + Expect>, + Expect['chartType']>>, +]; + +export type assertionEveryFamilyIsTheSpecs = [ + // Every declared family is a spec `ChartType`, so the `Extract` dropped nothing. + Expect, never>>, + // The undeclared ones are the spec's too, and the two lists cover the spec. + Expect, never>>, + Expect, never>>, +]; + +/** Non-vacuity: the declared set is a STRICT subset, so `Equal` can fail on it. */ +export type assertionInstrumentFires = [ + Expect, false>>, + Expect>, false>>, +]; + +/* ── Runtime ─────────────────────────────────────────────────────────────── */ + +type Issue = z.core.$ZodIssue & { values?: unknown[] }; + +const mirrorFamilies = (): string[] => enumOptions(ObjectChartSchema.shape.chartType); + +const FACES = [ + ['safeValidateSchema', (doc: unknown) => safeValidateSchema(doc)], + ['the strict authoring face', (doc: unknown) => StrictAnyComponentSchema.safeParse(doc)], +] as const; + +/** A bag node naming `family` on the inline object path the dashboard composes: `objectName` + `aggregate`. */ +const bagNode = (family: string) => ({ + type: 'object-chart', + properties: { chartType: family, objectName: 'opportunity', aggregate: { field: 'amount', function: 'sum', groupBy: 'stage' } }, +}); + +function issuesOf(result: { success: boolean; error?: { issues: Issue[] } }): Issue[] { + if (result.success) throw new Error('expected a refusal, the document parsed'); + return result.error!.issues; +} + +describe('object-chart `chartType` is the spec families plugin-charts draws (objectui#11513)', () => { + it('reads the declared set off the mirror, and it is this card\'s set', () => { + expect(mirrorFamilies()).not.toEqual([]); + expect([...mirrorFamilies()].sort()).toEqual([...DECLARED].sort()); + }); + + it('the set is a strict subset of the installed spec\'s ChartTypeSchema; the rest is named', () => { + const spec = enumOptions(SpecChartTypeSchema); + expect(spec.length, 'could not read ChartTypeSchema.options from the spec').toBeGreaterThan(DECLARED.length); + expect(mirrorFamilies().filter((f) => !spec.includes(f)), 'a declared family the spec does not declare').toEqual([]); + // A spec bump that adds a family turns this red: measure whether it draws, then declare it or list it here. + expect(spec.filter((f) => !mirrorFamilies().includes(f)).sort()).toEqual([...UNDECLARED_SPEC_FAMILIES].sort()); + }); + + it('`specType` stays the spec\'s ChartTypeSchema by identity (objectui#10770); `chartType` is picked out of it', () => { + // The spec export is a `lazySchema()` proxy; the enum it materialises is the object a member wraps. + const specEnum = SpecChartTypeSchema.optional().unwrap(); + expect(ObjectChartSchema.shape.specType.unwrap()).toBe(specEnum); + // `.extract` builds a new enum, so identity cannot hold here; its members are the spec's own. + expect(ObjectChartSchema.shape.chartType.unwrap()).not.toBe(specEnum); + expect(enumOptions(specEnum)).toEqual(expect.arrayContaining(mirrorFamilies())); + }); + + it.each(DECLARED.map((f) => [f] as const))('`%s` parses in the bag on both faces and on the flat mirror', (family) => { + for (const [face, parse] of FACES) expect(parse(bagNode(family)).success, face).toBe(true); + expect(ObjectChartSchema.safeParse({ type: 'object-chart', chartType: family }).success).toBe(true); + }); + + it.each([ + ...UNDECLARED_SPEC_FAMILIES.map((f) => [`the spec family \`${f}\``, f] as const), + ['an off-spec family', 'sunburst'], + ['a mis-cased family', 'Bar'], + ['a registry key written as a family', 'bar-chart'], + ] as const)('%s is refused at `properties.chartType` with the set named, on both faces', (_label, family) => { + for (const [face, parse] of FACES) { + const issue = issuesOf(parse(bagNode(family))).find((i) => i.code === 'invalid_value'); + expect(issue?.path, face).toEqual(['properties', 'chartType']); + expect([...(issue?.values ?? [])].sort(), face).toEqual([...DECLARED].sort()); + for (const declared of DECLARED) expect(issue?.message, `${face}: the refusal names ${declared}`).toContain(declared); + } + }); + + it('the flat mirror refuses the same spelling at `chartType`, and the lit control parses', () => { + const issue = issuesOf(ObjectChartSchema.safeParse({ type: 'object-chart', chartType: 'gauge' })).find((i) => i.code === 'invalid_value'); + expect(issue?.path).toEqual(['chartType']); + expect(ObjectChartSchema.safeParse({ type: 'object-chart', chartType: 'funnel' }).success).toBe(true); + }); +}); 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 index 2506cff822..656c94b771 100644 --- a/packages/types/src/__tests__/object-chart-properties-bag-11276.test.ts +++ b/packages/types/src/__tests__/object-chart-properties-bag-11276.test.ts @@ -118,7 +118,8 @@ export type assertionEveryBagKeyIsRefusedFlat = [ export type assertionTwinStaysPublished = [ Expect>, Expect>, - Expect>, + // objectui#11513 widened the family set; `object-chart-families-11513.test.ts` holds its members. + Expect['chartType']>>, Expect, TsObjectChartSchema>>, ]; @@ -273,7 +274,9 @@ describe('the bag is the flat mirror\'s own members, by reference (objectui#1127 }); it.each([ - ['a chart family outside the vocabulary', { chartType: 'radar' }, ['properties', 'chartType'], 'invalid_value'], + // `gauge` since objectui#11513: `radar` joined the declared families then, and `gauge` is a spec + // family this block draws no chart of, so it stays outside. + ['a chart family outside the vocabulary', { chartType: 'gauge' }, ['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'], @@ -421,6 +424,6 @@ describe('the arm moved; the post-hoist mirror stayed (objectui#11276)', () => { // 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); + expect(ObjectChartSchema.safeParse({ ...FLAT, chartType: 'gauge' }).success).toBe(false); }); }); diff --git a/packages/types/src/objectql.ts b/packages/types/src/objectql.ts index c234996590..d7fc34a206 100644 --- a/packages/types/src/objectql.ts +++ b/packages/types/src/objectql.ts @@ -5291,14 +5291,29 @@ export interface ObjectChartSchema extends BaseSchema { dataSource?: ElementDataSource; /** 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). + /** Chart type: the `@objectstack/spec` chart families plugin-charts draws + * (objectui#11513), `Extract`ed from the spec's `ChartType` by reference. + * The spec's single-value families (`gauge`, `solid-gauge`, `metric`, + * `kpi`, `bullet`) and tabular ones (`table`, `pivot`) are not declared: + * this block renders one row's number or a notice for them, not a chart. + * The zod mirror picks the same set out of the spec's enum + * (`OBJECT_CHART_FAMILIES`), and the objectui#11513 pins hold the two faces + * to each other and to what renders. * * 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'; + chartType?: Extract< + SpecChartType, + | 'bar' | 'horizontal-bar' | 'column' + | 'line' | 'area' + | 'pie' | 'donut' | 'funnel' + | 'scatter' + | 'treemap' | 'sankey' + | 'combo' + | 'radar' + >; /** * The react tier's chart family: the author's `type` on ``, * parked here by the react-page wrapper because `type` is this node's diff --git a/packages/types/src/zod/objectql.zod.ts b/packages/types/src/zod/objectql.zod.ts index d416cad0c6..04c4bbc227 100644 --- a/packages/types/src/zod/objectql.zod.ts +++ b/packages/types/src/zod/objectql.zod.ts @@ -3354,6 +3354,44 @@ const OBJECT_CHART_NEITHER_CHANNEL = neitherContentChannelGuidance( 'a chart of `objectName` (or inline `data`) drawn by `chartType` from `aggregate` / `series`', ); +/** + * The chart families `ObjectChartSchema.chartType` declares (objectui#11513): + * the installed `@objectstack/spec` `ChartTypeSchema`'s families that + * plugin-charts draws as a chart, in the spec's own order. + * + * Picked out of the spec's enum BY REFERENCE (`.extract`), so a family the + * spec drops fails here when the module loads, and a family the spec adds is + * not declared until something draws it. The spec's other families stay + * undeclared because this block draws no chart of them: the single-value + * families (`gauge`, `solid-gauge`, `metric`, `kpi`, `bullet`) render one + * row's number, and the tabular ones (`table`, `pivot`) a notice. + * `object-metric`, `object-data-table` and `object-pivot` are the blocks that + * draw those. + * + * The TS twin (`../objectql.ts`) is the same set, `Extract`ed from the spec's + * `ChartType`. `../__tests__/object-chart-families-11513.test.ts` holds both + * faces to this set and to the spec, and plugin-charts' + * `object-chart-declared-families-11513.test.tsx` renders every spec family + * through the real `SchemaRenderer` and holds the declared set to the families + * that draw a chart. + */ +const OBJECT_CHART_FAMILIES = [ + 'bar', 'horizontal-bar', 'column', + 'line', 'area', + 'pie', 'donut', 'funnel', + 'scatter', + 'treemap', 'sankey', + 'combo', + 'radar', +] as const; + +/** The refusal an undeclared `chartType` meets; it names the declared set. */ +const OBJECT_CHART_FAMILY_REFUSAL = + `\`chartType\` on an \`object-chart\` is one of ${OBJECT_CHART_FAMILIES.join(', ')} (objectui#11513): ` + + 'the `@objectstack/spec` chart families this chart block draws. The spec\'s single-value and tabular ' + + 'families draw no chart here: write a single number as an `object-metric`, rows as an ' + + '`object-data-table`, a cross-tab as an `object-pivot`.'; + /** * ObjectChart Schema */ @@ -3383,8 +3421,15 @@ export const ObjectChartSchema = BaseSchema.extend({ // `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'), + // + // objectui#11513: `chartType` takes the spec's families plugin-charts draws, + // `OBJECT_CHART_FAMILIES` above, out of the spec's own enum. It declared + // eight until then, while the dashboard composes this node with every series + // family the renderer draws. + chartType: stripImportedDefaults(SpecChartTypeSchema) + .extract(OBJECT_CHART_FAMILIES, { error: OBJECT_CHART_FAMILY_REFUSAL }) + .optional() + .describe(`Chart type — the metadata tier's spelling of the chart family: one of ${OBJECT_CHART_FAMILIES.join(', ')}, the @objectstack/spec ChartType families plugin-charts draws. 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 ── From 0703053bb65b12f367869b3ce1b0d7e080dd091f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 22:53:45 +0000 Subject: [PATCH 2/3] test(plugin-charts): object-chart draws a chart of exactly the families its chartType declares (objectui#11513) Every installed-spec ChartTypeSchema family is authored as an object-chart bag document on the inline object path and rendered through the real SchemaRenderer: a declared family parses on both faces and draws its own marks, an undeclared one is refused and draws a number or a notice. bar is the lit control, the off-spec sunburst the dark one. Claude-Session: https://claude.ai/code/session_01XvhGmGAP79ZB8swnkapxPC Co-authored-by: Claude --- ...ect-chart-declared-families-11513.test.tsx | 219 ++++++++++++++++++ 1 file changed, 219 insertions(+) create mode 100644 packages/plugin-charts/src/__tests__/object-chart-declared-families-11513.test.tsx diff --git a/packages/plugin-charts/src/__tests__/object-chart-declared-families-11513.test.tsx b/packages/plugin-charts/src/__tests__/object-chart-declared-families-11513.test.tsx new file mode 100644 index 0000000000..4c677fd708 --- /dev/null +++ b/packages/plugin-charts/src/__tests__/object-chart-declared-families-11513.test.tsx @@ -0,0 +1,219 @@ +/** + * 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#11513 — `ObjectChartSchema.chartType` declares exactly the + * `@objectstack/spec` chart families this block draws as a chart. + * + * ## What this pins + * + * The seat's ruling A: the face declares the installed spec's families that + * plugin-charts draws, and ⛔ no family nothing draws. So the declared set is + * a measurement, and this file re-takes it on every run: every family the + * installed spec's `ChartTypeSchema` lists is authored as an `object-chart` + * document in the `properties` bag, on the inline object path the dashboard + * composes (`objectName` + `aggregate`), and rendered through the real + * `SchemaRenderer` with this package's registrations live. + * + * - A family the face declares parses on both faces and draws ITS OWN marks: + * bars for the bar family (categories across for `bar` / `column`, down for + * `horizontal-bar`), a line, an area, pie sectors, funnel trapezoids, + * scatter symbols, treemap cells, sankey nodes and links, a radar polygon, + * and for `combo` the bar mark on the combo arm's two value axes. A family + * silently drawn as a bar fails here. + * - A spec family the face does not declare draws no chart: the single-value + * families render one row's number, the tabular ones a notice. If one of + * them starts drawing a chart, this file goes red, and the family is + * measured and declared rather than drawn undeclared. + * + * The declared set is read off the zod mirror, and the spec's list off the + * installed `@objectstack/spec`, so neither list is restated here. + * `@object-ui/types`' `object-chart-families-11513.test.ts` holds the two faces + * to the same set at the type level. + * + * Controls: `bar` is the lit control (a family that draws, so the mark + * counters can see a chart), and the off-spec `sunburst` is the dark one: it + * draws no chart, and the notice it renders is the one the "no chart" + * predicate must be able to see. + */ + +import React from 'react'; +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, cleanup, waitFor } from '@testing-library/react'; + +// Recharts' ResponsiveContainer measures via ResizeObserver, which reports 0x0 +// under the headless DOM, so nothing paints. Fix its size. +vi.mock('recharts', async () => { + const actual = await vi.importActual('recharts'); + return { + ...actual, + ResponsiveContainer: ({ children }: any) => + React.cloneElement(children, { width: 480, height: 320 }), + }; +}); + +import { ChartTypeSchema } from '@objectstack/spec/ui'; +import { chartCategoryKey, chartMeasureKey } from '@object-ui/core'; +import { SchemaRenderer, SchemaRendererProvider } from '@object-ui/react'; +import { enumOptions } from '@object-ui/test-support'; +import { ObjectChartSchema, StrictAnyComponentSchema, safeValidateSchema } from '@object-ui/types/zod'; +// The package entry, for its REGISTRATION side effects. Relative: a package +// must not import itself (`pnpm check:self-import`). +import '../index'; +// `ChartRenderer` renders its implementation behind +// `React.lazy(() => import('./AdvancedChartImpl'))`; importing it here pays the +// recharts graph in the import phase, which no test timeout applies to +// (AGENTS.md §测试纪律). +import '../AdvancedChartImpl'; + +afterEach(cleanup); + +// `ObjectChart` loads the grouped field's metadata over `fetch` for its labels. +// Answer it here, for the whole file, so no read reaches a socket. +vi.stubGlobal('fetch', async () => new Response('{}', { status: 404 })); + +const SPEC_FAMILIES: string[] = enumOptions(ChartTypeSchema); +const DECLARED: string[] = enumOptions(ObjectChartSchema.shape.chartType); + +const AGGREGATE = { field: 'amount', function: 'sum', groupBy: 'stage' } as const; +const MEASURE = chartMeasureKey(AGGREGATE, 'value'); +const CATEGORY = chartCategoryKey(AGGREGATE, 'name'); + +/** The rows the aggregate answers. Scatter plots its category as a position, so its rows are numeric. */ +const rowsFor = (family: string) => + (family === 'scatter' ? [1, 2, 3] : ['won', 'open', 'lost']).map((stage, i) => ({ [CATEGORY]: stage, [MEASURE]: i + 1 })); + +const sourceFor = (family: string) => ({ + find: async () => [], + findOne: async () => null, + aggregate: async () => rowsFor(family), + count: async () => 0, + getObject: async () => null, +}); + +/** + * The authored document: the `object-chart` node with its props in the `properties` bag, its + * category on the spec's `xAxis` and its measure on the spec's `{ name }` series arm. + */ +const documentFor = (family: string) => ({ + type: 'object-chart', + properties: { + chartType: family, + objectName: 'probe_deal', + aggregate: AGGREGATE, + xAxis: { field: CATEGORY }, + series: [{ name: MEASURE }], + }, +}); + +interface Drawn { + marks: Record; + valueAxes: number; + xTicks: string[]; + yTicks: string[]; + notice: string | null; + refusal: string | null; + surface: boolean; +} + +async function renderFamily(family: string): Promise { + const { container: c } = render( + + + , + ); + await waitFor( + () => { + const painted = + c.querySelector('.recharts-surface') ?? + c.querySelector('[data-chart-error]') ?? + c.querySelector('[data-testid^="advanced-chart-"]'); + expect(painted, `${family}: the chart never rendered past its loading state`).not.toBeNull(); + }, + { timeout: 10_000 }, + ); + const ticks = (axis: 'x' | 'y') => + [...c.querySelectorAll(`.recharts-${axis}Axis-tick-labels .recharts-cartesian-axis-tick-value`)].map((e) => e.textContent ?? ''); + const count = (selector: string) => c.querySelectorAll(selector).length; + return { + marks: { + bar: count('.recharts-bar'), + line: count('.recharts-line'), + area: count('.recharts-area'), + pie: count('.recharts-pie'), + funnel: count('.recharts-trapezoids'), + scatter: count('.recharts-scatter'), + treemap: count('.recharts-treemap-depth-1'), + sankey: count('.recharts-sankey-nodes') + count('.recharts-sankey-links'), + radar: count('.recharts-radar'), + }, + valueAxes: count('.recharts-yAxis'), + xTicks: ticks('x'), + yTicks: ticks('y'), + notice: c.querySelector('[data-testid^="advanced-chart-"]')?.getAttribute('data-testid') ?? null, + refusal: c.querySelector('[data-chart-error]')?.getAttribute('data-chart-error') ?? null, + surface: !!c.querySelector('.recharts-surface'), + }; +} + +const drawsAChart = (d: Drawn) => d.surface && d.notice === null && d.refusal === null; +const onlyMark = (d: Drawn, mark: string) => + Object.entries(d.marks).filter(([, n]) => n > 0).map(([name]) => name).join(',') === mark; + +/** What each declared family draws, read off what recharts put in the DOM. */ +const DRAWS_ITS_OWN_MARKS: Record boolean> = { + bar: (d) => onlyMark(d, 'bar') && d.xTicks.includes('won') && d.valueAxes === 1, + column: (d) => onlyMark(d, 'bar') && d.xTicks.includes('won') && d.valueAxes === 1, + 'horizontal-bar': (d) => onlyMark(d, 'bar') && d.yTicks.includes('won') && !d.xTicks.includes('won'), + line: (d) => onlyMark(d, 'line'), + area: (d) => onlyMark(d, 'area'), + pie: (d) => onlyMark(d, 'pie'), + donut: (d) => onlyMark(d, 'pie'), + funnel: (d) => onlyMark(d, 'funnel'), + scatter: (d) => onlyMark(d, 'scatter'), + treemap: (d) => onlyMark(d, 'treemap'), + sankey: (d) => onlyMark(d, 'sankey'), + radar: (d) => onlyMark(d, 'radar'), + // One measure on the combo arm: its positional first mark, on the arm's two value axes. + combo: (d) => onlyMark(d, 'bar') && d.valueAxes === 2, +}; + +describe('object-chart draws a chart of exactly the families its `chartType` declares (objectui#11513)', () => { + it('reads both lists, and the declared one is a strict subset of the spec\'s', () => { + expect(SPEC_FAMILIES.length, 'could not read ChartTypeSchema.options from the spec').toBeGreaterThan(0); + expect(DECLARED.length, 'could not read the declared families off the mirror').toBeGreaterThan(0); + expect(DECLARED.filter((f) => !SPEC_FAMILIES.includes(f))).toEqual([]); + expect(DECLARED.length).toBeLessThan(SPEC_FAMILIES.length); + // Every declared family has its own mark predicate below, so none can pass as "some chart". + expect(Object.keys(DRAWS_ITS_OWN_MARKS).sort()).toEqual([...DECLARED].sort()); + }); + + it('LIT and DARK controls: `bar` draws a chart, the off-spec `sunburst` draws none and says so', async () => { + const lit = await renderFamily('bar'); + expect(drawsAChart(lit), JSON.stringify(lit)).toBe(true); + cleanup(); + const dark = await renderFamily('sunburst'); + expect(drawsAChart(dark), JSON.stringify(dark)).toBe(false); + expect(dark.notice).toBe('advanced-chart-unknown-type'); + }); + + it.each(SPEC_FAMILIES.map((f) => [f] as const))('`%s`: declared on the face if and only if it draws a chart', async (family) => { + const declared = DECLARED.includes(family); + for (const parse of [safeValidateSchema, (doc: unknown) => StrictAnyComponentSchema.safeParse(doc)]) { + expect(parse(documentFor(family)).success, `${family} parses exactly when it is declared`).toBe(declared); + } + const drawn = await renderFamily(family); + expect(drawsAChart(drawn), `${family}: ${JSON.stringify(drawn)}`).toBe(declared); + if (declared) { + expect(DRAWS_ITS_OWN_MARKS[family](drawn), `${family} drew another family's marks: ${JSON.stringify(drawn)}`).toBe(true); + } else { + // What an undeclared spec family renders instead of a chart: a number, or a notice. + expect(['advanced-chart-single-value', 'advanced-chart-tabular-notice']).toContain(drawn.notice); + } + }); +}); From ae16cff80317e5da1ae439bcb706b42e647f0d2f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 23:03:41 +0000 Subject: [PATCH 3/3] docs(plugin-charts),changeset: the object-chart families, and the objectui#11513 changeset plugin-charts.mdx gains an Object Charts section naming the families chartType takes, what the spec families it refuses render instead, and a JSON and a typed example. Claude-Session: https://claude.ai/code/session_01XvhGmGAP79ZB8swnkapxPC Co-authored-by: Claude --- .changeset/11513-object-chart-families.md | 23 ++++++++++++++++ content/docs/plugins/plugin-charts.mdx | 33 ++++++++++++++++++++++- 2 files changed, 55 insertions(+), 1 deletion(-) create mode 100644 .changeset/11513-object-chart-families.md diff --git a/.changeset/11513-object-chart-families.md b/.changeset/11513-object-chart-families.md new file mode 100644 index 0000000000..471e210ac3 --- /dev/null +++ b/.changeset/11513-object-chart-families.md @@ -0,0 +1,23 @@ +--- +'@object-ui/types': minor +--- + +feat(types): `ObjectChartSchema.chartType` declares the `@objectstack/spec` chart families plugin-charts draws (objectui#11513) + +**Clause-②: yes (widening)** — the accept set only widens. Every document that parsed still parses. + +`chartType` on an `object-chart` node declared eight families on both faces: `bar`, `column`, `horizontal-bar`, `line`, `area`, `pie`, `donut` and `scatter`. The installed spec's `ChartTypeSchema` (17.5.0) declares twenty, and plugin-charts draws thirteen of them as a chart. The dashboard renderers compose an `object-chart` node with every series family they route, so the node they build named families the face refused. + +It now declares those thirteen. Five families are new: `funnel`, `treemap`, `sankey`, `combo` and `radar`. + +- **Zod (`@object-ui/types/zod`).** `ObjectChartSchema.chartType` is picked out of the spec's `ChartTypeSchema` by reference (`.extract`). The authored `object-chart` bag (`ObjectChartBlockSchema`, `properties.chartType`) reads the same member, so it widens too. A family the spec drops fails when the module loads. +- **TypeScript.** `ObjectChartSchema['chartType']` is the same thirteen, `Extract`ed from the spec's `ChartType`. +- **Still refused.** The spec's single-value families (`gauge`, `solid-gauge`, `metric`, `kpi`, `bullet`) and tabular ones (`table`, `pivot`) draw no chart on this block. A single-value family renders one row's number, and a tabular one renders a notice. They are refused at `chartType` (`properties.chartType` on an authored node), and the message names the declared set. Write a single number as an `object-metric`, rows as an `object-data-table`, a cross-tab as an `object-pivot`. + +```json +{ "type": "object-chart", "properties": { "chartType": "funnel", "objectName": "opportunity", "aggregate": { "field": "amount", "function": "sum", "groupBy": "stage" }, "xAxis": { "field": "stage" }, "series": [{ "name": "amount" }] } } +``` + +Nothing changes at render time: plugin-charts already drew all thirteen. `specType`, the react tier's family key, is unchanged. It is still the spec's whole `ChartTypeSchema`, by reference. + +⚠️ Shipped as `minor`, not `major` (objectui never declares `major`). The TypeScript union widens, so a consumer that reads it exhaustively can stop compiling: a `switch` with an exhaustiveness check, or a `Record` keyed by `NonNullable`, now needs entries for the five new families. diff --git a/content/docs/plugins/plugin-charts.mdx b/content/docs/plugins/plugin-charts.mdx index 810bba4eb5..a54fdb0386 100644 --- a/content/docs/plugins/plugin-charts.mdx +++ b/content/docs/plugins/plugin-charts.mdx @@ -89,7 +89,7 @@ It extends `BaseSchema`, so the shared component properties are available on a ## Chart Types -The plugin provides two different chart components: +The plugin provides three chart components: `bar-chart` and `chart` draw the rows they are given, and `object-chart` fetches its own: ### Simple Bar Chart (bar-chart) @@ -266,6 +266,37 @@ const schema: ChartSchema = { } ``` +### Object Charts (object-chart) + +An `object-chart` node charts an object's records (`objectName` with an `aggregate`) or a dataset (`dataset` with `dimensions` and `values`), and fetches the rows itself. An authored node takes its props in the `properties` bag. Its family is `chartType`, one of the `@objectstack/spec` chart families this package draws as a chart: `bar`, `horizontal-bar`, `column`, `line`, `area`, `pie`, `donut`, `funnel`, `scatter`, `treemap`, `sankey`, `combo` and `radar` (objectui#11513). + +```json +{ + "type": "object-chart", + "properties": { + "chartType": "funnel", + "objectName": "opportunity", + "aggregate": { "field": "amount", "function": "sum", "groupBy": "stage" }, + "xAxis": { "field": "stage" }, + "series": [{ "name": "amount" }] + } +} +``` + +The spec's single-value families (`gauge`, `solid-gauge`, `metric`, `kpi`, `bullet`) and tabular ones (`table`, `pivot`) are refused on `chartType`, and the refusal names the set above: this node draws no chart of them. Write a single number as an `object-metric`, rows as an `object-data-table`, and a cross-tab as an `object-pivot`. The TypeScript `ObjectChartSchema` in `@object-ui/types` declares the same set, taken from the spec's `ChartType`: + +```ts +import type { ObjectChartSchema } from '@object-ui/types' + +const pipeline: ObjectChartSchema = { + type: 'object-chart', + chartType: 'funnel', + objectName: 'opportunity', + aggregate: { field: 'amount', function: 'sum', groupBy: 'stage' }, + series: [{ name: 'amount' }] +} +``` + ## Examples ### Revenue Dashboard