@object-ui/plugin-dashboard 17.6.0 → 18.0.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.
Files changed (58) hide show
  1. package/CHANGELOG.md +5044 -0
  2. package/README.md +465 -85
  3. package/dist/DashboardConfigPanel.d.ts +1 -1
  4. package/dist/DashboardFilterBar.d.ts.map +1 -1
  5. package/dist/DashboardGridLayout.d.ts +10 -8
  6. package/dist/DashboardGridLayout.d.ts.map +1 -1
  7. package/dist/DashboardRenderer.d.ts +60 -2
  8. package/dist/DashboardRenderer.d.ts.map +1 -1
  9. package/dist/DashboardWithConfig.d.ts +1 -1
  10. package/dist/DashboardWithConfig.d.ts.map +1 -1
  11. package/dist/DatasetWidget.d.ts +41 -14
  12. package/dist/DatasetWidget.d.ts.map +1 -1
  13. package/dist/DrillDownDrawer.d.ts +14 -5
  14. package/dist/DrillDownDrawer.d.ts.map +1 -1
  15. package/dist/MetricCard.d.ts +7 -1
  16. package/dist/MetricCard.d.ts.map +1 -1
  17. package/dist/MetricWidget.d.ts +48 -6
  18. package/dist/MetricWidget.d.ts.map +1 -1
  19. package/dist/ObjectDataTable.d.ts +407 -18
  20. package/dist/ObjectDataTable.d.ts.map +1 -1
  21. package/dist/ObjectMetricWidget.d.ts +35 -8
  22. package/dist/ObjectMetricWidget.d.ts.map +1 -1
  23. package/dist/ObjectPivotTable.d.ts +47 -7
  24. package/dist/ObjectPivotTable.d.ts.map +1 -1
  25. package/dist/PivotTable.d.ts +20 -3
  26. package/dist/PivotTable.d.ts.map +1 -1
  27. package/dist/RecordDetailDrawer.d.ts +21 -2
  28. package/dist/RecordDetailDrawer.d.ts.map +1 -1
  29. package/dist/WidgetConfigPanel.d.ts +2 -2
  30. package/dist/WidgetConfigPanel.d.ts.map +1 -1
  31. package/dist/WidgetEmptyState.d.ts +27 -0
  32. package/dist/WidgetEmptyState.d.ts.map +1 -0
  33. package/dist/chartRenderHandoff.d.ts +9 -0
  34. package/dist/chartRenderHandoff.d.ts.map +1 -0
  35. package/dist/domPassthroughPins.d.ts.map +1 -1
  36. package/dist/index.d.ts +22 -21
  37. package/dist/index.d.ts.map +1 -1
  38. package/dist/index.js +1977 -1715
  39. package/dist/index.umd.cjs +3 -2
  40. package/dist/legacyRetiredWidget.d.ts +28 -3
  41. package/dist/legacyRetiredWidget.d.ts.map +1 -1
  42. package/dist/measureRefusal.d.ts +50 -0
  43. package/dist/measureRefusal.d.ts.map +1 -0
  44. package/dist/metricTile.d.ts +87 -0
  45. package/dist/metricTile.d.ts.map +1 -0
  46. package/dist/recordFields.d.ts +119 -8
  47. package/dist/recordFields.d.ts.map +1 -1
  48. package/dist/retired-node-types.d.ts +29 -0
  49. package/dist/retired-node-types.d.ts.map +1 -0
  50. package/dist/schemaHostProps.d.ts +55 -11
  51. package/dist/schemaHostProps.d.ts.map +1 -1
  52. package/dist/useDashboardAutoRefresh.d.ts +47 -0
  53. package/dist/useDashboardAutoRefresh.d.ts.map +1 -0
  54. package/dist/utils.d.ts +60 -13
  55. package/dist/utils.d.ts.map +1 -1
  56. package/dist/widgetDispatch.d.ts +174 -18
  57. package/dist/widgetDispatch.d.ts.map +1 -1
  58. package/package.json +15 -16
package/README.md CHANGED
@@ -66,30 +66,54 @@ const schema = {
66
66
  ### What the side-effect import registers
67
67
 
68
68
  That single import is the whole of registration — there is no components map to
69
- iterate over. Importing the entry runs the eight `ComponentRegistry.register(...)`
69
+ iterate over. Importing the entry runs the seven live `ComponentRegistry.register(...)`
70
70
  calls in `src/index.tsx`, which claim exactly these schema types. The keys below
71
71
  are read off those calls:
72
72
 
73
73
  | Namespaced key | Bare-name fallback | Renderer behind it |
74
74
  | --- | --- | --- |
75
- | `view:dashboard` | `dashboard` | `DashboardRenderer` — the widget container |
76
- | `plugin-dashboard:metric` | `metric` | `MetricWidget` — one KPI value |
77
- | `plugin-dashboard:metric-card` | `metric-card` | `MetricCard` — KPI with trend and icon |
75
+ | `plugin-dashboard:dashboard` | `dashboard` | `DashboardRenderer` — the widget container |
76
+ | `plugin-dashboard:metric` | none — `skipFallback: true` | `MetricWidget` — one KPI value |
77
+ | `plugin-dashboard:metric-card` | none — `skipFallback: true` | `MetricCard` — KPI with trend and icon |
78
78
  | `plugin-dashboard:object-metric` | `object-metric` | internal wrapper around `ObjectMetricWidget` — aggregates over an object |
79
79
  | `plugin-dashboard:pivot` | `pivot` | `PivotTable` — pivot over rows you pass in |
80
- | `plugin-dashboard:object-pivot` | `object-pivot` | internal wrapper around `ObjectPivotTable` — pivot queried from an object |
81
- | `plugin-dashboard:dashboard-grid` | `dashboard-grid` | `DashboardGridLayout` — the drag/resize editable grid |
80
+ | `plugin-dashboard:object-pivot` | `object-pivot` | `ObjectPivotTable` — a pivot queried from an object; its props go in `properties` |
82
81
  | `plugin-dashboard:object-data-table` | `object-data-table` | `ObjectDataTable` — table queried from an object |
83
82
 
84
83
  `ComponentRegistry.register` publishes `namespace:type`, and — unless the call
85
84
  passes `skipFallback: true` — the bare `type` as a back-compat fallback
86
- (`packages/core/src/registry/Registry.ts:194`, fallback branch at `:226`). No
87
- call in this package passes `skipFallback`, so each type above resolves under
88
- both spellings. The two `object-*` types are served by internal wrappers that
85
+ (`packages/core/src/registry/Registry.ts:194`, fallback branch at `:226`). Every
86
+ call behind the table above leaves `skipFallback` unset except `metric` and
87
+ `metric-card`, so each other type there resolves under both spellings.
88
+
89
+ `metric` and `metric-card` are two vocabularies that share a spelling: the
90
+ dashboard WIDGET types (`widgets[].type: 'metric'`, and the `metric-card` slot
91
+ entry) and the internal NODE keys the dashboard renders them through. The widget
92
+ types are unchanged. The node keys register with `skipFallback: true`
93
+ (objectui#10859 batch 8), and both dashboard surfaces emit the namespaced keys, so
94
+ only `plugin-dashboard:metric` / `plugin-dashboard:metric-card` resolve as nodes.
95
+
96
+ `dashboard-grid` is no longer registered (objectui#10859 batch 8: builder chrome,
97
+ with no producer and no runtime emitter). `DashboardGridLayout` is still exported;
98
+ mount it directly (see "DashboardGridLayout — persisting drag / resize edits"). The two `object-*` types are served by internal wrappers that
89
99
  first resolve the spec's per-element `dataSource` binding (through
90
100
  `ElementDataSourceGate` from `@object-ui/react`) and then render the exported
91
101
  component, which is why those rows name a wrapper rather than an export.
92
102
 
103
+ ⛔ One further registration is deliberately absent from the table above. Until
104
+ objectui#9533 the dashboard renderer was published as `view:dashboard`, while
105
+ `apps/console`'s lazy stubs and the CLI's known-type whitelist already spelled it
106
+ `plugin-dashboard:dashboard`; the bare `dashboard` key therefore declared one
107
+ namespace before the chunk loaded and the other after it, and the
108
+ `plugin-dashboard:dashboard` stub was never cleared, so that spelling could never
109
+ resolve. The renderer now registers under `plugin-dashboard`, and the retired
110
+ `view:dashboard` key is answered by a tombstone widget that refuses BY NAME and
111
+ names `plugin-dashboard:dashboard` as its replacement — so an authored
112
+ `view:dashboard` gets a visible refusal carrying its own migration, never a
113
+ silent fall-through. That tombstone registration passes `skipFallback: true`, so
114
+ it claims no bare key, and `view:dashboard` is deliberately NOT a renderable key:
115
+ `objectui check` reports it as unknown.
116
+
93
117
  ### Registering a component under your own key
94
118
 
95
119
  To serve one of this package's components under a key of your own, register the
@@ -107,11 +131,14 @@ ComponentRegistry.register('my-metric', MetricCard, {
107
131
  ```
108
132
 
109
133
  There is also a `dashboardComponents` export: the manual-integration map, keyed
110
- by the **same eight schema types** as the table above (objectui#5064 re-keyed it
111
- from component class names). Each key maps to the exact component the import
134
+ by the **same seven schema types** as the table above (objectui#5064 re-keyed it
135
+ from component class names; since objectui#10859 batch 8 the `metric` and
136
+ `metric-card` entries are keyed `plugin-dashboard:metric` /
137
+ `plugin-dashboard:metric-card`, the type each serves, so iterating the map cannot
138
+ re-create their retired bare keys). Each key maps to the exact component the import
112
139
  registers for that type — for the two `object-*` types that is the internal
113
140
  data-source-gate wrapper, not the exported widget. Iterating it with
114
- `ComponentRegistry.register(type, component)` therefore re-registers the eight
141
+ `ComponentRegistry.register(type, component)` therefore re-registers the seven
115
142
  types the import has already claimed, which is still not the manual registration
116
143
  above: each such call passes no `meta`, so it trips the no-namespace deprecation
117
144
  warning in `register` (`packages/core/src/registry/Registry.ts:198`) and
@@ -126,32 +153,92 @@ whole of registration.
126
153
  Container for dashboard widgets:
127
154
 
128
155
  ```typescript
129
- {
156
+ import type { DashboardComponentSchema, DashboardWidgetSchema } from '@object-ui/types';
157
+
158
+ declare const widgets: DashboardWidgetSchema[];
159
+
160
+ const schema: DashboardComponentSchema = {
130
161
  type: 'dashboard',
131
- widgets: Widget[],
132
- columns?: number, // Grid columns (default: 3)
133
- gap?: number, // Gap between widgets
134
- className?: string
135
- }
162
+ widgets,
163
+ columns: 3, // Grid columns (default: 3)
164
+ gap: 4, // Gap between widgets
165
+ className: 'w-full',
166
+ };
136
167
  ```
137
168
 
169
+ `type` and `widgets` are the required keys; every other key above is optional.
170
+
138
171
  ### Metric Card
139
172
 
140
- Display a single metric or KPI:
173
+ Display a single metric or KPI as a dashboard widget-slot entry:
141
174
 
142
175
  ```typescript
143
- {
176
+ import type { ComponentProps } from 'react';
177
+ import { MetricCard } from '@object-ui/plugin-dashboard';
178
+
179
+ // A `metric-card` entry sits directly in a dashboard's `widgets[]` — the closed
180
+ // component slot of the 2026-08-14 ruling. Its keys besides `type` are
181
+ // `MetricCard`'s own props. That props interface is not on this package's export
182
+ // surface (see "TypeScript Support" below), so they are read off the shipped
183
+ // component rather than restated here — a renamed or retyped prop stops this
184
+ // block compiling.
185
+ type MetricCardEntry = { type: 'metric-card' } & ComponentProps<typeof MetricCard>;
186
+
187
+ const card: MetricCardEntry = {
144
188
  type: 'metric-card',
145
- title: string,
146
- value: string | number,
147
- icon?: string, // Lucide icon name
148
- trend?: 'up' | 'down' | 'neutral',
149
- trendValue?: string,
150
- description?: string,
151
- className?: string
152
- }
189
+ title: 'Total Sales',
190
+ value: '$123,456',
191
+ icon: 'trending-up', // Lucide icon name
192
+ trend: 'up', // 'up' | 'down' | 'neutral'
193
+ trendValue: '+12%',
194
+ description: 'vs last month',
195
+ className: 'col-span-2',
196
+ };
197
+
198
+ const schema = { type: 'dashboard', widgets: [card] };
153
199
  ```
154
200
 
201
+ The dashboard renders the entry as the `plugin-dashboard:metric-card` node. The
202
+ bare `metric-card` NODE key is retired (objectui#10859), so a `metric-card`
203
+ outside a dashboard's `widgets[]` renders the "Unknown component type" panel.
204
+
205
+ `value` is the only required key. `title` and `description` take a plain string
206
+ or the spec's inline per-locale map (`I18nLabel`).
207
+
208
+ A card with no `value` is refused by `@object-ui/types/zod`, on the tolerant
209
+ face and the strict one. `metric-card` is not a widget type, so a card is never
210
+ read through widget keys, and no widget key stands in for `value`: the
211
+ dashboard reads a widget key (`dataset`, `options`, `component`,
212
+ `colorVariant`, …) on a widget alone, so a card draws its own keys whatever
213
+ else it carries, and the strict face refuses a widget key on it
214
+ (objectui#11483, objectui#11598). For a figure queried from a
215
+ dataset, write a `metric` widget with `dataset` and `values` (see "TypeScript
216
+ Support" below).
217
+
218
+ #### Percent `format` patterns (`'0%'`, `'0.00%'`)
219
+
220
+ A numeral pattern ending in `%` is handed whole to `formatPercent`
221
+ (`@object-ui/fields`), the same call the list-view percent cell and this
222
+ package's own record-field renderer already make. Two consequences, both of
223
+ them shared with every other percent surface in the console rather than decided
224
+ by the tile:
225
+
226
+ - **Magnitude** is read at a STATED storage, never guessed from the value. A
227
+ `metric` tile holds no field, only a value and a pattern, and numeral's `%`
228
+ multiplies by 100, so the pattern states a fraction: `0.25` reads `25%`, `1`
229
+ reads `100%`, `-0.05` reads `-5%`. An `object-metric` tile over a `percent`
230
+ field renders at that field's own storage instead, the one the list cell
231
+ reads (`percentScaleOf` in `@objectstack/spec/data`: a fraction unless the
232
+ field declares a `max` above 1), so a field declaring `max: 100` that
233
+ averages `50` reads `50%`.
234
+ - **The percent sign is the locale's**, not a literal `%`: a `de-DE` session
235
+ gets the no-break space German writes before the sign, and grouping follows
236
+ the locale (a fraction of `12.5` reads `1,250%` in `en`).
237
+
238
+ The pattern's decimal count still belongs to the tile — `'0.00%'` renders two
239
+ decimals — because that is an author declaration on the widget rather than a
240
+ guess about the value.
241
+
155
242
  ## Examples
156
243
 
157
244
  ### Basic Dashboard
@@ -205,26 +292,35 @@ const schema = {
205
292
  value: '$123,456'
206
293
  },
207
294
  {
208
- type: 'card',
295
+ id: 'sales_trend',
296
+ type: 'line',
209
297
  title: 'Sales Trend',
210
- body: {
211
- type: 'line-chart',
212
- data: [/* chart data */],
213
- height: 300
214
- }
298
+ dataset: 'sales',
299
+ dimensions: ['month'],
300
+ values: ['revenue']
215
301
  },
216
302
  {
217
- type: 'card',
303
+ id: 'revenue_by_category',
304
+ type: 'pie',
218
305
  title: 'Category Distribution',
219
- body: {
220
- type: 'pie-chart',
221
- data: [/* chart data */]
222
- }
306
+ dataset: 'sales',
307
+ dimensions: ['category'],
308
+ values: ['revenue']
223
309
  }
224
310
  ]
225
311
  };
226
312
  ```
227
313
 
314
+ A chart widget names its family in `type` — one of the spec's chart families,
315
+ the closed vocabulary `DashboardWidgetTypeName` declares — and binds a
316
+ `dataset` (ADR-0021), selecting the dimension it plots in `dimensions` and its
317
+ measures in `values`. It never carries rows: `options.data`,
318
+ `options.xField` and `options.yField` are not widget keys, and
319
+ `@object-ui/types/zod`'s `StrictAnyComponentSchema` refuses them by name
320
+ (objectui#11228). There is no `card` widget family and no nested `body` slot: a
321
+ widget whose `type` is outside that vocabulary is refused at validation, by
322
+ `@object-ui/types/zod`'s `DashboardComponentSchema`.
323
+
228
324
  ### Responsive Dashboard
229
325
 
230
326
  ```typescript
@@ -239,30 +335,68 @@ const schema = {
239
335
 
240
336
  ## Integration with Data Sources
241
337
 
242
- Connect dashboard to live data:
338
+ Connect dashboard to live data. `metric-card` renders the `value` it is handed:
339
+ it has no row in the spec's expression carriage map, so a `${…}` written in
340
+ `value` or `trend` reaches the screen as those characters. A widget that should
341
+ read live data is one of the `object-*` types above — they resolve the spec's
342
+ per-element `dataSource` binding and query the object themselves.
243
343
 
244
- ```typescript
245
- import { createObjectStackAdapter } from '@object-ui/data-objectstack';
344
+ **The adapter is not a schema key.** A schema is a serialisable document; a live
345
+ adapter is an object with methods, so it cannot travel in one. An `object-*`
346
+ widget reads its adapter from React context — `useContext(SchemaRendererContext)`
347
+ at `src/ObjectMetricWidget.tsx:159`, with an explicit `dataSource` prop taking
348
+ precedence when the host renders the widget directly.
246
349
 
247
- const dataSource = createObjectStackAdapter({
248
- baseUrl: 'https://api.example.com',
249
- token: 'your-auth-token'
250
- });
350
+ So the document stays plain data — every value in it survives `JSON.stringify`:
251
351
 
252
- const schema = {
352
+ ```typescript
353
+ import type { DashboardComponentSchema } from '@object-ui/types';
354
+
355
+ const schema: DashboardComponentSchema = {
253
356
  type: 'dashboard',
254
- dataSource,
255
357
  widgets: [
256
358
  {
257
359
  type: 'metric-card',
258
360
  title: 'Total Users',
259
- value: '${data.metrics.totalUsers}',
260
- trend: '${data.metrics.userTrend}'
361
+ value: 12480,
362
+ trend: 'up'
261
363
  }
262
364
  ]
263
365
  };
264
366
  ```
265
367
 
368
+ The adapter is installed once, above the whole tree, and every `object-*` widget
369
+ underneath reads it from context:
370
+
371
+ ```tsx
372
+ import { SchemaRendererProvider, SchemaRenderer } from '@object-ui/react';
373
+ import { createObjectStackAdapter } from '@object-ui/data-objectstack';
374
+ import '@object-ui/plugin-dashboard';
375
+ import type { DashboardComponentSchema } from '@object-ui/types';
376
+
377
+ // The document from the block above.
378
+ declare const schema: DashboardComponentSchema;
379
+
380
+ const dataSource = createObjectStackAdapter({
381
+ baseUrl: 'https://api.example.com',
382
+ token: 'your-auth-token'
383
+ });
384
+
385
+ export const App = () => (
386
+ <SchemaRendererProvider dataSource={dataSource}>
387
+ <SchemaRenderer schema={schema} />
388
+ </SchemaRendererProvider>
389
+ );
390
+ ```
391
+
392
+ > A `dataSource` key **does** mean something on a schema node, but it is not this:
393
+ > it is the spec's element **binding** (`PageComponentSchema.dataSource`) — a
394
+ > declarative descriptor such as `{ object: 'orders', view: 'my_view' }`, resolved
395
+ > against the host and mapped onto the widget's own keys by the `object-*`
396
+ > registry shells in `src/index.tsx`. It belongs on the widget that reads it, not
397
+ > on the dashboard node, and a live adapter written into that slot wires nothing
398
+ > up — it is a different kind of thing wearing the same name.
399
+
266
400
  ## Dashboard-level filters
267
401
 
268
402
  A dashboard can declare top-level filters — a date range and any number of
@@ -286,16 +420,21 @@ into each bound widget's inline query (`AND`-combined with the widget's own
286
420
  "name": "region", // stable filter name (defaults to field)
287
421
  "field": "region", // default binding target
288
422
  "label": "Region",
423
+ // optional "object": the object `field` lives on — its fields.<object>.<field> / fieldOptions.<object>.<field>.<value> bundle entries then win, "label" is the fallback
289
424
  "type": "select", // text | select | date | number | lookup
290
425
  // Canonical @objectstack/spec pair form — the only form the platform
291
- // accepts at publish. The bare-string shorthand (["EMEA", …]) is
292
- // deprecated: still lifted at runtime, now warns (objectui#4356).
426
+ // accepts at publish, and the only form `@object-ui/types` validates
427
+ // (objectui#7759). The bare-string shorthand (["EMEA", …]) is NOT
428
+ // accepted: the runtime no longer lifts it (objectui#4356) — a bare member
429
+ // yields no option, and a dev-mode warning names it. Rewrite each X as
430
+ // { "value": X, "label": X }.
293
431
  "options": [
294
432
  { "value": "EMEA", "label": "EMEA" },
295
433
  { "value": "APAC", "label": "APAC" },
296
434
  { "value": "AMER", "label": "AMER" }
297
435
  ]
298
- // or dynamic: "optionsFrom": { "object": "accounts", "valueField": "region" }
436
+ // or dynamic: "optionsFrom": { "object": "accounts", "valueField": "region", "labelField": "region" }
437
+ // (`labelField` is required by the spec, even when it names the value field)
299
438
  }
300
439
  ],
301
440
  "widgets": [
@@ -303,9 +442,13 @@ into each bound widget's inline query (`AND`-combined with the widget's own
303
442
  // dimensions/measures by name. The pre-ADR-0021 top-level `object` +
304
443
  // `categoryField`/`valueField`/`aggregate` shape was REMOVED — a widget
305
444
  // still carrying it renders "This widget uses a retired data format.
306
- // Edit it to bind a dataset." instead of a chart. A renderer-internal
307
- // query lives under `options.data` as `{ provider: 'object', object,
308
- // aggregate }`; an `options.data` array is fixed demo data.
445
+ // Edit it to bind a dataset." instead of a chart. Inline widget data
446
+ // (`options.data`, `options.xField` / `options.yField`) is not an
447
+ // authoring surface either: the strict authoring face refuses those keys
448
+ // by name (objectui#11228). A stored single-value widget (`metric`,
449
+ // `gauge`, `solid-gauge`, `kpi`, `bullet`, or one with no `type`) whose
450
+ // `options.data` is a `{ "provider": "object", … }` query draws that same
451
+ // retired-format prompt instead of its number (objectui#11525).
309
452
  //
310
453
  // Default binding: the filter's own `field` (dateRange → created_at).
311
454
  { "id": "w1", "type": "bar", "dataset": "invoices", "dimensions": ["status"], "values": ["count"] },
@@ -338,8 +481,6 @@ Notes:
338
481
  query time, so widgets resolve them exactly like hand-authored filters.
339
482
  - Dataset-bound widgets receive the merged filter through the dataset
340
483
  query's `runtimeFilter`.
341
- - Static-data widgets (inline `data` arrays) have no query to scope and are
342
- not filtered.
343
484
  - Filter values are also readable in widget expressions as `page.<name>`
344
485
  (e.g. `page.region`), since they are hosted as dashboard variables.
345
486
  - `optionsFrom` resolves distinct option values server-side (a dataset
@@ -358,7 +499,9 @@ A KPI widget can declare a semantic accent. The vocabulary is
358
499
  tokens the designer's swatch picker offers:
359
500
 
360
501
  ```typescript
361
- {
502
+ import type { DashboardWidgetSchema } from '@object-ui/types';
503
+
504
+ const atRisk: DashboardWidgetSchema = {
362
505
  id: 'kpi_at_risk',
363
506
  type: 'metric',
364
507
  title: 'At-Risk Projects',
@@ -366,14 +509,14 @@ tokens the designer's swatch picker offers:
366
509
  values: ['project_count'],
367
510
  filter: { health: 'red' },
368
511
  colorVariant: 'danger', // tints the value; card chrome stays neutral
369
- }
512
+ };
370
513
  ```
371
514
 
372
515
  Where the accent lands depends on the layout, not on the token:
373
516
 
374
517
  | Layout | Accent |
375
518
  | --- | --- |
376
- | Card chrome (`MetricWidget`, inline `object-metric`) | the icon chip's background + foreground |
519
+ | Card chrome (`MetricWidget`, an `object-metric` block) | the icon chip's background + foreground |
377
520
  | Chrome-less (`MetricWidget variant: 'bare'`, and every dataset-bound `metric`) | the big number's text colour |
378
521
 
379
522
  Both read one shared table (`src/colorVariants.ts`), so the same declaration
@@ -383,6 +526,80 @@ the enum gets no accent and is not aliased to a nearby colour: it is invalid
383
526
  metadata, rejected where it is authored and published rather than reinterpreted
384
527
  here.
385
528
 
529
+ ## A widget with no `type`, or a `type` that names no family
530
+
531
+ A widget that declares no `type` is a `metric` widget. `@objectstack/spec`'s
532
+ `DashboardWidget.type` defaults to `metric`, and both dashboard surfaces
533
+ (`DashboardRenderer` and `DashboardGridLayout`) read that default from the spec,
534
+ so the widget draws exactly as the same widget with `type: 'metric'` does,
535
+ inline or bound to a dataset (objectui#11514). objectui's validator accepts the
536
+ widget without a `type` and does not write the default in, so the surfaces are
537
+ where it resolves.
538
+
539
+ objectui's legacy `component` envelope (`{ id, component, layout }`) is not the
540
+ spec's widget, and the default is not applied to it: an envelope with no `type`
541
+ draws its `component` under its card heading.
542
+
543
+ The envelope takes a `metric-card` node only (objectui#12163), and draws one
544
+ only (objectui#12254). Both validator faces and
545
+ `DashboardWidgetSchema['component']` refuse any other node by name, at
546
+ `component`, with the fix: a dashboard widget binds a dataset (ADR-0021), so
547
+ write the widget itself in the spec form, or put a `metric-card` in the slot.
548
+ Both surfaces draw a stored envelope by the same rule: a `metric-card` draws as
549
+ written, and any other node (an `object-chart`, a `text`, an `object-metric`, a
550
+ node with no `type`) draws the same retired-format prompt as a dataset-less
551
+ metric widget and sends no query. A `custom` widget with no `component` draws
552
+ the labelled placeholder described next.
553
+
554
+ A `type` that names no widget family and no component type (a typo, or a family
555
+ the spec no longer has) is refused by both validator faces at `type`. A stored
556
+ one draws the labelled placeholder "「type」chart type is not supported yet", as
557
+ a known family with no renderer (`heatmap`) does, instead of the renderer's red
558
+ "Unknown component type" panel.
559
+
560
+ ## How many measures a widget renders
561
+
562
+ A dataset-bound widget queries every measure in `values`. What it renders
563
+ depends on its shape:
564
+
565
+ - **A metric-family widget** (`metric`, `kpi`, `gauge`, `solid-gauge`,
566
+ `bullet`) is a one-number tile. It takes exactly one measure, and
567
+ `@objectstack/spec` refuses a second one at its door; several numbers is a
568
+ different visual.
569
+ - **A widget with no `dimensions` and several measures** renders every one of
570
+ them when its type is `table` / `pivot` (one row of measures) or a chart of
571
+ the bar family (`bar`, `column`, `horizontal-bar`) or `line` / `area` /
572
+ `combo` (one mark per measure, the measures' labels on the category axis,
573
+ which runs down the side on a `horizontal-bar`).
574
+ - **Any other dimensionless widget with several measures** still renders the
575
+ first one as a tile.
576
+ - **A `pie`, `donut`, `funnel`, `treemap` or `sankey` with a dimension** draws
577
+ one series, the first measure, however many it declares: the shared chart
578
+ renderer reads the first series only on those families.
579
+
580
+ When a widget keeps a declared measure off the screen, `DatasetWidget` logs
581
+ one console warning naming the widget, the measures it renders, and the ones it
582
+ queried and never displayed. Three shapes do that. Two take the tile: a metric
583
+ tile stored before the spec narrowed, and a widget the third bullet above
584
+ covers (no `dimensions`, several measures, and a type the second bullet does not
585
+ list, so a dimensionless `pie` is one). The third is a `pie`, `donut`,
586
+ `funnel`, `treemap` or `sankey` with a dimension and several measures
587
+ (objectui#11417). For a tile the warning
588
+ then names the spec's ADR-0087 entry
589
+ `dashboard-widget-metric-family-multi-measure-refused`, whose `replacement`
590
+ says what to author instead of a one-number tile with several measures. For
591
+ one of those five charts it says that the chart family draws a single series,
592
+ the first declared measure, and points to no entry: that one answers for a
593
+ tile. Nothing about what is drawn changes. Every door accepts the chart shape
594
+ today; the spec is to refuse it (objectstack#21293).
595
+ The widget panel (`WidgetConfigPanel`) asks the same door the spec runs: it
596
+ offers no further measure the door would refuse, and shows the door's own
597
+ message under measures it already refuses.
598
+
599
+ The behaviour is pinned by `src/__tests__/DatasetWidget.unrenderedMeasures-8894.test.tsx`,
600
+ `src/__tests__/DatasetWidget.dimensionlessMeasures-11261.test.tsx` and
601
+ `src/__tests__/WidgetConfigPanel.measureDoor-8894.test.tsx`.
602
+
386
603
  ## TypeScript Support
387
604
 
388
605
  This package ships **components, not schema types** — its whole type export
@@ -392,14 +609,16 @@ The authored shape is typed by `@object-ui/types`:
392
609
 
393
610
  | Import from `@object-ui/types` | What it types |
394
611
  | --- | --- |
395
- | `DashboardComponentSchema` | the whole `type: 'dashboard'` node — `columns`, `gap`, `widgets`, `header`, `globalFilters`, `dateRange`, `refreshInterval`, … |
612
+ | `DashboardComponentSchema` | the whole `type: 'dashboard'` node — `columns`, `gap`, `widgets`, `header`, `globalFilters`, `dateRange`, `refreshIntervalSeconds`, … |
396
613
  | `DashboardWidgetSchema` | one entry of `widgets[]` — the spec's `DashboardWidget` keys, plus objectui's own (`component`, `layout`, `options`, …) |
614
+ | `DashboardWidgetSlotComponentSchema` | the other kind of `widgets[]` entry — a component node placed directly in the slot, `type` one of the closed component set (`metric-card`); its other keys are that component's own props, which it declares (`title`, `value`, `icon`, `trend`, `trendValue`, and `description` from `BaseSchema`), except the spec's widget `layout`, which places the node |
397
615
  | `DashboardWidgetLayout` | a widget's `{ x, y, w, h }` grid box |
398
616
 
399
617
  ```typescript
400
618
  import type {
401
619
  DashboardComponentSchema,
402
620
  DashboardWidgetSchema,
621
+ DashboardWidgetSlotComponentSchema,
403
622
  } from '@object-ui/types';
404
623
 
405
624
  // Dataset-bound KPI — the widget vocabulary (see "Dashboard-level filters").
@@ -420,8 +639,9 @@ const users: DashboardWidgetSchema = {
420
639
  options: { value: '1,234' },
421
640
  };
422
641
 
423
- // Component format: a registered component node in the widget's `component`
424
- // slot. Its keys are that COMPONENT's props, not widget keys.
642
+ // Component format: a `metric-card` node in the widget's `component` slot, the
643
+ // one node the slot takes (objectui#12163). Its keys are that COMPONENT's props,
644
+ // not widget keys; it is typed by the same arm as `kpi` below.
425
645
  const custom: DashboardWidgetSchema = {
426
646
  id: 'kpi_custom',
427
647
  component: {
@@ -434,11 +654,23 @@ const custom: DashboardWidgetSchema = {
434
654
  layout: { x: 0, y: 0, w: 3, h: 2 },
435
655
  };
436
656
 
657
+ // Component node directly in the slot — the shape every `metric-card` example
658
+ // above uses. `type` is one of the closed component set; the other keys are
659
+ // the component's own props, which `DashboardWidgetSlotComponentSchema`
660
+ // declares.
661
+ const kpi: DashboardWidgetSlotComponentSchema = {
662
+ type: 'metric-card',
663
+ title: 'Revenue',
664
+ value: '$123,456',
665
+ trend: 'up',
666
+ trendValue: '+12%',
667
+ };
668
+
437
669
  const dashboard: DashboardComponentSchema = {
438
670
  type: 'dashboard',
439
671
  columns: 3,
440
672
  gap: 4,
441
- widgets: [revenue, users, custom],
673
+ widgets: [revenue, users, custom, kpi],
442
674
  };
443
675
  ```
444
676
 
@@ -447,8 +679,66 @@ covers every `type` (`metric`, `bar`, `table`, …) and the family-specific
447
679
  settings live under `options`. `MetricCard`'s own props — `value`, `trend`,
448
680
  `trendValue` — are the component's, not the widget's: `DashboardWidgetSchema`
449
681
  declares none of them, and the component's props interface is not on this
450
- package's export surface either. So a `metric-card` node is typed only where it
451
- appears as a component (the `component` slot above), not as a widget family.
682
+ package's export surface either. A `metric-card` node is typed as a COMPONENT
683
+ node in both places it can appear: in a widget's `component` slot (`custom`
684
+ above) and directly in `widgets[]` (`kpi` above, `DashboardWidgetSlotComponentSchema`
685
+ — the component arm of `DashboardComponentSchema['widgets']`, first in the
686
+ declaration as in the zod schema's two-arm slot). Either way its keys are
687
+ checked against that arm, which declares the card's registered inputs, never
688
+ as widget keys:
689
+
690
+ | Key | Type |
691
+ | --- | --- |
692
+ | `value` | `string \| number` — required |
693
+ | `title` | `string` or an inline per-locale map (`I18nLabel`) |
694
+ | `icon` | `string` — a Lucide icon name |
695
+ | `trend` | `'up' \| 'down' \| 'neutral'` — drawn with `trendValue` |
696
+ | `trendValue` | `string` — drawn with `trend` |
697
+ | `description` | `string` or `I18nLabel`, from `BaseSchema` |
698
+
699
+ `@object-ui/types/zod` judges the same members, so `objectui validate` refuses
700
+ a card directly in `widgets[]` whose `trend` is outside the three, or whose
701
+ `value` is neither a string nor a number.
702
+
703
+ ### Reading a widget key off `widgets[]`
704
+
705
+ The widget keys (`colorVariant`, `filter`, `dataset`, …) are declared on the
706
+ widget arm, `DashboardWidgetSchema`, which takes them from the spec's
707
+ `DashboardWidget` row. The component arm declares none of them, so read
708
+ straight off a `widgets[]` entry such a key is a compile error: the union offers
709
+ only the keys both arms declare (before objectui#8347 removed `BaseSchema`'s index
710
+ signature, the component arm answered it as `any`). This package's own readers take an entry by the slot's
711
+ element type, `DashboardComponentSchema['widgets'][number]`. The component arm
712
+ is not assignable to `DashboardWidgetSchema`, whose `type` names no component
713
+ type, so narrow an entry on `type` first: `metric-card` is the one component
714
+ type the slot holds, and any other entry is the widget arm. The compiler checks
715
+ the narrowing, not an annotation, and the key gets its declared type:
716
+
717
+ ```typescript
718
+ import type { DashboardComponentSchema } from '@object-ui/types';
719
+
720
+ declare const dashboard: DashboardComponentSchema;
721
+
722
+ const accents = dashboard.widgets.map((w) =>
723
+ w.type === 'metric-card' ? 'default' : (w.colorVariant ?? 'default'),
724
+ );
725
+ ```
726
+
727
+ `title` and `layout` are the two widget keys both arms declare. `title` is the
728
+ card's heading on the component arm, typed as `MetricCard` reads it, so it reads
729
+ as a string or an `I18nLabel` straight off a `widgets[]` entry. The component
730
+ arm takes the spec's widget `layout` by reference, because Save Layout writes it
731
+ onto every entry of `widgets[]`, a `metric-card` node included (see
732
+ [DashboardGridLayout](#dashboardgridlayout--persisting-drag--resize-edits)). So
733
+ `layout` reads with the spec's type straight off a `widgets[]` entry, and the
734
+ strict authoring face accepts it on a component node. A malformed one (not
735
+ four numbers, or a key besides `x`, `y`, `w` and `h`) is refused on either arm.
736
+
737
+ A widget with no `layout` is auto-placed by the grid. `DashboardWithConfig`'s
738
+ width and height sliders write the whole box: the dimension a slider does not
739
+ edit, and `x` and `y`, come from where the grid auto-places that widget
740
+ (`completeWidgetLayout` and `defaultWidgetPlacement` from `@object-ui/types`,
741
+ objectui#11388), so a one-dimension edit never stores a box the spec refuses.
452
742
 
453
743
  ## Customization
454
744
 
@@ -456,10 +746,15 @@ All components support Tailwind CSS classes:
456
746
 
457
747
  ```typescript
458
748
  const schema = {
459
- type: 'metric-card',
460
- title: 'Custom Metric',
461
- value: '100',
462
- className: 'bg-gradient-to-r from-blue-500 to-purple-600 text-white'
749
+ type: 'dashboard',
750
+ widgets: [
751
+ {
752
+ type: 'metric-card',
753
+ title: 'Custom Metric',
754
+ value: '100',
755
+ className: 'bg-gradient-to-r from-blue-500 to-purple-600 text-white'
756
+ }
757
+ ]
463
758
  };
464
759
  ```
465
760
 
@@ -484,14 +779,37 @@ object schema once and infers the renderer from the bound field:
484
779
  | `date` / `datetime` | Locale-formatted date |
485
780
  | `currency` | Locale currency (or honour `format: '$0,0'`) |
486
781
  | `percent` | `0%` / `0.0%` formatted (honour `format`) |
782
+ | `password` / `secret` | `••••••`, and the column is flagged `masked` (see below) |
487
783
 
488
784
  Author overrides always win — pass `type`, `format`, `options`,
489
- `referenceTo`, or your own `cell` function on a column to bypass
490
- auto-detection.
785
+ `currency`, or your own `cell` function on a column to bypass
786
+ auto-detection. An explicit `currency` (ISO 4217 code, e.g. `"EUR"`)
787
+ wins over both the symbol inferred from `format` and the tenant default
788
+ currency. A lookup column's related-object target always comes from the
789
+ object schema's own field definition — there is no column-level override
790
+ for it (objectui#6597: measured no authoring story for one).
791
+
792
+ A masked column (objectui#10657) is one the table withholds: no Ctrl+C / Cmd+C
793
+ copy, no tooltip, no CSV export column, no match in the search box, no header
794
+ sort, and a width sized from its header rather than its values. The widget
795
+ decides it with `isMaskedFieldType()` from `@object-ui/fields`, over the
796
+ column's authored `type` and the object's field type, so this is the one place
797
+ an author override does not win: `type: 'text'` over a `secret` field keeps the
798
+ flag, though the cell then draws the value as text. Rows handed in as `data`
799
+ or through `bind` can be drawn before the object schema arrives, and a failed
800
+ schema read never delivers one; in that window the widget flags every column,
801
+ and a column with no `type` of its own draws as the mask, never as text, until
802
+ the schema arrives, or for good when the read failed (objectui#10657). A column
803
+ that authors its `type` draws from it meanwhile. The record drawer a row opens
804
+ (record drill-down) draws every value as the mask in that window too.
805
+
806
+ Authored as a node, the object-bound table is `object-data-table`. A bare `table`
807
+ is the static component: it takes its rows in `data` and declares neither
808
+ `objectName` nor a column `format`.
491
809
 
492
810
  ```jsonc
493
811
  {
494
- "type": "table",
812
+ "type": "object-data-table",
495
813
  "objectName": "opportunity",
496
814
  "columns": [
497
815
  { "accessorKey": "name", "header": "Opportunity" },
@@ -518,25 +836,87 @@ calls `onWidgetsReorder(nextWidgets)` with the reordered array; the host (e.g.
518
836
  A 5px pointer-activation distance keeps click-to-select working on the same
519
837
  widget surface.
520
838
 
839
+ ## DashboardRenderer — a document the server already translated (`localized`)
840
+
841
+ A dashboard read from ObjectStack's `/meta` route arrives translated for the
842
+ request's locale. The server resolves the packaged translation catalog, and it
843
+ keeps a published edit over that catalog: an explicit override beats the
844
+ packaged default. Pass `localized={true}` when your document came from such a
845
+ read. The renderer then draws these texts as given:
846
+
847
+ - the widget `title` and `description`;
848
+ - its own header `label` and `description`.
849
+
850
+ An inline per-locale map is still collapsed to the active language. The client
851
+ bundle is not consulted again. A second lookup would let the packaged catalog
852
+ win back over a published edit (objectui#11295). The console's dashboard page
853
+ (`DashboardView`) sets the prop.
854
+
855
+ Leave it unset for a document the server never translated: an inline block, a
856
+ preview or a design surface. The bundle keys under
857
+ `dashboards.<name>.widgets.<id>.*` are then that document's one translation
858
+ pass, as before. Header-action labels always go through the bundle, because the
859
+ server does not translate them.
860
+
861
+ ## DashboardRenderer — the host draws "Refresh All" (`onRefreshStateChange`)
862
+
863
+ When the renderer holds an `onRefresh` handler it draws a "Refresh All" button,
864
+ and the same handler arms the authored `refreshIntervalSeconds` timer. A host
865
+ that draws its own page chrome can put that button in its chrome instead: pass
866
+ `onRefreshStateChange` alongside `onRefresh`. The renderer then draws no
867
+ refresh button of its own, and calls the callback with the state of its one
868
+ refresh implementation:
869
+
870
+ - `refreshing` is true while a run is in flight, manual or timed. Spin the
871
+ button's icon and disable the button on it.
872
+ - `handleRefresh` is the run the renderer's own button would call. Wire the
873
+ host's button to it, so a click calls `onRefresh` and shows the indicator.
874
+
875
+ The callback is called on mount, again each time `refreshing` changes, and with
876
+ `null` when there is nothing to draw: no `onRefresh` is wired, or the renderer
877
+ unmounted. A `useState` setter is enough as the callback; a new function on
878
+ every render is fine too.
879
+
880
+ The timer stays the renderer's: `onRefresh` still arms it, so the host runs no
881
+ timer of its own. The record-count badge (`recordCount`) is not handed over and
882
+ stays where the renderer draws it. The console's dashboard page
883
+ (`DashboardView`) passes the option and draws the button among its page
884
+ header's actions (objectui#12554). A host that leaves it unset gets the
885
+ renderer's button, as before.
886
+
521
887
  ## DashboardGridLayout — persisting drag / resize edits
522
888
 
523
- `DashboardGridLayout` (registered as schema `type: 'dashboard-grid'`) has an
889
+ `DashboardGridLayout` (a React component you mount directly; its old schema
890
+ `type: 'dashboard-grid'` was retired by objectui#10859) has an
524
891
  inline **"Edit Layout"** mode that lets users drag and resize widgets via
525
892
  `react-grid-layout`. When the user clicks **Save Layout**, the new grid
526
893
  coordinates are merged back into `schema.widgets[].layout` and handed off
527
894
  through the `onSchemaChange` callback.
528
895
 
529
896
  ```tsx
530
- <DashboardGridLayout
531
- schema={dashboard}
532
- // ✅ Preferred — write the updated schema through your data adapter.
533
- onSchemaChange={(next) => {
534
- // `name` is optional on the schema, and `saveItem` requires it — decide
535
- // what an unnamed dashboard means to your host instead of writing through.
536
- if (!next.name) return;
537
- client.meta.saveItem('dashboard', next.name, next);
538
- }}
539
- />
897
+ import { DashboardGridLayout } from '@object-ui/plugin-dashboard';
898
+ import type { ObjectStackAdapter } from '@object-ui/data-objectstack';
899
+ import type { DashboardComponentSchema } from '@object-ui/types';
900
+
901
+ // `dashboard` is the node being edited; `adapter` is host-provided.
902
+ declare const dashboard: DashboardComponentSchema;
903
+ declare const adapter: ObjectStackAdapter;
904
+
905
+ function DashboardEditor() {
906
+ const client = adapter.getClient();
907
+ return (
908
+ <DashboardGridLayout
909
+ schema={dashboard}
910
+ // ✅ Preferred — write the updated schema through your data adapter.
911
+ onSchemaChange={(next) => {
912
+ // `name` is optional on the schema, and `saveItem` requires it — decide
913
+ // what an unnamed dashboard means to your host instead of writing through.
914
+ if (!next.name) return;
915
+ client.meta.saveItem('dashboard', next.name, next);
916
+ }}
917
+ />
918
+ );
919
+ }
540
920
  ```
541
921
 
542
922
  The guard is not defensive noise: the callback receives a