From 82838f4fac598d4374617a4b05a9435aeb138b07 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 16 Sep 2026 16:18:22 +0000 Subject: [PATCH] fix(spec): title every authorable row property on dashboard.widgets and globalFilters Studio renders a `type: 'repeater'` field as a table whose column headers read `items.properties[k].title ?? k` off the JSON Schema derived by `z.toJSONSchema(...)`. With no `title` the fallback arm runs in every locale, English included, so the maker saw raw machine keys inside an otherwise translated panel. This is a missing authoring label in the contract, not a translation gap: the bundle overlay only ever REPLACES a title already present, so the English default has to live on the schema. Adds `.meta({ title })` to the 17 authorable row properties of `dashboard.widgets[]` and the 10 of `dashboard.globalFilters[]`, beside the existing `.describe()` rather than in place of it, following the mechanism already landed on `dashboard.header.actions` and the skill/report/page carriers. The shrink-only ledger in `repeater-item-titles.test.ts` is EXACT in both directions, so both paid entries are deleted from it rather than left behind. No `retiredKey()` tombstone is titled: the five on this row declare their keys unwritable, and an authoring label would advertise them as writable. The sibling control in `dashboard.test.ts` used `widgets[].id` as its untitled example, which this change titles; it is re-pointed onto a tombstone, which makes the control pin the tombstone rule instead of merely borrowing a coincidence. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6 --- .../17505-dashboard-repeater-row-titles.md | 15 +++++ .../src/kernel/repeater-item-titles.test.ts | 17 +++--- packages/spec/src/ui/dashboard.test.ts | 10 +++- packages/spec/src/ui/dashboard.zod.ts | 57 ++++++++++--------- 4 files changed, 63 insertions(+), 36 deletions(-) create mode 100644 .changeset/17505-dashboard-repeater-row-titles.md diff --git a/.changeset/17505-dashboard-repeater-row-titles.md b/.changeset/17505-dashboard-repeater-row-titles.md new file mode 100644 index 00000000000..c18fb71613d --- /dev/null +++ b/.changeset/17505-dashboard-repeater-row-titles.md @@ -0,0 +1,15 @@ +--- +"@objectstack/spec": minor +--- + +`dashboard.widgets[]` (17) and `dashboard.globalFilters[]` (10) — every authorable row property of these two repeaters now carries a JSON Schema `title`, so Studio's property-panel table prints an authoring label instead of the raw machine key (#17505). + +`Clause-②: yes` — no authorable key moves, but each row property gains a `title` node in the emitted JSON Schema, which is a published artifact. + +Studio renders a `type: 'repeater'` field as a table whose column headers read `items.properties[k].title ?? k` off the schema derived by `z.toJSONSchema(...)`. With no `title` the fallback arm runs in **every** locale, English included, so the maker saw `requiresService`, `filterBindings` and `optionsFrom` inside an otherwise translated panel. That is a missing authoring label in the contract, not a translation gap — the English default has to live on the schema, because `resolveMetadataFormSchemaTitles` only ever REPLACES a `title` that is already there. + +- **Mechanism unchanged** — this applies the one ruled in #16458 and already landed on `dashboard.header.actions` and on the `ai/skill`, `ui/report` and `ui/page` carriers: `.meta({ title })` on the zod item schema, beside the existing `.describe()` rather than in place of it. +- **The debt record is deleted, not suppressed.** `repeater-item-titles.test.ts` keeps an exact, shrink-only ledger: a carrier in it must still be untitled, so paying a debt and leaving the entry behind is as red as never paying it. Both `dashboard:*` entries are gone from that set; five remain (`field:options`, `object:fields.options`, `view:columns`, `view:sort`, `view:tabs`). +- ⛔ **No tombstone was titled.** The five `retiredKey()` keys on this row (`actionUrl`, `actionType`, `actionIcon`, `responsive`, `aria`) declare their keys unwritable; an authoring label would advertise them as writable. All five still emit `title: undefined` in both `io: 'input'` and `io: 'output'`, and the sibling control in `dashboard.test.ts` was re-pointed onto one of them so the rule is now pinned rather than assumed. + +Measured through the platform's own predicate (`z.toJSONSchema` over `getMetadataTypeSchema`, `io: 'input'`), not by regexing source: `dashboard:widgets` untitled 17 → 0 and `dashboard:globalFilters` untitled 10 → 0, with all twenty other repeater carriers unchanged in the same run. diff --git a/packages/spec/src/kernel/repeater-item-titles.test.ts b/packages/spec/src/kernel/repeater-item-titles.test.ts index 98892745a10..8f9284870ea 100644 --- a/packages/spec/src/kernel/repeater-item-titles.test.ts +++ b/packages/spec/src/kernel/repeater-item-titles.test.ts @@ -84,16 +84,17 @@ const FORMS: ReadonlyArray = [ * Carriers still owed titles, as measured on `origin/main` at * e758131b3900eb13260f03643e295ca6d625c42b. SHRINK-ONLY — see the header. * - * The four `dashboard.*` / `view.*` / `field.*` entries were fenced out of - * #17232's round by in-flight PRs on their carrier files (#17474 `dashboard.zod.ts`, - * #17360 `view.zod.ts`, #17477 `field.zod.ts` — `field.options` and - * `object.fields.options` are the same `SelectOptionSchema`). This pin - * OBSERVES them without editing them, which is why the count below is the - * whole class and not the slice one PR could reach. + * The remaining `view.*` / `field.*` entries were fenced out of #17232's + * round by in-flight PRs on their carrier files (#17360 `view.zod.ts`, + * #17477 `field.zod.ts` — `field.options` and `object.fields.options` are + * the same `SelectOptionSchema`). This pin OBSERVES them without editing + * them, which is why the set below is the rest of the class and not the + * slice one PR could reach. + * + * `dashboard:widgets` and `dashboard:globalFilters` were paid by #17505 and + * DELETED from this set — a paid debt leaves no entry behind. */ const LEDGER: ReadonlySet = new Set([ - 'dashboard:widgets', - 'dashboard:globalFilters', 'field:options', 'object:fields.options', 'view:columns', diff --git a/packages/spec/src/ui/dashboard.test.ts b/packages/spec/src/ui/dashboard.test.ts index a3bf9405b69..31d7dc2adef 100644 --- a/packages/spec/src/ui/dashboard.test.ts +++ b/packages/spec/src/ui/dashboard.test.ts @@ -769,8 +769,16 @@ describe('#16458 — DashboardHeaderAction fields carry an item-level `title`', } // Control — a sibling item property with no authored title has none: // the pin above is reading a title, not a default the emitter invents. + // The control is a `retiredKey()` TOMBSTONE, which is the one widget row + // property that must stay untitled on purpose: it declares the key + // unwritable, and an authoring label would advertise it as writable. + // (`widgets[].id` held this role until its carrier was titled.) const widgetProps = js.properties.widgets.items.properties; - expect(widgetProps.id.title).toBeUndefined(); + expect(widgetProps.actionUrl.title).toBeUndefined(); + expect(widgetProps.actionUrl.description).toMatch(/^\[REMOVED\] /); + // Lit — the authorable sibling really does carry one, so the line above + // measures the tombstone rule, not an emitter that never writes titles. + expect(widgetProps.id.title).toBe('Widget ID'); }); } diff --git a/packages/spec/src/ui/dashboard.zod.ts b/packages/spec/src/ui/dashboard.zod.ts index 008e7a659bb..4c681c99842 100644 --- a/packages/spec/src/ui/dashboard.zod.ts +++ b/packages/spec/src/ui/dashboard.zod.ts @@ -535,22 +535,22 @@ export const DashboardWidgetSchema = lazySchema(() => strictObject({ guidanceSets: WIDGET_GUIDANCE_SETS, }, { /** Unique widget identifier (snake_case, used for targetWidgets references) */ - id: SnakeCaseIdentifierSchema.describe('Unique widget identifier (snake_case)'), + id: SnakeCaseIdentifierSchema.describe('Unique widget identifier (snake_case)').meta({ title: 'Widget ID' }), /** Widget Title */ - title: I18nLabelSchema.optional().describe('Widget title'), + title: I18nLabelSchema.optional().describe('Widget title').meta({ title: 'Title' }), /** Widget Description (displayed below the title) */ - description: I18nLabelSchema.optional().describe('Widget description text below the header'), + description: I18nLabelSchema.optional().describe('Widget description text below the header').meta({ title: 'Description' }), /** Visualization Type */ - type: ChartTypeSchema.default(WIDGET_TYPE_DEFAULT).describe('Visualization type'), + type: ChartTypeSchema.default(WIDGET_TYPE_DEFAULT).describe('Visualization type').meta({ title: 'Visualization Type' }), /** Chart Configuration */ - chartConfig: ChartConfigSchema.optional().describe('Chart visualization configuration'), + chartConfig: ChartConfigSchema.optional().describe('Chart visualization configuration').meta({ title: 'Chart Configuration' }), /** Color variant for the widget (e.g., KPI card accent color) */ - colorVariant: WidgetColorVariantSchema.optional().describe('Widget color variant for theming'), + colorVariant: WidgetColorVariantSchema.optional().describe('Widget color variant for theming').meta({ title: 'Color Variant' }), /** * Runtime capability gate — widget is hidden when the named object is @@ -561,13 +561,13 @@ export const DashboardWidgetSchema = lazySchema(() => strictObject({ * Set explicitly to the dataset's base object when the widget should be * gated on that object's availability. */ - requiresObject: z.string().optional().describe('Hide the widget unless the named object is registered'), + requiresObject: z.string().optional().describe('Hide the widget unless the named object is registered').meta({ title: 'Requires Object' }), /** * Runtime capability gate — widget is hidden when the named kernel * service is not registered. Mirrors `NavigationItem.requiresService`. */ - requiresService: z.string().optional().describe('Hide the widget unless the named kernel service is registered'), + requiresService: z.string().optional().describe('Hide the widget unless the named kernel service is registered').meta({ title: 'Requires Service' }), // `actionUrl` / `actionType` / `actionIcon` REMOVED (#5010, ADR-0049 D2): // the three keys described a per-widget header action BUTTON that no renderer @@ -583,7 +583,7 @@ export const DashboardWidgetSchema = lazySchema(() => strictObject({ actionIcon: retiredKey(WIDGET_ACTION_RETIRED('actionIcon')), /** Presentation-scope filter (MongoDB-style), ANDed into the dataset query as `runtimeFilter`. */ - filter: FilterConditionSchema.optional().describe('Presentation-scope filter (runtimeFilter)'), + filter: FilterConditionSchema.optional().describe('Presentation-scope filter (runtimeFilter)').meta({ title: 'Filter' }), /** * Period-over-period comparison window. @@ -689,7 +689,7 @@ export const DashboardWidgetSchema = lazySchema(() => strictObject({ */ dimension: z.string().optional() .describe('Time dimension to shift; omit when the selection has exactly one dated time dimension'), - }).optional().describe('Period-over-period comparison window ({ kind, dimension? })'), + }).optional().describe('Period-over-period comparison window ({ kind, dimension? })').meta({ title: 'Compare To' }), /** * ADR-0021 — the semantic-layer `dataset` this widget binds to. The widget @@ -699,11 +699,11 @@ export const DashboardWidgetSchema = lazySchema(() => strictObject({ * author-facing analytics shape (the legacy inline `object` + `categoryField` * + `valueField` + `aggregate` query was removed in the single-form cutover). */ - dataset: SnakeCaseIdentifierSchema.describe('Dataset name to bind (ADR-0021)'), + dataset: SnakeCaseIdentifierSchema.describe('Dataset name to bind (ADR-0021)').meta({ title: 'Dataset' }), /** Dimension names (from the dataset) for X / group / split. */ - dimensions: z.array(z.string()).optional().describe('Dimension names — X/group/split'), + dimensions: z.array(z.string()).optional().describe('Dimension names — X/group/split').meta({ title: 'Dimensions' }), /** Measure names (from the dataset) for the value axis. */ - values: z.array(z.string()).min(1).describe('Measure names — Y (at least one)'), + values: z.array(z.string()).min(1).describe('Measure names — Y (at least one)').meta({ title: 'Values' }), /** * Layout Position (React-Grid-Layout style) @@ -758,10 +758,10 @@ export const DashboardWidgetSchema = lazySchema(() => strictObject({ y: z.number(), w: z.number(), h: z.number(), - }).optional().describe('Grid layout position (auto-flowed when omitted)'), + }).optional().describe('Grid layout position (auto-flowed when omitted)').meta({ title: 'Layout' }), /** Widget specific options (colors, legend, etc.) — see {@link DashboardWidgetOptionsSchema}. */ - options: DashboardWidgetOptionsSchema.optional().describe('Widget specific configuration'), + options: DashboardWidgetOptionsSchema.optional().describe('Widget specific configuration').meta({ title: 'Options' }), /** * Per-widget bindings from a dashboard-level filter (referenced by its @@ -773,14 +773,15 @@ export const DashboardWidgetSchema = lazySchema(() => strictObject({ * (dateRange: `dateRange.field ?? 'created_at'`) */ filterBindings: z.record(z.string(), z.union([z.string(), z.literal(false)])).optional() - .describe("Per-widget dashboard-filter bindings: filter name → this widget's field, or false to opt out"), + .describe("Per-widget dashboard-filter bindings: filter name → this widget's field, or false to opt out") + .meta({ title: 'Filter Bindings' }), /** * Rule ids of build diagnostics intentionally suppressed on this widget * (e.g. `'table-count-only'` when a single-row summary table is deliberate). * Consumed by `objectstack build` / `objectstack lint`; no runtime effect. */ - suppressWarnings: z.array(z.string()).optional().describe('Build diagnostic rule ids suppressed on this widget'), + suppressWarnings: z.array(z.string()).optional().describe('Build diagnostic rule ids suppressed on this widget').meta({ title: 'Suppress Warnings' }), // `responsive` REMOVED (#4876): authorable and inert, exactly like the // same-named `view.responsive` retired four days earlier (#3896 close-out). @@ -1004,7 +1005,7 @@ export const GlobalFilterSchema = lazySchema(() => strictObject({ * Defaults to `field`. The name `"dateRange"` is reserved for the built-in * dashboard date range. */ - name: z.string().optional().describe('Stable filter name (variable key); defaults to field'), + name: z.string().optional().describe('Stable filter name (variable key); defaults to field').meta({ title: 'Name' }), /** * Field name to filter on — at the authoring layer it resolves against the @@ -1028,7 +1029,8 @@ export const GlobalFilterSchema = lazySchema(() => strictObject({ * dimension without the dataset declaring it, and `widget-dimension-unknown` * is what holds that line for authored dashboards. */ - field: z.string().describe('Field name to filter on — at the authoring layer it resolves against the object behind each bound widget\'s dataset (`dataset.object`), not against that dataset\'s declared `dimensions`; enforced by the lint rule `dashboard-filter-field-unknown` (severity error)'), + field: z.string().describe('Field name to filter on — at the authoring layer it resolves against the object behind each bound widget\'s dataset (`dataset.object`), not against that dataset\'s declared `dimensions`; enforced by the lint rule `dashboard-filter-field-unknown` (severity error)') + .meta({ title: 'Field' }), /** * Source object for i18n label resolution (#7804): when set, this filter's @@ -1046,13 +1048,14 @@ export const GlobalFilterSchema = lazySchema(() => strictObject({ * keyed by. `optionsFrom.object` already proves the schema is willing to * name an object here — this reuses that same primitive one level up. */ - object: z.string().optional().describe('Object whose `fields..` translation-bundle entry resolves this filter\'s field label and option labels'), + object: z.string().optional().describe('Object whose `fields..` translation-bundle entry resolves this filter\'s field label and option labels') + .meta({ title: 'Object' }), /** Display label for the filter */ - label: I18nLabelSchema.optional().describe('Display label for the filter'), + label: I18nLabelSchema.optional().describe('Display label for the filter').meta({ title: 'Label' }), /** Filter input type */ - type: z.enum(['text', 'select', 'date', 'number', 'lookup']).optional().describe('Filter input type'), + type: z.enum(['text', 'select', 'date', 'number', 'lookup']).optional().describe('Filter input type').meta({ title: 'Input Type' }), /** Static options for select/lookup filters */ options: z.array(strictObject({ @@ -1062,19 +1065,19 @@ export const GlobalFilterSchema = lazySchema(() => strictObject({ }, { value: z.union([z.string(), z.number(), z.boolean()]).describe('Option value'), label: I18nLabelSchema, - })).optional().describe('Static filter options'), + })).optional().describe('Static filter options').meta({ title: 'Options' }), /** Dynamic data binding for filter options */ - optionsFrom: GlobalFilterOptionsFromSchema.optional().describe('Dynamic filter options from object'), + optionsFrom: GlobalFilterOptionsFromSchema.optional().describe('Dynamic filter options from object').meta({ title: 'Options From' }), /** Default filter value */ - defaultValue: z.union([z.string(), z.number(), z.boolean()]).optional().describe('Default filter value'), + defaultValue: z.union([z.string(), z.number(), z.boolean()]).optional().describe('Default filter value').meta({ title: 'Default Value' }), /** Filter application scope */ - scope: z.enum(['dashboard', 'widget']).default('dashboard').describe('Filter application scope'), + scope: z.enum(['dashboard', 'widget']).default('dashboard').describe('Filter application scope').meta({ title: 'Scope' }), /** Widget IDs to apply this filter to (when scope is widget) */ - targetWidgets: z.array(z.string()).optional().describe('Widget IDs to apply this filter to'), + targetWidgets: z.array(z.string()).optional().describe('Widget IDs to apply this filter to').meta({ title: 'Target Widgets' }), }) // #4614 — the date `defaultValue` vocabulary check. Attached by identifier, // not inlined: the export is the rule a `.shape` mirror re-attaches (#16489),