Skip to content

Commit 09f8ffa

Browse files
committed
feat(types,plugin-charts): anchor ObjectChart props to ObjectChartSchema (#7946)
`ObjectChart` was published as `(props: any)`, so `ObjectChartSchema` anchored nothing and four keys its producers write and its renderer reads — `xAxisKey`, `series`, `aggregate`, `filter` — were declared on neither published copy. Maintainer ruling 2026-09-09 option A (director seat, summon #20, decision batch #108 item 3), applying objectui#6576's gallery treatment to the chart: - `ObjectChartProps.schema` is `ObjectChartSchema`; the published `.d.ts` no longer carries `props: any`. `ObjectChartProps` is exported. - The four keys are declared on BOTH copies, typed from their READ sites (`ChartRendererProps` for `xAxisKey`/`series`, `ObjectChart.tsx` for `aggregate`/`filter`), not copied from any producer's literal. - `colors` converges — the mirror declared it, the interface did not. - Read-census + compile-time pins join the `widget-schema-anchors` family. The anchor found a real defect it was pointed at: `groupByField` used the raw `groupBy` union as a row index, a field name and a drill-filter key, while its twin 200 lines up normalised the structured node. Both now share one normalisation. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01611D6ZaRaMmwTNQmSbk8MH
1 parent 093af32 commit 09f8ffa

15 files changed

Lines changed: 841 additions & 33 deletions
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
---
2+
"@object-ui/types": minor
3+
"@object-ui/plugin-charts": minor
4+
---
5+
6+
feat(types,plugin-charts): anchor `ObjectChart`'s props to `ObjectChartSchema` and declare the four keys its producers write
7+
8+
`ObjectChart` was published as `(props: any)`, so `ObjectChartSchema` anchored
9+
nothing: every `schema={{ … }}` literal handed to the component was type-checked
10+
against nothing at all. Four keys its producers write and its renderer reads —
11+
`xAxisKey`, `series`, `aggregate`, `filter` — were declared on neither published
12+
copy of the shape, and rode `BaseSchema`'s index signature / `.passthrough()`
13+
unvalidated. That is the mechanism that let objectui#7891's undeclared `config`
14+
rung survive from the day it was written.
15+
16+
Maintainer ruling 2026-09-09 (option A), applying objectui#6576's gallery
17+
treatment to the chart:
18+
19+
- `ObjectChartProps.schema` is `ObjectChartSchema`; the published `.d.ts` no
20+
longer says `props: any`. `ObjectChartProps` is exported.
21+
- The four keys are declared on BOTH copies, with value types taken from their
22+
READ sites (`ChartRendererProps` for `xAxisKey` / `series`, `ObjectChart.tsx`
23+
for `aggregate` / `filter`) rather than copied from any producer's literal.
24+
- `colors` converges: the zod mirror has declared it since objectui#3913 and the
25+
TS interface did not, a drift no ratchet could see because a mirror-only key
26+
is in neither of the parity guard's two difference ledgers.
27+
28+
Two of the four are AUTHORABLE (`aggregate`, `filter` — the spec names this
29+
component's own props as their carrier and parses `aggregate` at the react-page
30+
publish gate) and two are INTERNAL, relay-composed (`xAxisKey`, `series` — every
31+
producer computes them and the spec's author-facing vocabulary refuses the
32+
internal spellings by name). The internal pair is declared anyway, because it was
33+
already passing through unvalidated: declaring buys the value check without
34+
minting authorable vocabulary, and each description says which it is.
35+
36+
BEHAVIOUR, from what the anchor made visible: `ObjectChart` resolved the
37+
group-by column twice and only one site normalised the structured
38+
`groupBy: { field, dateGranularity }` node. The other used the raw union as a row
39+
index, a field name and a drill-filter key, so a date-bucketed chart lost its
40+
option-colour resolution, its label→raw reverse map and its drill filter to a
41+
lookup on the node's stringification. Both sites now share one normalisation.
42+
43+
⚠️ Anchoring does not buy rejection of a MISSPELLED key: `BaseSchema` carries
44+
`[key: string]: any` (objectui#5155), the same ceiling objectui#6576 accepted.
45+
What it buys is that a wrong VALUE TYPE is now a compile error.

‎packages/plugin-charts/src/ObjectChart.absentCategoryAxisRefusal-8168.test.tsx‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ vi.mock('recharts', async () => {
5555
});
5656

5757
import { ObjectChart, resolveChartCategoryField } from './ObjectChart';
58+
import type { ObjectChartSchema } from '@object-ui/types';
5859

5960
beforeEach(() => {
6061
vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, json: async () => ({}) })));
@@ -77,8 +78,13 @@ const dataSourceWithRows = () => ({
7778
aggregate: vi.fn().mockResolvedValue(ROWS),
7879
});
7980

80-
const renderChart = (schema: Record<string, unknown>, ds: any = dataSourceWithRows()) =>
81-
render(<ObjectChart schema={{ chartType: 'bar', isAnimationActive: false, ...schema }} dataSource={ds} />);
81+
// `type` moved into the base literal when objectui#7946 anchored
82+
// `ObjectChartProps.schema` to `ObjectChartSchema`, on which `type` is
83+
// required and pinned to the registry key. The per-case overrides stay a
84+
// partial of the anchored type — this file's whole subject is nodes that
85+
// declare no category axis, so the parameter must keep admitting them.
86+
const renderChart = (schema: Partial<ObjectChartSchema>, ds: any = dataSourceWithRows()) =>
87+
render(<ObjectChart schema={{ type: 'object-chart', chartType: 'bar', isAnimationActive: false, ...schema }} dataSource={ds} />);
8288

8389
/** The refusal is absent AND the chart got far enough to draw. */
8490
const expectNoRefusal = async () => {

‎packages/plugin-charts/src/ObjectChart.contractEnvelope-6839.test.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ const asBareArray: Envelope = (rows) => rows;
7575
const asRecords: Envelope = (rows) => ({ records: rows, total: rows.length });
7676

7777
const schema = {
78-
type: 'object-chart',
78+
type: 'object-chart' as const,
7979
chartType: 'bar' as const,
8080
objectName: 'crm_opportunity',
8181
xAxisKey: 'stage',

‎packages/plugin-charts/src/ObjectChart.drillNavigate.test.tsx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -90,8 +90,8 @@ afterEach(() => {
9090
});
9191

9292
const schema = (drillDown: Record<string, unknown>) => ({
93-
type: 'object-chart',
94-
chartType: 'bar',
93+
type: 'object-chart' as const,
94+
chartType: 'bar' as const,
9595
objectName: 'opportunity',
9696
xAxisKey: 'stage',
9797
data: [{ stage: 'won', amount: 42 }],

‎packages/plugin-charts/src/ObjectChart.emptyResult.test.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ afterEach(() => {
5353
});
5454

5555
const schema = {
56-
type: 'object-chart',
56+
type: 'object-chart' as const,
5757
chartType: 'bar' as const,
5858
objectName: 'crm_opportunity',
5959
xAxisKey: 'stage',

‎packages/plugin-charts/src/ObjectChart.optionColors.test.tsx‎

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -111,8 +111,8 @@ describe('ObjectChart — category option colors (objectui#4106)', () => {
111111
render(
112112
<ObjectChart
113113
schema={{
114-
type: 'object-chart',
115-
chartType: 'bar',
114+
type: 'object-chart' as const,
115+
chartType: 'bar' as const,
116116
objectName: 'opportunity',
117117
xAxisKey: 'stage',
118118
data: [
@@ -166,8 +166,8 @@ describe('ObjectChart — category option colors (objectui#4106)', () => {
166166
render(
167167
<ObjectChart
168168
schema={{
169-
type: 'object-chart',
170-
chartType: 'bar',
169+
type: 'object-chart' as const,
170+
chartType: 'bar' as const,
171171
dataset: 'showcase_task_metrics',
172172
dimensions: ['status'],
173173
values: ['task_count'],
@@ -209,8 +209,8 @@ describe('ObjectChart — category option colors (objectui#4106)', () => {
209209
render(
210210
<ObjectChart
211211
schema={{
212-
type: 'object-chart',
213-
chartType: 'bar',
212+
type: 'object-chart' as const,
213+
chartType: 'bar' as const,
214214
objectName: 'opportunity',
215215
xAxisKey: 'stage',
216216
data: [{ stage: 'won', amount: 42 }],
@@ -255,8 +255,8 @@ describe('ObjectChart — option-color probe routing (objectui#4114)', () => {
255255
};
256256

257257
const OBJECT_CHART_SCHEMA = {
258-
type: 'object-chart',
259-
chartType: 'bar',
258+
type: 'object-chart' as const,
259+
chartType: 'bar' as const,
260260
objectName: 'opportunity',
261261
xAxisKey: 'stage',
262262
data: [{ stage: 'won', amount: 42 }],
@@ -326,8 +326,8 @@ describe('ObjectChart — option-color probe routing (objectui#4114)', () => {
326326
>
327327
<ObjectChart
328328
schema={{
329-
type: 'object-chart',
330-
chartType: 'bar',
329+
type: 'object-chart' as const,
330+
chartType: 'bar' as const,
331331
dataset: 'showcase_task_metrics',
332332
dimensions: ['status'],
333333
values: ['task_count'],

‎packages/plugin-charts/src/ObjectChart.tsx‎

Lines changed: 108 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { ComponentRegistry, chartMeasureKey, humanizeLabel, extractRecords, comp
77
import { Sheet, SheetContent, SheetHeader, SheetTitle, Dialog, DialogContent, DialogHeader, DialogTitle, RefreshIndicator, Button, ChartSkeleton, DataEmptyState } from '@object-ui/components';
88
import { AlertCircle, ArrowUpRight, Inbox } from 'lucide-react';
99
import { builtinAggregateLabels, useSafeFieldLabel, useSafeTranslate } from '@object-ui/i18n';
10-
import type { DrillDownConfig } from '@object-ui/types';
10+
import type { DrillDownConfig, ObjectChartSchema } from '@object-ui/types';
1111

1212
/**
1313
* Humanize a snake_case or kebab-case string into Title Case.
@@ -126,14 +126,46 @@ export function resolveChartCategoryField(schema: {
126126
/** Suffix the previous-window value carries under a compareTo overlay. */
127127
export const COMPARISON_SUFFIX = '__comparison';
128128

129+
/**
130+
* The COLUMN an inline `aggregate` projects its group under — the string every
131+
* column lookup in this file needs.
132+
*
133+
* `groupBy` is a union: a bare field name, or the structured date-bucketing
134+
* node `{ field, dateGranularity, alias }` the engine takes, in which case the
135+
* projected column is the `alias` (or the `field` it defaults to). This is that
136+
* normalisation, hoisted out of the comparison-merge leg below, which has
137+
* spelled it inline since the structured node arrived — "Normalise to the
138+
* underlying string field name so all column lookups work".
139+
*
140+
* ⚠️ Module-local on purpose: it is a spelling this file already owned twice,
141+
* not a new published export. It is also NOT a fourth answer to "what is the
142+
* category axis" — {@link resolveChartCategoryField} remains that one, and it
143+
* answers a wider question (it also resolves the spec's `xAxis` through
144+
* `normalizeChartSchema`). This one answers only "which column did the
145+
* aggregate project the group under", which is what a row lookup needs.
146+
*/
147+
function aggregateGroupByKey(
148+
aggregate: ObjectChartSchema['aggregate'],
149+
): string | undefined {
150+
const gb = aggregate?.groupBy;
151+
if (gb && typeof gb === 'object' && !Array.isArray(gb)) return gb.alias || gb.field;
152+
return typeof gb === 'string' ? gb : undefined;
153+
}
154+
129155
/**
130156
* Client-side aggregation for fetched records.
131157
* Groups records by `groupBy` field and applies the aggregation function
132158
* to the `field` values in each group.
159+
*
160+
* `function` is optional because the `switch` below already implements that:
161+
* an absent (or unknown) function falls through to `sum`. The parameter used to
162+
* say `function: string`, which was a claim about the CALLER rather than about
163+
* this body — and no caller could satisfy it once `ObjectChartSchema.aggregate`
164+
* was declared with the requiredness its own reads have (objectui#7946).
133165
*/
134166
export function aggregateRecords(
135167
records: any[],
136-
aggregate: { field?: string; function: string; groupBy: string }
168+
aggregate: { field?: string; function?: string; groupBy: string }
137169
): any[] {
138170
const { field, function: aggFn, groupBy } = aggregate;
139171
const valueKey = aggregateValueKey(aggregate);
@@ -358,11 +390,53 @@ export async function resolveGroupByLabels(
358390
// Re-export extractRecords from @object-ui/core for backward compatibility
359391
export { extractRecords } from '@object-ui/core';
360392

361-
export const ObjectChart = (props: any) => {
393+
/**
394+
* Props of {@link ObjectChart} — anchored to the published `ObjectChartSchema`
395+
* (objectui#7946, maintainer ruling 2026-09-09 option A), as objectui#6576 did
396+
* for `ObjectGalleryProps.schema`.
397+
*
398+
* ## What this replaces, and what it buys
399+
*
400+
* This component was published as `(props: any)`, so every `schema={{ … }}`
401+
* literal handed to it — including the two in `app-shell`'s `ObjectView` — was
402+
* type-checked against NOTHING. That is the mechanism that let objectui#7891's
403+
* undeclared `config` rung live from the day it was written until someone read
404+
* the spec by hand, and it is why the two `as any` casts on those literals
405+
* measured INERT: an `any` consumer accepts a cast and its absence alike.
406+
*
407+
* With the anchor, a wrong VALUE TYPE on a declared key is a compile error at
408+
* the producer (`xAxisKey: 42`, `series: 'x'`, `type: 'chart'`, and every
409+
* `BaseSchema` member — `visible: 42`). ⚠️ A MISSPELLED key is still accepted:
410+
* `BaseSchema` carries `[key: string]: any` (objectui#5155), the same ceiling
411+
* objectui#6576 accepted knowingly. `__tests__/ObjectChart.schemaAnchor-7946.test.ts`
412+
* pins both halves, the ceiling included, so the anchor is not read as more
413+
* than it is.
414+
*/
415+
export interface ObjectChartProps {
416+
/**
417+
* The `object-chart` node — anchored to the exported schema type. Every
418+
* `BaseSchema` member is writable, `bind` / `className` / `data` included;
419+
* the widget's own keys are declared there.
420+
*/
421+
schema: ObjectChartSchema;
422+
/**
423+
* Host data source. `any` deliberately, and it is NOT a residue of the shape
424+
* this card removed: it is the type `SchemaRendererContext.dataSource`
425+
* itself carries, and this component falls back to that context value, so a
426+
* narrower declaration here would claim a guarantee the fallback cannot
427+
* keep. Narrowing it is a repo-wide `dataSource` interface, not this card.
428+
*/
429+
dataSource?: any;
430+
/**
431+
* Optional host-owned segment click. When provided (e.g. a dataset widget
432+
* that owns precise drill-through), it takes over the chart click and the
433+
* widget's own object-drill drawer is suppressed.
434+
*/
435+
onSegmentClick?: (ev: ChartSegmentClickEvent) => void;
436+
}
437+
438+
export const ObjectChart = (props: ObjectChartProps) => {
362439
const { schema } = props;
363-
// Optional host-owned segment click. When provided (e.g. a dataset widget
364-
// that owns precise drill-through), it takes over the chart click and the
365-
// widget's own object-drill drawer is suppressed.
366440
const onSegmentClick: ((ev: ChartSegmentClickEvent) => void) | undefined = props.onSegmentClick;
367441
const context = useContext(SchemaRendererContext);
368442
const dataSource = props.dataSource || context?.dataSource;
@@ -616,8 +690,16 @@ export const ObjectChart = (props: any) => {
616690
if (typeof ds.find === 'function') {
617691
const results = await ds.find(schema.objectName, { $filter: filterForRun });
618692
let data = extractRecords(results);
619-
if (schema.aggregate && data.length > 0) {
620-
data = aggregateRecords(data, schema.aggregate);
693+
// `aggregateRecords` buckets on `record[groupBy]`, so it needs the
694+
// projected COLUMN, not the raw union: a structured `groupBy` node used
695+
// as an index stringifies, and every row lands in one bucket keyed by
696+
// that stringification. Declaring the key (objectui#7946) is what made
697+
// that reachable to a compiler. When no column resolves there is nothing
698+
// to group by, and the chart is already refused for exactly that reason
699+
// by the absent-category screen below (objectui#8168).
700+
const clientGroupBy = aggregateGroupByKey(schema.aggregate);
701+
if (schema.aggregate && clientGroupBy && data.length > 0) {
702+
data = aggregateRecords(data, { ...schema.aggregate, groupBy: clientGroupBy });
621703
}
622704
return data;
623705
}
@@ -753,15 +835,23 @@ export const ObjectChart = (props: any) => {
753835
// Resolve groupBy value→label using field metadata. Now that the
754836
// merge has happened on raw keys, the resolver can convert the
755837
// shared groupBy column (e.g. 'closed_won' → 'Closed Won') uniformly.
756-
if (groupByField && typeof ds.getObjectSchema === 'function') {
838+
// `schema.objectName` joins the guard rather than being asserted:
839+
// it is OPTIONAL since ADR-0021 (a chart may bind a `dataset`
840+
// instead), and every read in this leg — the metadata fetch and the
841+
// per-option label lookup — is keyed by an object NAME. Naming the
842+
// precondition is what `props: any` used to hide (objectui#7946);
843+
// the leg is on the object-bound path, so it is the shape it already
844+
// assumed.
845+
if (groupByField && schema.objectName && typeof ds.getObjectSchema === 'function') {
846+
const objectName = schema.objectName;
757847
try {
758-
const objectSchema = await ds.getObjectSchema(schema.objectName);
848+
const objectSchema = await ds.getObjectSchema(objectName);
759849
data = await resolveGroupByLabels(
760850
data,
761851
groupByField,
762852
objectSchema,
763853
ds,
764-
(value, fallback) => fieldOptionLabel(schema.objectName, groupByField, value, fallback),
854+
(value, fallback) => fieldOptionLabel(objectName, groupByField, value, fallback),
765855
);
766856
} catch {
767857
// Schema fetch failed — continue with raw values
@@ -853,7 +943,13 @@ export const ObjectChart = (props: any) => {
853943
// composes an `object-chart` schema directly, and stays absent from the
854944
// registry `inputs` below.
855945
const drillDown = (schema as { drillDown?: DrillDownConfig }).drillDown;
856-
const groupByField = schema.aggregate?.groupBy || schema.xAxisKey;
946+
// Spelled through the shared normalisation rather than `aggregate?.groupBy`
947+
// raw: this value is used as a ROW INDEX (`row[groupByField]`), as a FIELD
948+
// NAME (`fieldOptionLabel`) and as a drill-filter key, and a structured
949+
// `groupBy` node is none of those. The comparison-merge leg above has always
950+
// normalised before its own column lookups; this site did not, and `props:
951+
// any` is why nothing said so (objectui#7946).
952+
const groupByField = aggregateGroupByKey(schema.aggregate) || schema.xAxisKey;
857953

858954
// Build a label→raw map from the resolved chart data. resolveGroupByLabels
859955
// stashes the original raw enum/id under `__raw_${groupByField}`. The chart

‎packages/plugin-charts/src/__tests__/ObjectChart.compareTo.scatter.test.tsx‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ vi.mock('../ChartRenderer', () => ({
3838
}));
3939

4040
import { ObjectChart, COMPARISON_SUFFIX } from '../ObjectChart';
41+
import type { ObjectChartSchema } from '@object-ui/types';
4142

4243
/** ObjectChart probes `/api/v1/meta/object/deal` for option colors; answer it. */
4344
function installMetaFetchDouble() {
@@ -80,10 +81,14 @@ const makeSource = () => ({
8081
]),
8182
});
8283

83-
const renderChart = (chartType: string, dataSource: unknown, series?: unknown) =>
84+
// `chartType` takes the anchored union rather than a bare `string`
85+
// (objectui#7946): `ObjectChartProps.schema` is `ObjectChartSchema`, whose
86+
// `chartType` is the declared family list, and `type` is required there.
87+
const renderChart = (chartType: ObjectChartSchema['chartType'], dataSource: unknown, series?: ObjectChartSchema['series']) =>
8488
render(
8589
<ObjectChart
8690
schema={{
91+
type: 'object-chart',
8792
objectName: 'deal',
8893
chartType,
8994
aggregate: { field: 'amount', function: 'sum', groupBy: 'stage' },

‎packages/plugin-charts/src/__tests__/ObjectChart.compareTo.test.tsx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@ const renderChart = (compareTo: unknown, dataSource: unknown) =>
9898
render(
9999
<ObjectChart
100100
schema={{
101+
type: 'object-chart',
101102
objectName: 'deal',
102103
chartType: 'bar',
103104
aggregate: { field: 'amount', function: 'sum', groupBy: 'stage' },

‎packages/plugin-charts/src/__tests__/ObjectChart.fieldOptionLabelRefetch.test.tsx‎

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -75,9 +75,10 @@ afterEach(() => {
7575

7676
/** Stable module-level schema — a fresh object per render would move `fetchData`'s OTHER deps. */
7777
const SCHEMA = {
78+
type: 'object-chart' as const,
7879
objectName: 'deal',
79-
chartType: 'bar',
80-
aggregate: { field: 'amount', function: 'sum', groupBy: 'stage' },
80+
chartType: 'bar' as const,
81+
aggregate: { field: 'amount', function: 'sum' as const, groupBy: 'stage' },
8182
xAxisKey: 'stage',
8283
};
8384

@@ -116,7 +117,13 @@ const settle = (ms = 40) => new Promise((resolve) => setTimeout(resolve, ms));
116117
*/
117118
async function countFetchesAcrossRerenders(wrap: (chart: React.ReactElement) => React.ReactElement) {
118119
const src = makeSource();
119-
const tree = (tick: number) => wrap(<ObjectChart key="chart" schema={SCHEMA} dataSource={src} tick={tick} />);
120+
// The `tick` used to ride ON `ObjectChart` as an undeclared prop, which only
121+
// compiled because the component was published as `(props: any)`
122+
// (objectui#7946). It never reached the component — it exists to make each
123+
// rendered element distinct — so it moves to a wrapper element that really
124+
// takes it. What the test measures (re-render count vs fetch count) is
125+
// unchanged.
126+
const tree = (tick: number) => wrap(<ObjectChart key="chart" schema={SCHEMA} dataSource={src} data-tick={tick} />);
120127

121128
const { rerender } = render(tree(0));
122129
await waitFor(() => expect(lastSchema?.data?.length).toBe(1));

0 commit comments

Comments
 (0)