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
39 changes: 39 additions & 0 deletions .changeset/8266-dashboard-count-aggregate-series-key.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
---
'@object-ui/core': patch
'@object-ui/plugin-dashboard': patch
'@object-ui/plugin-charts': patch
---

Fix a dashboard chart widget with a FIELDLESS `count` aggregate plotting nothing
(objectui#8266).

A widget bound to an object with `aggregate: { function: 'count', groupBy: 'status' }`
and no `field` — the normal way to author "how many records per status" — rendered an
empty chart. No error, no empty state: a plot frame with the category ticks drawn and
not one mark in it, which reads exactly like "this object has no rows yet".

**Cause.** The two dashboard relays (`DashboardGridLayout`, `DashboardRenderer`) each
built the series binding as `aggregate?.field || (options.yField || 'value')`, which for
a fieldless count resolves to `'value'`. The rows an object-bound fieldless count
returns are keyed `'count'` — the alias the engine projects `COUNT(*)` under, pinned
since framework#3701. A `dataKey` naming a column no row carries plots nothing, and
neither of the renderer's two guards fires on it: the rows DO carry the category key,
and the series array is not empty.

**Fix.** `chartMeasureKey` is a new `@object-ui/core` export delegating to
`chartAggregateValueKey` in `@objectstack/spec/ui` — the contract's own derivation of
"the value column an object-bound aggregate produces". Both relays now consult it, and
the row-projection side (`aggregateValueKey` in `@object-ui/plugin-charts`) is routed
through the same function, so the two halves of the question cannot drift again.

**What moves on screen.** A chart that was blank now draws. Charts that already drew are
unaffected: a field-bearing aggregate resolves to its raw field under both the old and
the new reading, and a chart with no `aggregate` at all keeps the author's `yField`.
One authored key changes meaning: a `yField` written on an object-bound chart that
ALSO declares an aggregate no longer wins over the aggregate's own column — it named a
record column that a grouped aggregate never returns, so it plotted nothing before.

**Not fixed here, and out of scope.** The same widget with no `options.xField` is
refused by the category-axis guard naming `name`, a key the author never wrote (they
wrote `aggregate.groupBy`). That is the category half of the same relay gap and is
filed separately.
4 changes: 4 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,10 @@ export * from './utils/dashboard-filters.js';
export * from './utils/merge-filters.js';
export * from './utils/compare-to.js';
export * from './utils/chart-series.js';
// "Which result column carries the measure?" — one answer for the row
// 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';
// 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
61 changes: 61 additions & 0 deletions packages/core/src/utils/chart-measure-key.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
/**
* 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#8266 — the ONE answer to "which result column carries the measure?".
*
* The regression this exists for is the FIELDLESS COUNT arm: it is the only
* shape where the contract's answer and a `aggregate?.field || yField` read
* disagree, and it is the normal way to author "how many records per status".
* Every other arm agrees under both readings, which is precisely why the
* disagreement survived in the tree — see the two dashboard relays, pinned in
* `plugin-dashboard/src/__tests__/DashboardChart.countSeriesKey-8266.test.tsx`.
*
* The fallback arms are the other half: a floor that fires too eagerly would
* take the caller's `yField` back over an aggregate the contract CAN answer for,
* re-creating the bug through the fix.
*/
import { describe, it, expect } from 'vitest';
import { chartMeasureKey } from './chart-measure-key';

describe('chartMeasureKey — the contract answers', () => {
it('is the raw field for a field-bearing aggregate, never the caller floor', () => {
expect(chartMeasureKey({ field: 'amount', function: 'sum' }, 'value')).toBe('amount');
});

it('is the literal "count" for a fieldless count — NOT the caller floor', () => {
// THE REGRESSION. `aggregate?.field || yField` answered 'value' here, and
// the rows carry 'count', so the chart plotted nothing and said nothing.
expect(chartMeasureKey({ function: 'count', groupBy: 'status' }, 'value')).toBe('count');
});

it('still prefers an explicit field even for count', () => {
expect(chartMeasureKey({ field: 'amount', function: 'count' }, 'value')).toBe('amount');
});

it('ignores a caller floor the author chose, when the aggregate answers', () => {
// An authored `yField` names a column an object-bound aggregate does not
// project. Honouring it would plot nothing, exactly as 'value' did.
expect(chartMeasureKey({ function: 'count', groupBy: 'status' }, 'total')).toBe('count');
});
});

describe('chartMeasureKey — 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 yField IS the key.
expect(chartMeasureKey(undefined, 'value')).toBe('value');
expect(chartMeasureKey(undefined, 'amount')).toBe('amount');
});

it('falls back for an aggregate shape ChartAggregateSchema rejects', () => {
// "only 'count' may omit field" — a fieldless sum is not a declaration the
// spec admits, so the contract has no column to name.
expect(chartMeasureKey({ function: 'sum' }, 'value')).toBe('value');
expect(chartMeasureKey({}, 'value')).toBe('value');
});
});
73 changes: 73 additions & 0 deletions packages/core/src/utils/chart-measure-key.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
/**
* 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-measure-key — the ONE answer to "which result column carries the
* measure?" for an object-bound chart (objectui#8266).
*
* ## The disagreement this replaces
*
* The question was answered independently in three places, and for a FIELDLESS
* `count` — the normal way to author "how many records per status", and the
* most common dashboard chart there is — two of them answered differently:
*
* - the ROW PROJECTION (`aggregateValueKey` / `aggregateRecords` /
* `runAggregate`'s alias, all in `plugin-charts/src/ObjectChart.tsx`) keys
* the value column `'count'`, the alias the engine projects `COUNT(*)`
* under;
* - the SERIES BINDING, spelled twice in `plugin-dashboard`
* (`DashboardGridLayout.tsx` and `DashboardRenderer.tsx`), read
* `aggregate.field || (options.yField || 'value')` and so bound `'value'`.
*
* A `dataKey` naming a column no row carries plots nothing, and nothing says
* so: measured on `origin/main` `0fa7a9c83`, rows `[{status:'open',count:2},
* {status:'paid',count:5}]` with `series:[{dataKey:'value'}]` rendered a
* `.recharts-surface` with the category ticks "open"/"paid", **0 bars, 0
* rectangles**, no refusal and no empty state — the same rows under
* `dataKey:'count'` drew 2 rectangles. An empty chart reads as "no data yet",
* which is exactly what an author with a genuinely empty object also sees.
*
* ## Why the answer is delegated rather than restated
*
* `chartAggregateValueKey` in `@objectstack/spec/ui` is the CONTRACT's own
* derivation — "the VALUE column an object-bound aggregate produces … what a
* chart's series / y-axis binding must name". Producers and checkers already
* read it. Restating the rule here would make this file a fourth opinion of the
* same question, which is the shape (objectui#5042 / #7544 / #8193 / #8168)
* this module exists to end. So the rule lives upstream in the spec and this is
* the seam objectui-side callers share, exactly as `humanizeLabel` (objectui#5444)
* and `buildChartSeries` (ADR-0021) already are.
*
* ⛔ The inverse fix — making the row projection key a fieldless count
* `'value'` — is not available: it contradicts a pin the tree already carries
* (`ObjectChart.aggregateResultColumns.test.ts`) and renames the column every
* other consumer reads, including the engine's own `COUNT(*)` alias.
*/

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

export type { ChartAggregateLike };

/**
* The result column a chart's series must bind to.
*
* `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 `yField` is the right key),
* or an aggregate shape `ChartAggregateSchema` 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 chartMeasureKey(
aggregate: ChartAggregateLike | undefined,
fallback: string,
): string {
return chartAggregateValueKey(aggregate) ?? fallback;
}
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,29 @@ describe('aggregateValueKey', () => {
it('prefers an explicit field even for count', () => {
expect(aggregateValueKey({ field: 'total', function: 'count' })).toBe('total');
});

/**
* objectui#8266 routed this function through `chartMeasureKey`
* (`@object-ui/core`), which delegates to the contract's own
* `chartAggregateValueKey`, so the SERIES binding the dashboard relays
* compose cannot drift from the column this projects. The delegation is
* behaviour-preserving, and these two arms are the only shapes where that
* claim is not already covered above: the contract answers `undefined` for
* them (`ChartAggregateSchema` refuses a fieldless non-count outright), so
* everything after the delegation is this renderer's own floor.
*
* They are unreachable from validated metadata and reachable from
* unvalidated. The floor exists so such a row keys a COLUMN rather than the
* literal string "undefined", which is the regression the file below pins.
*/
it('floors a fieldless non-count on the function name, as before the delegation', () => {
expect(aggregateValueKey({ function: 'sum' })).toBe('sum');
expect(aggregateValueKey({ function: 'avg' })).toBe('avg');
});

it('floors an empty aggregate on "count", as before the delegation', () => {
expect(aggregateValueKey({})).toBe('count');
});
});

describe('aggregateRecords — result columns', () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
/**
* 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#8266, the RENDER half — what a `dataKey` naming a column the rows do
* not carry actually draws.
*
* `plugin-dashboard/src/__tests__/DashboardChart.countSeriesKey-8266.test.tsx`
* pins which column the two dashboard relays name. That is necessary and not
* sufficient: a seam assertion cannot tell a honoured binding from an ignored
* one, and the whole claim of that card is that the mismatch is SILENT. So this
* file renders the real chain — `ChartRenderer` → `normalizeChartSchema` →
* `AdvancedChartImpl` — over the rows a fieldless count actually returns, once
* under each key, and reads the DOM.
*
* It lives here because `recharts` resolves inside `plugin-charts` alone, so
* this is the only package that can mock `ResponsiveContainer` to a measured
* box and count marks at all (the same reason
* `plugin-dashboard/src/__tests__/DatasetWidget.chartConfig.dom.test.tsx`
* states for its own split).
*
* ## Measured on `origin/main` `0fa7a9c83`, before any fix
*
* dataKey 'value' (what the relays composed): surface drawn, x ticks
* ["open","paid"], 0 `.recharts-bar`, 0 `.recharts-rectangle`, NO
* `[data-chart-error]`, NO `chart-empty-state`.
* dataKey 'count' (the column the rows carry): 1 `.recharts-bar`,
* 2 `.recharts-rectangle`, y ticks 0..8.
*
* Same rows, same harness — which is what makes the zero a statement about the
* binding rather than about the harness. The zero arm is kept as a pin
* deliberately: it is the reason the seam file's assertions are worth making,
* and if a future guard DOES start refusing this shape loudly, this is the test
* that must be re-decided rather than a silent behaviour change nobody notices.
*
* ⚠️ Mark counts are harness-bound (`ResponsiveContainer` is fixed at 480x320
* here); they are re-derived in this file and never carried in from another.
*/

import React from 'react';
import { describe, it, expect, vi, afterEach } from 'vitest';
import { render, screen, cleanup, waitFor } from '@testing-library/react';

// Recharts measures via ResizeObserver, which reports 0x0 under the headless
// DOM, so nothing paints. Fix its size — the shim every render test in this
// package uses.
vi.mock('recharts', async () => {
const actual = await vi.importActual<any>('recharts');
return {
...actual,
ResponsiveContainer: ({ children }: any) =>
React.cloneElement(children, { width: 480, height: 320 }),
};
});

import { ChartRenderer } from './ChartRenderer';
import { aggregateRecords } from './ObjectChart';

afterEach(cleanup);

/**
* Not transcribed — produced by the very builder the row projection uses, so a
* change to the projected column name lands in this fixture instead of leaving
* the pin asserting against a shape the product stopped emitting.
*/
const COUNT_ROWS = aggregateRecords(
[
{ status: 'open' }, { status: 'open' },
{ status: 'paid' }, { status: 'paid' }, { status: 'paid' }, { status: 'paid' }, { status: 'paid' },
],
{ function: 'count', groupBy: 'status' },
);

const drawWith = async (dataKey: string) => {
const { container } = render(
<ChartRenderer
schema={{
chartType: 'bar',
data: COUNT_ROWS,
xAxisKey: 'status',
series: [{ dataKey }],
isAnimationActive: false,
} as any}
/>,
);
// `AdvancedChartImpl` is lazy — wait for the real outcome, not the skeleton.
// Either terminal state satisfies this, so a refusal is never mistaken for a
// timeout and a timeout is never read as "it drew nothing".
await waitFor(() => {
if (!container.querySelector('.recharts-surface') && !container.querySelector('[data-chart-error]')) {
throw new Error('neither a plot nor a refusal');
}
}, { timeout: 5000 });
return {
marks: container.querySelectorAll('.recharts-rectangle').length,
series: container.querySelectorAll('.recharts-bar').length,
refusal: container.querySelector('[data-chart-error]')?.getAttribute('data-chart-error') ?? null,
emptyState: !!screen.queryByTestId('chart-empty-state'),
ticks: Array.from(container.querySelectorAll('.recharts-cartesian-axis-tick-value')).map((n) => n.textContent),
};
};

describe('a fieldless count projects its value under "count" (objectui#8266)', () => {
it('is the shape the row builder emits — the premise the rest of the file rests on', () => {
expect(COUNT_ROWS).toEqual([
{ status: 'open', count: 2 },
{ status: 'paid', count: 5 },
]);
});

it('draws its marks when the series names that column', async () => {
const drawn = await drawWith('count');
expect(drawn.refusal).toBeNull();
expect(drawn.series).toBe(1);
expect(drawn.marks).toBe(2);
// The values really reached an axis, so "it drew" is not just a container.
expect(drawn.ticks).toEqual(expect.arrayContaining(['open', 'paid', '0', '2', '4']));
});

it('draws NOTHING, silently, when the series names "value" instead', async () => {
const drawn = await drawWith('value');
// The failure this card is about: a plot frame with the categories on it…
expect(drawn.ticks).toEqual(['open', 'paid']);
// …and not one mark in it.
expect(drawn.marks).toBe(0);
expect(drawn.series).toBe(0);
// …and nothing anywhere says so. Both guards this renderer carries decline:
// `hasNoCategoryKey` is satisfied (the rows DO have `status`) and
// `hasNoPlottableSeries` keys on `series: []`, which this is not.
expect(drawn.refusal).toBeNull();
expect(drawn.emptyState).toBe(false);
});
});
14 changes: 12 additions & 2 deletions packages/plugin-charts/src/ObjectChart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import React, { useState, useEffect, useContext, useCallback, useMemo } from 're
import { useDataScope, SchemaRendererContext, SchemaRenderer, useDrillNavigation, useFilterScope, ElementDataSourceGate, type ElementDataSourceMapping } from '@object-ui/react';
import { ChartRenderer } from './ChartRenderer';
import { normalizeChartSchema } from './normalizeChartSchema';
import { ComponentRegistry, humanizeLabel, extractRecords, computeDrillFilter, isDrillEnabled, resolveDrillTitle, resolveFilterPlaceholders, resolveContextTokens, shiftFilterByCompareTo, compareToTrendLabelKey, buildChartSeries, buildOptionColorMap, deriveDimensionLabelMaps, dimensionOptionTranslator, loadDimensionFieldMeta, relabelDimensions, localizeFieldOptions, elementDataSourceBlock, type DimensionFieldMeta, type CompareToConfig, type DrillEvent, type ChartResultField, type ChartSegmentClickEvent } from '@object-ui/core';
import { ComponentRegistry, chartMeasureKey, humanizeLabel, extractRecords, computeDrillFilter, isDrillEnabled, resolveDrillTitle, resolveFilterPlaceholders, resolveContextTokens, shiftFilterByCompareTo, compareToTrendLabelKey, buildChartSeries, buildOptionColorMap, deriveDimensionLabelMaps, dimensionOptionTranslator, loadDimensionFieldMeta, relabelDimensions, localizeFieldOptions, elementDataSourceBlock, type DimensionFieldMeta, type CompareToConfig, type DrillEvent, type ChartResultField, type ChartSegmentClickEvent } from '@object-ui/core';
import { Sheet, SheetContent, SheetHeader, SheetTitle, Dialog, DialogContent, DialogHeader, DialogTitle, RefreshIndicator, Button, ChartSkeleton, DataEmptyState } from '@object-ui/components';
import { AlertCircle, ArrowUpRight, Inbox } from 'lucide-react';
import { builtinAggregateLabels, useSafeFieldLabel, useSafeTranslate } from '@object-ui/i18n';
Expand Down Expand Up @@ -35,7 +35,17 @@ export { humanizeLabel };
* agrees on one key instead of each re-deriving it.
*/
export function aggregateValueKey(aggregate: { field?: string; function?: string }): string {
return aggregate.field || aggregate.function || 'count';
// The contract's own derivation answers every aggregate `ChartAggregateSchema`
// ADMITS; the tail is this renderer's floor for shapes it REJECTS (a
// non-count aggregate that names no field, or an empty bag), which reach this
// function only from unvalidated metadata and must still key a column rather
// than the string "undefined". Byte-identical to the three-rung `field ||
// function || 'count'` it replaces — the two extra rungs are unreachable for
// any aggregate the spec answers for. Delegated rather than restated so this
// cannot drift from the SERIES binding the dashboard relays compose, which is
// exactly how a fieldless count came to project `'count'` and be plotted as
// `'value'` (objectui#8266).
return chartMeasureKey(aggregate, aggregate.function || 'count');
}

/**
Expand Down
Loading
Loading