Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions .changeset/10770-object-chart-react-tier-node.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
---
'@object-ui/types': minor
---

`ObjectChartSchema` (and its TS twin) accepts the `object-chart` node that the react tier's `<ObjectChart>` 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 `<ObjectChart>` 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
`<ObjectChart objectName="showcase_invoice" type="bar" … series={[{ name: 'total', label: 'Invoice value' }]} />`.
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 }>`.
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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).
Expand Down
5 changes: 5 additions & 0 deletions packages/types/src/__tests__/imported-defaults-8317.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -200,6 +201,10 @@ const IMPORTED: Array<readonly [string, z.ZodType]> = [
// 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. */
Expand Down
Original file line number Diff line number Diff line change
@@ -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 `<ObjectChart>` node, on the
* `ObjectChartSchema` face.
*
* The objectstack showcase `renewals-pipeline` page authors
* `<ObjectChart objectName="showcase_invoice" type="bar" … series={[{ name:
* 'total', label: 'Invoice value' }]} />`. 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 `<ObjectChart>`: `{ 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<A, B> =
(<T>() => T extends A ? 1 : 2) extends (<T>() => T extends B ? 1 : 2) ? true : false;
type Expect<T extends true> = T;
type SeriesEntry = NonNullable<ObjectChartSchema['series']>[number];

/** The author arm IS the spec's `ChartSeries`, read off the union by its binding key. */
export type assertionAuthorArmIsTheSpecSeries = Expect<Equal<Extract<SeriesEntry, { name: string }>, SpecChartSeries>>;
/** The internal arm is still there, and still binds through a required `dataKey`. */
export type assertionInternalArmKeepsDataKey = Expect<Equal<Extract<SeriesEntry, { dataKey: string }>['dataKey'], string>>;
/** `specType` is the spec's `ChartType`, by reference. */
export type assertionSpecTypeIsTheSpecChartType = Expect<Equal<ObjectChartSchema['specType'], SpecChartType | undefined>>;
/** `chartType` is optional on this face; the mirror carries the one-of floor. */
export type assertionChartTypeIsOptional = Expect<Equal<undefined extends ObjectChartSchema['chartType'] ? true : false, true>>;
/** The helper can FAIL — synthetic control (an undeclared key reads `any`). */
export type assertionEqualCanFail = Expect<Equal<Equal<any, SpecChartType | undefined>, 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<Array<{ code: string; keys?: string[] }>> }).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);
});
});
16 changes: 11 additions & 5 deletions packages/types/src/__tests__/widget-schema-anchors-7946.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<ObjectChart>` 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)
*
Expand Down Expand Up @@ -111,12 +115,13 @@ export type assertionXAxisKeyDeclared = Expect<Equal<ObjectChartSchema['xAxisKey
export type assertionColorsConverged = Expect<Equal<ObjectChartSchema['colors'], string[] | Record<string, string> | undefined>>;
export type assertionFilterAdmitsBothArms = Expect<Equal<ObjectChartSchema['filter'], any[] | Record<string, any> | 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<Equal<NonNullable<ObjectChartSchema['series']>[number]['dataKey'], string>>;
export type assertionSeriesBindsDataKey = Expect<Equal<Extract<NonNullable<ObjectChartSchema['series']>[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
Expand Down Expand Up @@ -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)', () => {
Expand Down Expand Up @@ -208,7 +214,7 @@ describe('the zod mirror declares the same keys (objectui#7946)', () => {

const refusals: Array<readonly [string, unknown]> = [
['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' }],
Expand Down
Loading
Loading