@object-ui/plugin-dashboard 17.2.0 → 17.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,1003 @@
1
1
  # @object-ui/plugin-dashboard
2
2
 
3
+ ## 17.5.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 7084f7d: `DashboardRenderer` and `ListView` serve the props they declare — the index signature stops erasing them
8
+
9
+ Both components declared a full props interface and neither was enforced. A `[key: string]: any` on `DashboardRendererProps` and `ListViewProps` puts `string` into `keyof Props`, so `'ref' extends keyof Props` is always true and React's `PropsWithoutRef` takes its `Omit` branch — and `Omit` over a type carrying a string index signature keeps only the index signature. Every declared property was dropped from the resolved type, on both sides: the render function received `{ [x: string]: any }` (so even `schema` was `any` inside the component), and every JSX call site was unchecked. Measured on the pre-fix source, `keyof ComponentProps<typeof DashboardRenderer>` was `string | number` and `ComponentProps<typeof DashboardRenderer>['onWidgetClick']` was `any`, while the interface went on declaring `(widgetId: string | null) => void`. `ListView` measured identically for `onRowClick`. This is objectui#4422 / PR #4438's trap in the two packages that issue left unswept.
10
+
11
+ Graded **minor, not major**: the interfaces have always DECLARED these props; the index signature erased them from the resolved type. Restoring what the interface documents is a FIX to the published contract, not a contract break — no documented capability is removed, and `any`-typed accidental passthrough was never the documented surface. Nothing in either package's README or docs endorses relying on it.
12
+
13
+ The props each component genuinely reads but never declared are now declared by name, at the type each one lands on: `dataSource` on both, plus `onAddRecord` / `onBulkAction` / `onPageSizeChange` / `onEdit` / `onDelete` / `onBulkDelete` on `ListView`. `DashboardRenderer`'s DOM pass-through keys are derived from `toDomProps`' whitelist constant itself, so the declaration and the runtime filter cannot drift — the "declare it and forward it by name" direction `@object-ui/core`'s `dom-props` doctrine asks for, rather than reopening the spread.
14
+
15
+ Type-only: the emitted JS for both packages is byte-identical before and after (verified by sha256 on `dist/index.js` and `dist/index.umd.cjs`), and both packages' runtime suites are untouched and green.
16
+
17
+ Three latent defects the erasure had been hiding are fixed with it, each surfaced by the repo-wide type-check: `DashboardWithConfig` typed its widget-select handler `(widgetId: string)` while `DashboardRenderer` calls `onWidgetClick(null)` to deselect; `InterfaceListPage` built a list schema whose `viewType` was a bare `string`; and `StudioDesignSurface` forwarded a `refreshKey` prop that no component in the chain declares or reads, so it was silently dropped. Per-package structural guards now pin the shape in both packages, covering the public `forwardRef` that takes its props whole — the spelling objectui#4438's `schema`-destructuring scan could not see.
18
+
19
+ - b8bda9a: The editable dashboard grid renders dataset-bound widgets — and says so visibly when it cannot
20
+
21
+ `DashboardGridLayout` had no dataset path at all. It never read `widget.dataset`, never imported `DatasetWidget`, and took no `dataSource` prop — so a widget authored the way ADR-0021 says to author them (`{ id, type: 'bar', dataset: 'invoices', values: ['count'] }`) fell straight through to the static-data branch and rendered nothing. Measured on the node the grid handed `SchemaRenderer`, the silence had three flavours rather than the one reported: a `bar` became `{ type: 'chart', data: [] }` (a chart drawn over nothing), a `metric` became `{ type: 'metric', value: '—' }` (an em dash, which reads as a rendered value rather than an error), and a `table` became `{ type: 'data-table', data: [] }`. No data, no diagnostic, no path to fix — on the surface registered as the `dashboard-grid` SDUI component and exported by name from the package entry.
22
+
23
+ This is the defect objectui#4612 fixed for the RETIRED authoring shape, one level up: same surface, same silence, but the shape that is current. The sibling `DashboardRenderer` has routed these widgets through the governed `queryDataset` path since ADR-0021, so the cure is that surface's own mechanics rather than a second dispatch idiom — the `datasetBound` predicate decided per widget, and `DatasetWidget` picked at the render site.
24
+
25
+ `DashboardGridLayout` therefore gains an optional `dataSource` prop, forwarded to `DatasetWidget` for dataset-bound widgets. A dataset-bound metric now also takes the shared `Card` wrapper, matching the sibling: `DatasetWidget` renders just the value, so without the card it would show as bare text with no title beside its neighbours.
26
+
27
+ A dataset-bound widget arriving with NO data source renders a visible state, never a blank. No new placeholder was declared for it: `DatasetWidget`'s own no-capability rendering — an alert reading "This data source does not support dataset queries." — was measured to render visibly when handed no adapter, so routing through it unconditionally cures both halves with one diagnostic and one wording. That case is not hypothetical: `dashboard-grid`'s SDUI registration declares only `title` and `className` inputs, so schema-driven hosts render this component with no adapter at all, and every such host keeps working exactly as before.
28
+
29
+ Nothing else moves. The objectui#4612 legacy sentinel keeps its position and its verdict — the two conditions are mutually exclusive by construction, since the shared detector returns false the moment a widget carries `dataset` — and static-data widgets, `options.data` provider widgets and legacy-retired widgets all render as they did and never reach the dataset query. The new prop is additive and optional, so existing call sites are untouched.
30
+
31
+ - 8640cec: One legacy detector, two dashboard surfaces — the editable grid stops rendering a silent blank chart
32
+
33
+ framework#3320 retired the pre-ADR-0021 inline-analytics widget shape (top-level `object` + `categoryField` / `valueField` / `aggregate`, pivot `rowField` / `columnField`) and shipped a graceful fallback for the stored metadata that still carries it: a visible tile reading "This widget uses a retired data format. Edit it to bind a dataset." The fallback was applied to `DashboardRenderer` and to nothing else. `DashboardGridLayout` — separately exported, and registered as the `dashboard-grid` SDUI component — had no sentinel at all, so the identical stored widget fell through to its static-data branch with `data: []`. Same metadata, same product, two different outcomes: a rebind prompt on one surface, a silent blank chart on the other. The blank is worse for the author than the pre-retirement state, because it carries no chart, no diagnostic and no path to fix — the exact outcome the retirement's own test header says must not happen.
34
+
35
+ The fix is not a second copy of the condition, because one copy is why the defect existed. The detector and the placeholder now live in a single module (`legacyRetiredWidget.ts`) that both surfaces import; `DashboardRenderer`'s observable behaviour is unchanged, pinned by its existing suite, and the new grid suite mirrors that suite's structure on the surface nobody had pinned. Four positive cases go from blank to placeholder, two of them the widget shapes stored byte-for-byte in the schema catalog's `filtered-dashboard` entry.
36
+
37
+ The negative controls are the load-bearing half, because the retired shape is one character away from a live one. `options.data = { provider: 'object', … }` carries its OWN nested `object` and `aggregate`: it is a different, still-live authoring surface, read off the widget's data rather than off the widget top level, and it keeps rendering untouched — as do dataset-bound widgets and static-data widgets, on both surfaces. `DashboardRenderer`'s pivot arm stays deliberately surface-local rather than shared: it returns the placeholder for the entire pivot family because that surface emits no pivot block at all, which is a fact about what it can draw, not about the widget being legacy. The grid does draw pivots, from static data and from the provider config, so exporting that arm would have retired two working branches. A legacy pivot is caught on both surfaces by the shared sentinel instead, via the top-level `object` it carries.
38
+
39
+ - f244273: `MetricWidgetProps` / `MetricCardProps` declare the DOM pass-through their spread has always accepted
40
+
41
+ Both KPI components end their prop list with a `...domProps` spread onto the Shadcn `Card`, and objectui#4357 (PR #4428) kept that spread deliberately — it is their only accessibility pass-through, and removing it would delete the only way a host can put an `id`, a `role` or an `aria-label` on a KPI card. Neither props interface declared any of it. So the type refused what the runtime accepted: a JS consumer, and every SDUI author going through `SchemaRenderer` (untyped at that boundary), got the pass-through, while a TypeScript consumer importing the component directly got `error TS2322` on `id` / `role` / `aria-label` and needed a cast.
42
+
43
+ `MetricWidgetProps` now extends `React.HTMLAttributes<HTMLDivElement>`, and `MetricCardProps` extends the same minus `title`. That is the repo's measured convention for an exported props interface that spreads onto a host element (`PageHeaderComponentProps`, `ChatbotProps`, `ChatbotEnhancedProps`, `TypingIndicatorProps`, `RefreshIndicatorProps`, `FieldProps`, and shadcn's `BadgeProps`), and the `Omit` carve-out is `ComboboxProps`'s spelling for a name the component's own contract owns.
44
+
45
+ Graded `minor` rather than `patch` per the objectui#4403 precedent: two exported interfaces widen. The widening is purely additive for existing callers — every prop that compiled before still compiles, and nothing narrows — so no source change is required to upgrade.
46
+
47
+ Semantics worth knowing, because both are contract statements rather than incidental:
48
+
49
+ - **`MetricCard.title` stays the heading.** HTML's `title` is a tooltip; this card's `title` is its heading, in the `I18nLabel` vocabulary, destructured out and rendered into `CardTitle`. No `title` attribute has ever reached this element, so the inherited DOM `title` is omitted rather than declared and silently dropped — the "declared but not delivered" failure this repo treats as first-class (objectui#3290, objectui#3222). `MetricWidget` has no such collision (its heading is `label`) and extends the DOM attributes whole.
50
+ - **`MetricWidget.onClick` stays zero-arg**, narrower than the inherited `MouseEventHandler`, because the same handler is wired to Enter/Space where there is no mouse event to hand over. A zero-arg function is assignable to the inherited signature, so callers already passing `(e) => …` keep compiling.
51
+
52
+ Not declared, deliberately: the schema-shaped keys `SchemaRenderer` injects (`schema` / `bind` / `events` / `props` / `ariaLabel` / `ariaDescribedBy` / `dataSource`). None is an HTML attribute name, all seven are destructured out before the spread, and declaring them would re-assert as public contract exactly what PR #4428 stripped from the DOM. They stay in `SchemaHostProps`, intersected in at each component's own signature — accepted so the renderer can inject them, never part of the documented authoring surface.
53
+
54
+ Zero runtime change: no component body was touched, and PR #4428's pins pass untouched.
55
+
56
+ - c1d939f: One `SchemaNode`, and one label vocabulary — the union wins, and labels resolve where the locale lives
57
+
58
+ Two packages published a type called `SchemaNode` and they were not the same type. `@object-ui/core` hand-declared `interface SchemaNode { type: string; … [key: string]: any }`; `@object-ui/types` exported `type SchemaNode = BaseSchema | string | number | boolean | null | undefined`, whose own doc comment names `'Plain string'` a valid node. Both were exported under one name from packages the same consumers import together, so which declaration a call site got depended on which package it happened to import from — #4548's canary measured 19 of 35 errors as exactly that collision. Core's declaration is now a re-export of types', so there is one declaration left to disagree with. Core's entry surface is unchanged: `dist/index.d.ts` is byte-identical across the change.
59
+
60
+ Reconciling it exposed a real defect rather than a mechanical narrowing, which is why the first attempt was withdrawn instead of forced. The spec bridges write `spec.label` — the spec's `I18nLabel`, an INLINE locale map like `{ en: 'Owner', 'zh-CN': '负责人' }` — into `node.label`, and `BaseSchema.label` declared `string`. Under core's old index signature that assignment was invisibly `any`; under one honest `SchemaNode` it is a type error. `BaseSchema.label` and `.description` therefore now accept `string | I18nLabel`, and the two bridge assignments compile with their expressions untouched.
61
+
62
+ Resolution happens at READ time, in the renderer, against the display locale — not at the bridge. Resolving at the bridge was measured unimplementable: it is a plain class method that cannot call a hook, `BridgeContext` declares no locale, and `updateContext()` has zero callers, so a bridge-resolved label would freeze one audience's language into the node tree with no re-translation channel. React's own invalidation re-translates for free at the read site.
63
+
64
+ The widening turned every blind `schema.label`-as-string read into a named compiler error, and that inventory is the audit: it named four sites repo-wide, all one class — the label reaching a React child position, where a map does not render as `[object Object]` but THROWS `Objects are not valid as a React child`, failing the whole subtree. Three are `@object-ui/components` renderers (`filter-builder`, `sidebar-group`, `dropdown-menu`), which now resolve with the spec's own `resolveI18nLabel` against `useDisplayLocale()`. The fourth is `plugin-dashboard`'s `DashboardGridLayout` heading, which resolves with `pickLocalized` against the active UI language — matching the widget-title resolution already in that same component rather than putting two resolvers and two disagreeing locale channels in one render; the two resolvers are limb-for-limb twins with a parity test pinning them.
65
+
66
+ One interface now carries both label vocabularies two properties apart — `label`/`description` are the spec's INLINE map, `ariaLabel` is the KEYED bundle reference — and each accepts the other's shape vacuously. That confusability is objectui#4167's known hazard, inherent to the spec's `I18nLabel` design; both shapes are named with cross-referenced doc comments stating which resolver owns which slot, and a pin asserts the two unions do not collapse into each other.
67
+
68
+ Finally, the spec bridges declare their return type as `BaseSchema` instead of the union. Both bridges end in a single `return node` on an object literal, so the union described nothing real while forcing a narrowing at every read — 272 mechanical errors across five suites in the first round. That change is a type annotation only; the emitted JavaScript is byte-identical.
69
+
70
+ - 36310dc: `formatPercent` groups its output and follows the display locale — the last
71
+ tooltip/cell channel (objectui#4553).
72
+
73
+ PR #4557 threaded the gantt tooltip's number and currency rows and measured that
74
+ the percent row could not follow: `formatPercent(value, precision)` took no
75
+ locale parameter, and its whole body was
76
+ `${percentDisplayValue(value).toFixed(precision)}%`. It built no
77
+ `Intl.NumberFormat` and never reached `formatDisplayNumber` — so unlike its
78
+ siblings it did not render in the MACHINE's locale, it rendered in **no** locale:
79
+ an ASCII decimal mark, never a grouping separator, byte-identical on every
80
+ machine.
81
+
82
+ **English output MOVES, and that is the fix.** Because the function never
83
+ grouped, `1235%` was wrong in en-US too, not only in German. Grouping and locale
84
+ therefore land together:
85
+
86
+ | | before | after |
87
+ | ---------- | ------- | -------------- |
88
+ | en, 1234.5 | `1235%` | `1,235%` |
89
+ | de, 1234.5 | `1235%` | `1.235\u00a0%` |
90
+ | de, 80 | `80%` | `80\u00a0%` |
91
+
92
+ Values below the grouping threshold are unchanged in English (`80%`, `12.5%`,
93
+ `33.33%`), so the move is confined to four digits and up. German changes at every
94
+ magnitude, because the no-break space before the sign is part of the locale's
95
+ percent convention — which is what routing through `Intl` buys over appending a
96
+ literal `%`.
97
+
98
+ The scaling contract is untouched: `percentDisplayValue` still disambiguates a
99
+ fraction-stored percent (`0.8` → 80%) from a whole one, so the list cell and the
100
+ dashboard measure formatter still agree.
101
+
102
+ Consumers are threaded in the same change, the parameter never landing
103
+ speculatively:
104
+
105
+ - **fields** — `PercentCellRenderer`, on BOTH of its paths. Its whole-percent
106
+ branch (`progress` / `completion` fields, which store 0-100 and must skip the
107
+ fraction scaling) was a second bare `toFixed` call; leaving it behind would
108
+ have made one grid internally inconsistent, so both branches now share one
109
+ locale-aware body and differ only in the scaling policy.
110
+ - **plugin-gantt** — the tooltip percent row, completing objectui#4553's switch.
111
+ - **plugin-grid** — the mobile card's percent cell, which sits in the same
112
+ density row as a date cell objectui#4272 had already localized.
113
+ - **plugin-dashboard** — `renderFieldValue`'s percent branch. It is a plain
114
+ function rather than a component, so it takes the locale as an optional fourth
115
+ parameter beside the `tenantCurrency` already threaded that way, and both of
116
+ its callers pass it and declare it in their memo dependency arrays.
117
+
118
+ Bumps follow each package's own `.d.ts` diff, measured in both directions.
119
+ `@object-ui/fields` and `@object-ui/plugin-dashboard` are `minor` on the
120
+ objectui#4272 / PR #4544 precedent — quoted from that changeset: "`@object-ui/fields`
121
+ is `minor` because `formatDateTime`'s new optional parameter is visible in the
122
+ package's entry `.d.ts`; the plugin packages' own `.d.ts` files are
123
+ byte-identical, so their change is module-local." Here `formatPercent` and
124
+ `renderFieldValue` each gain an entry-visible optional parameter, while
125
+ plugin-gantt's and plugin-grid's `.d.ts` files are byte-identical and stay
126
+ `patch`.
127
+
128
+ ### Patch Changes
129
+
130
+ - ee26e65: Analytics: the dimension label net's fetch-and-memo glue is written once, not once per surface
131
+
132
+ PR #4388 (objectui#4330) put the same React glue on two surfaces — the dashboard's `DatasetWidget` and plugin-report's dataset block. The resolution RULES were never duplicated (both call the same `@object-ui/core` helpers), but the wiring around them was: read the object schema through the host's authenticated `apiFetch`, keep the fetched metadata locale-free in state, derive the label maps in a render memo. Two copies meant two statements of the same two bug fixes, which is a drift surface rather than a defect — nothing a user could hit today, filed as objectui#4389 so it was retired deliberately.
133
+
134
+ It is now split along the layer that can actually hold each half. `@object-ui/core` gains the React-free parts — `loadDimensionFieldMeta` (the base-object read composed with the dimension walk), `deriveDimensionLabelMaps` (the locale-applying derivation) and `dimensionOptionTranslator` (binding the bundle resolver to the object that OWNS a terminal field, which for a dotted path is the relationship target). `@object-ui/react` gains `useDatasetDimensionLabels` / `useDatasetDimensionMeta`, the React wiring that cannot live in core, beside the `useViewData` / `useElementDataSource` / `useDiscovery` hooks that already read `SchemaRendererContext` the same way. Both plugins consume it; the dashboard keeps its chart-only per-category colour and category-order derivation layered locally, since a table renders no palette.
135
+
136
+ The card originally proposed `@object-ui/core` as the whole glue's home. That home was disproven by measurement and retired in the card's PM RULING #2: `SchemaRendererContext` is defined in `@object-ui/react`, which depends on core, so core importing it back is a cycle — and core is React-free by declaration, by content, and by the topology in AGENTS.md. objectui#3367 had already ruled this direction for the same family (core-canonical logic, react re-exports).
137
+
138
+ Behaviour is unchanged by construction: same read count, same best-effort fallback, same memoization boundary. The two bug fixes are now stated once and pinned at the shared hook — the read rides the host's authenticated `apiFetch` (objectui#4121, pinned by asserting that a new channel re-issues the read, i.e. that it really is in the effect's deps), and the fetched metadata stays locale-free (objectui#4030 / PR #4324, pinned by switching language at runtime and asserting the labels flip with no second metadata read). All 39 assertions PR #4388 landed across both surfaces pass unchanged, and their files are byte-identical to before.
139
+
140
+ - 5900ac5: Analytics surfaces now run resolved select-option labels through the locale bundle — the chart legend and the related list on one page stop disagreeing
141
+
142
+ A dashboard widget grouped by a `select` field rendered the option's authored English label while the related list beside it rendered the translation. The decisive evidence in objectui#4030 is the stored value `orion`: the chart read `Orion Engineered Carbons`, a string with no resemblance to the value and matching the object's `label` byte for byte. So the analytics path had already RESOLVED the option label — it simply never ran the result through the i18n bundle before display. (`domestic → Domestic` differs from its value by case alone, which is why the first diagnosis, "the report groups by stored value", was wrong.)
143
+
144
+ There is exactly one resolution channel and this change reuses it rather than adding a chart-side dialect: `fieldOptionLabel` from `useObjectLabel`, i.e. `{ns}.fieldOptions.<object>.<field>.<value>` — the convention `@objectstack/spec` names objectui as the reader of, and the one list, form, kanban and record-picker surfaces already translate select options through. The bundle is applied ONCE, at the output of the label net that landed in objectui#4053/#4263, on the shared option list every consumer reads: chart axis and legend, the table/pivot cells of a dotted dimension, that table's CSV export, per-category colours and the declared category order. `@object-ui/core` gains `localizeFieldOptions` (the pure mirror of `translateOptions`), an optional translator on `buildDimensionLabelMap`, and `resolveDimensionFieldMeta` — the same single relationship walk `resolveDimensionFieldOptions` performs, now keeping the object that OWNS the terminal field, because for `crm_account.industry` the bundle key is `crm_account`, not the dataset's base object.
145
+
146
+ Two properties the fix is shaped around. The rows reach this net keyed either way — by stored value when the server did not resolve the dimension, by the English label when it did (ADR-0021) — and the reported screen is the second case, so the map answers to both keys and lands on the same translated display. And identity is untouched: `relabelDimensions` still rewrites display only, so a drilled chart segment clicked as `欧励隆` filters by `orion`, bucket ids and pivot totals keep their raw keys, and an option with no bundle entry (or an `en` console) renders exactly the authored label it renders today.
147
+
148
+ The per-locale work moved from the metadata fetch into the render, so switching language now re-labels in place instead of waiting for a refetch.
149
+
150
+ Not covered, and unchanged here: a LOCAL select dimension on a table/pivot, whose label the server resolves and whose client-side net is deliberately off (objectui#4263), and a dashboard global filter's own field label, which has no object name in its metadata to key a bundle lookup with — tracked on objectui#4030.
151
+
152
+ - 3c6e84c: Dashboard `combo` widgets draw as combos on the dataset path — the dataset owns the data, the author owns the presentation
153
+
154
+ A widget authoring the spec's own combo shape — `series[].type` plus `series[].yAxis: 'left'|'right'` and two `yAxis` entries — rendered as two bar series on one shared axis. Measured in the DOM: 2 bars, 0 lines, 1 y-axis, where 1 bar, 1 line and 2 axes were authored, so a percentage measure was plotted against a raw count's scale.
155
+
156
+ Two halves caused it, and fixing either alone leaves a worse state than before. `CHART_TYPE_MAP` had no `combo` entry, so a `combo` widget fell through its `?? 'bar'` default — bars, whatever the series said. And `chartConfigPresentation` refused to forward `series` / `xAxis` / `yAxis` at all, on the stated grounds that they are derived from the dataset selection, so the per-series mark and the axis binding could never reach the renderer even once the family resolved.
157
+
158
+ That belief was half right. The dataset does own the series MEMBERSHIP — which columns become series, which rows, which buckets — and it still does: an authored entry naming a measure the dataset did not select is ignored, and a derived series the author said nothing about keeps the family default. What the dataset never owned is the PRESENTATION carried on those same objects: the per-series mark, its left/right axis binding, label, colour, stack, and the axis definitions' title, format, min, max, step, grid and position. Those are the author's, and they now merge onto the derived bindings by name/key match with the explicit binding winning — one merge function, not a spread per attribute. The split runs through the two binding keys: `ChartSeries.name` and `ChartAxis.field` name a column and stay with the dataset; everything else on the object travels.
159
+
160
+ This is objectui#2880's S2 rule, which PR #2883 landed in `ObjectChart` and which the dataset path never carried over. Dropping `ChartAxis.field` on the way through is what makes forwarding the axes safe rather than merely guarded: it is the one key by which an authored axis could have named a series, since the renderer synthesises series from `yAxis[].field` when a chart declares none.
161
+
162
+ Two consequences beyond the reported bug. A non-combo widget can now declare one line series and get the combo the renderer already knew how to derive from disagreeing series types. And a `compareTo` overlay inherits its own measure's mark and axis, so the comparison of a bar-on-the-left measure no longer draws as a line on the right the moment the chart becomes a combo.
163
+
164
+ Dashboards that never authored `chartConfig.series` or `chartConfig.yAxis` emit exactly what they emitted before.
165
+
166
+ - 0bf3f44: `DashboardRenderer`'s widget grid now passes only whitelisted DOM props to its container (objectui#4432)
167
+
168
+ `view:dashboard` resolves to this component, so `SchemaRenderer` handed it the dashboard node's own keys, the contents of the node's `props` container, the ARIA it resolved and the host's trailing props — and every key the component did not destructure was spread raw onto the grid container. React writes unknown lowercase attributes through in silence and stringifies object values, so the failure was invisible. Measured through the real SDUI path: **13 non-DOM attributes**, including `events="[object Object]"`, `props="[object Object]"` and a camelCase `arialabel` sitting next to the resolved `aria-label`, so the element carried each ARIA value twice under two spellings — one of them meaningless to assistive technology.
169
+
170
+ The container is now consume-or-whitelist per objectui#4425 phase 2: only `toDomProps`' output reaches the element, and it is spread FIRST so the component's own computed attributes stay authoritative. The resolved `aria-label` / `aria-describedby`, `role`, `id`, `tabIndex`, `className` and the `data-*` family still arrive — dropping them would have been an accessibility regression dressed as a leak fix, so the new pin asserts the delivered set exactly, not just the absent one. Both layout branches are covered: the responsive desktop grid and the mobile stack spread the same props onto the same host element.
171
+
172
+ Three behaviours move with the spread, all of them consequences of a trailing spread that used to override the component's own computed props:
173
+
174
+ - **`onClick` now has one carrier.** It is a declared DOM pass-through key AND this container computes a design-mode background handler, and the old spread let the incoming handler replace the computed one — so a host that passed `onClick` silently lost background deselection. Both run now, container affordance first. An authored non-function `onClick` (SDUI spells click behaviour `events: { onClick }`, which is data and is dropped) is ignored instead of handed to React, which used to throw on it.
175
+ - **An authored `style` no longer replaces the computed grid layout.** `style` is not in the SDUI pass-through set, and this container computes its own `gridTemplateColumns` / `gridAutoRows` / `gap`; an authored `style` used to overwrite all of it and collapse the grid.
176
+ - **An authored `data-user-actions` no longer overrides the value computed from the `userActions` prop.** The `data-*` family still passes the whitelist; only this one collision with a computed attribute resolves the other way now.
177
+
178
+ The injected `disabled` verdict is also dropped rather than forwarded. Nothing in this component ever read it: it only became a `disabled` attribute on a container element that has no such attribute, which is the leak, not a behaviour.
179
+
180
+ - eb7f586: Dashboard dataset measures follow the display locale (objectui#4566).
181
+
182
+ `formatMeasure` and `formatDimensionValue` in `@object-ui/core` formatted every
183
+ value with a bare `undefined` locale tag at all three of their `Intl` sites.
184
+ `undefined` is not "the user's locale", it is the MACHINE's — neither of the
185
+ repo's two locale channels. A German session read a dashboard KPI as `1,234.5`
186
+ next to a grid cell rendering the same number as `1.234,5`, and inverted
187
+ separators read as a different number, not as an unstyled one.
188
+
189
+ Both functions take the display locale as a new OPTIONAL LAST parameter, and
190
+ `DatasetWidget` threads `useDisplayLocale()` into every site it formats through:
191
+ the KPI, the grouped table's measure and dimension cells, and the cross-tab's
192
+ header labels and cells.
193
+
194
+ **English output does not move**, and that is the discriminator against the
195
+ sibling fix. These sites already went through `Intl` with default grouping, so
196
+ the only thing that changes is WHOSE locale is used:
197
+
198
+ | | before | after |
199
+ | ----------------- | ----------- | --------------------- |
200
+ | en, 1234.5 `0.0` | `1,234.5` | `1,234.5` (unchanged) |
201
+ | de, 1234.5 `0.0` | `1,234.5` | `1.234,5` |
202
+ | de, 1234.5 EUR | `€1,234.50` | `1.234,50 €` |
203
+ | de, 0.6083 `0.0%` | `60.8%` | `60,8%` |
204
+
205
+ Contrast objectui#4553, where `formatPercent` had never grouped at all and
206
+ moving en `1235%` → `1,235%` WAS the fix.
207
+
208
+ Omitting the new argument reproduces the previous output byte for byte, so
209
+ callers that do not thread a locale yet are unaffected.
210
+
211
+ Two behaviours are deliberately preserved rather than "improved" alongside the
212
+ locale fix, both measured:
213
+
214
+ - **Integers stay verbatim.** The integer branch renders no separator and no
215
+ decimal mark, so a locale has nothing to change there — and routing it through
216
+ `Intl` WOULD change it (a locale with its own numbering system re-digits it,
217
+ and `1e21` expands to 22 digits).
218
+ - **The percent sign stays a literal suffix.** `Intl`'s `style: 'percent'`
219
+ re-scales by 100, and that round trip loses precision at the top of the range
220
+ (en `100,000,000,000,000,000,000,000%` becomes
221
+ `99,999,999,999,999,990,000,000%`). The consequence — a German list cell
222
+ writing `1.234,5 %` with a no-break space where a dashboard measure writes
223
+ `1.234,5%` — is filed separately rather than smuggled in behind a locale fix.
224
+
225
+ `@object-ui/core` is `minor` because two of its ENTRY exports gained an optional
226
+ parameter (measured in the built `.d.ts`). `@object-ui/plugin-dashboard` is
227
+ `patch`: its published declarations are unchanged — `buildPivot`'s new optional
228
+ parameter is internal, as that function is not on the package's `exports`
229
+ surface.
230
+
231
+ - 54d34d2: A dashboard chart's null-value bucket now reads the app's language instead of the English `(None)`
232
+
233
+ `buildChartSeries` groups rows whose category value is `null` under a labelled bucket, so the group draws as a bar instead of vanishing off the axis (objectui#4466). The label comes from the caller: `@object-ui/core` is React-free, cannot read the locale bundle, and falls back to the English constant `(None)`. `ObjectChart` passes its resolved label and localizes; `DatasetWidget` called the same helper with no options, so a dashboard widget in a zh app labelled the bucket `(None)` while the standalone chart one panel over labelled it `(未指定)`. It now passes `chart.nullCategory` from the i18n channel, which every locale pack already carries.
234
+
235
+ The same label goes to `findChartSeriesRow`, and that half is what keeps the bar clickable. That helper is the inverse map behind segment-click drill-through: it compares the clicked category against its own copy of the bucket label, defaulting to the same English floor. Passing the localized label to only the forward call would draw a bar reading `(未指定)` while the drill matched `(None)` — the click resolves to no row and the drawer never opens, which is a worse outcome than the untranslated word this fixes. Both calls now read one binding, so they cannot drift apart.
236
+
237
+ Nothing else moves: non-null categories chart and drill exactly as before, an `en` app still reads `(None)` (now via its locale pack rather than the hardcoded floor), and a widget over data with no null group is untouched.
238
+
239
+ - ee7a68d: `DatasetWidget`'s option-color / dimension-label probe now rides the host's
240
+ authenticated fetch (`SchemaRendererContext.apiFetch`) instead of the bare global
241
+ `fetch`.
242
+
243
+ The one metadata read the effect makes — `GET /api/v1/meta/object/{object}` — went
244
+ out on the global `fetch`, so in a hosted console it skipped whatever the host
245
+ supplies on that channel (Authorization / tenant headers, base-URL rewrite,
246
+ draft-preview params). A bearer-token session carries its credential in a header
247
+ rather than a cookie, so `credentials: 'include'` alone left this read
248
+ unauthenticated. The effect is best-effort and swallows every failure, which made
249
+ the symptom silent: a dataset chart's semantic per-category colors and its
250
+ dimensions' value → label maps simply never applied, and the widget fell back to
251
+ the positional theme palette and the raw stored values on the axis.
252
+
253
+ Standalone embeds are unaffected — with no provider (or a provider that supplies no
254
+ `apiFetch`) the probe still uses the global `fetch`, the same documented fallback
255
+ `useRecordEditable` and `provider: 'api'` view sources use.
256
+
257
+ This is the `plugin-dashboard` twin of the same fix made to `plugin-charts`'
258
+ `ObjectChart`.
259
+
260
+ - 436681e: fix(dashboard): resolve a dotted dimension's labels on table and pivot dataset widgets
261
+
262
+ A dataset widget's client-side dimension-label safety net returned early for
263
+ `table` / `pivot` / metric widgets, so a DOTTED dimension (`crm_account.industry`)
264
+ rendered the raw stored enum (`education`) there — the same symptom objectui#4053
265
+ fixed for charts, on the widget types its fix did not reach.
266
+
267
+ The early return stays for LOCAL dimensions, which is what made it correct in the
268
+ first place: on a table the server resolves those labels (ADR-0021), so running
269
+ the client net for them would be a second resolution of an already-resolved
270
+ value. It now opens only for dotted paths — the case the server is silent on too —
271
+ reusing the existing `resolveDimensionFieldOptions` walk unchanged, multi-hop
272
+ paths included. A table with no dotted dimension resolves nothing and issues no
273
+ metadata read at all, so those widgets render byte-identically.
274
+
275
+ A pivot's marginal totals take the same relabel as its rows, because their bucket
276
+ ids are re-derived from the dimension values that the headers are built from; the
277
+ CSV export follows the table's cells for the same reason. Drill-through still
278
+ filters by the stored value — the relabel preserves row order and count, so the
279
+ raw rows it indexes stay aligned.
280
+
281
+ Metric widgets are unaffected by design: that branch renders one measure value
282
+ and its header label and puts no dimension value on screen, so it has nothing to
283
+ resolve.
284
+
285
+ - 613b167: A dataset dimension on a dotted relationship path now renders its option labels instead of the raw stored enum
286
+
287
+ A `DatasetDimension` whose `field` is a relationship path (`crm_account.industry`) got no select-option resolution at all: the chart plotted `education`, `finance`, `manufacturing` — the database column, unresolved — while the **same underlying field** reached as a **local** dimension rendered `Education`, `Finance`, `Manufacturing` beside it on the same dashboard. Nothing errored, so the widget just quietly showed database enum values to end users; on a non-English deployment those are words that appear nowhere else in the UI, since every form and list shows the translated label.
288
+
289
+ The label lookup read options as `baseObject.fields[<path>]`, which only ever matches the local spelling. For a dotted path the options live on the **related** object, so the lookup missed and the renderer fell through to the stored value.
290
+
291
+ The object-resolution step of that one lookup now walks the path: each segment before the last must be a declared relationship (`lookup` / `master_detail`, target read from `reference` / `reference_to` / `referenceTo` / `reference_to_object`), and the terminal field's options are read off the object that actually owns it. This is the same lookup for both spellings rather than a dotted-path variant beside it — a single-segment path never enters the walk and resolves exactly as before, so the local and joined paths cannot drift apart. Multi-hop paths (`crm_account.owner.department`) resolve too, which is the shape the dataset designer already emits.
292
+
293
+ Hops ride the caller's existing `GET /meta/object/:name` channel — the same authenticated read that fetched the base object — so no new fetch layer is introduced, and objects are fetched once per resolution even when several dimensions share a prefix. Every failure stays best-effort: a segment that is not a relationship, a target that cannot be loaded, or a terminal field with no options yields no mapping and the raw value survives, exactly as it does today.
294
+
295
+ Applies to both surfaces that carried this lookup: dashboard dataset widgets (`DatasetWidget`) and the chart view's dataset path (`ObjectChart`).
296
+
297
+ Scope: this ends at "the label is in hand". Whether that label then passes through the i18n bundle is a separate gap tracked upstream as objectstack#5076.
298
+
299
+ - bb68488: An inline per-locale label now renders its locale's string at the thirteen read sites the `@objectstack/spec` 17.0.0-rc.6 bump exposed
300
+
301
+ rc.6 widened `I18nLabel` from `string` to `string | Record<string, string>`, so an author may write `label: { en: 'Owner', 'zh-CN': '负责人' }` anywhere the spec accepts a display label. PR #4169 repaired eight such sites; these thirteen were invisible to it because the five packages involved build through vite/rolldown, so `turbo run build` never type-checks their sources — only `turbo run type-check` does. All thirteen are now resolved through a shared resolver against a real locale, and `turbo run type-check` is 78/78 with zero errors.
302
+
303
+ | package | what an author can now write and see |
304
+ | ----------------------------- | --------------------------------------------------------------------------------------- |
305
+ | `@object-ui/layout` | `NavigationArea.label` — the sidebar area switcher's button and its tooltip |
306
+ | `@object-ui/plugin-list` | `ViewTab.label` — the inline pill row, and the mobile dropdown's trigger and menu items |
307
+ | `@object-ui/plugin-dashboard` | `DashboardWidget.title` — the widget card heading and its `title` attribute |
308
+ | `@object-ui/plugin-designer` | `DashboardWidget.title` — the widget card and the preview tile |
309
+ | `@object-ui/app-shell` | `ActionParam.label` **and** each `ActionParam.options[].label` |
310
+
311
+ **Patch, not minor, in every case: no public surface changes meaning.** Every entry above is a read site that previously could only be reached with a value the type system rejected, so no caller's working code changes behaviour. `@object-ui/app-shell` is the only package with an exported-type change and it is purely additive on the authoring side — `RawActionParam.label` and `RawActionParam.options[].label` widen to `I18nLabel` (they accept strictly more), `ResolveActionParamsContext` gains an optional `locale`, and the new `RawActionParamOption` names the authoring shape that was previously spelled with the resolved one. What `resolveActionParams` **emits** is unchanged: `ActionParamDef.label` and its options' labels are still plain `string`s.
312
+
313
+ Two consequences worth knowing:
314
+
315
+ - **The dashboard designer's title input is deliberately read-only for a map-valued title.** Resolving a per-locale map into a single-line input and writing `e.target.value` back would collapse every other locale on the first keystroke, so the write is guarded and an inline map survives an unrelated edit-and-save round trip untouched — the same conservative branch #4169 took for `DashboardWidgetInspector`. What Studio should actually offer for authoring a per-locale label is objectui#4163 part 2, which is unclaimed and pending design.
316
+ - **`@object-ui/layout` resolves at the spec's `en` default, not the viewer's language.** That package carries no i18n dependency by design (its whole i18n story is injection), and `AppSchemaRendererProps` exposes no locale to thread. The choice and what would change it are documented at the call site.
317
+
318
+ - 326a70f: Analytics: a LOCAL select dimension on a table / pivot widget — and on a dataset-bound report — now renders its option label through the locale bundle
319
+
320
+ A dashboard table grouped by a select field showed `Domestic` on a zh-CN console while the related list on the same screen showed 国内. The value was never untranslated by accident: the server resolves that dimension's display label (ADR-0021) and hands the row over carrying the object's AUTHORED English label. The locale bundle is keyed by the option's stored VALUE (`{ns}.fieldOptions.<object>.<field>.<value>`), so translating one needs the option LIST — and the table path deliberately loaded no object metadata at all, which is why objectui#4030 / PR #4324 fixed charts and dotted dimensions and left this half open.
321
+
322
+ Table, pivot and the dataset report block now take the one metadata read that gives the bundle something to translate against, and feed it to the SAME seam #4324 landed (`resolveDimensionFieldMeta` → `localizeFieldOptions` / `buildDimensionLabelMap` → `relabelDimensions`). No second resolution dialect: the map carries both the stored value and the authored label as keys, and the relabel is value-wise and idempotent, so a value the server already resolved lands on the same display it would have from the raw value. Cells, pivot headers on both axes, the server's marginal totals, the CSV export and a report's embedded chart all read the one map, which is what keeps a subtotal's bucket lookup meeting the header it belongs to.
323
+
324
+ Untranslated apps are unchanged by construction: with no bundle entry the display equals the authored label, no key is emitted, and the rows come back by identity. Identity keys stay untranslated — a drilled row or cell still filters records by the values the server sent, and measures still export as bare numbers.
325
+
326
+ This deliberately amends the acceptance boundary objectui#4263 landed ("a local-only table issues no metadata read"), which was ruled for label RESOLUTION before the read had a second consumer. The pins that stated it are rewritten in place, in the same change, and say so.
327
+
328
+ - 7e4f0e5: fix(dashboard,i18n): KPI cards and dashboard filters resolve authored labels instead of dropping them (#4032)
329
+
330
+ A `type: 'metric'` dashboard widget rendered raw English while every other widget
331
+ type on the same dashboard rendered the translation, and dashboard filter chips
332
+ rendered `[object Object]` or the raw stored value. Both come from the same
333
+ cause: authored labels reaching a render site that could not read the
334
+ vocabulary `@objectstack/spec` actually admits.
335
+
336
+ - **KPI cards rejoin the widget translation channel.** The self-contained
337
+ `metric` branch built its own label from the raw `widget.title`, so the
338
+ `{ns}.dashboards.{dash}.widgets.{id}.title` value the renderer had already
339
+ resolved was computed and thrown away. It now reads that channel like every
340
+ other widget header.
341
+ - **The three private `resolveLabel` copies** (`DashboardRenderer`,
342
+ `MetricWidget`, `MetricCard`) are gone. Each read the retired
343
+ `{ key, defaultValue }` key-reference form and ended `defaultValue || key`, so
344
+ handed the inline per-locale map the spec admits today they returned nothing —
345
+ a KPI card with a map title rendered the literal string `metric`. All three
346
+ now use `pickLocalized`, the resolver already used for this vocabulary
347
+ elsewhere in the package.
348
+ - **Dashboard filter labels and static option labels resolve per locale.**
349
+ `DashboardFilterDef.label` widens to `string | I18nLabel`, the filter bar
350
+ resolves before rendering (fixing `[object Object]: All` in the trigger, and
351
+ in `aria-label` / `placeholder`), and the `def.label || def.name` gate now
352
+ tests the RESOLVED string — an object is always truthy, so it never reached
353
+ the fallback before.
354
+ - **Option labels are no longer discarded.** `normalizeFilterOptions` coerced a
355
+ map label to the raw stored value in every locale, English included, so
356
+ `{ value: 'domestic', label: { en: 'Domestic', … } }` displayed as `domestic`.
357
+ The pair shape is still normalized; the label vocabulary is preserved for the
358
+ render side to resolve.
359
+ - **`DashboardComponentSchema.globalFilters` is bound to the spec's
360
+ `GlobalFilter`** instead of restated by hand. The restatement was both too
361
+ narrow (`label?: string`, which is what made these read sites invisible to
362
+ `tsc`) and too wide (it declared a bare-string option shorthand the spec
363
+ rejects at publish).
364
+
365
+ Plain-string labels are unaffected and render byte-identically.
366
+
367
+ - 306c101: KPI cards no longer write their own schema onto the DOM — `MetricWidget` and
368
+ `MetricCard` keep `SchemaRenderer`'s schema-shaped props out of the `...props`
369
+ spread (objectui#4357).
370
+
371
+ Both components are two things at once: an SDUI block reached through
372
+ `SchemaRenderer`, and a plain React component a host may render directly. The
373
+ React half wants a `...props` spread on its root so callers can pass `aria-*`,
374
+ `data-*`, `id`, `role`. The SDUI half means that spread also received the node's
375
+ own metadata — and React writes unknown lowercase attributes straight to the DOM,
376
+ stringifying object values. Every KPI card therefore carried
377
+ `schema="[object Object]"`, and a widget authored with events, a binding or a
378
+ props container carried `events="[object Object]"`, `bind="data.revenue"` and
379
+ `props="[object Object]"` beside it.
380
+
381
+ Seven props were measured arriving at the call site that are not HTML attribute
382
+ names — `schema`, `events`, `props`, `bind`, `ariaLabel`, `ariaDescribedBy` (the
383
+ last two are the camelCase authored forms of ARIA the renderer already emits in
384
+ their dashed spelling) and `dataSource`. They are destructured out; the spread
385
+ survives untouched for everything that IS a DOM attribute: `id`, `name`, `role`,
386
+ `disabled`, `aria-*`, `data-*`, `className`. Nothing else about the render moves
387
+ — no text, no class, no element.
388
+
389
+ `dataSource` is the one that only a live dashboard shows. It is not a schema key
390
+ (the renderer strips the schema's own `dataSource` binding by name); it is the
391
+ injected adapter `DashboardRenderer` hands its `SchemaRenderer` call, which
392
+ arrives through the renderer's trailing props. Every fixture in this package
393
+ renders without an adapter, so it read `undefined` and wrote nothing — while
394
+ every deployment that actually loads data put `datasource="[object Object]"` on
395
+ the card. The pin renders a dashboard with an adapter so the case that only
396
+ production had is now a test.
397
+
398
+ The cost of this was never visible; it was that the defect poisoned the
399
+ assertion this area attracts. objectui#4163 pins
400
+ `not.toContain('[object Object]')` on the dashboard grid, and objectui#4032
401
+ wanted the same pin on the metric path but could not write it: the card carried
402
+ the attribute before and after any i18n fix, so the container assertion was red
403
+ for a reason unrelated to labels and the tempting repair was to loosen it. That
404
+ suite asserted on the card heading instead, with a comment. The workaround is
405
+ now removed and the container assertion is back.
406
+
407
+ The exported `MetricWidgetProps` / `MetricCardProps` interfaces are unchanged —
408
+ the components' accepted props widen only by the optional, ignored
409
+ `SchemaHostProps` keys, so no consumer type narrows.
410
+
411
+ - 45e1949: Numbers render in the user's locale, and a `Field.number` year is no longer `2,026`
412
+
413
+ Every numeric field the console rendered went through an `Intl.NumberFormat` built with the locale hardcoded to `en-US` and `useGrouping` never set. Two defects rode in that one construction: a `zh-CN` or `de-DE` console still grouped and pointed decimals the US way, and a four-digit **year** stored as `Field.number({ scale: 0 })` rendered as `2,026` — in every locale, with no field property able to turn it off. Apps had been converting year columns to `Field.text` to escape it, permanently trading numeric comparison, range filters and dataset dimension types for a display detail.
414
+
415
+ The construction had been copied into five places — the number cell renderer, the currency cell renderer, the `CurrencyField` widget, the compact `formatNumber` helper, and the dashboard `MetricWidget` — so fixing any one surface never changed the answer. They now share one formatter, `formatDisplayNumber` in `@object-ui/i18n`, which owns the locale and the grouping policy together, plus one locale resolver, `useDisplayLocale`.
416
+
417
+ `useDisplayLocale` composes the two locale channels this repo already had rather than adding a third: the tenant's regional default (`useLocalization().locale`, ADR-0053) when an org has configured one, otherwise the active UI language (`useObjectTranslation().language`) so grouping and decimal marks follow a language switch. That second step is what covers the case the report was measured in — a fresh database, where the tenant localization endpoint has no locale to give.
418
+
419
+ Grouping is now suppressed when a field declares `scale: 0` and carries no currency, which is what makes years, fiscal periods and other ordinals render plainly. This is an **interim default** with an accepted cost: a large scale-0 _count_ loses its separators too. It holds only until the spec gains an authorable presentation hint, which is being specified separately, contract-first; when that lands it overrides this heuristic.
420
+
421
+ Three surfaces deliberately keep their separators, because a zero-decimal display there does not come from a field declaration: the dashboard `MetricWidget` (its decimals are parsed from a numeral.js format pattern, and its own contract calls the separators load-bearing — "`1,930,000` not `1930000`"), the `element:number` aggregate renderer, and every currency path including amounts whose currency code could not be resolved. An **undeclared** `scale` also keeps grouping — absent means "decimals unknown", not "integer".
422
+
423
+ `formatCurrency`, `formatCompactCurrency` and `formatNumber` each take a new optional trailing `locale` argument. Existing calls are unaffected; omitting it now follows the runtime default rather than forcing US conventions.
424
+
425
+ - 844ed3a: Dashboard global filters sourced from `optionsFrom` now commit the RAW value instead of the display label.
426
+
427
+ The option source is a server GROUP BY whose response carries both forms of every grouped value: `rows` holds the resolved display labels (`{status: 'In Review'}`) and the index-aligned `drillRawRows` holds the raw stored values (`{status: 'in_review'}`). `DashboardFilterBar` read the value off `rows`, so picking an option broadcast a label no record carries into every bound widget's `runtimeFilter` and each widget repainted to "No rows". Options are now paired index-wise — value from `drillRawRows`, label from the displayed row — mirroring how the drill path has always read the same response. The trigger still displays the label, and statically declared `options` are unaffected. When the raw rows are absent, disagree in length with `rows`, or carry no such field, the previous read is kept rather than guessing at a pairing.
428
+
429
+ - 49ae9f4: Pivot buckets encode an empty dimension value as JSON `null`, so it no longer collides with a row whose value is literally the placeholder character
430
+
431
+ objectstack#5473 / objectstack#5665 replaced the pivot's delimiter-joined ids
432
+ with `JSON.stringify`, because every delimiter that had been tried — an empty
433
+ string, a plain space, a control character — assumed the data would not contain
434
+ it, and each assumption failed on ordinary data. This closes the last place the
435
+ same assumption survived: the ids were JSON, but the VALUES fed into them were
436
+ spelled `String(row[d] ?? '∅')`, so an absent dimension value became the
437
+ ordinary string `"∅"` and shared a bucket with a row whose value literally is
438
+ that character (U+2205). One bucket, later row overwriting the earlier one — the
439
+ cell showed a different row's measure, the overwritten row was unreachable, and
440
+ drill-through followed the same wrong index into the wrong records, all without
441
+ an error. The trigger requires that character to appear as a dimension value, so
442
+ this is the assumption being removed rather than a defect users hit today.
443
+
444
+ An empty value now encodes as JSON `null`, which `JSON.stringify` renders as a
445
+ bare `null` that no string can spell. The normalization lives in
446
+ `@object-ui/core` as `pivotDimensionValue` (absent ⇒ `null`, everything else ⇒
447
+ its string form) rather than at each call site, because a placeholder spelled by
448
+ a caller is a placeholder that can collide again — which is exactly how this one
449
+ survived the previous fix. `pivotBucketId` accepts `Array<string | null>`
450
+ accordingly; that is a widening, so existing callers passing `string[]` are
451
+ unaffected.
452
+
453
+ Both renderers' bucket keys move together, which the fix requires: a bucket id
454
+ and the subtotal map keyed by it are built from the same expression, so changing
455
+ one alone would split the headers while the subtotal map still merged, landing
456
+ every column subtotal under the wrong header. In `plugin-dashboard`'s
457
+ `DatasetWidget` that is the row bucket id, the column bucket id, the cell key,
458
+ and both the `rowTotalById` and `colTotalById` lookups; in `plugin-report`'s
459
+ `DatasetReportRenderer` the single `bucketId` helper already feeds all five.
460
+
461
+ The dashboard's column bucket id also stops being a bare string and becomes a
462
+ one-element tuple through the same shared encoder. It was the one id in the
463
+ family still built by hand, on the reasoning that a single value needs no
464
+ boundary — true of the boundary, false of everything else the encoder does, and
465
+ it is why the across axis kept carrying this collision after the row ids were
466
+ fixed.
467
+
468
+ No display change: these placeholders only ever entered ids, never labels. An
469
+ unset dimension still renders through `formatDimensionValue` exactly as before,
470
+ and data containing neither an absent value nor that character buckets
471
+ identically — the ids are opaque lookup keys, never parsed back into a value,
472
+ never shown, never persisted.
473
+
474
+ - a3ae404: fix(components,plugin-dashboard): a static-data `table` widget renders instead of crashing
475
+
476
+ A dashboard widget authored as `{ type: 'table', options: { data: [ … ] } }` fell into the
477
+ error boundary with "Maximum update depth exceeded" the moment its tile re-rendered, while
478
+ every chart family on the identical static surface rendered clean.
479
+
480
+ - `data-table` no longer re-renders itself to death. Its `columns` / `data` fallbacks are
481
+ module-scope empties instead of per-render array literals, and the prop→state column sync
482
+ re-seeds on a value change rather than on a new identity — so a consumer that derives its
483
+ columns each render (which both dashboard surfaces do) costs the table nothing.
484
+ - Both dashboard surfaces now give the static table the `columns` key `DataTableSchema`
485
+ requires, derived from the rows when the author declared none — the same derivation the
486
+ `provider: 'object'` half of the widget family already performed. Previously such a table
487
+ drew one empty row per record: no headers, no cells.
488
+ - `DashboardGridLayout` reads an authored `options.data` ARRAY for its static table, which
489
+ its `widgetData?.items` expression resolved to `[]`. `DashboardRenderer` had the arm all
490
+ along.
491
+
492
+ - 3f5f87c: `SchemaRenderer` states its real contract — a typed, required `schema` and a deliberate forwarding surface
493
+
494
+ `SchemaRenderer` is the renderer loop: every registered SDUI component is rendered through it. It handed `forwardRef` a props type of `{ schema: SchemaNode } & Record<string, any>`, which puts `string` into `keyof Props`, so `'ref' extends keyof Props` was always true, React's `PropsWithoutRef` took its `Omit` branch, and `Omit` over a type carrying a string index signature keeps only the index signature. Every declared prop was erased. Measured on the pre-fix source: `keyof ComponentProps<typeof SchemaRenderer>` was `string` and `ComponentProps<typeof SchemaRenderer>['schema']` was `any`, while the type argument went on declaring `SchemaNode`. The other half is the same defect seen from the call site — `<SchemaRenderer />` with no schema at all, `<SchemaRenderer schema={12345} />`, and an arbitrary misspelled prop each type-checked in silence. This is objectui#4422 / PR #4438's trap in the most central component in the repo, spelled `Record<string, any>` rather than `[key: string]: any`, which is why every previous sweep's grep and both shipped guards' detector reported the site as clean.
495
+
496
+ Graded **minor, not major**, on objectui#4528's reasoning: the type argument has always DECLARED `schema`; the index signature erased it from the resolved type, and restoring what the declaration documents is a fix to the published contract rather than a contract break.
497
+
498
+ **The forwarding surface is kept, deliberately.** This component forwards every prop it does not read to the component the schema names, resolved at runtime from a plugin-extensible registry — `packages/react/README.md` documents exactly that, and `@object-ui/components`' form renderer consumes the `onSubmit` it shows being forwarded. Closing that surface would state a false contract and would force every leaf plugin's props into this package. So the two halves are separated: the `forwardRef` type argument is the honest `SchemaRendererProps`, with no index signature for `PropsWithoutRef` to collapse, and the open surface is stated once in an explicit export annotation, which nothing routes through `Omit`. The published `.d.ts` shows the erasure disappearing: `ForwardRefExoticComponent<Omit<{ schema: SchemaNode } & Record<string, any>, "ref"> & RefAttributes<any>>` becomes `ForwardRefExoticComponent<SchemaRendererProps & Record<string, any> & RefAttributes<any>>`.
499
+
500
+ `SchemaRendererProps.schema` is declared as `BaseSchema | string | null | undefined` — what this component actually handles. It previously declared `@object-ui/core`'s `SchemaNode` interface, which requires `type: string` and so contradicted the component's own early returns for strings and nullish, while every caller held `@object-ui/types`' wider union. The erasure hid that mismatch completely.
501
+
502
+ **One declared behaviour change.** A non-object, non-string primitive schema now renders as its own text. It previously fell through to the shallow copy `{ ...schema }`, which spreads a primitive to an empty object, lost the `type` the renderer then looked up, and surfaced the red "Unknown component type: undefined" box — an accident of the spread rather than a decision. The declared props type excludes `number` / `boolean` so no author is invited to pass them; the runtime handling is defence-in-depth for untyped callers and stored metadata. Strings, `null`, `undefined`, `0` and `false` render exactly as before, and an object naming an unregistered type still gets the error box; all four are pinned.
503
+
504
+ Latent defects the erasure had been hiding, each surfaced by the repo-wide type-check and fixed at its call site: `DashboardRenderer` cast its widget schema to `Record<string, any>`, dropping the `type` every branch of `getComponentSchema` sets; `DashboardGridLayout`'s equivalent now states its return type instead of inferring a union that admitted a shape with no `type`; and `ReportViewer` handed a section's `content` array to the renderer whole, so a multi-node section rendered the unknown-component box instead of its content — arrays are mapped rather than widened into the renderer's declared input.
505
+
506
+ A repo-wide structural guard replaces the two per-package siblings' blocked direction: it judges every `forwardRef` in `packages/*/src` (219 sites) and its detector resolves `Record<string, …>` and `string`-keyed mapped types in addition to literal index signatures — the spelling the previous detector went blind on. It judges the type argument only, where an index signature is an accidental eraser, and never an export annotation, where one is a stated contract.
507
+
508
+ - Updated dependencies [0e67b53]
509
+ - Updated dependencies [ceccdcf]
510
+ - Updated dependencies [d6e5124]
511
+ - Updated dependencies [debad27]
512
+ - Updated dependencies [dc2aa3e]
513
+ - Updated dependencies [ee66e2e]
514
+ - Updated dependencies [e2e6360]
515
+ - Updated dependencies [ee26e65]
516
+ - Updated dependencies [5900ac5]
517
+ - Updated dependencies [932cbcd]
518
+ - Updated dependencies [734d186]
519
+ - Updated dependencies [f650253]
520
+ - Updated dependencies [3d9769a]
521
+ - Updated dependencies [8f85f8b]
522
+ - Updated dependencies [d0c3b26]
523
+ - Updated dependencies [3fc2971]
524
+ - Updated dependencies [aca27fa]
525
+ - Updated dependencies [dde7283]
526
+ - Updated dependencies [f7c6430]
527
+ - Updated dependencies [4dadf0d]
528
+ - Updated dependencies [ae10a01]
529
+ - Updated dependencies [0f21348]
530
+ - Updated dependencies [d2e2caf]
531
+ - Updated dependencies [92876f0]
532
+ - Updated dependencies [f279deb]
533
+ - Updated dependencies [4b70d28]
534
+ - Updated dependencies [eb7f586]
535
+ - Updated dependencies [e901131]
536
+ - Updated dependencies [ebb4e0e]
537
+ - Updated dependencies [3a9021e]
538
+ - Updated dependencies [d9d3463]
539
+ - Updated dependencies [2a40f69]
540
+ - Updated dependencies [bec3e14]
541
+ - Updated dependencies [613b167]
542
+ - Updated dependencies [b4d3c22]
543
+ - Updated dependencies [1f9b905]
544
+ - Updated dependencies [8f60d73]
545
+ - Updated dependencies [cb13400]
546
+ - Updated dependencies [828549a]
547
+ - Updated dependencies [e1ade8f]
548
+ - Updated dependencies [bc64bfe]
549
+ - Updated dependencies [abb0f81]
550
+ - Updated dependencies [38ab505]
551
+ - Updated dependencies [3e19fe7]
552
+ - Updated dependencies [bb58d1d]
553
+ - Updated dependencies [433ff9f]
554
+ - Updated dependencies [5cc847c]
555
+ - Updated dependencies [e7663f2]
556
+ - Updated dependencies [fa21254]
557
+ - Updated dependencies [33c32bf]
558
+ - Updated dependencies [66fb4fa]
559
+ - Updated dependencies [b953a97]
560
+ - Updated dependencies [d7f3e30]
561
+ - Updated dependencies [6d641c9]
562
+ - Updated dependencies [7e4f0e5]
563
+ - Updated dependencies [a84385b]
564
+ - Updated dependencies [45e1949]
565
+ - Updated dependencies [92250d6]
566
+ - Updated dependencies [c1d939f]
567
+ - Updated dependencies [58bebf6]
568
+ - Updated dependencies [36310dc]
569
+ - Updated dependencies [52d878a]
570
+ - Updated dependencies [405e808]
571
+ - Updated dependencies [49ae9f4]
572
+ - Updated dependencies [a3ae404]
573
+ - Updated dependencies [bfdf3d4]
574
+ - Updated dependencies [bb68488]
575
+ - Updated dependencies [c0f9a4b]
576
+ - Updated dependencies [b1e42d0]
577
+ - Updated dependencies [2459a3e]
578
+ - Updated dependencies [ac853ce]
579
+ - Updated dependencies [fa51109]
580
+ - Updated dependencies [d6aa172]
581
+ - Updated dependencies [fe52a04]
582
+ - Updated dependencies [d46f9b8]
583
+ - Updated dependencies [3f5f87c]
584
+ - Updated dependencies [2fea4d2]
585
+ - Updated dependencies [f5e1143]
586
+ - Updated dependencies [7f1cb33]
587
+ - Updated dependencies [f148a64]
588
+ - Updated dependencies [bb68488]
589
+ - Updated dependencies [2e3b0c0]
590
+ - Updated dependencies [9461dd3]
591
+ - Updated dependencies [78fa331]
592
+ - Updated dependencies [47f551b]
593
+ - Updated dependencies [31ab1ac]
594
+ - Updated dependencies [0082db8]
595
+ - Updated dependencies [ab04728]
596
+ - Updated dependencies [5bf09fd]
597
+ - Updated dependencies [06915b0]
598
+ - Updated dependencies [ff84b05]
599
+ - @object-ui/i18n@17.5.0
600
+ - @object-ui/react@17.5.0
601
+ - @object-ui/components@17.5.0
602
+ - @object-ui/core@17.5.0
603
+ - @object-ui/fields@17.5.0
604
+ - @object-ui/types@17.5.0
605
+
606
+ ## 17.4.0
607
+
608
+ ### Patch Changes
609
+
610
+ - 4bc6c23: Converge dashboard widget `compareTo` on the executor's `{ kind, dimension? }` contract, and make the dataset path actually render a comparison
611
+
612
+ `CompareToConfig` was a three-branch union (`'previousPeriod' | 'previousYear' | { offset }`). `@objectstack/spec` collapsed it to the shape the analytics executor already implements — `DatasetCompareTo`, a plain strict object `{ kind: 'previousPeriod' | 'previousYear'; dimension?: string }` (objectstack#5011) — so this renderer now reads that one shape:
613
+
614
+ - `shiftFilterByCompareTo` / `compareToTrendLabelKey` dispatch on `compareTo.kind`. The `{ offset }` duration shift is gone: `{ offset: '1y' }` is `kind: 'previousYear'`, while `'7d'` / `'1M'` have no faithful target and are restated by the author on the widget's own `filter` plus `kind: 'previousPeriod'`. No trend label key is retired — the offset arm resolved to `vsPreviousPeriod`, which survives as the `previousPeriod` fallback.
615
+ - `DatasetWidget` no longer discards part of `compareTo`. It used to forward only the object form because the two string forms had no meaning downstream; with one shape there is nothing to discard, and a stale string is now invalid metadata rejected where it is authored rather than silently reinterpreted here.
616
+ - **The comparison now actually runs on the dataset path.** A widget states its window in its own `filter` (a date macro, or the dashboard date-range filter merged in), but the executor shifts a `timeDimensions` entry carrying a `dateRange` — so a dataset widget asking for a comparison got "compareTo needs a dated window to shift" and rendered none. When (and only when) a comparison is requested, the resolved filter's bounded date windows are lowered into `selection.timeDimensions[].dateRange` and moved out of `runtimeFilter` (a copy left behind would intersect the shifted window with the current one and empty every comparison column). Which dimension gets shifted stays the executor's decision: every window found is lowered under the name the author wrote, and zero or two candidates surfaces the executor's own error, listing them.
617
+ - The `<measure>__compare` columns that come back are now shown: a delta + window label on KPI widgets, a comparison column on tables, and a `variant: 'comparison'` overlay series on charts — the same treatment and the same `dashboard.trend.*` labels the inline object-provider widgets already use.
618
+
619
+ - 230ffd8: Dashboard metadata's `chartConfig` presentation keys now take effect for the first time
620
+
621
+ `DashboardWidgetSchema.chartConfig` is declared as the full spec
622
+ `ChartConfigSchema`, but the ADR-0021 dataset path lowered exactly one key onto
623
+ the chart renderer: `showLegend` (objectui#3135). Everything else an author wrote
624
+ there — the chart's own `title`/`subtitle`, the accessibility `description`, an
625
+ explicit plot `height`, a `colors` palette or per-category colour map,
626
+ `showDataLabels`, `annotations`, `interaction` — parsed as valid metadata,
627
+ reached `DatasetWidget`, and was dropped before the chart schema was built. The
628
+ underlying chart block draws all of them; only the dashboard's hand-off was
629
+ missing.
630
+
631
+ `DatasetWidget` now lowers each of those keys, on two mechanical criteria, both
632
+ of which have to hold:
633
+
634
+ 1. **The chart block draws it end to end on this path.** `{ type: 'chart' }`
635
+ resolves to `ChartRenderer` → `AdvancedChartImpl`, which draws
636
+ `title`/`subtitle` above the plot, turns `description` into the chart
637
+ container's `role="img"` + `aria-label`, applies `height` as that container's
638
+ inline height, paints `colors`, prints `showDataLabels` as per-point labels,
639
+ draws `annotations` as reference lines/bands and honours `interaction` as the
640
+ tooltip toggle plus the range selector. Each is pinned at the DOM level, so a
641
+ key is never forwarded to a prop that ignores it.
642
+ 2. **It does not fight the dataset derivation.** `xAxis`, `yAxis` and `series`
643
+ are derived from the widget's dataset selection, so an authored one would
644
+ shadow the derived binding and blank the chart; they stay unforwarded, as does
645
+ `type` (the widget's own `type` already picks the chart family). `aria` stays
646
+ unforwarded too, for the other reason: nothing on this path reads it.
647
+
648
+ `colors` is split the way the react tier already splits it, because the two arms
649
+ reach the renderer through different props: a `string[]` is the positional
650
+ palette, a `{ value: color }` record is a per-category map merged over the
651
+ category dimension's own option colours.
652
+
653
+ **Behaviour-opening surface.** A dashboard that already wrote any of these keys
654
+ goes from having them ignored to having them applied — the point of the change,
655
+ but visible: a widget that declared `chartConfig.title` now shows that title
656
+ inside the plot area (in addition to the widget card's own `title`, which is a
657
+ separate key), one that declared `height` no longer fills its card, one that
658
+ declared `colors` stops using the theme palette, and `showDataLabels`,
659
+ `annotations` and `interaction.brush` start drawing. Widgets with no
660
+ `chartConfig`, or with only `showLegend`, render exactly as before: undeclared
661
+ keys are never emitted, so the renderer's own defaults stay in charge.
662
+
663
+ Part of objectstack#5175 (the enforce half); the narrowing half — what to do
664
+ about `aria`, and about `xAxis`/`yAxis`/`series` being declared on a surface that
665
+ derives them — is still open there.
666
+
667
+ - c4c0ac8: Dataset-bound metric cards honour their declared `colorVariant` (objectui#3359, objectstack#5010 ruling B)
668
+
669
+ `DashboardWidgetSchema.widgets[].colorVariant` has been spec-declared, offered by
670
+ every authoring surface (the widget inspector, the dashboard editor, the config
671
+ panel) and authored **16 times** in shipped metadata — `system_overview` ×7 in
672
+ `platform-objects`, app-showcase's `ops-dashboard` / `revenue-pulse` ×9 — with
673
+ every one of those a `type: 'metric'` widget bound to a dataset. None of them
674
+ ever rendered a colour.
675
+
676
+ The reason is structural rather than a missing branch: `dataset` is **required**
677
+ on `DashboardWidgetSchema`, so every legal widget reaches `DatasetWidget` through
678
+ one of `DashboardRenderer`'s two dispatch sites, and `DatasetWidget` read the key
679
+ nowhere. Only the inline (`object` + `valueField`) path had a colour affordance,
680
+ via the `...options` spread into `MetricWidget` — a path the current schema
681
+ cannot produce. Declared, authored, offered in the designer, and inert: the
682
+ renderer painted all sixteen the same.
683
+
684
+ The metric card now maps the declaration onto the accent system this package
685
+ already has, instead of a second one:
686
+
687
+ - the vocabulary is the spec's `WidgetColorVariantSchema` enum, read from the
688
+ spec **in a test** rather than restated in prose — `default`, `blue`, `teal`,
689
+ `orange`, `purple`, `success`, `warning`, `danger`;
690
+ - the accent lands on the big number, the way `MetricWidget`'s chrome-less
691
+ `bare` layout carries it, because a dataset-bound metric renders no icon chip
692
+ and no card of its own. A dataset-bound KPI and an inline `bare` KPI declaring
693
+ the same variant now read the same;
694
+ - the two class tables both layouts use moved into one shared module
695
+ (`colorVariants.ts`) rather than being copied — the designer's swatch picker
696
+ already calls itself a mirror of "the renderer's colorVariant tokens", and a
697
+ second copy of a palette is how a declared-but-unenforced key becomes the
698
+ harder bug: a key declared two disagreeing ways.
699
+
700
+ Nothing changes for a widget that declares no `colorVariant`: its markup is
701
+ pinned byte-for-byte against the pre-change render, as is the enum's own
702
+ `'default'` (its name for "no accent"). Off-spec tokens — including the swatch
703
+ picker's three display-only aliases `green` / `red` / `amber`, which exist so a
704
+ legacy stored value can still be drawn as a swatch — get no accent and no
705
+ aliasing here: the spec enum rejects them where metadata is authored and
706
+ published, and teaching the renderer a second spelling would hand AI-authored
707
+ metadata a dialect the contract does not have.
708
+
709
+ - 5bfaabd: `PageComponentSchema.dataSource` now reaches every object-bound block, not just
710
+ `list-view` — and `element:record_picker` stops discarding `view`
711
+ (objectstack#6953).
712
+
713
+ objectstack#5576 wired the spec's per-element data binding
714
+ (`dataSource: { object, view?, filter?, sort?, limit? }`) to `list-view` and left
715
+ the same declaration inert on every other page component. Two gaps remained, and
716
+ both were silent:
717
+
718
+ - **`element:record_picker` read four of the five keys and dropped `view`.** So
719
+ `dataSource: { object: 'account', view: 'hot' }` — the spec's own example —
720
+ built a picker over EVERY account instead of the rows the saved view selects.
721
+ Nothing threw and nothing rendered an error; the option list was simply wider
722
+ than what was authored, which also means a user could select a record the page
723
+ said was out of scope.
724
+ - **`object-grid` / `object-form` / `object-kanban` / `object-calendar` /
725
+ `object-chart` / `object-metric` / `record:related_list` read none of it.**
726
+ Each gates its fetch on its own `objectName`, and nothing mapped
727
+ `dataSource.object` onto it, so a page written the way the spec documents
728
+ rendered an empty grid / a field-less form / a board with no cards / an empty
729
+ month / an empty chart / a static metric number — with no request and no
730
+ diagnostic anywhere. Spec-valid metadata rendering nothing is the
731
+ objectstack#4413 shape.
732
+
733
+ Composition follows objectstack#5576's landed semantics unchanged on every block:
734
+ a named saved view supplies the baseline, a key written on the component itself
735
+ overrides it, an explicit binding key overrides both, `filter` AND-combines
736
+ ("additional filter criteria" — a binding can narrow a view, never widen it), and
737
+ a `view` name that does not resolve renders a configuration error instead of
738
+ degrading to the object's full scope.
739
+
740
+ - `@object-ui/react` — new `useElementDataSourceSchema(schema, mapping, dataSource?)`
741
+ and `ElementDataSourceGate` apply a resolved binding to the schema keys a given
742
+ block reads, plus `ElementDataSourceErrorPanel` / `ElementDataSourceLoadingPanel`
743
+ for the two non-final states. One precedence table for all blocks rather than
744
+ one copy per block — that copy is how "additional filter criteria" would have
745
+ become two dialects.
746
+ - A mapping names **only** keys its block genuinely reads. A composed value
747
+ written onto a key the block ignores would be accepted and dropped, which is
748
+ the defect being removed, one layer deeper — so a kanban's swimlane `columns`
749
+ never receive a view's field list, and a block with no row cap leaves `limit`
750
+ unmapped. The per-block coverage table, including two residual gaps that are
751
+ named rather than papered over, is in `content/docs/guide/data-source.md`.
752
+
753
+ No behaviour changes for a block that carries no `dataSource`: the binding-free
754
+ path returns the schema by reference, so nothing remounts and nothing refetches.
755
+
756
+ - 022002a: `PageComponentSchema.dataSource` now reaches the remaining object-bound public
757
+ blocks: `object-gantt` / `object-timeline` / `object-map` / `object-pivot` /
758
+ `object-master-detail-form` / `embeddable-form` / `record:line_items`
759
+ (objectstack#7121).
760
+
761
+ objectstack#6953 wired the spec's per-element data binding
762
+ (`dataSource: { object, view?, filter?, sort?, limit? }`) to the eight blocks it
763
+ named and left the same declaration inert on these seven. Each gates its fetch on
764
+ its own object key and nothing mapped `dataSource.object` onto it, so a page
765
+ written the way the spec documents rendered an empty gantt / an empty timeline
766
+ rail / a map with no markers / an empty cross-tab / a field-less form — with no
767
+ request and no diagnostic anywhere. Spec-valid metadata rendering nothing is the
768
+ objectstack#4413 shape.
769
+
770
+ Composition follows objectstack#5576's landed semantics unchanged, through the
771
+ shared `ElementDataSourceGate` (no change to it or to the resolution layer): a
772
+ named saved view supplies the baseline, a key written on the component itself
773
+ overrides it, an explicit binding key overrides both, `filter` AND-combines
774
+ ("additional filter criteria" — a binding can narrow a view, never widen it), and
775
+ a `view` name that does not resolve renders a configuration error on every one of
776
+ these blocks instead of degrading to the object's full scope.
777
+
778
+ Each block maps **only** the keys it genuinely reads, which for this batch means
779
+ several keys stay deliberately unmapped rather than being parked somewhere
780
+ plausible:
781
+
782
+ - `object-gantt` and `object-map` take `object` / `filter` / `sort`; neither has a
783
+ row cap or a field-list read site.
784
+ - `object-pivot` takes `object` / `filter`; a cross-tab orders itself by its own
785
+ row/column grouping and cannot be computed over a truncated page.
786
+ - `object-timeline` takes `object` only — its fetch is
787
+ `find(objectName, { options: { $top: 100 } })`, with no filter/sort read site
788
+ at all, so a named view is error-checked and then contributes nothing.
789
+ - `embeddable-form` and `object-master-detail-form` take `object` only (the
790
+ parent object, in the master-detail case); a form that writes one record has no
791
+ collection query for `filter` / `sort` / `limit` to narrow.
792
+ - `record:line_items` takes `object` onto **`childObject`** — the collection it
793
+ actually lists — and nothing else: its query is the parent FK plus a fixed
794
+ `$top: 500`, and its `columns` are editable `GridColumn` objects rather than a
795
+ field-name projection a view could supply.
796
+
797
+ The per-block coverage table, including every residual gap named above, is in
798
+ `content/docs/guide/data-source.md`.
799
+
800
+ No behaviour change for a block that carries no `dataSource`: the binding-free
801
+ path returns the schema by reference, so nothing remounts and nothing refetches.
802
+
803
+ - 02eb444: Show the `compareTo` comparison in a dataset pivot cross-tab instead of dropping it
804
+
805
+ A dataset widget with `type: 'pivot'` and two or more `dimensions` renders a true cross-tab, and that branch was the one render path the `compareTo` work left out (objectui#3614, following objectui#3337 / PR #3612). It laid out its columns as `bucket × measure` and never admitted the `<measure>__compare` columns the executor returns — so a pivot with a bounded date window and a `compareTo` ran a correct comparison query, received correct comparison data, and displayed none of it: headers, cells and all three subtotals were silent.
806
+
807
+ The comparison is now **stacked inside the cell** — current value on top, comparison value and its delta percentage beneath in smaller type:
808
+
809
+ - The pivot's column structure is unchanged. Giving the comparison a column of its own would turn `bucket × measure` into `bucket × measure × window`, doubling the width and adding a third header level on the widget family whose width is already the scarce resource.
810
+ - **Row, column and grand subtotals stack it the same way.** A Total that alone showed no comparison would read as "this row has none", which is a different and false statement.
811
+ - One caption names the comparison window ("vs last year") for the whole table, from the same `dashboard.trend.*` vocabulary the KPI and flat-table paths use, and the delta comes from the same helper — so a KPI and a cross-tab cell comparing the same two windows agree on sign and rounding.
812
+ - **CSV export stays data-shaped.** The cross-tab now exports a flat `<measure>__compare` column per compared measure, with bare numbers in the cells: a spreadsheet can compute on the export, and no stacked display string ("$120 $100 20%") ever reaches it.
813
+
814
+ Presence is detected from the returned data, as on every other path, so there is no new option to set — and a pivot the executor sent no comparison for renders exactly as it did before.
815
+
816
+ - c1e1e6b: Studio's widget config panel no longer authors the retired `actionUrl` widget key
817
+
818
+ `actionUrl` / `actionType` / `actionIcon` were retired at the WIDGET level in
819
+ `@objectstack/spec` 17.0.0-rc.3 (objectstack#5010, ADR-0049 D2). They are
820
+ `retiredKey` tombstones: `DashboardWidgetSchema` types them `never` and refuses
821
+ any value, so authoring one is a tsc error and a parse error. Two producers in
822
+ `plugin-dashboard` were still emitting the widget-level key anyway
823
+ (objectstack#7129):
824
+
825
+ - `WidgetConfigPanel` offered a Behavior-group field labelled "Click-through
826
+ URL", bound to `actionUrl`. That control was inert twice over: no dashboard
827
+ widget renderer has ever read `widget.actionUrl`, so a URL typed there never
828
+ navigated anywhere, and the value it wrote was refused by the spec.
829
+ - `DashboardWithConfig` seeded `actionUrl: widget.actionUrl ?? ''` into every
830
+ widget config handed to the panel. Because the ADR-0021 save scrub only knew
831
+ the dataset-shape keys, that seed rode through to `onWidgetSave` on EVERY
832
+ save — so a Studio author who merely renamed a widget still persisted
833
+ `actionUrl: ''` into stored metadata, a key the spec then refuses. This is
834
+ the wider half of the defect: it did not require anyone to use the field.
835
+
836
+ The Behavior group and the seed are both gone, and `sanitizeDraftForType` now
837
+ scrubs all three keys as a second line of defence, for stored widgets that
838
+ already carry them and for hosts that drive `WidgetConfigPanel` directly.
839
+
840
+ Behaviour change surface: the widget config panel loses its Behavior section
841
+ (that section contained only this one field). Nothing that rendered before stops
842
+ rendering — the field had no consumer. `header.actions[]` keeps its own,
843
+ unrelated and still-live `actionUrl`; only the widget-level key is a tombstone.
844
+
845
+ Also corrects the `DashboardWidgetSchema` docblock in `@object-ui/types`, which
846
+ listed the three retired keys among those that "flow in from the spec" next to
847
+ live keys like `colorVariant`. They do flow in — as `?: never`. The docblock now
848
+ says so, and notes that while authoring one is a tsc error, _reading_ one still
849
+ type-checks (`never | undefined`), which is exactly how these producers survived
850
+ the 2026-08-04 sweep that removed the renderer-side reads.
851
+
852
+ - Updated dependencies [794c497]
853
+ - Updated dependencies [993336f]
854
+ - Updated dependencies [f0a625a]
855
+ - Updated dependencies [b5980f4]
856
+ - Updated dependencies [8aad9fd]
857
+ - Updated dependencies [6719877]
858
+ - Updated dependencies [56ff091]
859
+ - Updated dependencies [0186cdc]
860
+ - Updated dependencies [7864f03]
861
+ - Updated dependencies [ea41a59]
862
+ - Updated dependencies [0cbdca8]
863
+ - Updated dependencies [d229dfa]
864
+ - Updated dependencies [ecae400]
865
+ - Updated dependencies [4bc6c23]
866
+ - Updated dependencies [d3e738a]
867
+ - Updated dependencies [c3b01a7]
868
+ - Updated dependencies [f5f8744]
869
+ - Updated dependencies [7ed3360]
870
+ - Updated dependencies [69becd2]
871
+ - Updated dependencies [5e52495]
872
+ - Updated dependencies [0fa5e4d]
873
+ - Updated dependencies [b750823]
874
+ - Updated dependencies [5bfaabd]
875
+ - Updated dependencies [e06810e]
876
+ - Updated dependencies [ab3ad4f]
877
+ - Updated dependencies [65bb513]
878
+ - Updated dependencies [c97a45e]
879
+ - Updated dependencies [b19162d]
880
+ - Updated dependencies [c2fd122]
881
+ - Updated dependencies [1bd6faa]
882
+ - Updated dependencies [ac2139c]
883
+ - Updated dependencies [b14ab3a]
884
+ - Updated dependencies [e24d767]
885
+ - Updated dependencies [8c60819]
886
+ - Updated dependencies [aca561a]
887
+ - Updated dependencies [e64a52e]
888
+ - Updated dependencies [844d17f]
889
+ - Updated dependencies [d8a0be4]
890
+ - Updated dependencies [48132f7]
891
+ - Updated dependencies [4dcd52a]
892
+ - Updated dependencies [42ae5c6]
893
+ - Updated dependencies [0ef9dfd]
894
+ - Updated dependencies [f4b97c8]
895
+ - Updated dependencies [1d723e3]
896
+ - Updated dependencies [0109f54]
897
+ - Updated dependencies [7e5bb5d]
898
+ - Updated dependencies [fbc23e0]
899
+ - Updated dependencies [6d762da]
900
+ - Updated dependencies [e6fdbdc]
901
+ - Updated dependencies [54233b1]
902
+ - Updated dependencies [c2ecbae]
903
+ - Updated dependencies [f9faa7d]
904
+ - Updated dependencies [97b63d7]
905
+ - Updated dependencies [6bb454a]
906
+ - Updated dependencies [11c1e71]
907
+ - Updated dependencies [523be48]
908
+ - Updated dependencies [7e2b7e9]
909
+ - Updated dependencies [33526fd]
910
+ - Updated dependencies [32413ec]
911
+ - Updated dependencies [c1e1e6b]
912
+ - @object-ui/components@17.4.0
913
+ - @object-ui/react@17.4.0
914
+ - @object-ui/core@17.4.0
915
+ - @object-ui/fields@17.4.0
916
+ - @object-ui/i18n@17.4.0
917
+ - @object-ui/types@17.4.0
918
+
919
+ ## 17.3.0
920
+
921
+ ### Patch Changes
922
+
923
+ - 509104a: Fix matrix report cells showing another bucket's numbers when dimension values run together.
924
+
925
+ The cross-tab in `DatasetReportRenderer` built its bucket ids by joining dimension values with the EMPTY string, so adjacent values had no boundary at all: `"x"` + `"yz"` and `"xy"` + `"z"` were the same bucket on both axes, and the later row silently overwrote the earlier one. Its cell key then joined the two bucket ids with a plain space, while dimension values contain spaces constantly ("New York", "In Progress"), so `"New"` × `"York Q1"` and `"New York"` × `"Q1"` also met in one key. A merged bucket showed a different row's measure, the overwritten row's value was unreachable, the per-row and per-column subtotals matched the wrong header, and drill-through followed the same wrong index into another record's list — none of it with an error.
926
+
927
+ Bucket ids and cell keys are now encoded with `JSON.stringify`, which carries the boundary in its own quoting rather than in a character the data is assumed never to contain. All four lookups in the renderer (row headers, column headers, row subtotals, column subtotals) share the one encoder, so they agree by construction.
928
+
929
+ The encoders moved to `@object-ui/core` as `pivotBucketId` / `pivotCellKey` and are now shared with the dashboard `DatasetWidget`, which carried the same defect and fixed it separately: two packages each hand-rolling the same key is why one fix left the other broken. The dashboard keeps its existing exports and behaviour.
930
+
931
+ - ce7cbe5: Fix dataset pivot cells showing another row's numbers when a dimension value contains a space.
932
+
933
+ The cross-tab cell key joined the row bucket id and the column bucket id with a plain space, so two rows whose ids met at a different point of the same string produced ONE key — `"New"` × `"York Q1"` and `"New York"` × `"Q1"` both spelled `New York Q1`. The later row silently overwrote the earlier one: the cell showed a different row's measure, the overwritten row's value was unreachable, and drill-through followed the same wrong index into the wrong records. Row and cell ids are now encoded with `JSON.stringify`, which needs no assumption about characters the data will not contain.
934
+
935
+ The row-subtotal lookup builds the same row bucket id and now shares that single encoder. It previously rolled its own join, which agreed with the row headers only when a pivot had exactly one row dimension, so the Total column rendered blank for any pivot with three or more dimensions.
936
+
937
+ - Updated dependencies [18cd432]
938
+ - Updated dependencies [b7165ce]
939
+ - Updated dependencies [532cf8b]
940
+ - Updated dependencies [680080a]
941
+ - Updated dependencies [a7651e6]
942
+ - Updated dependencies [d915c47]
943
+ - Updated dependencies [b71fc92]
944
+ - Updated dependencies [65516ba]
945
+ - Updated dependencies [94c5b7c]
946
+ - Updated dependencies [ca0fa8f]
947
+ - Updated dependencies [34595eb]
948
+ - Updated dependencies [3889ffb]
949
+ - Updated dependencies [5781fb1]
950
+ - Updated dependencies [7e2406a]
951
+ - Updated dependencies [9e9e9a9]
952
+ - Updated dependencies [19b8c9b]
953
+ - Updated dependencies [56409c2]
954
+ - Updated dependencies [042e09d]
955
+ - Updated dependencies [7d08c3f]
956
+ - Updated dependencies [9cbcbf4]
957
+ - Updated dependencies [85c4c9c]
958
+ - Updated dependencies [fd54c3e]
959
+ - Updated dependencies [4eeb932]
960
+ - Updated dependencies [6fe485b]
961
+ - Updated dependencies [5c856ec]
962
+ - Updated dependencies [23018cc]
963
+ - Updated dependencies [53811d1]
964
+ - Updated dependencies [68b6a28]
965
+ - Updated dependencies [0554e88]
966
+ - Updated dependencies [d915c47]
967
+ - Updated dependencies [f44d872]
968
+ - Updated dependencies [28b2e65]
969
+ - Updated dependencies [509104a]
970
+ - Updated dependencies [825bbe3]
971
+ - Updated dependencies [6195841]
972
+ - Updated dependencies [5dd0127]
973
+ - Updated dependencies [06632e9]
974
+ - Updated dependencies [a415684]
975
+ - Updated dependencies [a4cff5b]
976
+ - Updated dependencies [175bd79]
977
+ - Updated dependencies [5af2852]
978
+ - Updated dependencies [34d9169]
979
+ - Updated dependencies [5881a2c]
980
+ - Updated dependencies [9bc3709]
981
+ - Updated dependencies [f833d3a]
982
+ - Updated dependencies [30ae33a]
983
+ - Updated dependencies [a6ec93d]
984
+ - Updated dependencies [2a9513d]
985
+ - Updated dependencies [49f7449]
986
+ - Updated dependencies [71be406]
987
+ - Updated dependencies [d22ae31]
988
+ - Updated dependencies [c7ed4c3]
989
+ - Updated dependencies [2409e1d]
990
+ - Updated dependencies [789fe3e]
991
+ - Updated dependencies [f789c3b]
992
+ - Updated dependencies [a321fa4]
993
+ - Updated dependencies [8d8094a]
994
+ - @object-ui/core@17.3.0
995
+ - @object-ui/fields@17.3.0
996
+ - @object-ui/components@17.3.0
997
+ - @object-ui/types@17.3.0
998
+ - @object-ui/i18n@17.3.0
999
+ - @object-ui/react@17.3.0
1000
+
3
1001
  ## 17.2.0
4
1002
 
5
1003
  ### Patch Changes
@@ -949,6 +1947,7 @@
949
1947
  project `health`) painted its categories from the generic `--chart-1..5`
950
1948
  palette — the same gap the chart view (`object-chart`) had before #1932. It now
951
1949
  resolves the dimension field's option colors (using the dataset's base `object`
1950
+
952
1951
  - dimension→field map the query already returns) and threads them to the
953
1952
  renderer as a per-category `categoryColors` map, so health green/red/yellow
954
1953
  paints semantically.
@@ -1200,6 +2199,7 @@
1200
2199
  - 991b62d: Add `compareTo` field to dashboard widgets for period-over-period
1201
2200
  comparison. Supports `'previousPeriod'`, `'previousYear'`, and
1202
2201
  `{ offset: '7d' | '4w' | '1M' | '1y' }`.
2202
+
1203
2203
  - **Metric / gauge widgets** now compute a delta percentage when `compareTo`
1204
2204
  is set and surface it as a derived `trend` (auto-labelled via
1205
2205
  `dashboard.trend.vsLast*` i18n keys sniffed from the filter macros).
@@ -1364,6 +2364,7 @@
1364
2364
  platform primitive for "no records / no data" states. Two new props
1365
2365
  keep it flexible enough to absorb the hand-rolled variants that lived
1366
2366
  in `plugin-list`, `plugin-kanban`, and `plugin-dashboard`:
2367
+
1367
2368
  - `showIcon?: boolean` — drops the icon container entirely. Used by the
1368
2369
  kanban board-level empty banner, which is a status banner rather than
1369
2370
  a true empty-state.
@@ -1372,6 +2373,7 @@
1372
2373
  empty state, which uses a large standalone glyph).
1373
2374
 
1374
2375
  Adopters:
2376
+
1375
2377
  - `plugin-list` (`ListView` grid empty-state) — preserves the existing
1376
2378
  large icon, title, message, add-record button and `data-testid`s,
1377
2379
  but delegates the structural markup to `DataEmptyState`.
@@ -1394,6 +2396,7 @@
1394
2396
  `defaultCurrency` is set on the field/column, formatting is unchanged.
1395
2397
 
1396
2398
  Fixed call sites:
2399
+
1397
2400
  - `@object-ui/fields`: `formatCurrency`, `formatCompactCurrency`, and
1398
2401
  `CurrencyCellRenderer` no longer default-param `'USD'`.
1399
2402
  - `@object-ui/i18n`: `formatCurrency()` falls back to `formatNumber`
@@ -1775,6 +2778,7 @@
1775
2778
  Library builds (vite lib mode) now externalize every non-relative import instead of bundling third-party CJS dependencies into the published dist. This avoids inlined `require("react")` / `require("react-dom")` calls that cause `Calling \`require\` for "react" in an environment that doesn't expose the \`require\` function` runtime errors when consumer apps re-bundle the published dist.
1776
2779
 
1777
2780
  Specifically fixes:
2781
+
1778
2782
  - `@object-ui/plugin-dashboard` no longer inlines `react-grid-layout` (and its transitive `react-draggable` / `react-resizable` CJS bundles). `react-grid-layout` is now declared as a peer dependency so consumers install a single ESM-friendly copy.
1779
2783
  - `@object-ui/components`, `@object-ui/plugin-calendar`, `@object-ui/plugin-charts`, `@object-ui/plugin-designer` no longer inline `react-i18next` / `i18next` / `use-sync-external-store` CJS shims.
1780
2784
  - All plugin packages now use a unified `external: (id) => !/^[./]/.test(id) && !id.startsWith(__dirname)` rule, ensuring future additions of CJS deps are automatically externalized.