Skip to content
This repository was archived by the owner on Oct 26, 2026. It is now read-only.

Commit a84f4ea

Browse files
committed
fix(plugin-dashboard,core): resolve an object-bound chart's category axis through the aggregate
Both dashboard relays floored the category binding on a literal -- `options.xField || 'name'` -- and handed it to the `object-chart` node without consulting the aggregate that decides it. An object-bound aggregate returns one row per group keyed by the raw `groupBy` field, so a widget declaring `aggregate: { function: 'count', groupBy: 'status' }` and no `xField` bound `'name'` against rows keyed `'status'`, and the category-axis guard refused the whole widget naming a key its author never wrote. `chartCategoryKey` is a new `@object-ui/core` export delegating to `chartAggregateCategoryKey` in `@objectstack/spec/ui` -- the contract's own published derivation and the sibling of the `chartAggregateValueKey` the measure half already adopted. Both relays consult it on the object-provider branch only; the authored-literal-rows branch keeps its floor, because there is no aggregate to consult and the author's `xField` names a column of their own rows. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YBWFb5YgMU5dw8p2VKj16S
1 parent 3c6394c commit a84f4ea

8 files changed

Lines changed: 726 additions & 3 deletions

File tree

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
---
2+
'@object-ui/core': patch
3+
'@object-ui/plugin-dashboard': patch
4+
'@object-ui/plugin-charts': patch
5+
---
6+
7+
Fix a dashboard chart widget that declares its category as `aggregate.groupBy` being
8+
refused for lacking a `name` column (objectui#8269).
9+
10+
A widget bound to an object with `aggregate: { function: 'count', groupBy: 'status' }`
11+
and no `options.xField` rendered a refusal instead of a chart:
12+
13+
> This chart cannot plot its category axis: no row has a `name` field.
14+
15+
The author wrote `groupBy: 'status'`. Nothing on screen said `groupBy` was the key that
16+
had been ignored, and `name` appeared nowhere in their metadata — so the diagnostic sent
17+
them to debug the wrong layer.
18+
19+
**Cause.** The two dashboard relays (`DashboardGridLayout`, `DashboardRenderer`) each
20+
floored the category binding on a literal — `options.xField || 'name'` — and handed it to
21+
the `object-chart` node without ever consulting the aggregate that decides it. An
22+
object-bound aggregate returns one row per group keyed by the raw `groupBy` field, so no
23+
row carried `name` and the category-axis guard (framework#4033) fired correctly on a
24+
binding that was already wrong when it arrived.
25+
26+
**Fix.** `chartCategoryKey` is a new `@object-ui/core` export delegating to
27+
`chartAggregateCategoryKey` in `@objectstack/spec/ui` — the contract's own derivation of
28+
"the category column an object-bound aggregate produces", and the published sibling of the
29+
`chartAggregateValueKey` that objectui#8266 adopted for the measure axis. Both relays now
30+
consult it for the object-provider branch.
31+
32+
**What moves on screen.** A widget that rendered a refusal now draws. Measured through
33+
`ChartRenderer` at 480x320 over the rows a fieldless count returns
34+
(`[{status:'open',count:2},{status:'paid',count:5}]`): the composed binding went from
35+
`xAxisKey: 'name'` — a `missing-category-key` refusal, 0 marks — to `xAxisKey: 'status'`,
36+
1 series and 2 marks with the category ticks drawn.
37+
38+
**Unaffected.** A chart with no `aggregate` at all keeps the author's `xField` (its rows
39+
are raw records, so that key is the right one), an UNGROUPED aggregate keeps it too (it
40+
returns a single row with no category column), and the authored-literal-rows branch — the
41+
`chart` node composed after the object-provider check fails — keeps its floor unchanged.
42+
One authored key changes meaning, exactly as objectui#8266's `yField` did: an `xField`
43+
written on an object-bound chart that ALSO declares a `groupBy` no longer wins over the
44+
aggregate's own column — it named a record column a grouped aggregate never returns, so it
45+
produced the same refusal before.

‎packages/core/src/index.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,11 @@ export * from './utils/chart-series.js';
7676
// projection and the series binding alike, delegated to the spec's own
7777
// derivation so the two cannot drift (objectui#8266).
7878
export * from './utils/chart-measure-key.js';
79+
// "Which result column carries the CATEGORY?" — the same move on the other
80+
// axis, delegated to the contract's own published derivation so a relay cannot
81+
// floor the x-axis binding on a literal the aggregate contradicts
82+
// (objectui#8269).
83+
export * from './utils/chart-category-key.js';
7984
// The AUTHORED half of a dataset-bound chart (objectui#4229's data/presentation
8085
// split), shared by the dashboard widget and the report's embedded chart so the
8186
// same spec keys are lowered identically on both (objectui#4877).
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
/**
10+
* objectui#8269 — the ONE answer to "which result column carries the category?".
11+
*
12+
* The regression arm is the FIRST one: a `groupBy` with no `options.xField`
13+
* beside it, which is the whole authoring shape the card is about. The relays
14+
* answered `'name'` there and the rows carry the raw groupBy field, so
15+
* `hasNoCategoryKey` refused the widget by the name of a key nobody wrote.
16+
*
17+
* The fallback arms are the other half: a floor that fired too eagerly would
18+
* take the caller's `xField` back over a `groupBy` the contract CAN answer for,
19+
* or invent a category column for an UNGROUPED aggregate that returns exactly
20+
* one row and has none.
21+
*/
22+
import { describe, it, expect } from 'vitest';
23+
import { chartCategoryKey } from './chart-category-key';
24+
25+
describe('chartCategoryKey — the contract answers', () => {
26+
it('is the raw groupBy field, never the caller floor', () => {
27+
// THE REGRESSION. `options.xField || 'name'` answered 'name' here, and the
28+
// rows carry 'status', so the chart refused, naming 'name'.
29+
expect(chartCategoryKey({ function: 'count', groupBy: 'status' }, 'name')).toBe('status');
30+
});
31+
32+
it('ignores a caller floor the author chose, when the groupBy answers', () => {
33+
// An authored `xField` names a column of the RECORDS, and a grouped
34+
// aggregate does not return records. Honouring it refuses the chart for
35+
// exactly the same reason 'name' did.
36+
expect(chartCategoryKey({ function: 'count', groupBy: 'status' }, 'stage')).toBe('status');
37+
});
38+
39+
it('answers for a field-bearing aggregate the same way', () => {
40+
expect(chartCategoryKey({ field: 'amount', function: 'sum', groupBy: 'stage' }, 'name')).toBe('stage');
41+
});
42+
43+
it('reads a structured groupBy node by its FIELD', () => {
44+
expect(
45+
chartCategoryKey({ function: 'count', groupBy: { field: 'closed_at', dateGranularity: 'month' } }, 'name'),
46+
).toBe('closed_at');
47+
});
48+
49+
it('prefers a structured groupBy ALIAS, because the alias renames the projected column', () => {
50+
// The one place this seam and `plugin-charts`' `resolveChartCategoryField`
51+
// deliberately disagree: that resolver answers "which FIELD?" (and returns
52+
// `closed_at`, which is what a field-metadata probe needs), this one
53+
// answers "which COLUMN do the rows carry?" — and `ObjectChart`'s own fetch
54+
// path keys those rows `alias || field`.
55+
expect(
56+
chartCategoryKey({ function: 'count', groupBy: { field: 'closed_at', alias: 'month' } }, 'name'),
57+
).toBe('month');
58+
});
59+
});
60+
61+
describe('chartCategoryKey — the caller floor, only where the contract is silent', () => {
62+
it('falls back when there is no aggregate at all', () => {
63+
// A provider whose rows are raw records: the author's xField IS the key.
64+
expect(chartCategoryKey(undefined, 'name')).toBe('name');
65+
expect(chartCategoryKey(undefined, 'stage')).toBe('stage');
66+
});
67+
68+
it('falls back for an UNGROUPED aggregate, which returns no category column', () => {
69+
// One row, one number. There is no category to name, so the caller's floor
70+
// is the only answer available — and refusing to invent one here is what
71+
// keeps a single-value aggregate from being bound to a column that would
72+
// never exist.
73+
expect(chartCategoryKey({ field: 'amount', function: 'sum' }, 'name')).toBe('name');
74+
});
75+
76+
it('falls back for a groupBy shape ChartGroupBySchema rejects', () => {
77+
expect(chartCategoryKey({ function: 'count', groupBy: '' }, 'name')).toBe('name');
78+
expect(chartCategoryKey({ function: 'count', groupBy: {} }, 'name')).toBe('name');
79+
expect(chartCategoryKey({ function: 'count', groupBy: ['status'] }, 'name')).toBe('name');
80+
expect(chartCategoryKey({}, 'name')).toBe('name');
81+
});
82+
});
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
/**
10+
* chart-category-key — the ONE answer to "which result column carries the
11+
* CATEGORY?" for an object-bound chart (objectui#8269).
12+
*
13+
* The mirror of `chart-measure-key` (objectui#8266) on the other axis. That
14+
* card landed `chartMeasureKey` over the contract's `chartAggregateValueKey`;
15+
* the contract publishes a CATEGORY sibling in the same module —
16+
* `chartAggregateCategoryKey` — and nothing in this repository read it.
17+
*
18+
* ## The disagreement this replaces
19+
*
20+
* Both dashboard relays composed the category binding as a LITERAL FLOOR:
21+
*
22+
* const xAxisKey = options.xField || 'name';
23+
*
24+
* and handed it to the `object-chart` node without ever consulting the
25+
* `aggregate` that decides it. An object-bound aggregate returns one row per
26+
* group, keyed by the raw `groupBy` field, so a widget declaring
27+
* `aggregate: { function: 'count', groupBy: 'status' }` and no `options.xField`
28+
* bound `'name'` against rows keyed `'status'`.
29+
*
30+
* Unlike the measure half, this one is LOUD: `hasNoCategoryKey`
31+
* (`plugin-charts/src/AdvancedChartImpl.tsx`, framework#4033) fires and the
32+
* author reads "This chart cannot plot its category axis: no row has a `name`
33+
* field." The diagnostic is wrong-CAUSE — it names a binding the author never
34+
* wrote and never mentions the `groupBy` they did — so it points at the wrong
35+
* layer. Being loud makes it a lesser harm than objectui#8266, not a
36+
* non-defect: measured over the rows a fieldless count returns,
37+
* `[{status:'open',count:2},{status:'paid',count:5}]`, an `xAxisKey` of
38+
* `'name'` refuses under BOTH series bindings, i.e. objectui#8266's fix does
39+
* not reach this authoring shape at all.
40+
*
41+
* ## Why the answer is delegated rather than restated
42+
*
43+
* Restating the rule here would make this file a second opinion of a question
44+
* the contract already answers — the objectui#5042 / #7544 / #8193 / #8168
45+
* drift shape that `chart-measure-key` exists to end. So the rule stays
46+
* upstream in `@objectstack/spec/ui` and this is the seam objectui-side callers
47+
* share.
48+
*
49+
* ## ⚠️ NOT the same function as `resolveChartCategoryField`
50+
*
51+
* `plugin-charts`' own `resolveChartCategoryField` (objectui#8168) reads
52+
* `aggregate.groupBy` first too — which is exactly why `ObjectChart` does NOT
53+
* refuse this shape at its own level, and then forwards `schema.xAxisKey`
54+
* verbatim as the render binding anyway. But the two answer DIFFERENT
55+
* questions and must not be collapsed:
56+
*
57+
* - `resolveChartCategoryField` answers "which FIELD is the category?" — its
58+
* structured-`groupBy` leg returns `node.field`, because its two readers
59+
* are the refusal (does the author name a category at all?) and the
60+
* field-metadata probe that loads that field's option labels and colours.
61+
* - this function answers "which COLUMN do the returned rows carry it
62+
* under?" — `groupBy.alias ?? groupBy.field` per the contract, because an
63+
* `alias` (admitted by `ChartGroupBySchema`) renames the projected column.
64+
*
65+
* They coincide whenever no alias is written, which is why the distinction is
66+
* easy to miss; conflating them would either bind an axis to a column the rows
67+
* do not carry, or probe field metadata for a field that does not exist.
68+
*/
69+
70+
import { chartAggregateCategoryKey, type ChartAggregateLike } from '@objectstack/spec/ui';
71+
72+
/**
73+
* The result column a chart's category axis / x-axis binding must name.
74+
*
75+
* `fallback` is the caller's own floor, used ONLY when the contract has no
76+
* answer — a chart that declares no `aggregate` at all (its rows are raw
77+
* records or authored literals, where the author's `xField` is the right key),
78+
* an UNGROUPED aggregate (one row, no category column), or a `groupBy` shape
79+
* `ChartGroupBySchema` already rejects. It is never a second opinion about an
80+
* aggregate the contract CAN answer for.
81+
*
82+
* @param aggregate the chart's inline aggregate, or `undefined`
83+
* @param fallback the key to bind when the contract has no answer
84+
*/
85+
export function chartCategoryKey(
86+
aggregate: ChartAggregateLike | undefined,
87+
fallback: string,
88+
): string {
89+
return chartAggregateCategoryKey(aggregate) ?? fallback;
90+
}

0 commit comments

Comments
 (0)