Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .changeset/17505-dashboard-repeater-row-titles.md
Original file line number Diff line number Diff line change
@@ -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.
17 changes: 9 additions & 8 deletions packages/spec/src/kernel/repeater-item-titles.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,16 +84,17 @@ const FORMS: ReadonlyArray<readonly [string, unknown]> = [
* 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<string> = new Set([
'dashboard:widgets',
'dashboard:globalFilters',
'field:options',
'object:fields.options',
'view:columns',
Expand Down
10 changes: 9 additions & 1 deletion packages/spec/src/ui/dashboard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
}

Expand Down
57 changes: 30 additions & 27 deletions packages/spec/src/ui/dashboard.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -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).
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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.<object>.<field>` translation-bundle entry resolves this filter\'s field label and option labels'),
object: z.string().optional().describe('Object whose `fields.<object>.<field>` 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({
Expand All @@ -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),
Expand Down
Loading