fix(plugin-dashboard,core): resolve an object-bound chart's category axis through the aggregate - #8298
Merged
Conversation
…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
Contributor
✅ Console Performance Budget
The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it. 📦 Bundle Size Report
Size Limits
|
os-justin
marked this pull request as ready for review
September 7, 2026 09:40
os-justin
deleted the
claude/issue-8269-dashboard-category-axis-groupby
branch
September 7, 2026 10:06
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #8269
The defect
A dashboard chart widget bound to an object whose category is declared only as
aggregate.groupBy— nooptions.xField— rendered a refusal instead of a chart:The author wrote
groupBy: 'status'. Nothing on screen saidgroupBywas the key thathad been ignored, and
nameappeared nowhere in their metadata, so the diagnostic sentthem to debug the wrong layer.
Both dashboard relays floored the category binding on a literal and handed it to the
object-chartnode without consulting the aggregate that decides it:An object-bound aggregate returns one row per group keyed by the raw
groupByfield, sono row carried
nameandhasNoCategoryKey(framework#4033) fired correctly on a bindingthat was already wrong when it arrived.
The fix — the mirror of the measure half
chartCategoryKeyis a new@object-ui/coreexport delegating tochartAggregateCategoryKeyin@objectstack/spec/ui— the contract's own publishedderivation of "the category column an object-bound aggregate produces", and the sibling of
the
chartAggregateValueKeythat the measure axis adopted in PR #8272. Before this changethat category sibling had 0 readers in this repository while its measure twin had 7.
Not a literal repaired in place, and not a second local resolver: the authority stays
upstream of this repo, exactly as PR #8272 put it.
All FOUR
xAxisKeyconsumers, settled — two changed, two deliberately notEach relay's single
xAxisKeylocal feeds two composed nodes. They are not onedecision spelled twice:
DashboardGridLayout.tsx:263chartCategoryKeyDashboardGridLayout.tsx:278DashboardRenderer.tsx:640chartCategoryKeyDashboardRenderer.tsx:666The two literal-rows sites are reached only after
isObjectProviderreturns false, for awidget whose rows are an array the author wrote. There is no aggregate to consult and the
author's
xFieldnames a column of their own rows, so the floor is right there — the samesplit PR #8272's author found for the series binding. Both verdicts are pinned, in both
directions, so a future "unify the two" refactor cannot quietly break either.
DatasetWidget.tsxalso produces anxAxisKey, frombuildChartSerieson the ADR-0021dataset path. Different shape, different authority, explicitly carved out of the
object-bound refusal — untouched.
Render measurement, not only a seam assertion
The card set that bar on itself, so both tables below are measured through
ChartRendererat 480x320 withResponsiveContainermocked, over the rows a fieldlesscount actually returns —
[{status:'open',count:2},{status:'paid',count:5}], produced byaggregateRecordsrather than transcribed.Baseline reproduced (the card's own 4-row table, re-measured on
3c6394cb2):xAxisKeydataKeystatusvaluestatuscountnamevaluemissing-category-keynamingnamenamecountmissing-category-keynamingnameAfter — the same chain driven end to end through the real
ObjectChartfetchpipeline (
runAggregateto comparison merge toresolveGroupByLabelstoChartRenderer),against a data source whose
statusfield carries picklist options:xAxisKeyname(what both relays composed before)missing-category-keynamingnamestatus(what they compose now)Clause 2, stated plainly: a widget that renders a refusal today starts drawing. That
is the whole user-visible change.
The ordering trap the card named — measured, not reasoned about
It is.
resolveGroupByLabelsreplaces the values under the existing key(
row[groupByField] = label) and stashes the raw value beside them undera side-channel key spelled
__raw_immediately followed by that same key name; it neverrenames the column. The last row of the "after" table is the
measurement rather than the argument: marks drawn under humanized ticks is a
two-in-one reading — the rewrite really ran (the ticks are "Open cases"/"Paid cases", not
the raw enums) and the resolved key survived it (had the column been renamed,
hasNoCategoryKeywould have refused instead of drawing).One thing worth recording:
ObjectChartalready derived the label pass's own key asgroupBy.alias || groupBy.field(or the bare string), i.e. the same derivation this seamnow shares. So the label pass was always keyed correctly; the pre-change defect was purely
in the axis binding beside it.
Ablation — every new pin proven able to fail, and to discriminate
Both legs mutate on disk (
grep -cFcounted before and after, injected line printed),restore by state (
git diff HEADempty andgit hash-objectequal togit rev-parse HEAD:PATH), never by an exit code, and carry atrap ... EXIT INT TERM.Leg A — neuter the seam (
chart-category-key.tsbody toreturn fallback;):16 failed / 18 passed.
chart-category-key.test.ts: 5 failed (the contract-answers block), 3 passed (thecaller-floor block — a floor-only implementation still satisfies those).
ObjectChart.categoryAxisKeyRender-8269.test.tsx: 3 failed, 5 passed — the baselinematrix block hard-codes its pairs and is unaffected, which is what makes it a baseline.
DashboardChart.categoryAxisKey-8269.test.tsx: 8 failed (4 object arms x 2 surfaces),10 passed (both floor arms, both literal-rows arms, and the measure-key arm).
Leg B — revert only the relay wiring (
xAxisKey: effectiveXAxisKeyback toxAxisKey: xAxisKeyat both object sites): 8 failed / 26 passed, and only theplugin-dashboardseam file failed. The core unit file and theplugin-chartsrenderfile stayed fully green, so the seam file is what pins the relay wiring and is not
redundant with the other two.
A leg that reddens everything discriminates nothing; these two redden disjoint, predicted
sets.
Verification
pnpm exec vitest run packages/core/ packages/plugin-dashboard/ packages/plugin-charts/— 270 files, 3936 tests, all passed.
turbo run type-checkfor@object-ui/core,@object-ui/plugin-dashboard,@object-ui/plugin-charts— 16 tasks, all successful (it builds the dependency closurefirst, so the new export is verified present in
packages/core/dist/).eslint --no-inline-configon the seven changed files — exit 0, 27 warnings, all of thesame classes the surrounding files already carry.
lint.ymlsets no--max-warnings.check:control-bytes,check:unreferenced-sources,check:self-import,check:spec-symbols,check:phantom-deps,check:vi-mock-specifiers,check:vi-mock-inherit,check:side-effects-array,changeset:check,check-changeset-presence.mjs.check:governed-queue-guard --teston all eight changed paths: NOT GOVERNED.finding, and CI supplies both:
check:readme-exports(378 findings, every one"type entry
./dist/index.d.tsis not on disk — runpnpm buildfirst", none of themnaming any file in this diff) and
check:eager-closure(needsapps/console/dist/eager-closure.json; it reports itself as a broken gauge).Changeset
.changeset/8269-dashboard-category-axis-groupby.md,patchfor the three packages whosesrc/changed. Nomajor, per the version-alignment rule.Scope
The measure axis (objectui#8266, PR #8272) is done and is not revisited here. The literal
floors at the
ListView/plugin-view ObjectView/app-shell ObjectViewfaces belongto objectui#7547 and are untouched, as objectui#8244 deliberately left them.
🤖 Generated with Claude Code
https://claude.ai/code/session_01YBWFb5YgMU5dw8p2VKj16S
Generated by Claude Code