Uh oh!
There was an error while loading. Please reload this page.
fix(plugin-dashboard,i18n): a self-explaining default empty state for dashboard widgets - #7124
Conversation
… dashboard widgets
Maintainer ruling 2026-08-31 (hotcrm#1212, following hotcrm#1203): a widget that
renders a bare row-placeholder on an empty result is the platform's defect and
must be fixed uniformly — apps must not compensate widget by widget
(objectstack#13848).
There was no shared placeholder to fix. Three dashboard surfaces wrote their own,
in two different strings: DatasetWidget used `dashboard.noRows` ('No rows' /
暂无数据行), while ObjectDataTable and PivotTable used `dashboard.noDataAvailable`.
`WidgetEmptyState` is the seam they now share.
The default reads as a STATE rather than a failure — `role="status"` where the
branches beside it are `role="alert"` and the empty branches previously carried
no role at all — carries a title plus an explanation instead of one terse
fragment, and names the widget's data source with zero authored copy.
Copy is platform i18n: `dashboard.empty.title` / `.message` / `.sourceLabel` in
`en` and all nine sibling packs. No inline defaultValue, no interpolation.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM✅ 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-warren
commented
Sep 1, 2026
✅ ACCEPT (on the substance) — PM seat ( |
Uh oh!
There was an error while loading. Please reload this page.
…r an empty result Measured, not assumed: recharts derives ticks from the data, so an empty result draws two hairline axis rules and zero text nodes (bar/line) or nothing at all (pie) — there are no labelled axes to read. Beside the component's own red failure box that is an unexplained blank tile. Routes through the primitive that is already shared (DataEmptyState in @object-ui/components, already a dependency) with the i18n keys #7124 landed, so no package is promoted, no dependency edge is added and no new key is introduced. Charts with inline authored data are unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM
…r an empty result (objectstack-ai#7139) * fix(plugin-charts): a self-describing empty state for ObjectChart over an empty result Measured, not assumed: recharts derives ticks from the data, so an empty result draws two hairline axis rules and zero text nodes (bar/line) or nothing at all (pie) — there are no labelled axes to read. Beside the component's own red failure box that is an unexplained blank tile. Routes through the primitive that is already shared (DataEmptyState in @object-ui/components, already a dependency) with the i18n keys objectstack-ai#7124 landed, so no package is promoted, no dependency edge is added and no new key is introduced. Charts with inline authored data are unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM * test(app-shell): give the two object-bound chart sweep targets rows The DOM-leak sweep mounts `plugin-charts:object-chart` and `view:chart` against an adapter that answers every query with no rows. Since the empty branch in this branch's first commit, that lands them on the empty state rather than chart markup, so `[data-slot="chart"]` never matches and the sweep's readiness guard refuses to scan — the guard working as designed. Fixed on the fixture, not the guard: the two targets author `data` / `series` (both already used by the six inline chart targets beside them, and `data" is a declared registry input on this component) while staying object-bound. The readiness selector and the guard are untouched, and these two now scan strictly more markup than before — the pre-existing reading swept a chart frame with no marks in it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM --------- Co-authored-by: Claude <noreply@anthropic.com>
Fixes#7063
Maintainer ruling 2026-08-31 (hotcrm#1212 adjudication, verbatim 「1212 也是」, following 「hotcrm#1203 …这种也是平台的问题啊」): this class is the platform's to fix uniformly; apps must not compensate widget by widget (objectstack#13848 — apps are simplified business implementations, uniform behaviour belongs to the platform).
All evidence below was measured at 79accd6 (the final commit on this branch).
The card's premise was half wrong, and that changed the shape of the fix
Two of the dispatch's stated assumptions did not survive measurement. Both are reported here rather than worked around.
1.
暂无数据行is NOT one shared placeholder — there was no seam to fixThree dashboard surfaces wrote their own empty state, in two different strings, with no common code:
DatasetWidget(the measured one)dashboard.noRowsNo rows暂无数据行ObjectDataTabledashboard.noDataAvailableNo data available暂无数据PivotTabledashboard.noDataAvailableNo data available暂无数据dashboard.noRowshad exactly one call site repo-wide (DatasetWidget.tsx:727), so the measured暂无数据行came from the dataset-bound path only — which is the ADR-0021 governed path the hotcrm quota-attainment tile takes.So "uniformly" could not mean "fix the shared placeholder". The new
WidgetEmptyStateis the seam: it is added, and the three surfaces are routed through it.2. The authored
emptyStateoverride does not exist on this surfaceClause 3 assumes an existing authored
emptyStatecontract that always overrides. Measured:emptyStateoccurs 0 times inpackages/plugin-dashboard/srcandpackages/plugin-charts/src. Control in the same sweep, same paths:widget, 1154 hits — so the zero is a reading, not a broken query.emptyStateis a list-view contract (ObjectGridSchema,NamedListViewinpackages/types/src/objectql.ts), honoured inpackages/plugin-list/src/ListView.tsx.@objectstack/spec@17.2.0'sDashboardWidgetSchemadeclares no such key. Its full key set, read from the built package at runtime rather than recalled:id, title, description, type, chartConfig, colorVariant, requiresObject, requiresService, actionUrl, actionType, actionIcon, filter, compareTo, dataset, dimensions, values, layout, options, filterBindings, suppressWarnings, responsive, ariaConsequence, and why this is not the stop-and-report case. Clause 3 is satisfied here vacuously: there is no authored override on dashboard widgets for the default to lose to, so improving the default cannot displace one. No spec key was added and none is needed to deliver this card — the deliverable is a better DEFAULT, and defaults need no authoring surface. Making clause 3 non-vacuous (i.e. giving dashboard widgets a real authored override) would require a new spec key, which is the contract question the card fences off. That is flagged for the seat, not taken.
What changed
New:
packages/plugin-dashboard/src/WidgetEmptyState.tsx— the dashboard-local seam.The ruling's four properties, and how each is delivered:
role="status", where the failure branches sitting beside these call sites arerole="alert"on destructive colours. The empty branches previously carried no role at all, so assistive tech got a bare fragment with no state either way. Visually: muted treatment and an inbox glyph, never destructive colour and a warning triangle.PivotTablegains one optional component prop (sourceLabel), which is React-side only.dashboard.empty.title/.message/.sourceLabelinenand all nine sibling packs.What
sourcenames, and why it is a raw identifier. The card allows the widget's label and/or its data source. The widget's title is already rendered by the card header directly above every tile (DashboardRenderer), so repeating it says nothing new; the data source is the half the reader cannot see. What is reachable at each render site is the authored binding —widget.dataseton the dataset path,schema.objectNameon the object-bound table/pivot — i.e. a raw metadata name such ascrm_forecast, not a localized label. It is rendered as a labelled monospace value rather than folded into a sentence: a truthful narrow statement beats an invented friendly one.Why the copy carries no interpolation.
useSafeTranslatetakes a positional fallback and no options bag, so an interpolated key would have to come from a rawuseObjectTranslationt()— and with noI18nProvidermounted (the standalone-host and test configuration, whichvitest.setup.i18n-global.tsdeliberately preserves) that renders the raw KEY unless the call site also carries an inlinedefaultValue, which #3517 rules out. A label that carries its own punctuation (Source:/数据源:/مصدر البيانات:) needs neither, and concatenates no separator in code.Files:
WidgetEmptyState.tsx(new) ·DatasetWidget.tsx·ObjectDataTable.tsx·PivotTable.tsx·ObjectPivotTable.tsx· ten locale packs · one new test file ·DatasetWidget.test.tsx· one changeset.Evidence
Tests
pnpm exec vitest run packages/plugin-dashboard packages/i18n packages/app-shell/src/__tests__/widget-dom-leak-sweep.test.tsx --maxWorkers=2at 79accd6:New pin:
packages/plugin-dashboard/src/__tests__/WidgetEmptyState.uniformDefault.test.tsx(5 cases). It pins the ruling's properties, never the sentence — copy is review's to move. It also pins that all four dashboard entry points reach the one seam, which is the property that decays first: a fourth widget can quietly write a fifth placeholder while every per-widget test stays green.Four pins in
DatasetWidget.test.tsxwere rewritten rather than left passing. Three of them assertedqueryByText('No rows')).not.toBeInTheDocument()— after this change that string exists nowhere, so they would have passed for the wrong reason. They now select the empty state by test id.Ablation — two legs, direction predicted before running
Leg 1 — revert
DatasetWidget's empty branch to the pre-carddashboard.noRowsone-liner. Predicted: RED, and only on the DatasetWidget surface.WidgetEmptyState(as a JSX open tag) 1 to 0; injected-markertt('dashboard.noRows'0 to 1; blobf8f61e71...to951c8251...Tests 4 failed | 59 passed, vitest exit 1 (read from the run's own printedABLATED_VITEST_EXIT=1, not a bare$?behind a pipe). All four failures on the DatasetWidget surface; theObjectDataTable,PivotTableandObjectPivotTablecases stayed green, so the pins resolve per surface rather than firing as a block.git diff HEADempty,git status --porcelainempty, blob back tof8f61e712947e3cdf3dacf3cdab3e6a8cccd25ba= its HEAD blob.Leg 2 — delete
role="status"from the shared component, i.e. ablate property (1) itself. Predicted: RED on the role assertions; the load-failure test stays green.role="status"2 to 1 (the second occurrence is in the file's doc comment; the python anchor asserted exactly one match on the JSX line); bloba62aaa96...to39a46faf...Tests 4 failed | 59 passed, exit 1 — the three seam cases plus theDatasetWidget.test.tsxrole case.a load FAILURE still reads as a failurestayed green, so the two halves of property (1) are independent.git diff HEADempty, blob back toa62aaa96ec88cede54f96579451a6922b3aceb3a.Both legs used an
EXIT INT TERMtrap with an absoluteREPO_ROOTpath, and restored withgit checkout HEAD -- path(never a baregit checkout -- path, which restores from the index and would hand back the mutation).No rebuild leg, stated rather than skipped. This repo's vitest resolves
@object-ui/*topackages/*/srcviaresolve.aliasinvitest.config.mts, and the pins import the components by relative path, so nodistcan shadow the mutation. A stale build cannot make an ablation falsely green here.Gates
vitest(plugin-dashboard, i18n, widget-dom-leak-sweep)pnpm --filter @object-ui/plugin-dashboard --filter @object-ui/i18n run type-checktsc --noEmit && tsc -p tsconfig.test.json, so the new test file was type-checked, not merely excludedpnpm --filter @object-ui/plugin-dashboard --filter @object-ui/i18n run lintno-explicit-anyon the new test file, the same schema-cast shape its sibling suites use)check:i18n-keyscheck:i18n-driftcheck:control-bytescheck-changeset-presencecheck:changeset-fixedcheck:changeset-no-majormajorbump."check:phantom-deps/check:self-import/check:side-effects-array/check:vi-mock-specifiers/check:vi-mock-inherit/check:esm-specifierscheck:readme-exportsturbo run buildfirst. Narrowing argument:packages/plugin-dashboard/src/index.tsxis untouched and does not exportWidgetEmptyState(control in the same query:PivotTable, which it does export, hits), so the package's published export surface is unchanged and no README moved.check:eager-closureapps/console; produced byperformance-budget.ymlin CI.Changeset verdicts are quoted verbatim above rather than predicted; both gates were run.
Zero-hit readings, each with its control
emptyStateplugin-dashboard/src+plugin-charts/srcwidget, same scopeWidgetEmptyStateplugin-dashboard/src/index.tsxPivotTable, same fileWhat is NOT verified here, and why
The hotcrm acceptance scenario is unverified by me. The card's acceptance is a hotcrm
sales_dashboardfresh install. hotcrm is outside this session's repo scope — it is not attached and I did not try to reach it. What I can state is the objectui-side mechanism: the quota-attainment tile is a dataset-bound table widget, which renders throughDatasetWidget, whose empty branch is the one call site of the暂无数据行string this PR replaces. The end-to-end confirmation on a fresh hotcrm install still needs to be run by someone with that repo.Shared-abstraction temptation, reported rather than taken.
DataEmptyStatein@object-ui/componentsis the presentational primitive already used byplugin-list,plugin-kanban,plugin-dashboardandplugin-detail— the surface of the parallel card #7064. Changing its defaults would have been a smaller diff that repaired both surfaces at once. It is deliberately consumed unchanged here: a shared empty-state abstraction designed twice in parallel by two agents is worse than two honest local fixes, and converging the two is its own sequenced card. Nothing underpackages/plugin-detailis touched by this PR.Residue this creates, not fixed here.
dashboard.noRowsanddashboard.noDataAvailablenow have no call site.check:i18n-dead-keys(report-only, exit 0, not wired into CI) lists both asneeds-reviewafter this change. They are left in all ten packs deliberately: the dispatch asks locale edits to stay additive so parallel merges stay trivial, and the dead-key sweep is its own tracked workstream (objectui#4658). Filed as a follow-up rather than ridden on this PR.Generated by Claude Code