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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions .changeset/8269-dashboard-category-axis-groupby.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
---
'@object-ui/core': patch
'@object-ui/plugin-dashboard': patch
'@object-ui/plugin-charts': patch
---

Fix a dashboard chart widget that declares its category as `aggregate.groupBy` being
refused for lacking a `name` column (objectui#8269).

A widget bound to an object with `aggregate: { function: 'count', groupBy: 'status' }`
and no `options.xField` rendered a refusal instead of a chart:

> This chart cannot plot its category axis: no row has a `name` field.

The author wrote `groupBy: 'status'`. Nothing on screen said `groupBy` was the key that
had been ignored, and `name` appeared nowhere in their metadata — so the diagnostic sent
them to debug the wrong layer.

**Cause.** The two dashboard relays (`DashboardGridLayout`, `DashboardRenderer`) each
floored the category binding on a literal — `options.xField || 'name'` — and handed it to
the `object-chart` node without ever consulting the aggregate that decides it. An
object-bound aggregate returns one row per group keyed by the raw `groupBy` field, so no
row carried `name` and the category-axis guard (framework#4033) fired correctly on a
binding that was already wrong when it arrived.

**Fix.** `chartCategoryKey` is a new `@object-ui/core` export delegating to
`chartAggregateCategoryKey` in `@objectstack/spec/ui` — the contract's own derivation of
"the category column an object-bound aggregate produces", and the published sibling of the
`chartAggregateValueKey` that objectui#8266 adopted for the measure axis. Both relays now
consult it for the object-provider branch.

**What moves on screen.** A widget that rendered a refusal now draws. Measured through
`ChartRenderer` at 480x320 over the rows a fieldless count returns
(`[{status:'open',count:2},{status:'paid',count:5}]`): the composed binding went from
`xAxisKey: 'name'` — a `missing-category-key` refusal, 0 marks — to `xAxisKey: 'status'`,
1 series and 2 marks with the category ticks drawn.

**Unaffected.** A chart with no `aggregate` at all keeps the author's `xField` (its rows
are raw records, so that key is the right one), an UNGROUPED aggregate keeps it too (it
returns a single row with no category column), and the authored-literal-rows branch — the
`chart` node composed after the object-provider check fails — keeps its floor unchanged.
One authored key changes meaning, exactly as objectui#8266's `yField` did: an `xField`
written on an object-bound chart that ALSO declares a `groupBy` no longer wins over the
aggregate's own column — it named a record column a grouped aggregate never returns, so it
produced the same refusal before.
5 changes: 5 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,11 @@ export * from './utils/chart-series.js';
// projection and the series binding alike, delegated to the spec's own
// derivation so the two cannot drift (objectui#8266).
export * from './utils/chart-measure-key.js';
// "Which result column carries the CATEGORY?" — the same move on the other
// axis, delegated to the contract's own published derivation so a relay cannot
// floor the x-axis binding on a literal the aggregate contradicts
// (objectui#8269).
export * from './utils/chart-category-key.js';
// The AUTHORED half of a dataset-bound chart (objectui#4229's data/presentation
// split), shared by the dashboard widget and the report's embedded chart so the
// same spec keys are lowered identically on both (objectui#4877).
Expand Down
82 changes: 82 additions & 0 deletions packages/core/src/utils/chart-category-key.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
/**
* 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#8269 — the ONE answer to "which result column carries the category?".
*
* The regression arm is the FIRST one: a `groupBy` with no `options.xField`
* beside it, which is the whole authoring shape the card is about. The relays
* answered `'name'` there and the rows carry the raw groupBy field, so
* `hasNoCategoryKey` refused the widget by the name of a key nobody wrote.
*
* The fallback arms are the other half: a floor that fired too eagerly would
* take the caller's `xField` back over a `groupBy` the contract CAN answer for,
* or invent a category column for an UNGROUPED aggregate that returns exactly
* one row and has none.
*/
import { describe, it, expect } from 'vitest';
import { chartCategoryKey } from './chart-category-key';

describe('chartCategoryKey — the contract answers', () => {
it('is the raw groupBy field, never the caller floor', () => {
// THE REGRESSION. `options.xField || 'name'` answered 'name' here, and the
// rows carry 'status', so the chart refused, naming 'name'.
expect(chartCategoryKey({ function: 'count', groupBy: 'status' }, 'name')).toBe('status');
});

it('ignores a caller floor the author chose, when the groupBy answers', () => {
// An authored `xField` names a column of the RECORDS, and a grouped
// aggregate does not return records. Honouring it refuses the chart for
// exactly the same reason 'name' did.
expect(chartCategoryKey({ function: 'count', groupBy: 'status' }, 'stage')).toBe('status');
});

it('answers for a field-bearing aggregate the same way', () => {
expect(chartCategoryKey({ field: 'amount', function: 'sum', groupBy: 'stage' }, 'name')).toBe('stage');
});

it('reads a structured groupBy node by its FIELD', () => {
expect(
chartCategoryKey({ function: 'count', groupBy: { field: 'closed_at', dateGranularity: 'month' } }, 'name'),
).toBe('closed_at');
});

it('prefers a structured groupBy ALIAS, because the alias renames the projected column', () => {
// The one place this seam and `plugin-charts`' `resolveChartCategoryField`
// deliberately disagree: that resolver answers "which FIELD?" (and returns
// `closed_at`, which is what a field-metadata probe needs), this one
// answers "which COLUMN do the rows carry?" — and `ObjectChart`'s own fetch
// path keys those rows `alias || field`.
expect(
chartCategoryKey({ function: 'count', groupBy: { field: 'closed_at', alias: 'month' } }, 'name'),
).toBe('month');
});
});

describe('chartCategoryKey — the caller floor, only where the contract is silent', () => {
it('falls back when there is no aggregate at all', () => {
// A provider whose rows are raw records: the author's xField IS the key.
expect(chartCategoryKey(undefined, 'name')).toBe('name');
expect(chartCategoryKey(undefined, 'stage')).toBe('stage');
});

it('falls back for an UNGROUPED aggregate, which returns no category column', () => {
// One row, one number. There is no category to name, so the caller's floor
// is the only answer available — and refusing to invent one here is what
// keeps a single-value aggregate from being bound to a column that would
// never exist.
expect(chartCategoryKey({ field: 'amount', function: 'sum' }, 'name')).toBe('name');
});

it('falls back for a groupBy shape ChartGroupBySchema rejects', () => {
expect(chartCategoryKey({ function: 'count', groupBy: '' }, 'name')).toBe('name');
expect(chartCategoryKey({ function: 'count', groupBy: {} }, 'name')).toBe('name');
expect(chartCategoryKey({ function: 'count', groupBy: ['status'] }, 'name')).toBe('name');
expect(chartCategoryKey({}, 'name')).toBe('name');
});
});
90 changes: 90 additions & 0 deletions packages/core/src/utils/chart-category-key.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
/**
* 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.
*/

/**
* chart-category-key — the ONE answer to "which result column carries the
* CATEGORY?" for an object-bound chart (objectui#8269).
*
* The mirror of `chart-measure-key` (objectui#8266) on the other axis. That
* card landed `chartMeasureKey` over the contract's `chartAggregateValueKey`;
* the contract publishes a CATEGORY sibling in the same module —
* `chartAggregateCategoryKey` — and nothing in this repository read it.
*
* ## The disagreement this replaces
*
* Both dashboard relays composed the category binding as a LITERAL FLOOR:
*
* const xAxisKey = options.xField || 'name';
*
* and handed it to the `object-chart` node without ever 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 `options.xField`
* bound `'name'` against rows keyed `'status'`.
*
* Unlike the measure half, this one is LOUD: `hasNoCategoryKey`
* (`plugin-charts/src/AdvancedChartImpl.tsx`, framework#4033) fires and the
* author reads "This chart cannot plot its category axis: no row has a `name`
* field." The diagnostic is wrong-CAUSE — it names a binding the author never
* wrote and never mentions the `groupBy` they did — so it points at the wrong
* layer. Being loud makes it a lesser harm than objectui#8266, not a
* non-defect: measured over the rows a fieldless count returns,
* `[{status:'open',count:2},{status:'paid',count:5}]`, an `xAxisKey` of
* `'name'` refuses under BOTH series bindings, i.e. objectui#8266's fix does
* not reach this authoring shape at all.
*
* ## Why the answer is delegated rather than restated
*
* Restating the rule here would make this file a second opinion of a question
* the contract already answers — the objectui#5042 / #7544 / #8193 / #8168
* drift shape that `chart-measure-key` exists to end. So the rule stays
* upstream in `@objectstack/spec/ui` and this is the seam objectui-side callers
* share.
*
* ## ⚠️ NOT the same function as `resolveChartCategoryField`
*
* `plugin-charts`' own `resolveChartCategoryField` (objectui#8168) reads
* `aggregate.groupBy` first too — which is exactly why `ObjectChart` does NOT
* refuse this shape at its own level, and then forwards `schema.xAxisKey`
* verbatim as the render binding anyway. But the two answer DIFFERENT
* questions and must not be collapsed:
*
* - `resolveChartCategoryField` answers "which FIELD is the category?" — its
* structured-`groupBy` leg returns `node.field`, because its two readers
* are the refusal (does the author name a category at all?) and the
* field-metadata probe that loads that field's option labels and colours.
* - this function answers "which COLUMN do the returned rows carry it
* under?" — `groupBy.alias ?? groupBy.field` per the contract, because an
* `alias` (admitted by `ChartGroupBySchema`) renames the projected column.
*
* They coincide whenever no alias is written, which is why the distinction is
* easy to miss; conflating them would either bind an axis to a column the rows
* do not carry, or probe field metadata for a field that does not exist.
*/

import { chartAggregateCategoryKey, type ChartAggregateLike } from '@objectstack/spec/ui';

/**
* The result column a chart's category axis / x-axis binding must name.
*
* `fallback` is the caller's own floor, used ONLY when the contract has no
* answer — a chart that declares no `aggregate` at all (its rows are raw
* records or authored literals, where the author's `xField` is the right key),
* an UNGROUPED aggregate (one row, no category column), or a `groupBy` shape
* `ChartGroupBySchema` already rejects. It is never a second opinion about an
* aggregate the contract CAN answer for.
*
* @param aggregate the chart's inline aggregate, or `undefined`
* @param fallback the key to bind when the contract has no answer
*/
export function chartCategoryKey(
aggregate: ChartAggregateLike | undefined,
fallback: string,
): string {
return chartAggregateCategoryKey(aggregate) ?? fallback;
}
Loading
Loading