Skip to content

Commit b4333ab

Browse files
fix(plugin-dashboard): a dimensionless table renders a row of every measure and a dimensionless chart one mark per measure, as the spec states (objectui#11261) (#11278)
Fixes #11261 Clause-②: no ## What this changes `DatasetWidget` classified every widget that declares no `dimensions` as a one-number tile (`isMetric = METRIC_TYPES.has(widgetType) || dimensions.length === 0`). So the card's `{ type: 'table', dataset: 'sales', values: ['revenue', 'cost', 'margin'] }` rendered `revenue` alone. Every door accepts that widget, and the spec's own metric-family arity refusal sends authors there. Its ADR-0087 entry `dashboard-widget-metric-family-multi-measure-refused` (`replacement`, read in the installed `@objectstack/spec` 17.5.0) says: "`type: 'table'` renders a row of measures, and the chart families (`bar` / `line` / `area` / `combo`) render one mark per measure". Per triage `5918660676` (the renderer delivers the stated behaviour; no spec narrowing): - **Only a metric-family type is a tile by type.** A widget with no dimension stays a tile, unless it carries two or more measures on a type the spec names. Those are `table` / `pivot`, or a widget whose family (`CHART_TYPE_MAP`) is `bar` / `line` / `area` / `combo` (`MEASURE_AXIS_CHART_FAMILIES`). - **table / pivot:** the existing flat-table branch renders the one row the dimensionless query answers, with every measure as a column. No totals footer is requested or drawn, because that row already IS the `[]` grand-total grouping. - **bar / line / area / combo:** the single row is transposed into one chart row per declared measure, in declaration order. The category is the measure's `headerLabel` (the widget's one measure-label channel). The value sits under one series labelled with the existing `dashboard.total` key, so no new string is minted. A `compareTo` the widget asks for draws as one overlay series beside each measure. The dimensioned overlay path is byte-identical (same data keys and labels). - **One measure, no dimension:** unchanged. It takes the metric branch on every type and renders the tile, byte-identical. This is pinned for `table`, `pivot`, `bar`, `line`, `area` and `combo`. - **Types the spec's text does not name** (`pie`, `donut`, `funnel`, `scatter`, `column`, `horizontal-bar`, `radar`, `treemap`, `sankey`): measured and unchanged. A dimensionless one with two measures is still a tile. It renders `values[0]`, and objectui#8894's warning still names the dropped measure. This goes back as an open question in the report, and no display semantics are invented for these types. - objectui#8894's warning code is untouched. It follows `isMetric`, so it no longer fires for the widgets that now render every measure. One example in `DatasetWidget.unrenderedMeasures-8894.test.tsx` moved. Its SUBJECT "covers the dimensionless arm" used a dimensionless `bar`, which is no longer a tile, so it now uses a `pie`, which still is. Its assertions are unchanged. ## Pins `DatasetWidget.dimensionlessMeasures-11261.test.tsx` draws real Recharts marks through the registry, using the `DatasetWidget.chartConfigMarks-9203` harness. | Row | Kind | |---|---| | the card's `table` (and `pivot`) over revenue, cost, margin renders one row with all three, no footer, no warning | SUBJECT | | a two-measure dimensionless `bar` draws two `.recharts-bar-rectangle`, with category ticks `Revenue`, `Cost` | SUBJECT | | the same for `combo`; `line` / `area` plot across the two measure labels | SUBJECT | | a comparison on a dimensionless `bar` draws 4 marks (2 + 2 overlay) | SUBJECT | | a two-measure `metric` tile with no dimension renders the tile markup and still warns | CONTROL | | a ONE-measure dimensionless table / pivot / bar / line / area / combo renders the tile markup | CONTROL | | a dimensionless `table` / `bar` queries exactly `{ dimensions: [], measures: values }` (no `totals`) | CONTROL | | nine unnamed types stay a tile and warn (measured; the open question) | CONTROL | PR objectui#8887's two pins stay green: the drop on a metric-type tile, and the no-sub-caption byte-identity (`DatasetWidget.subCaption.test.tsx`). ## Ablation (restore the old `isMetric`) The fix was committed first. `node ../objectstack/scripts/ablation-replace.mjs` then ran in WRAP mode (the anchor must hit once, the write is verified on disk, and a trap restores it). It replaced the new `isMetric` line with the old one and ran the new file, the objectui#8894 file and the sub-caption file. - The mutation landed: anchor 1 -> 0, blob `9640acd2abcc` -> `4bf0c16ab58b`. - **7 failed / 38 passed**: exactly the 7 SUBJECT rows went red. All 18 CONTROL rows, all 8 objectui#8894 rows and all 12 sub-caption rows stayed green. - Restored: blob == HEAD `9640acd2abcc`, and `git diff HEAD` is empty (re-checked independently). ## Gates (at `7d54b7cfb`) - `pnpm exec vitest run packages/plugin-dashboard/src/`: 155 files passed, 1420 passed / 6 skipped. At base `f61dab169` it was 154 files, 1395 / 6; the difference is the new file's 25. - `pnpm --filter @object-ui/plugin-dashboard type-check`, with the closure built first by turbo: exit 0. `tsc -p tsconfig.test.json --listFiles` includes both touched test files. - `pnpm exec eslint` on the 3 changed source and test files: 0 errors. The 11 warnings in `DatasetWidget.tsx` are the same 11, under the same rules, as at base. The new file has 0 errors and 0 warnings. - `node scripts/check-changeset-presence.mjs`: exit 0. `node scripts/check-control-bytes.mjs`: exit 0. `pnpm check:new-line-citations`: 0 new citations. - The changeset is markdown, so `node scripts/markdown-test-inputs.mjs --audit` ran (exit 0), and so did `pnpm exec vitest run scripts/__tests__/` (177 files passed, 2 skipped; 5371 passed). - Consumers: the four app-shell files that render `DatasetWidget`, plus PR objectui#11257's two door pins (types, app-shell): 6 files, 84 passed. ## Acceptance notes - Census: every authored multi-measure dashboard widget in objectstack's examples declares a dimension (`git grep` over its `origin/main` at `75519e1c0`). objectui's `examples/` and `apps/` author none. So no shipped example changes rendering. - Docs: no doc or README stated the old "no dimensions is a tile" rule (`grep` over `content/docs`, the package README and `skills/`), so none became false. The changeset carries the user-facing description. - Observation, not measured through a door, and not filed (no carrier): `chartType = CHART_TYPE_MAP[widgetType] ?? 'bar'` sends a widget with no `type` and a dimension to a bar chart, while the spec defaults `type` to `metric`. A spec-parsed document always carries a type, so this reaches only an unparsed client draft. This change reads `CHART_TYPE_MAP` without that default, so it adds no path through it. --- _Generated by [Claude Code](https://claude.ai/code/session_011p7ikEivgXefNDaE5S5Uec)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent e420df3 commit b4333ab

4 files changed

Lines changed: 343 additions & 19 deletions

File tree

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
---
2+
'@object-ui/plugin-dashboard': patch
3+
---
4+
5+
fix(plugin-dashboard): a dimensionless table renders a row of every measure, and a dimensionless chart one mark per measure
6+
7+
`DatasetWidget` treated every widget that declares no `dimensions` as a
8+
one-number tile. So `{ type: 'table', dataset: 'sales', values: ['revenue', 'cost', 'margin'] }`
9+
rendered `revenue` alone, and `cost` and `margin` were queried and never
10+
shown. Every door accepts that widget, and the spec's metric-family arity
11+
refusal sends authors to it: "`type: 'table'` renders a row of measures, and
12+
the chart families (`bar` / `line` / `area` / `combo`) render one mark per
13+
measure".
14+
15+
Only a metric-family type is now a tile by type. With no dimension and two or
16+
more measures:
17+
18+
- a `table` / `pivot` renders one row carrying every measure, with no totals
19+
footer (that one row is the grand total);
20+
- a `bar` / `line` / `area` / `combo` chart plots one mark per measure, with the
21+
measures' labels on the category axis, under one series labelled with the
22+
existing `dashboard.total` string. A comparison the widget asks for is drawn
23+
beside each measure.
24+
25+
Unchanged: a widget with one measure and no dimension is still a tile on every
26+
type, since nothing is dropped. A dimensionless widget of a type the spec's
27+
text does not name (`pie`, `donut`, `funnel`, `scatter`, `column`,
28+
`horizontal-bar`, `radar`, `treemap`, `sankey`) also keeps its tile. The query
29+
still selects exactly the declared measures.

‎packages/plugin-dashboard/src/DatasetWidget.tsx‎

Lines changed: 105 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,12 @@
99
* dataset-bound reports use — so the numbers match everywhere.
1010
*
1111
* Rendering dispatch (by `widget.type`):
12-
* - metric / kpi / gauge / solid-gauge / bullet (or no dimensions) → KPI value
13-
* with the measure's display label + format.
12+
* - metric / kpi / gauge / solid-gauge / bullet → KPI value with the
13+
* measure's display label + format. So does a widget of any other type that
14+
* declares no dimensions, UNLESS it carries two or more measures and is a
15+
* table / pivot or a bar / line / area / combo chart (objectui#11261): those
16+
* render every measure — one row of them, or one mark per measure with the
17+
* measures' labels on the category axis.
1418
* - table / pivot → a grouped table of `dimensions` + `values`. Rows drill
1519
* through to the underlying records (ADR-0021 D2) when the server returns the
1620
* dataset's `object` + dimension→field mapping.
@@ -349,6 +353,31 @@ export function extractDateWindows(filter: unknown): {
349353
/** Single-value KPI widget types — rendered as a number, not a chart. */
350354
const METRIC_TYPES = new Set(['metric', 'kpi', 'gauge', 'solid-gauge', 'bullet']);
351355

356+
/**
357+
* The chart families that draw a DIMENSIONLESS widget's measures: one mark per
358+
* measure, the measures' labels on the category axis (objectui#11261).
359+
*
360+
* Exactly the four the spec names, read off the family a widget renders AS
361+
* (`CHART_TYPE_MAP`). The metric-family arity refusal and its ADR-0087 entry
362+
* (`dashboard-widget-metric-family-multi-measure-refused`) send an author who
363+
* wants several numbers in one widget here: "`type: 'table'` renders a row of
364+
* measures, and the chart families (`bar` / `line` / `area` / `combo`) render
365+
* one mark per measure". `table` / `pivot` are that sentence's other half
366+
* (`isTable`). The spec's text names no other family, so a dimensionless one
367+
* (pie, donut, funnel, scatter, column, horizontal-bar, radar, treemap, sankey)
368+
* keeps the tile it always rendered: its display semantics for several
369+
* measures are not invented here.
370+
*/
371+
const MEASURE_AXIS_CHART_FAMILIES = new Set(['bar', 'line', 'area', 'combo']);
372+
373+
/**
374+
* The columns of a dimensionless chart's transposed rows (objectui#11261): the
375+
* measure a mark stands for, and that measure's value. A transposed row carries
376+
* these and nothing else, so no dataset measure name can collide with them.
377+
*/
378+
const MEASURE_CATEGORY_KEY = '__measure';
379+
const MEASURE_VALUE_KEY = '__value';
380+
352381
/**
353382
* Map a dashboard widget `type` to the advanced chart renderer's `chartType`.
354383
* Families the renderer doesn't draw distinctly fall back to their closest
@@ -441,8 +470,28 @@ export function DatasetWidget({ widget, dataSource, subCaption }: { widget: any;
441470
const dimensions: string[] = useMemo(() => (Array.isArray(widget?.dimensions) ? widget.dimensions.filter(Boolean) : []), [widget]);
442471
const values: string[] = useMemo(() => (Array.isArray(widget?.values) ? widget.values.filter(Boolean) : []), [widget]);
443472
const widgetType = String(widget?.type ?? '');
444-
const isMetric = METRIC_TYPES.has(widgetType) || dimensions.length === 0;
445473
const isTable = widgetType === 'table' || widgetType === 'pivot';
474+
// ── Which widgets are a one-number tile (objectui#11261) ─────────────────
475+
// By TYPE, only the metric family. By SHAPE, a widget that declares no
476+
// dimension, with one exception: two or more measures on a type the spec
477+
// states a dimensionless rendering for. A `table` / `pivot` renders one row
478+
// carrying every measure, and a `bar` / `line` / `area` / `combo` one mark per
479+
// measure (`MEASURE_AXIS_CHART_FAMILIES`). Those used to become a tile as
480+
// well and render `values[0]` alone, although every door accepts them and
481+
// the spec's own refusal text sends authors there for "several numbers in
482+
// ONE widget".
483+
//
484+
// ONE measure and no dimension stays a tile on every type: nothing is
485+
// dropped there, and the tile is what it has always rendered. So does every
486+
// type the spec's text does not name, whatever its measure count — see
487+
// `MEASURE_AXIS_CHART_FAMILIES` for why.
488+
//
489+
// Read off `CHART_TYPE_MAP` directly, never through `chartType`'s `?? 'bar'`
490+
// default: a widget with no `type` resolves to `metric` in the spec, and the
491+
// default would route it to a bar chart.
492+
const rendersEveryMeasureWithoutDimension =
493+
values.length > 1 && (isTable || MEASURE_AXIS_CHART_FAMILIES.has(CHART_TYPE_MAP[widgetType] ?? ''));
494+
const isMetric = METRIC_TYPES.has(widgetType) || (dimensions.length === 0 && !rendersEveryMeasureWithoutDimension);
446495
// The chart family a widget that reaches the chart branch below renders as —
447496
// `bubble` → `scatter`, `pyramid` → `funnel` (CHART_TYPE_MAP). Only meaningful
448497
// when neither `isMetric` nor `isTable` holds; resolved up here because the
@@ -524,6 +573,10 @@ export function DatasetWidget({ widget, dataSource, subCaption }: { widget: any;
524573
// against the rows in front of them — and "don't make the reader add it up"
525574
// is the whole point of the row. No request is issued in that case, so the
526575
// cost is not paid either.
576+
//
577+
// Nor for a DIMENSIONLESS table (objectui#11261). Its selection groups by
578+
// nothing, so the one row it answers already IS the `[]` grouping, and a
579+
// footer would print that row a second time.
527580
const wantsFlatTotals = isTable && !isMatrix && dimensions.length > 0 && limit == null;
528581
const totalsGroupings = isMatrix
529582
? [rowDims, [colDim], []]
@@ -1584,9 +1637,10 @@ export function DatasetWidget({ widget, dataSource, subCaption }: { widget: any;
15841637
{totalsRow && (
15851638
<tfoot className="bg-muted/30">
15861639
<tr className="border-t font-medium" data-testid="dataset-table-total-row">
1587-
{/* One label cell spanning the dimension columns. This branch
1588-
always has at least one (a zero-dimension widget renders as
1589-
a KPI and returns above), so the span is never 0. */}
1640+
{/* One label cell spanning the dimension columns. The footer
1641+
renders only under `wantsFlatTotals`, which requires a
1642+
dimension (a dimensionless table's one row IS the total,
1643+
objectui#11261), so the span is never 0. */}
15901644
<td colSpan={dimensions.length} className="px-2 py-1 whitespace-nowrap">{totalRowLabel}</td>
15911645
{measureColumns.map((c) => {
15921646
// The SAME per-column formatter the body cells use —
@@ -1640,10 +1694,41 @@ export function DatasetWidget({ widget, dataSource, subCaption }: { widget: any;
16401694
// from the locale bundle here — `计数` on a zh console, not the server's
16411695
// English `Count` — while an author-declared measure carries no discriminator
16421696
// and keeps its wire `label` verbatim (objectui#4106).
1643-
const { data: chartData, xAxisKey, series } = buildChartSeries(chartRows, dimensions, values, state.fields, {
1644-
nullCategoryLabel,
1645-
builtinAggregateLabels: builtinAggregateLabels(tt),
1646-
});
1697+
//
1698+
// ── A dimensionless chart plots its MEASURES as the categories (objectui#11261) ──
1699+
// Reached only by a bar / line / area / combo with two or more measures
1700+
// (`isMetric` keeps every other dimensionless widget a tile). The query
1701+
// grouped by nothing, so it answered ONE row carrying every measure, the
1702+
// `[]` grand-total grouping; that row is `state.rows[0]`, the same row the
1703+
// tile reads. `buildChartSeries` would plot it as one unlabelled category
1704+
// holding a series per measure. The spec states the other shape, one mark per
1705+
// measure, so the row is transposed: one chart row per declared measure, in
1706+
// declaration order, its category the measure's label (`headerLabel`, the
1707+
// widget's one measure-label channel: the tile caption, the table header and
1708+
// the comparison overlay all read it) and its value under a single series.
1709+
// That series is labelled `dashboard.total`, the grouping this row IS and the
1710+
// key the flat table's footer already prints for that same grouping, so no
1711+
// new string is minted.
1712+
const measuresOnAxis = dimensions.length === 0;
1713+
const measureAxisSeriesLabel = tt('dashboard.total', 'Total');
1714+
const { data: chartData, xAxisKey, series } = measuresOnAxis
1715+
? {
1716+
data: values.map((m) => ({
1717+
[MEASURE_CATEGORY_KEY]: headerLabel(m),
1718+
[MEASURE_VALUE_KEY]: state.rows[0]?.[m],
1719+
// The comparison window's value rides beside its measure, under the
1720+
// single series' own `__compare` column, for the overlay below.
1721+
...(comparedValues.includes(m)
1722+
? { [compareColumn(MEASURE_VALUE_KEY)]: state.rows[0]?.[compareColumn(m)] }
1723+
: {}),
1724+
})),
1725+
xAxisKey: MEASURE_CATEGORY_KEY,
1726+
series: [{ dataKey: MEASURE_VALUE_KEY, label: measureAxisSeriesLabel }],
1727+
}
1728+
: buildChartSeries(chartRows, dimensions, values, state.fields, {
1729+
nullCategoryLabel,
1730+
builtinAggregateLabels: builtinAggregateLabels(tt),
1731+
});
16471732

16481733
// The author's PRESENTATION, merged onto those derived bindings — per-series
16491734
// mark and axis binding, plus the axis definitions (#4229). Membership stays
@@ -1663,20 +1748,26 @@ export function DatasetWidget({ widget, dataSource, subCaption }: { widget: any;
16631748
// definition at the top), so no comparison was queried and `comparedValues`
16641749
// is empty (objectui#7495, objectui#7402).
16651750
const pivotedSeries = dimensions.length >= 2 && values.length === 1;
1751+
// The series a comparison pairs with: one per compared measure, or — on a
1752+
// dimensionless chart (objectui#11261) — the single transposed series, whose
1753+
// rows carry each compared measure's earlier value.
1754+
const comparedSeries = measuresOnAxis
1755+
? (comparedValues.length > 0 ? [{ dataKey: MEASURE_VALUE_KEY, label: measureAxisSeriesLabel }] : [])
1756+
: comparedValues.map((m) => ({ dataKey: m, label: headerLabel(m) }));
16661757
const comparisonSeries = pivotedSeries
16671758
? []
1668-
: comparedValues.map((m) => {
1759+
: comparedSeries.map(({ dataKey, label }) => {
16691760
// An overlay is the SAME measure one period back, so it takes its
16701761
// primary's mark and axis — read off the already-merged series, never
16711762
// re-read from `chartConfig` (one merge path). Without this a combo's
16721763
// overlay fell to the renderer's positional guess and drew a bar
16731764
// measure as a line on the opposite axis. `stack` is deliberately NOT
16741765
// inherited: stacking an overlay onto its own primary would add the
16751766
// two periods together.
1676-
const primary = presentedSeries.find((s) => s.dataKey === m);
1767+
const primary = presentedSeries.find((s) => s.dataKey === dataKey);
16771768
return {
1678-
dataKey: compareColumn(m),
1679-
label: `${headerLabel(m)} · ${compareLabel}`,
1769+
dataKey: compareColumn(dataKey),
1770+
label: `${label} · ${compareLabel}`,
16801771
variant: 'comparison' as const,
16811772
...(primary?.chartType ? { chartType: primary.chartType } : {}),
16821773
...(primary?.yAxis ? { yAxis: primary.yAxis } : {}),

0 commit comments

Comments
 (0)