diff --git a/.changeset/objectchart-schema-anchor-7946.md b/.changeset/objectchart-schema-anchor-7946.md new file mode 100644 index 0000000000..492f89f608 --- /dev/null +++ b/.changeset/objectchart-schema-anchor-7946.md @@ -0,0 +1,98 @@ +--- +"@object-ui/types": minor +"@object-ui/plugin-charts": minor +--- + +feat(types,plugin-charts): anchor `ObjectChart`'s props to `ObjectChartSchema` and declare the four keys its producers write + +`ObjectChart` was published as `(props: any)`, so `ObjectChartSchema` anchored +nothing: every `schema={{ … }}` literal handed to the component was type-checked +against nothing at all. Four keys its producers write and its renderer reads — +`xAxisKey`, `series`, `aggregate`, `filter` — were declared on neither published +copy of the shape, and rode `BaseSchema`'s index signature / `.passthrough()` +unvalidated. That is the mechanism that let objectui#7891's undeclared `config` +rung survive from the day it was written. + +Maintainer ruling 2026-09-09 (option A), applying objectui#6576's gallery +treatment to the chart: + +- `ObjectChartProps.schema` is `ObjectChartSchema`; the published `.d.ts` no + longer says `props: any`. `ObjectChartProps` is exported. +- The four keys are declared on BOTH copies, with value types taken from their + READ sites (`ChartRendererProps` for `xAxisKey` / `series`, `ObjectChart.tsx` + for `filter`) rather than copied from any producer's literal — except where + `@objectstack/spec` already owns the shape, which is `aggregate`: that one is + declared BY REFERENCE as `ChartAggregate` / `ChartAggregateSchema`, so the + authoring door here and at the react-page publish gate are one shape and + cannot drift into two dialects. +- `colors` converges: the zod mirror has declared it since objectui#3913 and the + TS interface did not, a drift no ratchet could see because a mirror-only key + is in neither of the parity guard's two difference ledgers. + +Two of the four are AUTHORABLE (`aggregate`, `filter` — the spec names this +component's own props as their carrier and parses `aggregate` at the react-page +publish gate) and two are INTERNAL, relay-composed (`xAxisKey`, `series` — every +producer computes them and the spec's author-facing vocabulary refuses the +internal spellings by name). The internal pair is declared anyway, because it was +already passing through unvalidated: declaring buys the value check without +minting authorable vocabulary, and each description says which it is. + +`filter` keeps BOTH arms (a `FilterArray` or the ObjectQL `$filter` object), and +narrowing to one is a decision LOCAL TO THIS NODE rather than a fleet-wide one: +the six sibling `object-*` widgets that declare `filter` are already array-only, +so there is no cross-widget convention to renegotiate. What blocks the narrowing +is this component's own drill-down spread, which mis-composes the array arm into +index keys; that is named as the successor on the member's docblock. + +BEHAVIOUR, from what the anchor made visible: `ObjectChart` resolved the +group-by column twice and only one site normalised the structured +`groupBy: { field, dateGranularity }` node. The other used the raw union as a row +index, a field name and a drill-filter key, so a date-bucketed chart lost its +option-colour resolution, its label→raw reverse map and its drill filter to a +lookup on the node's stringification. Both sites now share one normalisation, and +the behaviour is pinned at runtime by +`plugin-charts/src/__tests__/ObjectChart.structuredGroupBy-7946.test.tsx` (drill +filter keyed by the projected column, alias and field arms, and the label→raw +recovery) — a compile-time pin cannot see a wrong runtime value flowing from a +correctly-typed read. + +The drill drawer's heading fallback now resolves `schema.title` through +`pickLocalized` (`@object-ui/i18n`) instead of using it as a bare string. The +spec types that slot as `I18nLabel` — a plain string or an inline locale map — +and the map arm used to reach the heading as an object. + +## Migration — what a TS consumer of `` must change + +The headline is that a wrong VALUE TYPE is now a compile error, but three +NARROWINGS bite first, and they are what the eight edited test files in this +change had to absorb: + +- **`type: 'object-chart'` is now required on the literal.** A minimal + `schema={{ objectName: 'account', chartType: 'bar' }}` no longer compiles. +- **`chartType` must be the declared union.** A literal written inline is fine; + one hoisted into a non-`const` object widens to `string` and is refused. Use + `as const` (or annotate the holder as `ObjectChartSchema`). +- **`series` entries must be `dataKey`-shaped.** The renderer's internal arm is + `{ dataKey, … }`; the spec's author-facing `{ name, … }` arm is a different + shape, translated by `normalizeChartSchema` one layer down. + +And, from the by-reference `aggregate`: + +- **`aggregate.function` and `aggregate.groupBy` are REQUIRED**, `aggregate.field` + stays optional (only `count` counts rows rather than a column), the structured + `groupBy` node must name its `field`, and unknown members are REFUSED by name + rather than dropped. `aggregate: {}` and `{ field: 'amount' }` used to compile + and no longer do. This is `ChartAggregateSchema`'s accept set, which the publish + gate has always enforced on authored `` literals — + so a document that compiles today is one the platform already accepted. + +The RENDERER still accepts more than this and still draws its named refusal +screen for an aggregate that declares no category axis (objectui#8168): untyped +producers forward `aggregate` as `any`, so out-of-contract documents keep +arriving at runtime. Narrowing the declaration is about what an author may +WRITE, not about what the renderer will tolerate. + +⚠️ Anchoring does not buy rejection of a MISSPELLED key on the node itself: +`BaseSchema` carries `[key: string]: any` (objectui#5155), the same ceiling +objectui#6576 accepted. `aggregate` is the exception, and only because the spec's +own object is strict. diff --git a/packages/plugin-charts/src/ObjectChart.absentCategoryAxisRefusal-8168.test.tsx b/packages/plugin-charts/src/ObjectChart.absentCategoryAxisRefusal-8168.test.tsx index 4f70655867..2b790979d6 100644 --- a/packages/plugin-charts/src/ObjectChart.absentCategoryAxisRefusal-8168.test.tsx +++ b/packages/plugin-charts/src/ObjectChart.absentCategoryAxisRefusal-8168.test.tsx @@ -55,6 +55,7 @@ vi.mock('recharts', async () => { }); import { ObjectChart, resolveChartCategoryField } from './ObjectChart'; +import type { ObjectChartSchema } from '@object-ui/types'; beforeEach(() => { vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, json: async () => ({}) }))); @@ -77,8 +78,37 @@ const dataSourceWithRows = () => ({ aggregate: vi.fn().mockResolvedValue(ROWS), }); -const renderChart = (schema: Record, ds: any = dataSourceWithRows()) => - render(); +// `type` moved into the base literal when objectui#7946 anchored +// `ObjectChartProps.schema` to `ObjectChartSchema`, on which `type` is +// required and pinned to the registry key. The per-case overrides stay a +// partial of the anchored type — this file's whole subject is nodes that +// declare no category axis, so the parameter must keep admitting them. +const renderChart = (schema: Partial, ds: any = dataSourceWithRows()) => + render(); + +/** + * ⭐ An aggregate the AUTHORING DOOR refuses, handed to the renderer anyway — + * which is the population this whole file is about, and the reason it needs a + * spelling of its own. + * + * objectui#7946's rework round declared `ObjectChartSchema.aggregate` as + * `@objectstack/spec`'s `ChartAggregate` BY REFERENCE, where `function` and + * `groupBy` are REQUIRED and the structured `groupBy` node must name its + * `field`. Two of the cases below are exactly those documents — a measure with + * no category, and a date-bucketing node naming no field — so they are no longer + * writable as typed literals. ⛔ That is not a reason to widen the declaration + * back: it is the reason this refusal screen exists. Every live producer + * forwards `aggregate` as `any` (`DashboardRenderer`'s `(widget as any).data`, + * app-shell's `viewDef: any`), so a document the door refuses still ARRIVES at + * this component at runtime, and what it owes the reader then is a named + * refusal rather than a bar labelled `Unknown`. + * + * So the out-of-contract shape is asserted HERE, once, named, and greppable — + * rather than by relaxing the parameter type, which would quietly make every + * other literal in this file unchecked too. + */ +const UNAUTHORABLE = (aggregate: unknown): ObjectChartSchema['aggregate'] => + aggregate as ObjectChartSchema['aggregate']; /** The refusal is absent AND the chart got far enough to draw. */ const expectNoRefusal = async () => { @@ -110,7 +140,7 @@ describe('ObjectChart — absent category axis refusal (objectui#8168)', () => { // the measure instead. renderChart({ objectName: 'crm_opportunity', - aggregate: { field: 'amount', function: 'sum' }, + aggregate: UNAUTHORABLE({ field: 'amount', function: 'sum' }), series: [{ dataKey: 'amount' }], }); @@ -122,7 +152,7 @@ describe('ObjectChart — absent category axis refusal (objectui#8168)', () => { // naming no field has no other spelling that could rescue it. renderChart({ objectName: 'crm_opportunity', - aggregate: { field: 'amount', function: 'sum', groupBy: { dateGranularity: 'day' } }, + aggregate: UNAUTHORABLE({ field: 'amount', function: 'sum', groupBy: { dateGranularity: 'day' } }), }); expect(await screen.findByTestId(REFUSAL)).toBeInTheDocument(); diff --git a/packages/plugin-charts/src/ObjectChart.contractEnvelope-6839.test.tsx b/packages/plugin-charts/src/ObjectChart.contractEnvelope-6839.test.tsx index 13ce0ff147..5720fb1442 100644 --- a/packages/plugin-charts/src/ObjectChart.contractEnvelope-6839.test.tsx +++ b/packages/plugin-charts/src/ObjectChart.contractEnvelope-6839.test.tsx @@ -75,7 +75,7 @@ const asBareArray: Envelope = (rows) => rows; const asRecords: Envelope = (rows) => ({ records: rows, total: rows.length }); const schema = { - type: 'object-chart', + type: 'object-chart' as const, chartType: 'bar' as const, objectName: 'crm_opportunity', xAxisKey: 'stage', diff --git a/packages/plugin-charts/src/ObjectChart.drillNavigate.test.tsx b/packages/plugin-charts/src/ObjectChart.drillNavigate.test.tsx index c1e9f0c8b4..7b69f5a43f 100644 --- a/packages/plugin-charts/src/ObjectChart.drillNavigate.test.tsx +++ b/packages/plugin-charts/src/ObjectChart.drillNavigate.test.tsx @@ -90,8 +90,8 @@ afterEach(() => { }); const schema = (drillDown: Record) => ({ - type: 'object-chart', - chartType: 'bar', + type: 'object-chart' as const, + chartType: 'bar' as const, objectName: 'opportunity', xAxisKey: 'stage', data: [{ stage: 'won', amount: 42 }], diff --git a/packages/plugin-charts/src/ObjectChart.emptyResult.test.tsx b/packages/plugin-charts/src/ObjectChart.emptyResult.test.tsx index d1e6a4fdc9..627e45891e 100644 --- a/packages/plugin-charts/src/ObjectChart.emptyResult.test.tsx +++ b/packages/plugin-charts/src/ObjectChart.emptyResult.test.tsx @@ -53,7 +53,7 @@ afterEach(() => { }); const schema = { - type: 'object-chart', + type: 'object-chart' as const, chartType: 'bar' as const, objectName: 'crm_opportunity', xAxisKey: 'stage', diff --git a/packages/plugin-charts/src/ObjectChart.optionColors.test.tsx b/packages/plugin-charts/src/ObjectChart.optionColors.test.tsx index bd28c57ff5..1da3a6ba65 100644 --- a/packages/plugin-charts/src/ObjectChart.optionColors.test.tsx +++ b/packages/plugin-charts/src/ObjectChart.optionColors.test.tsx @@ -111,8 +111,8 @@ describe('ObjectChart — category option colors (objectui#4106)', () => { render( { render( { render( { }; const OBJECT_CHART_SCHEMA = { - type: 'object-chart', - chartType: 'bar', + type: 'object-chart' as const, + chartType: 'bar' as const, objectName: 'opportunity', xAxisKey: 'stage', data: [{ stage: 'won', amount: 42 }], @@ -326,8 +326,8 @@ describe('ObjectChart — option-color probe routing (objectui#4114)', () => { > { +/** + * Props of {@link ObjectChart} — anchored to the published `ObjectChartSchema` + * (objectui#7946, maintainer ruling 2026-09-09 option A), as objectui#6576 did + * for `ObjectGalleryProps.schema`. + * + * ## What this replaces, and what it buys + * + * This component was published as `(props: any)`, so every `schema={{ … }}` + * literal handed to it — including the two in `app-shell`'s `ObjectView` — was + * type-checked against NOTHING. That is the mechanism that let objectui#7891's + * undeclared `config` rung live from the day it was written until someone read + * the spec by hand, and it is why the two `as any` casts on those literals + * measured INERT: an `any` consumer accepts a cast and its absence alike. + * + * With the anchor, a wrong VALUE TYPE on a declared key is a compile error at + * the producer (`xAxisKey: 42`, `series: 'x'`, `type: 'chart'`, and every + * `BaseSchema` member — `visible: 42`). ⚠️ A MISSPELLED key is still accepted: + * `BaseSchema` carries `[key: string]: any` (objectui#5155), the same ceiling + * objectui#6576 accepted knowingly. `__tests__/ObjectChart.schemaAnchor-7946.test.ts` + * pins both halves, the ceiling included, so the anchor is not read as more + * than it is. + */ +export interface ObjectChartProps { + /** + * The `object-chart` node — anchored to the exported schema type. Every + * `BaseSchema` member is writable, `bind` / `className` / `data` included; + * the widget's own keys are declared there. + */ + schema: ObjectChartSchema; + /** + * Host data source. `any` deliberately, and it is NOT a residue of the shape + * this card removed: it is the type `SchemaRendererContext.dataSource` + * itself carries, and this component falls back to that context value, so a + * narrower declaration here would claim a guarantee the fallback cannot + * keep. Narrowing it is a repo-wide `dataSource` interface, not this card. + */ + dataSource?: any; + /** + * Optional host-owned segment click. When provided (e.g. a dataset widget + * that owns precise drill-through), it takes over the chart click and the + * widget's own object-drill drawer is suppressed. + */ + onSegmentClick?: (ev: ChartSegmentClickEvent) => void; +} + +export const ObjectChart = (props: ObjectChartProps) => { const { schema } = props; - // Optional host-owned segment click. When provided (e.g. a dataset widget - // that owns precise drill-through), it takes over the chart click and the - // widget's own object-drill drawer is suppressed. const onSegmentClick: ((ev: ChartSegmentClickEvent) => void) | undefined = props.onSegmentClick; const context = useContext(SchemaRendererContext); const dataSource = props.dataSource || context?.dataSource; @@ -414,6 +488,15 @@ export const ObjectChart = (props: any) => { // Host-provided "open in list" navigation for the drill escape hatch. const { openRecordList } = useDrillNavigation(); const tt = useSafeTranslate(); + // The active UI language, for `pickLocalized` on the drill heading below. + // Read HERE rather than at the read site because that site lives inside + // `drillDrawer`, which runs after this component's conditional early returns — + // a hook called there would desync hook order between renders. + // + // `useObjectTranslation` is provider-safe (optional context read, falling back + // to the react-i18next global instance), which is why it can sit beside + // `useSafeTranslate` above without a provider in tests. + const { language } = useObjectTranslation(); // Stable JSON keys for aggregate/filter so that callers passing a fresh // object literal on each render (e.g. DashboardRenderer.getComponentSchema) @@ -577,7 +660,15 @@ export const ObjectChart = (props: any) => { // windows). Extracted so the two queries share identical logic. const runAggregate = useCallback(async (ds: any, filterForRun: any): Promise => { if (schema.aggregate && typeof ds.aggregate === 'function') { - const gb = schema.aggregate.groupBy as any; + // ⚠️ The RAW union, deliberately — this is the one read in the file that + // must NOT go through `aggregateGroupByKey`. The structured node is sent + // to the server verbatim as the query's `groupBy`, so normalising it to + // its projected column here would drop `dateGranularity` and turn a + // date-bucketed query into an ungrouped one. Every read that indexes a ROW + // or names a FIELD uses the helper instead. The `as any` this line used to + // carry is gone with objectui#7946's by-reference `aggregate`: the union is + // declared now, so `Array.isArray` narrows it without a cast. + const gb = schema.aggregate.groupBy; // Structured GroupBy node (e.g. `{ field, dateGranularity: 'day' }`) // requires the spec-shape `{ groupBy: GroupByNode[], aggregations, // where }` payload so the server-side date-bucket engine kicks in. @@ -616,8 +707,16 @@ export const ObjectChart = (props: any) => { if (typeof ds.find === 'function') { const results = await ds.find(schema.objectName, { $filter: filterForRun }); let data = extractRecords(results); - if (schema.aggregate && data.length > 0) { - data = aggregateRecords(data, schema.aggregate); + // `aggregateRecords` buckets on `record[groupBy]`, so it needs the + // projected COLUMN, not the raw union: a structured `groupBy` node used + // as an index stringifies, and every row lands in one bucket keyed by + // that stringification. Declaring the key (objectui#7946) is what made + // that reachable to a compiler. When no column resolves there is nothing + // to group by, and the chart is already refused for exactly that reason + // by the absent-category screen below (objectui#8168). + const clientGroupBy = aggregateGroupByKey(schema.aggregate); + if (schema.aggregate && clientGroupBy && data.length > 0) { + data = aggregateRecords(data, { ...schema.aggregate, groupBy: clientGroupBy }); } return data; } @@ -696,10 +795,15 @@ export const ObjectChart = (props: any) => { // groupBy may be a bare string or a structured `{field, dateGranularity}` // node (when categoryGranularity is configured upstream). Normalise // to the underlying string field name so all column lookups work. - const gbRaw = schema.aggregate?.groupBy as any; - const groupByField: string | undefined = (gbRaw && typeof gbRaw === 'object' && !Array.isArray(gbRaw)) - ? gbRaw.alias || gbRaw.field - : (gbRaw || schema.xAxisKey); + // + // ⭐ Through {@link aggregateGroupByKey}, the SAME spelling the drill / + // label leg below uses — the whole point of hoisting it (objectui#7946). + // This site carried its own inline copy of the normalisation behind an + // `as any`; while the two were spelled separately, one of them could + // drift without the other, which is precisely how the drill leg came to + // be missing it. One expression, two call sites, no cast. + const groupByField: string | undefined = + aggregateGroupByKey(schema.aggregate) || schema.xAxisKey; if (wantsComparison && comparisonRows.length > 0 && schema.aggregate) { const aggField = schema.aggregate.field; const aggFn = schema.aggregate.function; @@ -753,15 +857,23 @@ export const ObjectChart = (props: any) => { // Resolve groupBy value→label using field metadata. Now that the // merge has happened on raw keys, the resolver can convert the // shared groupBy column (e.g. 'closed_won' → 'Closed Won') uniformly. - if (groupByField && typeof ds.getObjectSchema === 'function') { + // `schema.objectName` joins the guard rather than being asserted: + // it is OPTIONAL since ADR-0021 (a chart may bind a `dataset` + // instead), and every read in this leg — the metadata fetch and the + // per-option label lookup — is keyed by an object NAME. Naming the + // precondition is what `props: any` used to hide (objectui#7946); + // the leg is on the object-bound path, so it is the shape it already + // assumed. + if (groupByField && schema.objectName && typeof ds.getObjectSchema === 'function') { + const objectName = schema.objectName; try { - const objectSchema = await ds.getObjectSchema(schema.objectName); + const objectSchema = await ds.getObjectSchema(objectName); data = await resolveGroupByLabels( data, groupByField, objectSchema, ds, - (value, fallback) => fieldOptionLabel(schema.objectName, groupByField, value, fallback), + (value, fallback) => fieldOptionLabel(objectName, groupByField, value, fallback), ); } catch { // Schema fetch failed — continue with raw values @@ -840,20 +952,35 @@ export const ObjectChart = (props: any) => { // — including `'navigate'` — is honoured below, and the two keys no renderer // read at all (`view`, `sort`) are gone from `DrillDownConfig`. // - // That leaves ONE asymmetry, deliberately not papered over here. The spec's - // `ChartDrillDownSchema` declares `target: 'drawer' | 'dialog'`, and its - // stated rationale was a measurement — every key has an `ObjectChart` read - // site, and at the time this component ignored `'navigate'`. That measurement - // changed with this issue, so the protocol's union is now narrower than what - // the renderer delivers. The fix belongs in the spec (extend the union), not - // here (objectstack#5435): widening the union renderer-side is free, but - // ADVERTISING it before - // the protocol does would collide with the publish gate that parses the - // strict schema. Until the spec moves, `'navigate'` works for any host that - // composes an `object-chart` schema directly, and stays absent from the - // registry `inputs` below. + // ⚠️ The asymmetry this paragraph used to describe IS GONE, and the correction + // is recorded rather than quietly deleted because the stale claim outlived the + // fact by two releases. It said `ChartDrillDownSchema` declares + // `target: 'drawer' | 'dialog'`, so the protocol's union was narrower than + // what this renderer delivers and `'navigate'` could not be advertised without + // colliding with the publish gate. objectstack#5435 extended the union — the + // spec now declares `['drawer', 'dialog', 'navigate']` + // (`@objectstack/spec/ui`, `ChartDrillDownSchema.target`), and the publish + // gate parses that same schema, so it accepts `'navigate'` today. + // + // ⇒ What is left is NOT a protocol gap: it is an unmade decision about the + // designer palette. The `description` on the registry `inputs` below still + // lists two arms, and `index.test.ts` pins that withholding by name. Widening + // an advertised authoring vocabulary is a contract decision about `drillDown`, + // a key objectui#7946 declares on NEITHER published face — it belongs to + // objectui#8885, which owns `drillDown` there and already records these sites. + // So this round corrects the false statement and leaves the advertisement + // alone; see this PR's acceptance notes for the named successor. + // + // `'navigate'` works today for any host that composes an `object-chart` + // schema directly. const drillDown = (schema as { drillDown?: DrillDownConfig }).drillDown; - const groupByField = schema.aggregate?.groupBy || schema.xAxisKey; + // Spelled through the shared normalisation rather than `aggregate?.groupBy` + // raw: this value is used as a ROW INDEX (`row[groupByField]`), as a FIELD + // NAME (`fieldOptionLabel`) and as a drill-filter key, and a structured + // `groupBy` node is none of those. The comparison-merge leg above has always + // normalised before its own column lookups; this site did not, and `props: + // any` is why nothing said so (objectui#7946). + const groupByField = aggregateGroupByKey(schema.aggregate) || schema.xAxisKey; // Build a label→raw map from the resolved chart data. resolveGroupByLabels // stashes the original raw enum/id under `__raw_${groupByField}`. The chart @@ -1227,7 +1354,30 @@ export const ObjectChart = (props: any) => { // drill to the host's list page, so the in-place drawer must not flash. const drillDrawer = !onSegmentClick && drillEvent && schema.objectName && !navigateOnly ? (() => { const merged = drillFilter ?? {}; - const title = resolveDrillTitle(drillDown, drillEvent, schema.title || 'Details'); + // `schema.title` is the drill drawer's heading FALLBACK, and it is not a + // plain string: `@objectstack/spec`'s `ChartConfigSchema.title` is + // `I18nLabel` — a string OR an inline locale map — and this package's + // `normalizeChartSchema` already resolves the chart's own heading through + // `label()`, which accepts both. This site did not, so an author who wrote + // the locale-map arm got the OBJECT here, stringified into the heading. + // + // ⛔ Resolved through `pickLocalized` from `@object-ui/i18n` — the published, + // locale-aware resolver whose docblock names avoiding exactly this + // stringification, and which is pinned as the twin of the spec's own + // `resolveI18nLabel` (`i18nLabel-resolver-parity.test.ts`). ⛔ NOT through + // this module's private `labelOf`-style helpers: those are not locale-aware, + // and a second answer here would disagree with the published resolver on the + // same value. + // + // `pickLocalized` answers `''` for an absent value, so `|| 'Details'` keeps + // the pre-existing fallback exactly as it was for the string arm. + // + // ⚠️ KNOWN INCONSISTENCY, recorded rather than papered over: this makes the + // DRILL heading locale-aware while the chart heading beside it still is not + // (that one is resolved one layer down, in `normalizeChartSchema`, from a + // schema this component has already narrowed). The asymmetry predates this + // change and is a successor, not a regression introduced here. + const title = resolveDrillTitle(drillDown, drillEvent, pickLocalized(schema.title, language) || 'Details'); const target = drillDown?.target ?? 'drawer'; const tableSchema = { type: 'object-data-table', @@ -1363,16 +1513,21 @@ ComponentRegistry.register('object-chart', ObjectChartBlock, { // framework#5022 closed — one layer down, in the designer palette. // // `target: 'navigate'` is DELIVERED by this component since - // objectui#3354 but is deliberately NOT advertised here yet, and the - // asymmetry is on purpose. `ChartDrillDownSchema` (`@objectstack/spec`) - // landed the chart drill as `target: 'drawer' | 'dialog'` — strict, and - // enforced at publish by `validate-react-page-props`, which PARSES it - // against the authored `drillDown={{…}}` literal. Listing `'navigate'` - // in this palette would therefore hand an author a value the publish - // gate then rejects: the platform's authority for a key its own gate - // refuses, which is precisely the failure framework#5022 was opened to - // stop. The protocol's union is the thing that has to move first; until - // it does, this description tracks the spec, not the renderer. + // objectui#3354 and is still NOT advertised here — but ⚠️ NOT for the + // reason this comment used to give. It said `ChartDrillDownSchema` + // "landed the chart drill as `target: 'drawer' | 'dialog'` — strict", + // so listing `'navigate'` would hand an author a value + // `validate-react-page-props` then rejects. That is FALSE as of + // objectstack#5435: the spec declares `['drawer','dialog','navigate']` + // and the publish gate parses that same schema, so it accepts the value. + // + // What remains is an unmade decision, not a protocol gap: widening an + // ADVERTISED authoring vocabulary is a contract decision about + // `drillDown` — a key objectui#7946 declares on neither published face + // and objectui#8885 does. Until that card takes it, the description + // below and the pin in `index.test.ts` stay as they are, with the reason + // written down where the next reader will find it rather than + // rediscovered from a claim that has already gone stale twice. // // `view` / `sort` are gone from `DrillDownConfig` entirely // (objectui#3354) — no renderer ever read them, so there is no longer a diff --git a/packages/plugin-charts/src/__tests__/ObjectChart.compareTo.scatter.test.tsx b/packages/plugin-charts/src/__tests__/ObjectChart.compareTo.scatter.test.tsx index 6256ec3660..28a02c5add 100644 --- a/packages/plugin-charts/src/__tests__/ObjectChart.compareTo.scatter.test.tsx +++ b/packages/plugin-charts/src/__tests__/ObjectChart.compareTo.scatter.test.tsx @@ -38,6 +38,7 @@ vi.mock('../ChartRenderer', () => ({ })); import { ObjectChart, COMPARISON_SUFFIX } from '../ObjectChart'; +import type { ObjectChartSchema } from '@object-ui/types'; /** ObjectChart probes `/api/v1/meta/object/deal` for option colors; answer it. */ function installMetaFetchDouble() { @@ -80,10 +81,14 @@ const makeSource = () => ({ ]), }); -const renderChart = (chartType: string, dataSource: unknown, series?: unknown) => +// `chartType` takes the anchored union rather than a bare `string` +// (objectui#7946): `ObjectChartProps.schema` is `ObjectChartSchema`, whose +// `chartType` is the declared family list, and `type` is required there. +const renderChart = (chartType: ObjectChartSchema['chartType'], dataSource: unknown, series?: ObjectChartSchema['series']) => render( ({ })); import { ObjectChart } from '../ObjectChart'; +import type { DashboardWidget as SpecDashboardWidget } from '@objectstack/spec/ui'; /** * objectui#4106 — answer ObjectChart's option-color probe from a double. @@ -94,10 +95,32 @@ const comparisonFromOf = (src: { aggregate: any }) => .map((c: any[]) => String(c[1].filter.close_date.$gte)) .find((from: string) => from !== CURRENT_FROM); -const renderChart = (compareTo: unknown, dataSource: unknown) => +/** + * ⚠️ `compareTo` is typed at the PRODUCER's own declaration rather than + * `unknown`, and the reason is a measurement rather than tidiness. + * + * `DashboardRenderer` composes this node with `compareTo: widget.compareTo`, + * forwarding the dashboard widget's key verbatim — so + * `DashboardWidget['compareTo']` is literally where every value that reaches + * this prop comes from. All four call sites below already pass exactly that + * shape (`{ kind }`, `{ kind, dimension }`, or nothing), so nothing this file + * expresses is lost. + * + * What it buys: objectui#8885 (PR objectui#8895) declares `compareTo` on + * `ObjectChartSchema` bound to that same symbol. With `unknown` here, the + * literal below stops compiling the moment the two PRs are UNIONISED — a defect + * neither branch can see alone, because on this branch the key still rides + * `BaseSchema`'s index signature. Measured on the merge of the two heads: the + * union's `tsc -p tsconfig.test.json` reported this line, and it was masked in + * the obvious reading because `type-check` is `tsc --noEmit && tsc -p + * tsconfig.test.json` — the `&&` short-circuits, so the first error hides every + * error the TEST project would have reported. + */ +const renderChart = (compareTo: SpecDashboardWidget['compareTo'], dataSource: unknown) => render( { /** Stable module-level schema — a fresh object per render would move `fetchData`'s OTHER deps. */ const SCHEMA = { + type: 'object-chart' as const, objectName: 'deal', - chartType: 'bar', - aggregate: { field: 'amount', function: 'sum', groupBy: 'stage' }, + chartType: 'bar' as const, + aggregate: { field: 'amount', function: 'sum' as const, groupBy: 'stage' }, xAxisKey: 'stage', }; @@ -116,7 +117,18 @@ const settle = (ms = 40) => new Promise((resolve) => setTimeout(resolve, ms)); */ async function countFetchesAcrossRerenders(wrap: (chart: React.ReactElement) => React.ReactElement) { const src = makeSource(); - const tree = (tick: number) => wrap(); + // The `tick` used to ride on `ObjectChart` as a bare `tick={n}` prop, which + // only compiled because the component was published as `(props: any)` + // (objectui#7946). It is spelled `data-tick` now, and ⚠️ the reason that + // compiles is worth stating exactly rather than approximately: TypeScript does + // not check JSX attribute names containing a hyphen against the component's + // prop type at all, so `data-tick` is accepted by the anchored + // `ObjectChartProps` without being declared on it. It still does not reach the + // component — `ObjectChart` reads only `schema` / `dataSource` / + // `onSegmentClick` — which is fine, because its only job is to make each + // rendered element distinct. What the test measures (re-render count vs fetch + // count) is unchanged. + const tree = (tick: number) => wrap(); const { rerender } = render(tree(0)); await waitFor(() => expect(lastSchema?.data?.length).toBe(1)); diff --git a/packages/plugin-charts/src/__tests__/ObjectChart.schemaAnchor-7946.test.ts b/packages/plugin-charts/src/__tests__/ObjectChart.schemaAnchor-7946.test.ts new file mode 100644 index 0000000000..cd6484dd93 --- /dev/null +++ b/packages/plugin-charts/src/__tests__/ObjectChart.schemaAnchor-7946.test.ts @@ -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#7946 — `ObjectChartProps.schema` is anchored to the exported + * `ObjectChartSchema` (`extends BaseSchema`), not `any`. + * + * Maintainer ruling 2026-09-09, option A (director seat, summon #20, decision + * batch #108 item 3): as objectui#6576 did for the gallery, `ObjectChart`'s + * props are typed by `ObjectChartSchema`, the four keys its producers write and + * its renderer reads are declared on both published copies, and the `colors` + * drift between them converges. B (keep `any`) and C (declare without + * anchoring) were refused. + * + * ## Why this pin is compile-time + * + * The change is a TYPE declaration; the widget renders identically before and + * after it, so a rendering test is blind to the whole change. What moves is the + * ACCEPT SET of a published prop type, and `tsconfig.test.json` compiles this + * file, so each statement below is real enforcement. Every `@ts-expect-error` + * fails the build (TS2578) the moment the refusal it names stops happening. + * + * ## The before-state, and why "it still compiles" was never evidence + * + * objectui#7946 measured that removing both `as any` casts from `ObjectView`'s + * two object-chart literals left `tsc --noEmit` GREEN over a program + * `--listFiles` confirmed contained the file. That green said nothing: the + * consumer was `(props: any)`, so every `schema={{ … }}` literal was checked + * against nothing at all, cast or no cast. The probe had to MOVE, and the + * `@ts-expect-error` blocks below are where it moves — each one was accepted + * silently before this card. + * + * ## The ceiling, stated rather than assumed (objectui#5155) + * + * Anchoring buys DECLARED members their declared types. It does NOT buy + * rejection of a MISSPELLING: `BaseSchema` carries `[key: string]: any`, which + * `ObjectChartSchema` inherits, so `xAxisKy` compiles. objectui#6576 accepted + * that cost knowingly for the gallery; the counter-probe at the bottom keeps it + * visible so nobody reads this anchor as more than it is. Closing it is + * objectui#5155, not this card. + * + * The schema type's own members, the read census and the source-level pins are + * in `packages/types/src/__tests__/widget-schema-anchors-7946.test.ts`. + */ + +import { describe, it, expect } from 'vitest'; +import type { ObjectChartProps } from '../ObjectChart'; +import type { BaseSchema, ObjectChartSchema } from '@object-ui/types'; + +type Equal = + (() => T extends A ? 1 : 2) extends (() => T extends B ? 1 : 2) ? true : false; +type Expect = T; + +/** The anchor itself — invariant equality, so `any` or a second literal cannot creep back. */ +export type assertionSchemaIsAnchored = Expect>; +export type assertionSchemaExtendsBase = Expect<[ObjectChartProps['schema']] extends [BaseSchema] ? true : false>; +/** + * ⭐ The pin that fails for the RIGHT reason. `Equal` is `false` for + * every `X`, so the two assertions above already refuse a regression to + * `props: any` — but only this one says so by name, and only this one keeps + * working if `Equal` is ever replaced by a one-way `extends` check (which + * `any` satisfies in both directions — the objectui#7087 lesson). + */ +export type assertionAnchorIsNotAny = Expect>; +/** The helpers can FAIL — synthetic controls, so a vacuous `Equal` cannot pass this file. */ +export type assertionEqualCanFail = Expect, false>>; +export type assertionAnyProbeCanFire = Expect>; + +describe('ObjectChartProps.schema — anchored to ObjectChartSchema (objectui#7946)', () => { + it('accepts the node the relays actually compose — the dataset shape, verbatim', () => { + // `ObjectView`'s dataset branch, with the same value TYPES its locals have + // (`dims`/`vals` are `string[]`). This is the half that must not break: + // an anchor that refused a working producer would be a worse contract than + // `any`. + const dims = ['stage']; + const vals = ['amount']; + const node: ObjectChartProps['schema'] = { + type: 'object-chart', + dataset: 'deals', + dimensions: dims, + values: vals, + chartType: 'bar', + xAxisKey: dims[0], + series: vals.map((v) => ({ dataKey: v, label: v })), + className: 'h-[400px] w-full', + }; + expect(node.series?.[0]?.dataKey).toBe('amount'); + }); + + it('accepts the legacy inline-aggregate shape, both filter arms', () => { + const arrayFilter: ObjectChartProps['schema'] = { + type: 'object-chart', + objectName: 'crm_opportunity', + chartType: 'bar', + aggregate: { field: 'amount', function: 'sum', groupBy: 'stage' }, + xAxisKey: 'stage', + series: [{ dataKey: 'amount', label: 'Amount' }], + filter: [['stage', '=', 'won']], + }; + // The ObjectQL `$filter` object the in-repo corpus authors, and the shape + // the drill-down spread requires. Both arms are declared because both are + // read; see the `filter` docblock on `ObjectChartSchema`. + const objectFilter: ObjectChartProps['schema'] = { + type: 'object-chart', + objectName: 'deal', + chartType: 'bar', + aggregate: { function: 'count', groupBy: { field: 'close_date', dateGranularity: 'month', alias: 'month' } }, + filter: { close_date: { $gte: '{current_quarter_start}' } }, + }; + expect([Array.isArray(arrayFilter.filter), Array.isArray(objectFilter.filter)]).toEqual([true, false]); + }); + + it('REFUSES a wrong value type on each of the four keys the ruling declared', () => { + // Every one of these compiled silently before this card, because the + // consuming component was `(props: any)`. + + // @ts-expect-error — `xAxisKey` is `string`; a column index is not a column NAME. + 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 — `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). + const badGranularity: ObjectChartProps['schema'] = { type: 'object-chart', chartType: 'bar', aggregate: { function: 'count', groupBy: 'stage', dateGranularity: 'month' } }; + // @ts-expect-error — `filter` is a FilterArray or an ObjectQL `$filter` object; a query STRING is neither. + const badFilter: ObjectChartProps['schema'] = { type: 'object-chart', chartType: 'bar', filter: 'stage=won' }; + + expect([badX.xAxisKey, badSeries.series, badSeriesEntry.series, badFn.aggregate, badGranularity.aggregate, badFilter.filter]).toHaveLength(6); + }); + + /** + * ⭐ What binding `aggregate` to `@objectstack/spec`'s `ChartAggregate` + * bought, on the face an author writes against. + * + * The first cut of this card declared `aggregate` as a local near-copy with + * all three members optional, reasoning from the RENDERER's accept set (every + * read here is guarded). What that published was an authoring door wider than + * the spec's — `{}` and `{ field: 'amount' }` were legal — on a shape whose + * publish gate parses `ChartAggregateSchema`. Every literal below compiled + * under that declaration and is refused now, and nothing in the workspace + * needed the relaxation: every live producer forwards this key as `any`. + * + * ⚠️ The renderer still ACCEPTS these documents at runtime, by design — see + * `ObjectChart.absentCategoryAxisRefusal-8168.test.tsx`, which asserts the + * named refusal screen each of them draws. The two facts are not in tension: + * one is what an author may write, the other is what a renderer owes a + * document an untyped producer handed it. + */ + it('REFUSES the aggregates the SPEC refuses — the by-reference narrowing', () => { + // @ts-expect-error — `function` and `groupBy` are REQUIRED; an empty bag names neither a measure nor a category. + const empty: ObjectChartProps['schema'] = { type: 'object-chart', chartType: 'bar', aggregate: {} }; + // @ts-expect-error — a measure with no category axis: `groupBy` is required. + const noCategory: ObjectChartProps['schema'] = { type: 'object-chart', chartType: 'bar', aggregate: { field: 'amount', function: 'sum' } }; + // @ts-expect-error — `function` is required even when the category is declared. + const noFunction: ObjectChartProps['schema'] = { type: 'object-chart', chartType: 'bar', aggregate: { groupBy: 'stage' } }; + // @ts-expect-error — the structured `groupBy` node must NAME its field; `runAggregate` sends the node to the server verbatim, so a node without one can resolve no category column at all. + const nodeWithoutField: ObjectChartProps['schema'] = { type: 'object-chart', chartType: 'bar', aggregate: { function: 'count', groupBy: { dateGranularity: 'day' } } }; + // @ts-expect-error — the spec's object is STRICT: a mis-cased member is refused by name, where a local `z.object` copy would have dropped it silently. + const misCased: ObjectChartProps['schema'] = { type: 'object-chart', chartType: 'bar', aggregate: { function: 'count', groupby: 'stage' } }; + + // …and the control: `field` stays OPTIONAL, because `count` counts rows + // rather than a column. Without this the block above would be equally green + // for a declaration that simply required all three. + const counting: ObjectChartProps['schema'] = { type: 'object-chart', chartType: 'bar', aggregate: { function: 'count', groupBy: 'stage' } }; + + expect([empty, noCategory, noFunction, nodeWithoutField, misCased]).toHaveLength(5); + expect(counting.aggregate?.field).toBeUndefined(); + }); + + it('REFUSES the `colors` drift the two copies used to disagree about', () => { + // The zod mirror has declared `colors` since objectui#3913; the TS + // interface did not, so on THIS face a number palette was `any`. + // @ts-expect-error — `colors` is a `string[]` palette or a value→color map. + const node: ObjectChartProps['schema'] = { type: 'object-chart', chartType: 'bar', colors: 42 }; + expect(node.colors).toBe(42); + }); + + it('NARROWS: `type` is required and is the registry key; `chartType` is the declared family list', () => { + // @ts-expect-error — `type` is required; the minimal `{ objectName }` literal no longer compiles. + 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']); + }); + + it('refuses a wrong-typed inherited base member for the DECLARED reason', () => { + // @ts-expect-error — `visible` is `boolean | ExpressionWire` through `BaseSchema`. + const node: ObjectChartProps['schema'] = { type: 'object-chart', chartType: 'bar', visible: 42 }; + expect(node.visible).toBe(42); + }); + + it('WIDENS: every real `BaseSchema` member is writable, `visibleWhen` included', () => { + const node: ObjectChartProps['schema'] = { + type: 'object-chart', + chartType: 'bar', + objectName: 'account', + visibleWhen: '${data.ready}', + bind: 'rows', + }; + expect(node.visibleWhen).toBe('${data.ready}'); + }); + + it('the ceiling, stated: an UNKNOWN key still compiles (inherited index signature, objectui#5155)', () => { + // Counter-probe against reading the anchor as more than it is. The ruling's + // acceptance is a wrong VALUE TYPE being refused loudly; a misspelled KEY + // is not reachable from here, exactly as on `ObjectGallerySchema`. + const node: ObjectChartProps['schema'] = { type: 'object-chart', chartType: 'bar', xAxisKy: 'stage' }; + expect(node.xAxisKy).toBe('stage'); + }); +}); diff --git a/packages/plugin-charts/src/__tests__/ObjectChart.structuredGroupBy-7946.test.tsx b/packages/plugin-charts/src/__tests__/ObjectChart.structuredGroupBy-7946.test.tsx new file mode 100644 index 0000000000..75d081c3ff --- /dev/null +++ b/packages/plugin-charts/src/__tests__/ObjectChart.structuredGroupBy-7946.test.tsx @@ -0,0 +1,257 @@ +/** + * 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#7946 — the RUNTIME half of the anchor: a structured + * `aggregate.groupBy` node resolves to its projected COLUMN everywhere the + * column is used. + * + * ## Why this file exists + * + * The card's changeset claims a behaviour fix, and a claim without a pin is a + * claim. Its two new companions are compile-time and census pins — the anchored + * prop type (`ObjectChart.schemaAnchor-7946.test.ts`) and the declared-key + * census (`packages/types/src/__tests__/widget-schema-anchors-7946.test.ts`) — + * and NEITHER can see this: the defect is a wrong runtime VALUE flowing from a + * correctly-typed read. + * + * ## The defect, stated so an ablation reads cleanly + * + * `aggregate.groupBy` is a union — a bare field name, or the structured + * date-bucketing node `{ field, dateGranularity?, alias? }` the engine takes. + * When it is the node, the column the result rows are keyed by is `alias`, or + * the `field` it defaults to — NOT the node. `ObjectChart.tsx` resolved that + * twice: the comparison-merge leg normalised, and the drill / label leg 200 + * lines below used the raw union as + * + * - a ROW INDEX (`row[groupByField]` — the label→raw reverse map), and + * - a DRILL-FILTER KEY (`computeDrillFilter(…, { groupByField })`). + * + * A JavaScript object used as either is stringified, so on every date-bucketed + * chart the reverse map was empty and the drill filtered on the literal key + * `[object Object]` — silently, with a drawer that opened on the wrong rows. + * `(props: any)` is why no instrument said so. Both sites now share + * `aggregateGroupByKey`. + * + * ## Why the observation point is `target: 'navigate'` + * + * The drill filter is the value under test, and through the navigate arm the + * component hands it to the host verbatim — one spy, no DOM archaeology over a + * drilled table. The drawer arm composes the SAME `drillFilter` memo (hoisted + * precisely so both targets drill by one filter), so this is not a + * navigate-only property. `ChartRenderer` is mocked down to a click surface for + * the reason `ObjectChart.drillNavigate.test.tsx` gives: what is under test is + * this component's key resolution, not recharts' hit-testing. + * + * ⭐ Each case asserts the WHOLE filter object, not just its value, so the + * pre-fix state fails on the KEY (`[object Object]`) rather than needing a + * separate probe for it — and the negative assertion is spelled out anyway, + * because a key that changed to something else wrong would otherwise read the + * same as a key that was fixed. + */ +import React from 'react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, cleanup, waitFor, fireEvent } from '@testing-library/react'; +import { DrillNavigationProvider } from '@object-ui/react'; +import type { ObjectChartSchema } from '@object-ui/types'; + +/** The category the fake segment reports as clicked, per test. */ +let clickedCategory = ''; + +vi.mock('../ChartRenderer', () => ({ + // Rows arrive on `schema.data` — `ObjectChart` hands the renderer ONE schema + // (`finalSchemaWithColors`), not a separate `data` prop. + ChartRenderer: ({ onChartClick, schema }: any) => ( + + ), +})); + +import { ObjectChart } from '../ObjectChart'; + +const OBJECT = 'crm_opportunity'; + +/** + * The option-colour probe reads `GET /api/v1/meta/object/` off the + * GLOBAL fetch for any schema carrying `objectName`. Answering `{}` reproduces + * the failed-request outcome exactly (the effect swallows it by design), and + * recording the URLs keeps an escape to any OTHER endpoint a failure rather + * than swallowed stderr — the shape `ObjectChart.drillNavigate.test.tsx` + * established. + */ +let metaCalls: string[] = []; +beforeEach(() => { + clickedCategory = ''; + metaCalls = []; + vi.stubGlobal( + 'fetch', + vi.fn(async (input: unknown) => { + metaCalls.push(String(input)); + return { ok: true, json: async () => ({}) }; + }), + ); +}); +afterEach(() => { + expect(metaCalls.filter((u) => u !== `/api/v1/meta/object/${OBJECT}`)).toEqual([]); + vi.unstubAllGlobals(); + cleanup(); +}); + +/** Renders through the navigate arm and hands back the host spy. */ +function renderChart(schema: Partial, ds: Record) { + const openRecordList = vi.fn(); + render( + + + , + ); + return openRecordList; +} + +describe('ObjectChart — a structured `aggregate.groupBy` resolves to its projected column (objectui#7946)', () => { + it('drills on the ALIAS the aggregate projects the group under, not on the node', async () => { + // The engine projects the bucketed group under `alias`, so this is the + // column every result row is keyed by — and the only key a drill filter on + // this chart can mean. + const ds = { + aggregate: vi.fn(async () => [ + { month: '2026-03-01', count: 7 }, + { month: '2026-04-01', count: 3 }, + ]), + find: vi.fn(async () => []), + }; + clickedCategory = '2026-03-01'; + + const openRecordList = renderChart( + { + aggregate: { function: 'count', groupBy: { field: 'close_date', dateGranularity: 'month', alias: 'month' } }, + series: [{ dataKey: 'count' }], + }, + ds, + ); + + await waitFor(() => expect(screen.getByTestId('fake-segment')).toBeTruthy()); + fireEvent.click(screen.getByTestId('fake-segment')); + + await waitFor(() => expect(openRecordList).toHaveBeenCalled()); + const [objectName, filter] = openRecordList.mock.calls[0]; + expect(objectName).toBe(OBJECT); + // Whole object: the pre-fix state fails on the KEY, not on the value. + expect(filter).toEqual({ month: '2026-03-01' }); + // …spelled out, because a differently-wrong key reads the same as a fixed one. + expect(Object.keys(filter as object)).not.toContain('[object Object]'); + }); + + it('falls back to the node`s FIELD when it declares no alias', async () => { + // `alias` is optional; with none, the engine projects under `field`. The + // control for the case above — it proves the resolution reads the node + // rather than always finding a key called `month`. + const ds = { + aggregate: vi.fn(async () => [{ close_date: '2026-03-01', count: 7 }]), + find: vi.fn(async () => []), + }; + clickedCategory = '2026-03-01'; + + const openRecordList = renderChart( + { + aggregate: { function: 'count', groupBy: { field: 'close_date', dateGranularity: 'month' } }, + series: [{ dataKey: 'count' }], + }, + ds, + ); + + await waitFor(() => expect(screen.getByTestId('fake-segment')).toBeTruthy()); + fireEvent.click(screen.getByTestId('fake-segment')); + + await waitFor(() => expect(openRecordList).toHaveBeenCalled()); + expect(openRecordList.mock.calls[0][1]).toEqual({ close_date: '2026-03-01' }); + }); + + it('recovers the RAW value behind a resolved option label — the reverse map, on a structured node', async () => { + // The other half the raw union broke. `resolveGroupByLabels` replaces the + // group column with the option's LABEL and stashes the original under + // `__raw_`; the click handler reverses that so the drill filters on + // what the backend stores. Both the stash and the lookup are keyed by the + // projected column, so with the raw node they were keyed by its + // stringification — the map came back empty and the label leaked into the + // filter. + const ds = { + aggregate: vi.fn(async () => [{ stage: 'won', count: 4 }]), + find: vi.fn(async () => []), + getObjectSchema: vi.fn(async () => ({ + fields: { + stage: { type: 'select', options: [{ value: 'won', label: 'Closed Won' }] }, + }, + })), + }; + // The user clicks the LABEL — that is what the chart drew. + clickedCategory = 'Closed Won'; + + const openRecordList = renderChart( + { + aggregate: { function: 'count', groupBy: { field: 'stage', alias: 'stage' } }, + series: [{ dataKey: 'count' }], + }, + ds, + ); + + // Non-vacuity: the label resolution must actually have run, otherwise the + // reverse map is trivially the identity and this pin measures nothing. + await waitFor(() => { + const rows = JSON.parse(screen.getByTestId('fake-segment').getAttribute('data-rows') ?? '[]'); + expect(rows[0]?.stage).toBe('Closed Won'); + expect(rows[0]?.__raw_stage).toBe('won'); + }); + + fireEvent.click(screen.getByTestId('fake-segment')); + + await waitFor(() => expect(openRecordList).toHaveBeenCalled()); + expect(openRecordList.mock.calls[0][1]).toEqual({ stage: 'won' }); + }); + + it('still drills on a BARE-STRING groupBy — the arm that always worked', async () => { + // The control that keeps the three cases above from passing for a reason + // that has nothing to do with the union: the legacy string arm must be + // untouched by the normalisation. + const ds = { + aggregate: vi.fn(async () => [{ stage: 'won', count: 4 }]), + find: vi.fn(async () => []), + }; + clickedCategory = 'won'; + + const openRecordList = renderChart( + { + aggregate: { function: 'count', groupBy: 'stage' }, + series: [{ dataKey: 'count' }], + }, + ds, + ); + + await waitFor(() => expect(screen.getByTestId('fake-segment')).toBeTruthy()); + fireEvent.click(screen.getByTestId('fake-segment')); + + await waitFor(() => expect(openRecordList).toHaveBeenCalled()); + expect(openRecordList.mock.calls[0][1]).toEqual({ stage: 'won' }); + }); +}); diff --git a/packages/plugin-charts/src/index.test.ts b/packages/plugin-charts/src/index.test.ts index 753a5a15a3..bb0fc70fc2 100644 --- a/packages/plugin-charts/src/index.test.ts +++ b/packages/plugin-charts/src/index.test.ts @@ -159,13 +159,22 @@ describe('object-chart — drillDown is a declared input (framework#5022)', () = // advertising them here would re-open the gap framework#5022 closed — one // layer down, in the designer palette. // - // `navigate` stays on the withheld side, and after objectui#3354 that is no - // longer because the chart ignores it — it honours it now. It is withheld - // because `ChartDrillDownSchema` declares the chart drill target as - // `'drawer' | 'dialog'`, strictly, and `validate-react-page-props` PARSES - // that schema against the authored literal at publish time. Advertising - // `'navigate'` here would hand an author a value the publish gate rejects. - // The protocol union has to move first; this palette tracks the spec. + // `navigate` stays on the withheld side — but ⚠️ NOT for the reason this + // comment carried until objectui#7946's rework round. It said + // `ChartDrillDownSchema` declares the chart drill target as + // `'drawer' | 'dialog'` strictly, so advertising `'navigate'` would hand an + // author a value `validate-react-page-props` rejects at publish. Measured + // on `@objectstack/spec` 17.4.0, that is FALSE: `ChartDrillDownSchema.target` + // is `['drawer','dialog','navigate']` (objectstack#5435 widened it after + // objectui#3354 implemented the arm), and the publish gate parses that same + // schema — so it accepts the value. + // + // The assertion below is UNCHANGED anyway, because what is left is an unmade + // decision rather than a protocol gap: widening an advertised authoring + // vocabulary is a contract decision about `drillDown`, a key objectui#7946 + // declares on neither published face and objectui#8885 does. When that card + // takes it, `'navigate'` moves from the withheld list to the described one + // and this rationale goes with it. // // `view` / `sort` are not listed on either side any more — objectui#3354 // deleted them from `DrillDownConfig`, so there is no key left to advertise diff --git a/packages/plugin-charts/src/index.tsx b/packages/plugin-charts/src/index.tsx index 5aca138ca6..12a42f1732 100644 --- a/packages/plugin-charts/src/index.tsx +++ b/packages/plugin-charts/src/index.tsx @@ -14,6 +14,10 @@ import { ObjectChartBlock } from './ObjectChart'; export type { BarChartSchema } from './types'; export { ChartBarRenderer, ChartRenderer }; export { ObjectChart, ObjectChartBlock } from './ObjectChart'; +// objectui#7946 — the prop type is part of the published surface, exactly as +// `plugin-list` exports `ObjectGalleryProps` (objectui#6576). A prop type that +// anchors a schema but is not exported cannot be asserted against by a consumer. +export type { ObjectChartProps } from './ObjectChart'; // The ONE place the author-facing chart schema is translated into the // renderer's internal pipeline contract (#2880 S1). Published from this entry diff --git a/packages/types/src/__tests__/imported-defaults-8317.test.ts b/packages/types/src/__tests__/imported-defaults-8317.test.ts index 30afd60f79..f4033350be 100644 --- a/packages/types/src/__tests__/imported-defaults-8317.test.ts +++ b/packages/types/src/__tests__/imported-defaults-8317.test.ts @@ -73,6 +73,7 @@ import { AriaPropsSchema as SpecAriaPropsSchema, NavigationConfigSchema as SpecNavigationConfigSchema, I18nLabelSchema as SpecI18nLabelSchema, + ChartAggregateSchema as SpecChartAggregateSchema, } from '@objectstack/spec/ui'; import { SelectOptionSchema as SpecSelectOptionSchema } from '@objectstack/spec/data'; import { stripImportedDefaults } from '../zod/imported-defaults.js'; @@ -173,6 +174,7 @@ const IMPORTED: Array = [ ['AriaPropsSchema', SpecAriaPropsSchema], ['NavigationConfigSchema', SpecNavigationConfigSchema], ['I18nLabelSchema', SpecI18nLabelSchema], + ['ChartAggregateSchema', SpecChartAggregateSchema], ['SelectOptionSchema', SpecSelectOptionSchema], ] as const; diff --git a/packages/types/src/__tests__/widget-schema-anchors-7946.test.ts b/packages/types/src/__tests__/widget-schema-anchors-7946.test.ts new file mode 100644 index 0000000000..3f2e1721da --- /dev/null +++ b/packages/types/src/__tests__/widget-schema-anchors-7946.test.ts @@ -0,0 +1,383 @@ +/** + * 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#7946 — the `widget-schema-anchors` family's third widget. + * + * `widget-schema-anchors-6576.test.ts` beside this file did the gallery and the + * data table: two `Object*Props` whose `schema` anchored to a hand-rolled + * literal. `ObjectChart` was the harder case of the same shape — its prop type + * anchored to nothing at all, because the component was published as + * `(props: any)`. Maintainer ruling 2026-09-09 (director seat, summon #20, + * decision batch #108 item 3) applied #6576's option A to it: anchor the props, + * declare the four keys the producers write and the renderer reads, converge + * the `colors` drift, and pin the read census here. + * + * ## What this file pins, and where the rest lives + * + * 1. TYPE-LEVEL, on `ObjectChartSchema` in this package: it extends + * `BaseSchema`, carries the registry key as its `type` literal, inherits the + * base members with their DECLARED types (not `any`), and declares the four + * keys with the value types measured at their READ sites — `ChartRendererProps` + * for `xAxisKey`/`series`, `ObjectChart.tsx` for `aggregate`/`filter` — not + * copied from any producer's literal, which is what the ruling asked for. + * 2. MIRROR PARITY, per key: the zod copy declares the same four, and `colors` + * is now on both faces. Before this card the mirror declared `colors` and + * the interface did not, and NOTHING ratcheted that direction — + * `zod-mirror-parity.test.ts` measures declared-but-unmirrored and + * mirror-wider-than-declared, and a key present ONLY on the mirror is in + * neither. That is why the drift survived from objectui#3913 to here. + * 3. READ CENSUS, source-level, on the widget file: every key read off `schema` + * is declared by the mirror or ledgered by name below. Read off disk the way + * `widget-schema-anchors-6576.test.ts` reads its widgets — this package + * cannot import the plugins. + * + * The anchoring of the PROP type to the schema type is pinned where it can be + * compiled, beside the widget: + * `plugin-charts/src/__tests__/ObjectChart.schemaAnchor-7946.test.ts`. + * + * ## AUTHORABLE vs INTERNAL — the ruling asked for the reading, per key + * + * The four keys do not share one verdict, and the ledger below records the two + * that are INTERNAL so the declaration is not mistaken for new authorable + * vocabulary. Ground for each is in `ObjectChartSchema`'s own docblock; the + * short form: + * + * - `aggregate`, `filter` — AUTHORABLE. `@objectstack/spec` names their + * carrier as this component's own react props and parses `aggregate` at the + * react-page publish gate; the registry `inputs` advertises both. + * ⭐ `aggregate` is therefore declared BY REFERENCE (`ChartAggregate` / + * `ChartAggregateSchema`), not as a local shape of the same name: where the + * spec owns the authoring door, one symbol is the whole point, and the + * differential at the bottom of the mirror section is what proves it is the + * spec's and not a look-alike. + * - `xAxisKey`, `series` — INTERNAL. All five producers COMPUTE them, the + * spec's author-facing vocabulary spells the same slots `xAxis`/`{ name }` + * and REFUSES the internal spellings by name, and neither appears in the + * registry `inputs`. They are declared anyway because they are already + * passed through `.passthrough()` unvalidated; declaring buys the VALUE + * check without minting authorable vocabulary. + * + * ## The ceiling, stated rather than assumed (objectui#5155) + * + * `BaseSchema` still carries `[key: string]: any`, so anchoring buys DECLARED + * members their declared types and does NOT buy rejection of a misspelling. + * The counter-probe below pins that honestly, as #6576's does. + */ + +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; +import { ChartAggregateSchema as SpecChartAggregateSchema, type ChartAggregate } from '@objectstack/spec/ui'; +import type { BaseSchema } from '../base.js'; +import type { ObjectChartSchema } from '../objectql.js'; +import { ObjectChartSchema as ObjectChartMirror } from '../zod/objectql.zod.js'; +import type { ExpressionWire } from '../expression'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = join(HERE, '..', '..', '..', '..'); +const WIDGET_FILE = 'packages/plugin-charts/src/ObjectChart.tsx'; + +/* ── Type-level pins (compiled by `tsc -p tsconfig.test.json`) ─────────────── */ + +type Equal = + (() => T extends A ? 1 : 2) extends (() => T extends B ? 1 : 2) ? true : false; +type Expect = T; +/** Tuple-wrapped so a union declared type is judged whole, not limb by limb. */ +type ExtendsBase = [T] extends [BaseSchema] ? true : false; + +export type assertionChartExtendsBase = Expect>; +export type assertionChartTypeIsRegistryKey = Expect>; +/** + * Inherited members resolve to their DECLARED types. `Equal`, not `extends`: + * through the index signature a missing member reads `any`, which a one-way + * check would accept (the objectui#7087 disabled-twin lesson). + */ +export type assertionChartInheritsVisible = Expect>; +export type assertionChartInheritsBind = Expect>; +/** The four declared keys keep the types measured at their read sites. */ +export type assertionXAxisKeyDeclared = Expect>; +export type assertionColorsConverged = Expect | undefined>>; +export type assertionFilterAdmitsBothArms = Expect | undefined>>; +/** + * `series`' element type 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. + */ +export type assertionSeriesBindsDataKey = Expect[number]['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 + * assertion that can tell those apart: a structurally identical local copy + * satisfies every `extends` check in both directions and would let the two + * dialects drift apart the day the protocol moves. + */ +export type assertionAggregateIsSpecSymbolByReference = + Expect, ChartAggregate>>; +/** + * The two members the spec REQUIRES, read off this interface rather than off the + * import — so a local re-declaration that made either optional is red here even + * if someone kept the name. `field` stays optional (only `count` counts rows + * rather than a column), which is the third member and the control that this + * pin is measuring requiredness rather than asserting it everywhere. + */ +export type assertionAggregateFunctionIsRequired = + Expect['function'] ? true : false, false>>; +export type assertionAggregateGroupByIsRequired = + Expect['groupBy'] ? true : false, false>>; +export type assertionAggregateFieldStaysOptional = + Expect['field'] ? true : false, true>>; +/** + * The structured `groupBy` arm names its `field` — the spec's shape, and the + * one the renderer's `aggregateGroupByKey` (`gb.alias || gb.field`) resolves. An + * earlier draft declared this arm locally with `field?: string`, which published + * a node that cannot resolve a category axis at all. + */ +export type assertionAggregateGroupByAdmitsStructuredNode = + Expect['groupBy']>, string | { field: string; dateGranularity?: 'day' | 'week' | 'month' | 'quarter' | 'year'; alias?: string }>>; +/** The helpers can FAIL — synthetic controls. */ +export type assertionExtendsBaseCanFail = Expect, false>>; +export type assertionEqualCanFail = Expect, false>>; + +describe('ObjectChartSchema — compile-time pins (objectui#7946)', () => { + it('refuses a wrong-typed inherited base member for the declared reason', () => { + // @ts-expect-error — `visible` is `boolean | ExpressionWire | undefined` through `BaseSchema`. + const chart: ObjectChartSchema = { type: 'object-chart', chartType: 'bar', visible: 42 }; + expect(chart.visible).toBe(42); + }); + + it('declares the four keys the producers write and the renderer reads', () => { + const chart: ObjectChartSchema = { + type: 'object-chart', + objectName: 'crm_opportunity', + chartType: 'bar', + aggregate: { field: 'amount', function: 'sum', groupBy: 'stage' }, + filter: [['stage', '=', 'won']], + xAxisKey: 'stage', + series: [{ dataKey: 'amount', label: 'Amount' }], + colors: { won: '#10B981' }, + }; + expect([chart.aggregate?.groupBy, chart.xAxisKey, chart.series?.[0].dataKey]).toEqual(['stage', 'stage', 'amount']); + }); + + it('still accepts a MISSPELLING — the ceiling, pinned honestly (objectui#5155)', () => { + const chart: ObjectChartSchema = { type: 'object-chart', chartType: 'bar', xAxisKy: 'stage' }; + expect(chart.xAxisKy).toBe('stage'); + }); +}); + +/* ── Mirror parity, per key ────────────────────────────────────────────────── */ + +describe('the zod mirror declares the same keys (objectui#7946)', () => { + it.each(['xAxisKey', 'series', 'aggregate', 'filter'])('the mirror declares `%s`', (key) => { + expect(Object.keys(ObjectChartMirror.shape)).toContain(key); + }); + + it('`colors` is on BOTH faces now — the drift the ratchets could not see', () => { + // The mirror has declared it since objectui#3913. This is the assertion + // that fails if the interface loses it again; the interface side is the + // `assertionColorsConverged` type pin above. + expect(Object.keys(ObjectChartMirror.shape)).toContain('colors'); + }); + + it('the mirror CHECKS the declared values, not just their presence — every declared key', () => { + // Non-vacuity for the `.toContain` assertions above: a key declared as + // `z.any()` would satisfy them and validate nothing. One accepting case + // first, so a mirror that refused EVERYTHING would not pass this by + // refusing on cue. + const ok = ObjectChartMirror.safeParse({ + type: 'object-chart', chartType: 'bar', + xAxisKey: 'stage', series: [{ dataKey: 'amount' }], + aggregate: { field: 'amount', function: 'sum', groupBy: 'stage' }, + filter: [['stage', '=', 'won']], colors: ['#10B981'], + }); + expect(ok.error?.issues ?? []).toEqual([]); + expect(ok.success).toBe(true); + + const refusals: Array = [ + ['xAxisKey is a column NAME, not an index', { xAxisKey: 0 }], + ['a series entry binds through `dataKey`', { 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' }], + ['colors is a palette or a value→color map, not a number', { colors: 42 }], + ]; + for (const [why, patch] of refusals) { + const r = ObjectChartMirror.safeParse({ type: 'object-chart', chartType: 'bar', ...(patch as object) }); + expect(r.success, why).toBe(false); + } + }); + + /** + * ⭐ What binding `aggregate` to `@objectstack/spec`'s own schema buys on THIS + * face, and it is not cosmetic. The first cut declared a local `z.object` + * mirror of it; zod 4 objects STRIP unknown keys, so a mis-cased member + * parsed clean and was silently dropped — the failure `ChartAggregateSchema`'s + * `strictObject` posture exists to prevent. Bound by reference, the posture + * and the requiredness arrive with the schema. + */ + describe('`aggregate` is the spec schema by reference (objectui#7946 rework)', () => { + const parse = (aggregate: unknown) => + ObjectChartMirror.safeParse({ type: 'object-chart', chartType: 'bar', aggregate }); + + it('REFUSES a mis-cased member instead of dropping it', () => { + const r = parse({ groupby: 'stage', function: 'count' }); + expect(r.success, 'a strip-postured local copy accepts this and loses `groupby`').toBe(false); + // Named, not just counted: the refusal must be ABOUT the unknown key, so a + // pass caused by the missing `groupBy` cannot stand in for it. + expect(JSON.stringify(r.error?.issues)).toContain('groupby'); + }); + + it('REFUSES the members the spec requires being left out', () => { + expect(parse({}).success).toBe(false); + expect(parse({ field: 'amount' }).success).toBe(false); + expect(parse({ function: 'sum' }).success, 'groupBy is required').toBe(false); + // …and a structured `groupBy` node that names no field, which + // `runAggregate` sends to the server verbatim and which can resolve no + // category column at all. + expect(parse({ function: 'count', groupBy: { dateGranularity: 'day' } }).success).toBe(false); + }); + + it('ACCEPTS what the spec accepts — the control for the four refusals above', () => { + expect(parse({ function: 'count', groupBy: 'stage' }).success).toBe(true); + expect(parse({ field: 'amount', function: 'sum', groupBy: 'stage' }).success).toBe(true); + expect(parse({ function: 'count', groupBy: { field: 'close_date', dateGranularity: 'month', alias: 'month' } }).success).toBe(true); + }); + + it('answers exactly as `ChartAggregateSchema` itself does — the by-reference property, differentially', () => { + // The assertion a structurally-similar local copy would fail. Probes span + // both verdicts so agreement is not agreement-on-refusing-everything. + const probes: unknown[] = [ + {}, { field: 'amount' }, { function: 'sum' }, { function: 'count', groupBy: 'stage' }, + { groupby: 'stage', function: 'count' }, { function: 'average', groupBy: 'stage' }, + { function: 'count', groupBy: { dateGranularity: 'day' } }, + { function: 'count', groupBy: { field: 'close_date', dateGranularity: 'month', alias: 'month' } }, + { function: 'count', groupBy: 'stage', dateGranularity: 'month' }, + ]; + const mine = probes.map((p) => parse(p).success); + const spec = probes.map((p) => SpecChartAggregateSchema.safeParse(p).success); + expect(mine).toEqual(spec); + // Non-vacuity: the two verdict lists must contain both answers. + expect(new Set(spec)).toEqual(new Set([true, false])); + }); + }); +}); + +/* ── Read census on the widget file ────────────────────────────────────────── */ + +/** + * Keys read off `schema` in `ObjectChart.tsx` that the mirror deliberately does + * NOT declare, each with the reason it is out of objectui#7946's ruled scope. + * Every entry must still be READ — a stale exception is a hole, so the census + * below re-checks that too. + * + * All three are the objectui#6914 class (a key read behind a cast and declared + * on neither published face), which is what that card fixed for + * `ObjectDataTableSchema`. They are the successor's inventory, not this card's + * remit: the 2026-09-09 ruling named exactly four keys plus `colors`, and + * widening past them would be a second contract decision taken without one. + */ +const LEDGERED_UNDECLARED_READS = [ + // `(schema as any).compareTo` — a `CompareToConfig` (the spec's converged + // `{ kind, dimension? }`), written by `DashboardRenderer` and read to + // synthesise the comparison overlay series. + 'compareTo', + // `(schema as { drillDown?: DrillDownConfig }).drillDown` — declared by this + // component's own registry `inputs` and by the spec's `ChartDrillDownSchema`, + // and by neither published copy of this shape. + 'drillDown', + // `pickLocalized(schema.title, language) || 'Details'` — the drill drawer + // heading fallback. Not a `BaseSchema` member either, so it rides the index + // signature as `any`. ⚠️ The read is now resolved through the published + // locale-aware resolver rather than used as a bare string: the spec types this + // slot as `I18nLabel` (a string OR an inline locale map), so the raw read put + // an OBJECT in a heading for the map arm. Declaring the key here is + // objectui#8885's, and the resolver is what makes this component correct + // either way in the meantime. + 'title', +] as const; + +/** + * Comments are STRIPPED before the census, and that is load-bearing rather than + * tidiness: `ObjectChart.tsx` discusses `schema.chart` in prose — explaining + * that the upstream list-view resolver could NOT be called here because that + * key is `undefined` on every schema this component receives. A census that + * reads comments would report a read that does not exist, and the only ways to + * clear it are to declare a dead key or to ledger a phantom. + */ +function stripComments(src: string): string { + return src.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/[^\n]*/g, '$1'); +} + +/** Every key read off `schema`, cast-aware: `schema.x`, `schema?.x`, `(schema as T).x`, `schema['x']`. */ +function schemaReads(src: string): Set { + const re = /\bschema(?:\?)?\.([A-Za-z_$][\w$]*)|\(\s*schema as [^)]*\)\.([A-Za-z_$][\w$]*)|\bschema\[['"]([A-Za-z_$][\w$]*)['"]\]/g; + const out = new Set(); + for (const m of src.matchAll(re)) out.add(m[1] ?? m[2] ?? m[3]); + return out; +} + +describe('ObjectChart.tsx — the prop type anchors `schema`, and every read is declared or ledgered (objectui#7946)', () => { + const source = readFileSync(join(REPO_ROOT, WIDGET_FILE), 'utf8'); + + it('`ObjectChartProps.schema` is `ObjectChartSchema`, with no `props: any` left on the component', () => { + const start = source.indexOf('export interface ObjectChartProps {'); + expect(start, `${WIDGET_FILE} no longer declares \`export interface ObjectChartProps\``).toBeGreaterThan(-1); + const iface = source.slice(start, source.indexOf('\n}', start) + 2); + expect(iface).toMatch(/\bschema:\s*ObjectChartSchema;/); + expect(iface, 'a hand-rolled inline literal is back').not.toMatch(/\bschema:\s*\{/); + expect(iface, 'a local `bind` re-declaration is back — it is inherited from BaseSchema (objectui#6357)').not.toMatch(/\bbind\?:/); + // The exact before-state this card removed. + expect(source, 'the component is published as `(props: any)` again').not.toContain('export const ObjectChart = (props: any)'); + expect(source).toContain('export const ObjectChart = (props: ObjectChartProps)'); + }); + + it('the `type` literal on ObjectChartSchema is the key the widget registers', () => { + expect(source).toMatch(new RegExp(`ComponentRegistry\\.register\\(\\s*'object-chart'`)); + }); + + it('every key read off `schema` is declared by the mirror, or ledgered by name', () => { + const reads = schemaReads(stripComments(source)); + // Non-vacuity: a widget that reads nothing off `schema` would pass vacuously. + expect(reads.size).toBeGreaterThan(10); + expect(reads.has('objectName')).toBe(true); + // The four the ruling declared are READ — otherwise the declarations are dead. + for (const key of ['xAxisKey', 'series', 'aggregate', 'filter']) { + expect(reads.has(key), `${key} is declared by objectui#7946 but no longer read`).toBe(true); + } + + const declared = new Set([...Object.keys(ObjectChartMirror.shape), ...LEDGERED_UNDECLARED_READS]); + const readNotDeclared = [...reads].filter((k) => !declared.has(k)).sort(); + expect(readNotDeclared, `${WIDGET_FILE} reads keys its schema type does not declare (objectui#6914 class)`).toEqual([]); + + // Each ledgered exception must still be READ — a stale exception is a hole. + for (const key of LEDGERED_UNDECLARED_READS) { + expect(reads.has(key), `${key} is ledgered as undeclared but no longer read`).toBe(true); + } + }); + + it('the census can see a drifted key, and does not see one that only a COMMENT mentions (non-vacuity controls)', () => { + // A census that returned an empty set for any input would pass the pin + // above while measuring nothing. + const reads = schemaReads(stripComments( + "const a = schema.objectName; const b = (schema as any).xAxisKy; const c = schema?.filter; const d = schema['data'];", + )); + expect([...reads].sort()).toEqual(['data', 'filter', 'objectName', 'xAxisKy']); + expect([...reads].filter((k) => !new Set(Object.keys(ObjectChartMirror.shape)).has(k))).toEqual(['xAxisKy']); + + // The comment half, which is the control the 6576 census does not have and + // this file needs (see `stripComments`). Both a block and a line comment, + // and a `//` inside a URL, which must NOT eat the code after it. + const commented = schemaReads(stripComments( + "/** asked for `schema.chart` it would read undefined */\n// see schema.phantom\nconst u = 'https://example.test/x'; const a = schema.objectName;", + )); + expect([...commented].sort()).toEqual(['objectName']); + }); +}); diff --git a/packages/types/src/__tests__/zod-mirror-parity.test.ts b/packages/types/src/__tests__/zod-mirror-parity.test.ts index 9427e7c150..61efcf3b2d 100644 --- a/packages/types/src/__tests__/zod-mirror-parity.test.ts +++ b/packages/types/src/__tests__/zod-mirror-parity.test.ts @@ -3096,6 +3096,12 @@ const SPEC_DERIVED_PAIRS: readonly string[] = [ 'complex.zod.ts#KanbanSchema', 'form.zod.ts#SelectOptionSchema', 'layout.zod.ts#PageNodeSchema', + // 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 moves + // ONE side of this pair — which is exactly what this list exists to make + // legible rather than mysterious. + 'objectql.zod.ts#ObjectChartSchema', 'objectql.zod.ts#ObjectGallerySchema', 'objectql.zod.ts#ObjectGanttSchema', // objectui#7762: `exportOptions` is the spec's OBJECT ARM by reference — peeled out of diff --git a/packages/types/src/objectql.ts b/packages/types/src/objectql.ts index 9b32d3525d..b9fd24b93d 100644 --- a/packages/types/src/objectql.ts +++ b/packages/types/src/objectql.ts @@ -97,6 +97,7 @@ import type { GalleryConfig, TimelineConfig, NavigationConfig, + ChartAggregate, GanttConfig as SpecGanttConfig, CalendarConfig as SpecCalendarConfig, } from '@objectstack/spec/ui'; @@ -3051,7 +3052,60 @@ export type KanbanConditionalFormattingRule = | SpecConditionalFormattingRule; /** - * Object Chart Component Schema + * Object Chart Component Schema — the node `plugin-charts`' `ObjectChart` + * renders (registered as `object-chart`) and, since objectui#7946, the anchor + * of the published `ObjectChartProps.schema`. + * + * Until that card this shape anchored NOTHING: `ObjectChart` was published as + * `(props: any)`, so every `schema={{ … }}` literal handed to it was checked + * against nothing at all, and four keys its producers write and its renderer + * reads — `xAxisKey`, `series`, `aggregate`, `filter` — were declared on + * neither this interface nor its zod mirror. They rode `BaseSchema`'s + * `[key: string]: any` / `.passthrough()` and arrived UNVALIDATED. + * + * ## The ceiling, stated rather than assumed (objectui#5155) + * + * `BaseSchema` still carries `[key: string]: any`, so anchoring buys DECLARED + * members their declared types — `xAxisKey: 42` is refused now — but does NOT + * buy rejection of a MISSPELLING: `xAxisKy: 'x'` still compiles, exactly as it + * does on `ObjectGallerySchema` (objectui#6576). The counter-probe in + * `__tests__/widget-schema-anchors-7946.test.ts` pins that honestly. + * + * ## AUTHORABLE vs INTERNAL, per key (objectui#7946, ADR-0049) + * + * The four keys added by that card do NOT share one verdict, and the ruling + * asked for the reading rather than the assumption: + * + * - `aggregate` — AUTHORABLE, and declared BY REFERENCE as the spec's own + * `ChartAggregate`. `ChartAggregateSchema` calls itself "Inline aggregation + * for an OBJECT-bound chart", names its carrier as the react tier's + * `` (ADR-0081), and objectstack#5020 + * wired the publish gate (`validate-react-page-props.ts` calls + * `ChartAggregateSchema.safeParse()`). This component's registry `inputs` + * advertises it too. Because the spec already owns the shape, the ONLY + * defensible declaration here is that same symbol: two dialects on one + * published key is the drift this whole card exists to close, and the + * member doc records what the first cut's local near-copy published. + * - `filter` — AUTHORABLE. The spec spells the carrier literally + * (`ChartAggregateSchema`'s own guidance: "`filter` is a prop on the chart + * itself (``)"), declares `ObjectChart.filter` as a + * `FilterArray` in its react-blocks prop table, and this component's + * registry `inputs` advertises `{ name: 'filter', type: 'array' }`. + * - `xAxisKey` — INTERNAL (relay-composed). `ChartRendererProps` calls it + * "Internal binding. Authors write the spec `xAxis: { field }`"; the + * author-facing spelling ON THIS NODE is `xAxisField` above. All five + * producers COMPUTE it (`dims[0]`, `chartCategoryKey(...)`), none forwards + * an authored value, and it is absent from the registry `inputs`. + * - `series` — INTERNAL (relay-composed). The `{ dataKey }` shape below is + * the renderer's internal contract; the spec's author-facing + * `ChartSeriesSchema` REFUSES `dataKey` by name (`dataKey` → `name` + * rename). All five producers compose it from something else. + * + * Both internal keys are still declared HERE and on the mirror: they are read + * and written today, `BaseSchema` is `.passthrough()`, so leaving them + * undeclared does not make them unauthorable — it only means an `xAxisKey: 42` + * rides through unchecked. Declaring buys the VALUE check without minting new + * authorable vocabulary, and the descriptions say which is which. */ export interface ObjectChartSchema extends BaseSchema { type: 'object-chart'; @@ -3072,6 +3126,128 @@ export interface ObjectChartSchema extends BaseSchema { dimensions?: string[]; /** Dataset measure names */ values?: string[]; + /** + * AUTHORABLE — query filter, forwarded verbatim as `$filter` on both query + * legs (`ds.aggregate` and `ds.find`), then spread into the drill-down + * filter. + * + * ⚠️ BOTH shapes, and the union is measured rather than tidied. The array arm + * is what `@objectstack/spec` publishes for this prop (`ObjectChart.filter` + * is a `FilterArray` in its react-blocks table) and what this component's + * registry `inputs` advertises (`{ name: 'filter', type: 'array' }`) — it is + * the spelling {@link ObjectGanttSchema.filter} and + * {@link ObjectKanbanSchema.filter} carry. The RECORD arm is what the reads + * require: the drill-down filter is built by spreading this value into an + * object (`{ ...(schema.filter || {}), ...computeDrillFilter(…) }`), and the + * in-repo corpus authors the ObjectQL object form + * (`{ close_date: { $gte, $lte } }`) against fakes that read it that way. + * Declaring only the array arm would have refused live, working charts. + * + * ⚠️ Narrowing to ONE arm is a decision LOCAL TO THIS NODE, not a + * cross-widget one — an earlier draft of this docblock said the opposite and + * the census refutes it. Six sibling `object-*` widgets declare `filter` on + * this interface and every one of them is array-only + * ({@link ObjectGanttSchema.filter}, {@link ObjectKanbanSchema.filter} and + * four more); this key is the only `object-*` `filter` with a record arm. So + * there is no fleet-wide convention to renegotiate — what is unresolved is + * only this component's own two-armed read, and objectui#7946 declares the + * accept set it measured rather than picking an arm without a ruling. + * + * ⭐ Successor, named rather than implied: the drill-down spread below + * (`{ ...(schema.filter || {}), ...computeDrillFilter(…) }`) MIS-COMPOSES the + * array arm — spreading a `FilterArray` into an object yields index keys + * (`{ 0: […] }`), not conditions. Fixing that composition is the work that + * makes narrowing to the spec's array-only `FilterArray` possible; until it + * lands, declaring only the array arm would refuse live, working charts. + * + * What this declaration buys today is that `filter: 'stage=won'` and + * `filter: 42` are compile errors, where before they were not. + */ + filter?: any[] | Record; + /** + * AUTHORABLE — inline aggregation for the legacy `objectName` path. + * + * ⛔ `ChartAggregate` from `@objectstack/spec/ui` BY REFERENCE, never a local + * near-copy — this file's standing rule ("Never Redefine Types. ALWAYS import + * them.") and the fork `check:spec-symbols` exists to stop. + * + * The first cut of objectui#7946 declared it as a local copy with all three + * members OPTIONAL, reasoning from this renderer's accept set (every read is + * guarded: `if (schema.aggregate)`, `schema.aggregate?.groupBy`, + * `aggregateValueKey`). What that PUBLISHES is a different thing, and the + * contract review measured it: + * + * - the TS face advertised `aggregate: {}` and `{ field: 'amount' }` as + * legal authoring, which `ChartAggregateSchema` refuses; + * - the zod mirror's local `z.object` is strip-postured, so + * `{ groupby: 'stage', function: 'count' }` parsed CLEAN and dropped the + * mis-cased key silently — the exact failure the spec's own + * `strictObject` history text was written to prevent; + * - no typed in-tree producer needed the relaxation: every live forward of + * this key is `any` (`DashboardRenderer`'s `(widget as any).data`, + * app-shell's `viewDef: any`). + * + * ⭐ And the cost is asymmetric — declaring the spec's requiredness now is + * free, tightening it later is a `major` on a published package. So the + * authoring door and this declaration are ONE shape: `function` and `groupBy` + * required, `field` optional (only `count` counts rows rather than a column), + * and the structured `groupBy` arm naming its `field`. + * + * The RENDERER's accept set is wider than this and stays wider on purpose — + * `ObjectChart.tsx` guards every read and draws an explicit refusal screen for + * an aggregate that names no category (objectui#8168), because untyped + * producers still hand it documents this declaration refuses. That refusal is + * what the narrower door costs at runtime; it is not a reason to advertise the + * wider shape as authorable. + * + * `groupBy` is the category axis — a bare field name, or the structured + * date-bucketing node the engine takes. `alias`, when present, is the column + * the projected group value lands under, which is what `ObjectChart.tsx`'s + * `aggregateGroupByKey` (`gb.alias || gb.field`) resolves. + */ + 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 + * producers of an `object-chart` node compute this key. + * + * Typed `string` from `ChartRendererProps.schema.xAxisKey`, the read this + * value ends at. + */ + xAxisKey?: string; + /** + * INTERNAL (relay-composed) — the plotted series, in the renderer's internal + * `{ dataKey }` contract. + * + * The element type is `ChartRendererProps.schema.series`' internal arm + * VERBATIM — that is the read this value ends at, and the ruling on + * objectui#7946 asked for the reads rather than a copy of any producer's + * literal. The spec's AUTHOR-facing `ChartSeriesSchema` is the other arm + * (`{ name }`), and it refuses `dataKey` by name; `normalizeChartSchema` is + * the one translation between them. + */ + series?: Array<{ + dataKey: string; + label?: string; + variant?: 'current' | 'comparison'; + opacity?: number; + dashArray?: string; + chartType?: 'bar' | 'line' | 'area'; + stack?: string; + yAxis?: 'left' | 'right'; + color?: string; + }>; + /** + * Positional palette (`string[]`) OR a value→color map + * (`{ value: color }`, kanban-style). Select/lookup option colors and + * explicit maps win over the palette per category. + * + * The zod mirror has declared this since objectui#3913; this interface did + * not, and nothing ratchets the mirror-declares-more direction — so the two + * published copies of one shape disagreed silently until objectui#7946. + */ + colors?: string[] | Record; } /** diff --git a/packages/types/src/zod/objectql.zod.ts b/packages/types/src/zod/objectql.zod.ts index c6b4330c61..1879a7c7b1 100644 --- a/packages/types/src/zod/objectql.zod.ts +++ b/packages/types/src/zod/objectql.zod.ts @@ -34,6 +34,7 @@ import { UserActionsConfigSchema as SpecUserActionsConfigSchema, AriaPropsSchema as SpecAriaPropsSchema, NavigationConfigSchema as SpecNavigationConfigSchema, + ChartAggregateSchema as SpecChartAggregateSchema, } from '@objectstack/spec/ui'; import { BaseSchema, specFieldsExcept } from './base.zod.js'; import { handlerKeyRefusal, retirementTombstone } from './tombstone.zod.js'; @@ -1249,6 +1250,60 @@ export const ObjectChartSchema = BaseSchema.extend({ dataset: z.string().optional().describe('Semantic-layer dataset name (ADR-0021)'), dimensions: z.array(z.string()).optional().describe('Dataset dimension names'), values: z.array(z.string()).optional().describe('Dataset measure names'), + // ── objectui#7946: the four keys the producers write and the renderer reads ── + // + // Declared on BOTH published copies by the 2026-09-09 ruling (option A), with + // value types derived from `ChartRendererProps` / `ObjectChart.tsx`'s reads + // and NOT copied from any producer's literal. `../objectql.ts`'s docblock + // carries the per-key AUTHORABLE / INTERNAL verdict and its ground; the short + // form is repeated in each `.describe()` because that string is what an + // author-facing tool renders. + // + // ⭐ Where the SPEC already owns the shape, the binding is BY REFERENCE and + // the local spelling is a defect, not a style: see `aggregate` below. A + // near-copy publishes a second dialect of one key, and — because a local + // `z.object` strips where the spec's `strictObject` refuses — the copy is + // quietly the more permissive of the two. + // + // Declaring an INTERNAL key here is not a promotion. `BaseSchema` is + // `.passthrough()`, so `xAxisKey` and `series` already rode through this + // mirror unexamined; what changes is that their VALUES are checked. Leaving + // them undeclared would instead have put them in `zod-mirror-parity`'s + // `UnmirroredDeclared` ledger — which that file calls a real defect in the + // pair, not a neutral state. + // BOTH `filter` arms are live and both are measured — see the twin docblock in + // `../objectql.ts`. The array arm is the spec's published `FilterArray` and + // the registry `inputs` spelling; the record arm is the ObjectQL `$filter` + // object the drill-down spread requires and the in-repo corpus authors. + // ⚠️ Narrowing to one arm is a decision local to THIS node — the six sibling + // `object-*` widgets are already array-only — and it is blocked on the + // drill-down spread, which mis-composes the array arm into index keys. + filter: z.union([ + z.array(z.any()), + z.record(z.string(), z.any()), + ]).optional().describe('AUTHORABLE — query filter, forwarded verbatim as $filter on both query legs, then spread into the drill-down filter. FilterArray (the spec/react-blocks and registry-inputs spelling) OR the ObjectQL $filter object'), + // ⛔ `aggregate` is the SPEC's own schema, never a local near-copy. The first + // cut of objectui#7946 spelled it as a local `z.object` with all three members + // optional; zod 4 objects are STRIP-postured, so + // `{ groupby: 'stage', function: 'count' }` parsed clean and dropped the + // mis-cased key silently — the failure `ChartAggregateSchema`'s own + // `strictObject` posture exists to prevent, reintroduced by the copy. Bound by + // reference the strict posture and the requiredness come with it, and the + // authoring door here and at the react-page publish gate are one shape. + aggregate: stripImportedDefaults(SpecChartAggregateSchema).optional() + .describe('AUTHORABLE — inline aggregation for the legacy objectName path. @objectstack/spec ChartAggregateSchema ({ field?, function, groupBy }), the same schema the react-page publish gate parses: function and groupBy are REQUIRED, field is optional because only count counts rows rather than a column, and unknown keys are refused rather than dropped'), + xAxisKey: z.string().optional().describe('INTERNAL (relay-composed) — the category column the renderer binds the x axis to. Authors write xAxisField (or the spec xAxis: { field } one layer down); all five producers compute this key'), + series: z.array(z.object({ + dataKey: z.string().describe('Result column this series plots'), + label: z.string().optional().describe('Series display label'), + variant: z.enum(['current', 'comparison']).optional().describe('Comparison overlays render muted'), + opacity: z.number().optional().describe('Series opacity override (0-1)'), + dashArray: z.string().optional().describe('SVG stroke-dasharray override'), + chartType: z.enum(['bar', 'line', 'area']).optional().describe('Per-series family override (combo charts)'), + stack: z.string().optional().describe('Stack identifier to group series'), + yAxis: z.enum(['left', 'right']).optional().describe('Bind to a specific Y axis'), + color: z.string().optional().describe('Series color (hex/rgb/token)'), + })).optional().describe("INTERNAL (relay-composed) — plotted series in the renderer's internal { dataKey } contract, the arm ChartRendererProps declares. The spec's author-facing ChartSeriesSchema is the { name } arm and refuses dataKey by name; normalizeChartSchema is the one translation"), // Colors are overloaded kanban-style: a string[] is the positional palette // (applied per category in order; fallback only), while a Record // is an explicit value→color map. A select/lookup dimension's option colors — diff --git a/scripts/check-doc-example-types.mjs b/scripts/check-doc-example-types.mjs index 4f3abfd68d..dcf237cf28 100644 --- a/scripts/check-doc-example-types.mjs +++ b/scripts/check-doc-example-types.mjs @@ -1015,7 +1015,7 @@ export const UNGATED_EXAMPLES = { reason: 'usage fragment: references `save`, `storedPage`, which the example never declares', }, - 'packages/types/src/objectql.ts:1614 ObjectFormSchema': { + 'packages/types/src/objectql.ts:1615 ObjectFormSchema': { card: null, codes: [1005, 1109], reason: