Skip to content

fix(plugin-dashboard,core): resolve an object-bound chart's category axis through the aggregate - #8298

Merged
os-justin merged 1 commit into
mainfrom
claude/issue-8269-dashboard-category-axis-groupby
Sep 7, 2026
Merged

fix(plugin-dashboard,core): resolve an object-bound chart's category axis through the aggregate#8298
os-justin merged 1 commit into
mainfrom
claude/issue-8269-dashboard-category-axis-groupby

Conversation

@os-justin

Copy link
Copy Markdown
Collaborator

Fixes #8269

The defect

A dashboard chart widget bound to an object whose category is declared only as
aggregate.groupBy — 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.

Both dashboard relays floored the category binding on a literal and handed it to the
object-chart node without consulting the aggregate that decides it:

DashboardGridLayout.tsx:227   const xAxisKey = options.xField || 'name';
DashboardRenderer.tsx:608     const xAxisKey = options.xField || 'name';

An object-bound aggregate returns one row per group keyed by the raw groupBy field, so
no row carried name and hasNoCategoryKey (framework#4033) fired correctly on a binding
that was already wrong when it arrived.

The fix — the mirror of the measure half

chartCategoryKey is a new @object-ui/core export delegating to
chartAggregateCategoryKey in @objectstack/spec/ui — the contract's own published
derivation of "the category column an object-bound aggregate produces", and the sibling of
the chartAggregateValueKey that the measure axis adopted in PR #8272. Before this change
that 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 xAxisKey consumers, settled — two changed, two deliberately not

Each relay's single xAxisKey local feeds two composed nodes. They are not one
decision spelled twice:

site (post-change line) branch verdict
DashboardGridLayout.tsx:263 object provider changed — resolves through chartCategoryKey
DashboardGridLayout.tsx:278 authored literal rows floor kept — correct as-is
DashboardRenderer.tsx:640 object provider changed — resolves through chartCategoryKey
DashboardRenderer.tsx:666 authored literal rows floor kept — correct as-is

The two literal-rows sites are reached only after isObjectProvider returns false, for a
widget whose rows are an array the author wrote. There is no aggregate to consult and the
author's xField names a column of their own rows, so the floor is right there — the same
split 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.tsx also produces an xAxisKey, from buildChartSeries on the ADR-0021
dataset 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
ChartRenderer at 480x320 with ResponsiveContainer mocked, over the rows a fieldless
count actually returns — [{status:'open',count:2},{status:'paid',count:5}], produced by
aggregateRecords rather than transcribed.

Baseline reproduced (the card's own 4-row table, re-measured on 3c6394cb2):

xAxisKey dataKey marks series refusal ticks
status value 0 0 none open, paid
status count 2 1 none open, paid, 0, 2, 4, 6, 8
name value 0 0 missing-category-key naming name none
name count 0 0 missing-category-key naming name none

After — the same chain driven end to end through the real ObjectChart fetch
pipeline (runAggregate to comparison merge to resolveGroupByLabels to ChartRenderer),
against a data source whose status field carries picklist options:

composed xAxisKey marks series refusal ticks
name (what both relays composed before) 0 0 missing-category-key naming name none
status (what they compose now) 2 1 none Open cases, Paid cases, 0, 2, 4, 6, 8

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

resolveGroupByLabels rewrites the groupBy column in place — check the resolved key
is still valid at the point the axis reads it.

It is. resolveGroupByLabels replaces the values under the existing key
(row[groupByField] = label) and stashes the raw value beside them under
a side-channel key spelled __raw_ immediately followed by that same key name; it never
renames 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,
hasNoCategoryKey would have refused instead of drawing).

One thing worth recording: ObjectChart already derived the label pass's own key as
groupBy.alias || groupBy.field (or the bare string), i.e. the same derivation this seam
now 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 -cF counted before and after, injected line printed),
restore by state (git diff HEAD empty and git hash-object equal to
git rev-parse HEAD:PATH), never by an exit code, and carry a trap ... EXIT INT TERM.

Leg A — neuter the seam (chart-category-key.ts body to return fallback;):
16 failed / 18 passed.

  • chart-category-key.test.ts: 5 failed (the contract-answers block), 3 passed (the
    caller-floor block — a floor-only implementation still satisfies those).
  • ObjectChart.categoryAxisKeyRender-8269.test.tsx: 3 failed, 5 passed — the baseline
    matrix 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: effectiveXAxisKey back to
xAxisKey: xAxisKey at both object sites): 8 failed / 26 passed, and only the
plugin-dashboard seam file failed
. The core unit file and the plugin-charts render
file 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-check for @object-ui/core, @object-ui/plugin-dashboard,
    @object-ui/plugin-charts — 16 tasks, all successful (it builds the dependency closure
    first, so the new export is verified present in packages/core/dist/).
  • eslint --no-inline-config on the seven changed files — exit 0, 27 warnings, all of the
    same classes the surrounding files already carry. lint.yml sets no --max-warnings.
  • Gates run green: 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 --test on all eight changed paths: NOT GOVERNED.
  • Two gates could not be measured locally, both for a missing prerequisite rather than a
    finding, and CI supplies both: check:readme-exports (378 findings, every one
    "type entry ./dist/index.d.ts is not on disk — run pnpm build first", none of them
    naming any file in this diff) and check:eager-closure (needs
    apps/console/dist/eager-closure.json; it reports itself as a broken gauge).

Changeset

.changeset/8269-dashboard-category-axis-groupby.md, patch for the three packages whose
src/ changed. No major, 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 ObjectView faces belong
to 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

…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
@github-actions

github-actions Bot commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Eager closure (gzip, 50 chunks) 3472.2 KB 3512.7 KB
Main entry chunk (gzip) 143.9 KB 350 KB
Entry file index-CoYVbpuS.js
Status PASS

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

Package Size Gzipped
app-shell (consoleActionDispatch.js) 0.20KB 0.19KB
app-shell (index.js) 15.67KB 5.75KB
app-shell (runtime-config.js) 20.68KB 7.36KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 10.06KB 3.86KB
auth (ActiveOrganizationStorage.js) 25.05KB 9.16KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 2.07KB 1.00KB
auth (AuthProvider.js) 40.18KB 10.59KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.15KB 5.39KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.65KB 2.22KB
auth (SocialSignInButtons.js) 9.61KB 3.89KB
auth (UserMenu.js) 3.41KB 1.23KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 40.21KB 10.80KB
auth (createAuthenticatedFetch.js) 8.46KB 3.43KB
auth (index.js) 3.19KB 1.44KB
auth (invitation-status.js) 1.22KB 0.70KB
auth (org-roles.js) 6.66KB 2.78KB
auth (phone-identifier.js) 1.11KB 0.66KB
auth (types.js) 0.59KB 0.35KB
auth (useAuth.js) 5.30KB 1.02KB
auth (useWorkspaceAdminStatus.js) 5.13KB 2.35KB
collaboration (CommentThread.js) 26.08KB 7.56KB
collaboration (LiveCursors.js) 3.17KB 1.27KB
collaboration (PresenceAvatars.js) 6.49KB 2.64KB
collaboration (PresenceProvider.js) 2.79KB 1.13KB
collaboration (index.js) 1.68KB 0.73KB
collaboration (useCollaborationTranslation.js) 6.05KB 2.52KB
collaboration (useCommentSearch.js) 1.98KB 0.88KB
collaboration (useConflictResolution.js) 7.75KB 1.86KB
collaboration (useMentionNotifications.js) 1.81KB 0.68KB
collaboration (usePresence.js) 6.33KB 1.84KB
collaboration (useRealtimeSubscription.js) 7.91KB 2.01KB
components (index.js) 497.99KB 113.90KB
core (index.js) 7.48KB 2.96KB
create-plugin (index.js) 10.08KB 3.26KB
data-objectstack (index.js) 189.15KB 52.56KB
fields (index.js) 243.14KB 61.38KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (builtinAggregateLabels.js) 0.86KB 0.49KB
i18n (currency.js) 1.22KB 0.64KB
i18n (fallbackInterpolation.js) 6.25KB 2.77KB
i18n (i18n.js) 6.57KB 2.76KB
i18n (index.js) 3.65KB 1.47KB
i18n (pickLocalized.js) 7.62KB 3.26KB
i18n (provider.js) 26.89KB 9.04KB
i18n (useDisplayLocale.js) 2.85KB 1.45KB
i18n (useObjectLabel.js) 34.34KB 9.17KB
i18n (useSafeTranslation.js) 5.60KB 2.33KB
layout (index.js) 38.84KB 10.94KB
mobile (MobileProvider.js) 0.92KB 0.49KB
mobile (ResponsiveContainer.js) 0.94KB 0.38KB
mobile (breakpoints.js) 1.51KB 0.70KB
mobile (createOfflineDataSource.js) 5.61KB 1.75KB
mobile (index.js) 1.99KB 0.87KB
mobile (offlineQueue.js) 3.91KB 1.35KB
mobile (pwa.js) 0.97KB 0.49KB
mobile (serviceWorker.js) 1.48KB 0.62KB
mobile (serviceWorkerSource.js) 3.41KB 1.48KB
mobile (useBreakpoint.js) 1.54KB 0.65KB
mobile (useGesture.js) 6.96KB 1.98KB
mobile (useOfflineSync.js) 1.99KB 0.72KB
mobile (usePullToRefresh.js) 2.53KB 0.85KB
mobile (useResponsive.js) 0.72KB 0.42KB
mobile (useSpecGesture.js) 4.39KB 1.66KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 11.71KB 4.29KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 6.24KB 2.16KB
permissions (discardProofCache.js) 1.04KB 0.55KB
permissions (evaluator.js) 5.12KB 1.74KB
permissions (index.js) 0.93KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.53KB
permissions (usePermissions.js) 4.83KB 2.27KB
plugin-ai (index.js) 15.16KB 3.68KB
plugin-calendar (index.js) 48.03KB 13.29KB
plugin-charts (index.js) 71.39KB 19.92KB
plugin-chatbot (index.js) 193.54KB 46.04KB
plugin-dashboard (index.js) 131.48KB 34.45KB
plugin-designer (index.js) 213.21KB 43.63KB
plugin-detail (index.js) 248.56KB 63.89KB
plugin-editor (index.js) 2.23KB 1.05KB
plugin-form (index.js) 131.01KB 32.32KB
plugin-gantt (index.js) 167.16KB 40.99KB
plugin-grid (index.js) 208.58KB 56.63KB
plugin-kanban (index.js) 52.83KB 14.63KB
plugin-list (index.js) 113.38KB 27.73KB
plugin-map (index.js) 20.49KB 6.83KB
plugin-markdown (index.js) 13.88KB 4.80KB
plugin-report (index.js) 43.42KB 11.92KB
plugin-timeline (index.js) 30.10KB 8.74KB
plugin-tree (index.js) 9.33KB 3.25KB
plugin-view (index.js) 84.46KB 20.80KB
providers (DataSourceProvider.js) 0.75KB 0.39KB
providers (MetadataProvider.js) 1.37KB 0.59KB
providers (ThemeProvider.js) 1.90KB 0.85KB
providers (UploadProvider.js) 11.66KB 3.50KB
providers (index.js) 0.45KB 0.23KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 5.62KB 2.34KB
react (LazyPluginLoader.js) 4.47KB 1.63KB
react (SchemaRenderer.js) 81.07KB 26.86KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 4.63KB 2.18KB
react (schema-input.js) 2.32KB 1.24KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (codegen.js) 6.58KB 2.74KB
sdui-parser (dashboard-widget-options.js) 3.08KB 1.30KB
sdui-parser (index.js) 5.55KB 2.45KB
sdui-parser (input-type.js) 2.84KB 1.40KB
sdui-parser (parse.js) 20.57KB 5.88KB
sdui-parser (provenance.js) 3.66KB 1.82KB
sdui-parser (types.js) 0.28KB 0.23KB
sdui-parser (validate.js) 13.64KB 4.59KB
types (ai.js) 0.20KB 0.17KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 2.87KB 1.00KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (complex.js) 2.74KB 1.41KB
types (crud.js) 0.20KB 0.18KB
types (dashboard-filter-alias.js) 6.23KB 2.74KB
types (data-display.js) 3.75KB 1.85KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.85KB 0.85KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
types (expression.js) 0.20KB 0.18KB
types (feedback.js) 0.20KB 0.18KB
types (field-types.js) 0.20KB 0.18KB
types (form.js) 0.20KB 0.18KB
types (http-inflight.js) 8.87KB 3.73KB
types (http-retry.js) 4.32KB 2.02KB
types (icon-key-migration.js) 4.26KB 1.63KB
types (index.js) 4.74KB 2.25KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 4.73KB 2.28KB
types (navigation.js) 0.20KB 0.18KB
types (objectql.js) 0.20KB 0.18KB
types (overlay.js) 0.20KB 0.18KB
types (permissions.js) 0.20KB 0.18KB
types (plugin-scope.js) 0.20KB 0.18KB
types (record-components.js) 0.20KB 0.19KB
types (record-semantics.js) 1.28KB 0.67KB
types (registry.js) 0.20KB 0.18KB
types (reports.js) 0.20KB 0.18KB
types (select-option.js) 0.20KB 0.19KB
types (spec-report.js) 5.05KB 1.93KB
types (spec-ui-namespace.js) 0.20KB 0.19KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 6.28KB 2.87KB
types (ui-action.js) 8.11KB 3.32KB
types (views.js) 0.20KB 0.18KB
types (widget.js) 0.20KB 0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

@os-justin
os-justin marked this pull request as ready for review September 7, 2026 09:40
@os-justin
os-justin added this pull request to the merge queue Sep 7, 2026
Merged via the queue into main with commit 8f9d87a Sep 7, 2026
34 checks passed
@os-justin
os-justin deleted the claude/issue-8269-dashboard-category-axis-groupby branch September 7, 2026 10:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

2 participants