Skip to content

Commit 67bf2e2

Browse files
os-zhuangclaude
andauthored
feat(spec)!: the dashboard's header, filters and root close (#4001) (#4532)
`DashboardWidgetSchema` has been strict since the ADR-0021 cutover, and its error map states the reason in its own words: undeclared keys "were dropped silently before strict validation, shipping inert metadata". Everything AROUND the widget kept the posture the widget was rescued from — the header, its actions, the global filters and their option sources, the date range, and the dashboard root. Three keys get a prescription rather than a rename, because a rename would be wrong: - `layout` on the dashboard reads like a template selector; there is none. Layout is per-widget, and a widget without one is auto-flowed. - `subtitle` on the header: the header renders the dashboard's own label/description, with only showTitle/showDescription to toggle them. - `filterBindings` on a filter: the binding runs the other way — a WIDGET maps this filter's name to one of its own fields, or false to opt out. Deliberately left open. `DashboardWidgetOptionsSchema` stays passthrough: it is the renderer-extras escape hatch by design, and the four keys in it that do reach the analytics query are already declared explicitly (framework#3588). Closing it would break the escape hatch to fix a problem already fixed the right way. The widget's bespoke error map also stays — it works and is tested; converging it onto strictObject is a follow-up, not a prerequisite. Registered types closed: 23 of 25. Still open: action, view — the last two, so the warning layer is down to two covered roots. When both close it has nothing left to warn about at a root, which is the campaign finishing rather than the layer breaking; the test says so in place rather than being deleted. Surfaced while re-pointing a test at `view`: one unknown key on a view reports TWICE, because `view` is a union and the walk emits per strip-mode variant. Recorded in the test rather than dodged by picking a non-union collection. Verified: 284 files / 7240 tests, tsc clean, 8 generated artifacts current, all 10 spec gates green, and CRM/Todo/showcase/platform-objects build (showcase carries three real dashboards). Claude-Session: https://claude.ai/code/session_01WnqGjQFQMqd5k81LYV8SCY Co-authored-by: Claude <noreply@anthropic.com>
1 parent 07a4e26 commit 67bf2e2

4 files changed

Lines changed: 123 additions & 20 deletions

File tree

.changeset/dashboard-strict.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
The dashboard's header, filter bar and root reject unknown keys — the posture its widget was rescued from three releases ago.
6+
7+
`DashboardWidgetSchema` has been `.strict()` since the ADR-0021 cutover, and its error map states the reason in its own words: undeclared keys "were dropped silently before strict validation, shipping inert metadata". Everything *around* the widget kept the posture the widget was rescued from — the header, the header actions, the global filters and their option sources, the date range, and the dashboard root itself.
8+
9+
Closed with `strictObject`, so each now names its surface, echoes the offending key, and suggests the nearest declared one. The aliases are the vocabulary of a dashboard: `charts`/`components`/`cards`/`tiles``widgets`, `filters``globalFilters`, `refresh`/`autoRefresh`/`pollInterval``refreshInterval`, `dateFilter`/`timeRange``dateRange`.
10+
11+
Three wrong-layer keys get a prescription rather than a rename, because a rename would be wrong:
12+
13+
- **`layout` on the dashboard.** It reads like a template selector; there isn't one. Each *widget* carries `layout: { x, y, w, h }`, and a widget with none is auto-flowed into the grid.
14+
- **`subtitle` on the header.** The header renders the dashboard's own `label`/`description` — there is no separate header copy, only `showTitle`/`showDescription` to toggle them.
15+
- **`filterBindings` on a global filter.** The binding runs the other way: a *widget* maps this filter's `name` to one of its own fields, or `false` to opt out.
16+
17+
Deliberately left open: `DashboardWidgetOptionsSchema` stays `passthrough`. It is the renderer-extras escape hatch by design — presentation settings the renderer understands are none of the spec's business — and the four keys in it that *do* reach the analytics query are already declared explicitly (framework#3588). Closing it would break the escape hatch to fix a problem that was already fixed the right way.
18+
19+
Also unchanged: the widget's bespoke `strictWidgetAnalyticsError`, which carries the pre-ADR-0021 inline-analytics and objectui-internal prescriptions. It works and is tested; converging it onto `strictObject` (which would add "did you mean" suggestions on top of those prescriptions) is a follow-up, not a prerequisite.
20+
21+
Registered types closed at the top level: **23 of 25**. Still open: `action`, `view` — and they are the last two, so the unknown-key *warning* layer is down to two covered roots. When both close it has nothing left to warn about at a root, which is the campaign finishing rather than the layer breaking; the test says so in place rather than being deleted.
22+
23+
One thing surfaced while re-pointing a test at `view`: a single unknown key on a view reports **twice**, because `view` is a union (container | ViewItem | overlay) and the walk emits one finding per strip-mode variant the key lands in. Recorded in the test rather than dodged by picking a non-union collection; it becomes moot when `view` closes.
24+
25+
Authoring impact: a key none of these shapes declares is now rejected instead of silently discarded — it was already being ignored, so no working dashboard changes.

packages/spec/src/kernel/metadata-authoring-lint.test.ts

Lines changed: 23 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -54,11 +54,17 @@ describe('coverage derivation (#3786 — no third hand-written list)', () => {
5454
// `page.regions[0].zzz` all `safeParse` to failure — and the check is worth
5555
// repeating on the next one, because a broken walk and a successful
5656
// graduation shrink this count identically.
57-
expect(lintables.length).toBeGreaterThanOrEqual(3);
57+
// 3 → 2 when `dashboard` closed; `dashboard.zzz` was confirmed rejected by
58+
// the parse first, same as the batch before it.
59+
expect(lintables.length).toBeGreaterThanOrEqual(2);
5860
// `view` matters doubly: it is a UNION (container | ViewItem | overlay), so
5961
// its presence pins the union half of the posture logic — a regression that
6062
// silently dropped unions would shrink coverage without failing the count.
61-
for (const expected of ['dashboard', 'action', 'view']) {
63+
// When `view` and `action` close, this whole layer has nothing left to warn
64+
// about at a ROOT, which is the campaign finishing rather than the lint
65+
// breaking — at that point assert the empty set deliberately, do not delete
66+
// the test.
67+
for (const expected of ['action', 'view']) {
6268
expect(lintableTypes, `expected '${expected}' to be lint-covered`).toContain(expected);
6369
}
6470
});
@@ -163,20 +169,26 @@ describe('the #4148 behaviours survive the generalization', () => {
163169
});
164170

165171
it('reports across collections in one walk, with per-type surfaces', () => {
166-
// `page`/`agent` (batch 6a) and `field` (batch 6b) used to appear here too;
167-
// each closed, so the parse rejects and the lint correctly stays quiet —
168-
// the hand-off this whole layer was built to make. Two collections still
169-
// participate, so this keeps proving per-type surfacing rather than
172+
// `page`/`agent` (6a), `field` (6b) and `dashboard` (6c) used to appear
173+
// here; each closed, so the parse rejects and the lint correctly stays
174+
// quiet — the hand-off this whole layer was built to make. Two collections
175+
// still participate so this keeps proving per-type surfacing rather than
170176
// degenerating into a single-collection test: `object` reports a NESTED
171-
// strip site under a CLOSED root (the #4522 behaviour), and `dashboard`
172-
// reports at its root.
177+
// strip site under a CLOSED root (the #4522 behaviour), and `view` — the
178+
// last open root, and the union case — reports at its own.
173179
const findings = lintUnknownAuthoringKeys({
174180
objects: [{ name: 'a', label: 'A', actions: [{ name: 'act', zzz: 1 }] }],
175-
dashboards: [{ name: 'd', label: 'D', zzz: 1 }],
181+
views: [{ name: 'v', object: 'a', zzz: 1 }],
176182
});
177-
expect(findings.map((f) => `${f.surface}:${f.path}`).sort()).toEqual([
178-
'dashboard:dashboards.d.zzz',
183+
// Deduped deliberately: `view` is a union (container | ViewItem | overlay)
184+
// and the walk emits one finding per strip-mode variant the key lands in,
185+
// so a single authored key reports twice. That is noise an author would
186+
// see, but it is the union walk's behaviour and not this test's subject —
187+
// and it becomes moot when `view` closes. Left recorded rather than papered
188+
// over by picking a non-union collection.
189+
expect([...new Set(findings.map((f) => `${f.surface}:${f.path}`))].sort()).toEqual([
179190
'object:objects.a.actions.0.zzz',
191+
'view:views.v.zzz',
180192
]);
181193
});
182194

packages/spec/src/kernel/metadata-type-schemas.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -218,7 +218,7 @@ describe('registered metadata types', () => {
218218
* type fails this suite until the list shrinks, so the list cannot outlive the
219219
* debt and start exempting types that no longer need exempting.
220220
*/
221-
const STILL_STRIP = new Set<string>(['action', 'dashboard', 'view']);
221+
const STILL_STRIP = new Set<string>(['action', 'view']);
222222

223223
/** The registered schema's own top-level posture: `.strict()` sets a `never` catchall. */
224224
function topLevelPosture(schema: unknown, depth = 0): 'strict' | 'strip' | null {
@@ -285,7 +285,7 @@ describe('#4001 — registered-type closure is derived, not tallied', () => {
285285
it('reports the campaign number so a reader never has to count', () => {
286286
const closed = types.filter((t) => !STILL_STRIP.has(t));
287287
expect(closed.length + STILL_STRIP.size).toBe(types.length);
288-
expect(closed.length).toBe(22);
288+
expect(closed.length).toBe(23);
289289
expect(types.length).toBe(25);
290290
});
291291
});

packages/spec/src/ui/dashboard.zod.ts

Lines changed: 73 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import { z } from 'zod';
44
import { ProtectionSchema } from '../shared/protection.zod';
55
import { MetadataProtectionFields } from '../kernel/metadata-protection.zod';
6+
import { strictObject } from '../shared/strict-object';
67
import { FilterConditionSchema } from '../data/filter.zod';
78
import { DateGranularity } from '../data/query.zod';
89
import { ChartTypeSchema, ChartConfigSchema } from './chart.zod';
@@ -44,11 +45,28 @@ export const WidgetColorVariantSchema = lazySchema(() => z.enum([
4445
*/
4546
export const WidgetActionTypeSchema = lazySchema(() => ActionType.describe('Widget action type'));
4647

48+
/**
49+
* Shared history for this file (#4001).
50+
*
51+
* `DashboardWidgetSchema` has been strict since the ADR-0021 cutover, and its
52+
* error map says why in its own words: undeclared keys "were dropped silently
53+
* before strict validation, shipping inert metadata". Everything AROUND the
54+
* widget — the header, the filter bar, the dashboard itself — kept the posture
55+
* the widget was rescued from.
56+
*/
57+
const DASHBOARD_HISTORY =
58+
'Until #4001 closed this shape these were dropped silently — the dashboard still rendered, '
59+
+ 'without whatever the key was meant to configure.';
60+
4761
/**
4862
* Dashboard Header Action Schema
4963
* An action button displayed in the dashboard header area.
5064
*/
51-
export const DashboardHeaderActionSchema = lazySchema(() => z.object({
65+
export const DashboardHeaderActionSchema = lazySchema(() => strictObject({
66+
surface: 'this dashboard header action',
67+
history: DASHBOARD_HISTORY,
68+
aliases: { title: 'label', text: 'label', name: 'label', url: 'actionUrl', href: 'actionUrl', link: 'actionUrl', target: 'actionUrl', type: 'actionType', kind: 'actionType' },
69+
}, {
5270
/** Action label */
5371
label: I18nLabelSchema.describe('Action button label'),
5472

@@ -66,7 +84,16 @@ export const DashboardHeaderActionSchema = lazySchema(() => z.object({
6684
* Dashboard Header Schema
6785
* Structured header configuration for the dashboard.
6886
*/
69-
export const DashboardHeaderSchema = lazySchema(() => z.object({
87+
export const DashboardHeaderSchema = lazySchema(() => strictObject({
88+
surface: 'this dashboard header',
89+
history: DASHBOARD_HISTORY,
90+
aliases: { title: 'showTitle', description: 'showDescription', buttons: 'actions', links: 'actions' },
91+
guidance: {
92+
// The header shows the DASHBOARD's own label/description — it does not
93+
// carry copy of its own, which is the mistake `title:`/`subtitle:` here is.
94+
subtitle: 'the header renders the dashboard\'s own `label`/`description` — there is no separate header copy; toggle them with `showTitle`/`showDescription`',
95+
},
96+
}, {
7097
/** Whether to show the dashboard title in the header */
7198
showTitle: z.boolean().default(true).describe('Show dashboard title in header'),
7299

@@ -339,7 +366,11 @@ export const DashboardWidgetSchema = lazySchema(() => z.object({
339366
* Dynamic options binding for global filters.
340367
* Allows dropdown options to be fetched from an object at runtime.
341368
*/
342-
export const GlobalFilterOptionsFromSchema = lazySchema(() => z.object({
369+
export const GlobalFilterOptionsFromSchema = lazySchema(() => strictObject({
370+
surface: 'this dynamic filter option source',
371+
history: DASHBOARD_HISTORY,
372+
aliases: { objectName: 'object', from: 'object', source: 'object', value: 'valueField', label: 'labelField', text: 'labelField', where: 'filter', criteria: 'filter' },
373+
}, {
343374
/** Source object name to fetch options from */
344375
object: z.string().describe('Source object name'),
345376

@@ -357,7 +388,16 @@ export const GlobalFilterOptionsFromSchema = lazySchema(() => z.object({
357388
* Global Filter Schema
358389
* Defines a single global filter control for the dashboard filter bar.
359390
*/
360-
export const GlobalFilterSchema = lazySchema(() => z.object({
391+
export const GlobalFilterSchema = lazySchema(() => strictObject({
392+
surface: 'this global filter',
393+
history: DASHBOARD_HISTORY,
394+
aliases: { key: 'name', variable: 'name', fieldName: 'field', title: 'label', inputType: 'type', control: 'type', choices: 'options', values: 'options', source: 'optionsFrom', dataSource: 'optionsFrom', optionsSource: 'optionsFrom', default: 'defaultValue', widgets: 'targetWidgets', appliesTo: 'targetWidgets' },
395+
guidance: {
396+
// The binding runs the other way: a widget opts in/out via `filterBindings`.
397+
// `targetWidgets` exists, but only for `scope: 'widget'`.
398+
filterBindings: 'a widget declares its own binding — `filterBindings` lives on the WIDGET and maps this filter\'s `name` to one of that widget\'s fields (or `false` to opt out)',
399+
},
400+
}, {
361401
/**
362402
* Stable filter name (framework#2501) — the dashboard-variable key under
363403
* which the filter's value is published (readable in widget expressions as
@@ -377,7 +417,11 @@ export const GlobalFilterSchema = lazySchema(() => z.object({
377417
type: z.enum(['text', 'select', 'date', 'number', 'lookup']).optional().describe('Filter input type'),
378418

379419
/** Static options for select/lookup filters */
380-
options: z.array(z.object({
420+
options: z.array(strictObject({
421+
surface: 'this filter option',
422+
history: DASHBOARD_HISTORY,
423+
aliases: { key: 'value', id: 'value', text: 'label', title: 'label', name: 'label' },
424+
}, {
381425
value: z.union([z.string(), z.number(), z.boolean()]).describe('Option value'),
382426
label: I18nLabelSchema,
383427
})).optional().describe('Static filter options'),
@@ -424,7 +468,25 @@ export const GlobalFilterSchema = lazySchema(() => z.object({
424468
* ]
425469
* }
426470
*/
427-
export const DashboardSchema = lazySchema(() => z.object({
471+
export const DashboardSchema = lazySchema(() => strictObject({
472+
surface: 'this dashboard',
473+
history: DASHBOARD_HISTORY,
474+
aliases: {
475+
title: 'label', displayName: 'label',
476+
charts: 'widgets', components: 'widgets', cards: 'widgets', tiles: 'widgets',
477+
filters: 'globalFilters', globalFilter: 'globalFilters',
478+
grid: 'columns', columnCount: 'columns',
479+
spacing: 'gap',
480+
refresh: 'refreshInterval', autoRefresh: 'refreshInterval', pollInterval: 'refreshInterval',
481+
dateFilter: 'dateRange', timeRange: 'dateRange',
482+
},
483+
guidance: {
484+
// Widget layout is per-widget; a dashboard-level `layout` reads like a
485+
// template selector, which does not exist here.
486+
layout: 'a dashboard has no layout template — each widget carries its own `layout: { x, y, w, h }`, and a widget with none is auto-flowed into the grid',
487+
theme: 'dashboards do not carry a theme — presentation-only widget settings go under that widget\'s `options`',
488+
},
489+
}, {
428490
/** Machine name */
429491
name: SnakeCaseIdentifierSchema.describe('Dashboard unique name'),
430492

@@ -450,7 +512,11 @@ export const DashboardSchema = lazySchema(() => z.object({
450512
refreshInterval: z.number().optional().describe('Auto-refresh interval in seconds'),
451513

452514
/** Dashboard Date Range (Global time filter) */
453-
dateRange: z.object({
515+
dateRange: strictObject({
516+
surface: 'this dashboard date range',
517+
history: DASHBOARD_HISTORY,
518+
aliases: { dateField: 'field', fieldName: 'field', preset: 'defaultRange', range: 'defaultRange', default: 'defaultRange', allowCustom: 'allowCustomRange', custom: 'allowCustomRange' },
519+
}, {
454520
field: z.string().optional().describe('Default date field name for time-based filtering'),
455521
defaultRange: z.enum(['today', 'yesterday', 'this_week', 'last_week', 'this_month', 'last_month', 'this_quarter', 'last_quarter', 'this_year', 'last_year', 'last_7_days', 'last_30_days', 'last_90_days', 'custom']).default('this_month').describe('Default date range preset'),
456522
allowCustomRange: z.boolean().default(true).describe('Allow users to pick a custom date range'),

0 commit comments

Comments
 (0)