Skip to content

Commit c1d54db

Browse files
os-billclaude
andauthored
feat(spec): item-level property names for the dashboard header-action repeater (#17227)
* feat(spec): item-level property names for the dashboard header-action repeater (#16458) DashboardHeaderAction fields carry a JSON Schema title; a new resolveMetadataFormSchemaTitles overlays metadataForms.<type>.fields.<path>.label onto a derived JSON Schema (stepping through an array's items so a repeater row property is <repeater>.<property>); GET /meta serves the localised schema beside the localised form; dashboardForm enumerates the header composite's children so the extractor emits their catalog keys. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH * chore(spec): regenerate api-surface and export-origins for resolveMetadataFormSchemaTitles (#16458) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH * chore(platform-objects): catalogs name the dashboard header children and the header.actions row properties (#16458) Regenerated with pnpm i18n:extract after dashboardForm enumerated the header composite's children; the zh-CN / ja-JP / es-ES leaves are hand-translated, so the provenance companions carry no entry for them. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH * test(platform-objects): pin the catalogs to the LIVE refresh key, against the card's inverted acceptance (#16458) Item ③'s first half prescribed the opposite of the truth: the card and its triage read `refreshInterval` as the spec's field and `refreshIntervalSeconds` as a catalog entry that "can never match". The direction is inverted — `refreshInterval` was renamed to `refreshIntervalSeconds` in @objectstack/spec 17 (#15680, ruling B on #14478) and is now a retiredKey tombstone that refuses at parse. The catalogs already named the live key; carrying the acceptance out literally would have written the tombstone into all four. The misreading came from a substring match — `refreshInterval` occurs in dashboard.zod.ts only inside `refreshIntervalSeconds`, in the rename comment and in the tombstone's own prose. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH * docs(changeset): record the MEASURED reason item ④ stayed out (#16458) The card reasoned that `.default(12)` on `DashboardSchema.columns` would be behaviour-preserving because the renderer already falls back to 12. Measured at objectui origin/main, it does not: a `columns`-less dashboard is inferred from the widget spans (4 unless a span exceeds 4, then 12), and the very next line switches positioned-grid vs responsive auto-flow on that value. The stop condition therefore fired on a stronger reading than the one it was written for, and the changeset now records what was measured rather than the premise. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 1555ed4 commit c1d54db

17 files changed

Lines changed: 924 additions & 11 deletions
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/rest": patch
4+
"@objectstack/platform-objects": patch
5+
---
6+
7+
feat(spec): a metadata-form repeater's row properties have a name — `DashboardHeaderAction` fields carry a JSON Schema `title`, and `resolveMetadataFormSchemaTitles` overlays a bundle's `metadataForms.<type>.fields.<path>.label` onto a derived JSON Schema (#16458)
8+
9+
## What was wrong
10+
11+
The Studio property panel renders `dashboard.header.actions[]` as a table whose
12+
column headers read `items.properties[k].title ?? k` from the JSON Schema
13+
derived by `z.toJSONSchema(DashboardSchema)`. None of the four item fields
14+
(`label`, `actionUrl`, `actionType`, `icon`) carried a `title`, so the fallback
15+
arm ran for every locale, English included, and the maker saw machine keys.
16+
Nothing could localise them either: the only channel, `resolveMetadataFormLabels`,
17+
decorates the `FormFieldSpec` tree, which the table never reads. And the platform
18+
catalogs carried `dashboard.fields.header` alone — `dashboard.form.ts` declared
19+
no children under the composite, so `os i18n extract` emitted no
20+
`header.showTitle` / `header.showDescription` / `header.actions` key and the
21+
console shipped a private overlay for exactly those three.
22+
23+
## What changed
24+
25+
- **`@objectstack/spec`**`DashboardHeaderActionSchema`'s four fields author
26+
`.meta({ title })` (`Label`, `Action URL`, `Action Type`, `Icon`), so the
27+
derived JSON Schema names each column. New export
28+
`resolveMetadataFormSchemaTitles(schema, type, bundle, opts)` in
29+
`@objectstack/spec/system`: every `metadataForms.<type>.fields.<path>.label`
30+
at any locale of the chain becomes the `title` of the node the path addresses,
31+
stepping through an array's `items` so a repeater ROW property is addressed
32+
as `<repeater>.<property>` (`header.actions.label`) — the same path the
33+
extractor emits. Pure; returns the input object itself when nothing applies.
34+
`dashboardForm` enumerates the `header` composite's children
35+
(`showTitle`, `showDescription`, `actions` with its four row properties) with
36+
labels equal to the schema titles, pinned equal in `dashboard.test.ts`.
37+
The mechanism is written down in `content/docs/protocol/kernel/i18n-standard.mdx`
38+
→ "Metadata authoring forms".
39+
- **`@objectstack/rest`**`GET /api/v1/meta` localises each entry's derived
40+
`schema` beside its `form`, through that overlay.
41+
- **`@objectstack/platform-objects`** — the four generated `metadata-forms`
42+
catalogs carry the seven new `dashboard.fields` keys, translated in `zh-CN`,
43+
`ja-JP` and `es-ES`.
44+
45+
Additive: no key removed, no accept set changed, no parsed output moved.
46+
47+
`DashboardSchema.columns` deliberately still declares no `.default(12)`, and
48+
the reason is stronger than the one #16458 assumed. The card reasoned that the
49+
renderer already falls back to 12, which would make `.default(12)`
50+
behaviour-preserving. Measured at objectui `origin/main`
51+
(`packages/plugin-dashboard/src/DashboardRenderer.tsx`), it does not: a
52+
`columns`-less dashboard is INFERRED from the widget spans — `maxSpan > 4`
53+
yields 12 and everything else yields **4** — and the next line switches the
54+
whole layout on that value (`hasExplicitColumns = schema.columns != null ||
55+
inferredColumns !== 4`, positioned grid vs responsive auto-flow). Declaring the
56+
default would therefore both retire the inference and flip every auto-flow
57+
dashboard into the positioned grid. A default that silently materialises a key
58+
is expensive to take back, so the round stopped at the declared condition and
59+
left the key alone; see #16458.

content/docs/protocol/kernel/i18n-standard.mdx

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -341,6 +341,60 @@ my-plugin/
341341
}
342342
```
343343

344+
### Metadata authoring forms (`metadataForms`)
345+
346+
The Studio property panels that author a metadata document (an object, a
347+
dashboard, a flow, …) are laid out by the type's authoring form
348+
(`dashboardForm`, …, `METADATA_FORM_REGISTRY`) and render field shapes from
349+
the JSON Schema derived from its zod schema. Those forms are authored in
350+
English; a bundle localises them under `metadataForms.<type>`:
351+
352+
```typescript
353+
metadataForms: {
354+
dashboard: {
355+
label: '仪表板',
356+
sections: { layout: { label: '布局' } }, // section by its slugged label
357+
fields: {
358+
columns: { label: '列数', helpText: '栅格列数' }, // a top-level form field
359+
'header.showTitle': { label: '显示标题' }, // a composite's child
360+
'header.actions': { label: '操作按钮' }, // a repeater
361+
'header.actions.label': { label: '标签' }, // a property of each repeater ROW
362+
},
363+
},
364+
}
365+
```
366+
367+
A field path is the dot path from the form root, and a repeater **row**
368+
property is `<repeater>.<property>` — no `items` segment (`fields.items.label`
369+
would name a declared child called `items`). The keys are emitted by
370+
`os i18n extract` from the children the form **declares** under a composite or
371+
repeater (`fields: [...]`), so a child the form does not enumerate has no key;
372+
enumerate all of a composite's children or none, because the panel prefers a
373+
declared list over the schema-derived one.
374+
375+
Two objects consume these entries, and a localised name reaches the panel only
376+
through the one the renderer reads for that node:
377+
378+
| Node | What the panel reads | Resolver |
379+
|:---|:---|:---|
380+
| a form field, a composite's child | `FormFieldSpec.label` / `helpText` / `placeholder` | `resolveMetadataFormLabels(form, type, bundle, opts)` |
381+
| a property of a repeater row (a table column header) | the JSON Schema `items.properties[k].title` | `resolveMetadataFormSchemaTitles(schema, type, bundle, opts)` |
382+
383+
Both run in `GET /api/v1/meta`, which serves every type's `form` and `schema`
384+
already localised for the request's locale; a client deriving the schema itself
385+
with `z.toJSONSchema` applies the second one to its own copy. Only `label`
386+
crosses over to the schema (as `title`); `helpText` and `placeholder` stay on
387+
the form.
388+
389+
**Naming an item-level property** therefore has three parts, none of which is
390+
a locale-catalog entry alone: the English name is a `.meta({ title: 'Action URL' })`
391+
on the zod item schema (`DashboardHeaderActionSchema`), because that is the
392+
object the table header reads and its fallback — the raw key — runs in every
393+
locale, English included; the form declares the child with the same `label`,
394+
so the extractor emits `fields['header.actions.actionUrl']` and the platform
395+
catalogs carry a translation for it; and a locale bundle's entry at that path is
396+
what the overlay writes back as the node's `title`.
397+
344398
## Translation API
345399

346400
### Basic Translation
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// #16458 — the platform catalogs name the `dashboard.header` composite's
4+
// children and the `header.actions[]` ROW properties, in every locale.
5+
//
6+
// Before this pin the four generated catalogs carried `header` alone: the
7+
// extractor walks a form field's DECLARED `fields`, `dashboard.form.ts`
8+
// declared none under `header`, so no `header.<child>` key was ever emitted
9+
// and the only localisation of those three children was a private overlay in
10+
// objectui. The row properties had no channel at all — a repeater's column
11+
// headers are read from the JSON Schema `title`, which the bundle overlays
12+
// through the `<repeater>.<property>` path pinned here.
13+
//
14+
// The English source of a row property's name lives in TWO places by
15+
// construction — the zod `.meta({ title })` the panel reads and the form's
16+
// declared `label` the extractor emits — and `packages/spec`'s
17+
// `dashboard.test.ts` pins those two equal. This file pins the catalog side:
18+
// the `en` leaf equals the form's declared label, and each translated locale
19+
// carries its own text rather than a copy of the source.
20+
21+
import { describe, it, expect } from 'vitest';
22+
import { dashboardForm } from '@objectstack/spec/ui';
23+
import { enMetadataForms } from './en.metadata-forms.generated.js';
24+
import { zhCNMetadataForms } from './zh-CN.metadata-forms.generated.js';
25+
import { jaJPMetadataForms } from './ja-JP.metadata-forms.generated.js';
26+
import { esESMetadataForms } from './es-ES.metadata-forms.generated.js';
27+
28+
const LOCALES = [
29+
{ name: 'en', forms: enMetadataForms as Record<string, any> },
30+
{ name: 'zh-CN', forms: zhCNMetadataForms as Record<string, any> },
31+
{ name: 'ja-JP', forms: jaJPMetadataForms as Record<string, any> },
32+
{ name: 'es-ES', forms: esESMetadataForms as Record<string, any> },
33+
];
34+
35+
const HEADER_CHILDREN = ['header.showTitle', 'header.showDescription', 'header.actions'];
36+
const ROW_PROPERTIES = ['label', 'actionUrl', 'actionType', 'icon'];
37+
const ROW_KEYS = ROW_PROPERTIES.map((p) => `header.actions.${p}`);
38+
39+
/** The `actions` repeater as `dashboard.form.ts` declares it, children included. */
40+
function declaredActionsRepeater(): any {
41+
for (const section of (dashboardForm as any).sections ?? []) {
42+
for (const field of section.fields ?? []) {
43+
if (field?.field === 'header') {
44+
return (field.fields ?? []).find((f: any) => f?.field === 'actions');
45+
}
46+
}
47+
}
48+
return undefined;
49+
}
50+
51+
describe('#16458 — dashboard header children and row properties in every catalog', () => {
52+
for (const { name, forms } of LOCALES) {
53+
it(`${name}: carries the three header children and the four row-property keys`, () => {
54+
const fields = forms.dashboard?.fields ?? {};
55+
for (const key of [...HEADER_CHILDREN, ...ROW_KEYS]) {
56+
expect(typeof fields[key]?.label, `${name} dashboard.fields['${key}'].label`).toBe('string');
57+
expect(fields[key].label.length, `${name} dashboard.fields['${key}'].label is empty`).toBeGreaterThan(0);
58+
}
59+
// The three composite children carry a hint too — the overlay objectui
60+
// shipped for them had one, and this is what makes it redundant.
61+
for (const key of HEADER_CHILDREN) {
62+
expect(typeof fields[key]?.helpText, `${name} dashboard.fields['${key}'].helpText`).toBe('string');
63+
}
64+
// Control — a neighbouring key known to exist, so an empty `fields` map
65+
// cannot pass by vacuity.
66+
expect(typeof fields.header?.label).toBe('string');
67+
});
68+
}
69+
70+
it('en: each row-property leaf is the form\'s declared label, the English name the panel reads', () => {
71+
const repeater = declaredActionsRepeater();
72+
expect(repeater, 'dashboard.form.ts declares header.actions with children').toBeDefined();
73+
const declared = new Map<string, string>(
74+
(repeater.fields as any[]).map((f) => [String(f.field), String(f.label)]),
75+
);
76+
expect([...declared.keys()]).toEqual(ROW_PROPERTIES);
77+
for (const prop of ROW_PROPERTIES) {
78+
expect(enMetadataForms.dashboard?.fields?.[`header.actions.${prop}`]?.label).toBe(declared.get(prop));
79+
}
80+
});
81+
82+
it('translated locales carry their own text for every new leaf, not a copy of the source', () => {
83+
for (const { name, forms } of LOCALES) {
84+
if (name === 'en') continue;
85+
for (const key of [...HEADER_CHILDREN, ...ROW_KEYS]) {
86+
const en = (enMetadataForms as any).dashboard.fields[key].label;
87+
expect(forms.dashboard.fields[key].label, `${name} dashboard.fields['${key}'].label still reads the en source`).not.toBe(en);
88+
}
89+
}
90+
});
91+
});
92+
93+
// ---------------------------------------------------------------------------
94+
// #16458 item ③, first half — the catalogs were ALREADY correct, and this pin
95+
// exists so the next reader cannot "repair" them backwards.
96+
//
97+
// The card and its triage both prescribe the opposite of the truth: "every
98+
// generated catalog names `refreshInterval`, not `refreshIntervalSeconds`".
99+
// That direction is inverted. `refreshInterval` was RENAMED to
100+
// `refreshIntervalSeconds` in @objectstack/spec 17 (#15680, ruling B on
101+
// #14478) and is now a `retiredKey` tombstone — authoring it is a parse error
102+
// (`packages/spec/src/ui/dashboard.test.ts` pins the refusal). The live
103+
// authorable key is `refreshIntervalSeconds`, which is what these catalogs and
104+
// `dashboard.form.ts` already name.
105+
//
106+
// The card's reading came from a substring: `refreshInterval` "occurs" in
107+
// `dashboard.zod.ts` only inside `refreshIntervalSeconds`, in the rename
108+
// comment and in the tombstone's own prose. Under `grep -P '\brefreshInterval\b'`
109+
// there is no live field by that name at all.
110+
//
111+
// So carrying out that acceptance literally would have written the tombstoned
112+
// key into all four catalogs and created exactly the never-matching entry the
113+
// card set out to remove.
114+
describe('#16458 item ③ — the catalogs name the LIVE refresh key, not the tombstone', () => {
115+
for (const { name, forms } of LOCALES) {
116+
it(`${name}: names \`refreshIntervalSeconds\` and never the retired \`refreshInterval\``, () => {
117+
const fields = forms.dashboard?.fields ?? {};
118+
expect(typeof fields.refreshIntervalSeconds?.label, `${name} names the live key`).toBe('string');
119+
expect(
120+
Object.keys(fields),
121+
`${name} carries the tombstoned \`refreshInterval\` — it is a parse error in the spec, so the entry could never match`,
122+
).not.toContain('refreshInterval');
123+
});
124+
}
125+
126+
it('the key the catalogs name is the key the form declares — one source, not two', () => {
127+
const declared = new Set<string>();
128+
for (const section of (dashboardForm as any).sections ?? []) {
129+
for (const field of section.fields ?? []) if (field?.field) declared.add(String(field.field));
130+
}
131+
// Control — the form really was walked, so an empty set cannot pass by vacuity.
132+
expect(declared.has('columns'), 'dashboardForm declares the neighbouring `columns`').toBe(true);
133+
expect(declared.has('refreshIntervalSeconds')).toBe(true);
134+
expect(declared.has('refreshInterval')).toBe(false);
135+
expect(Object.keys(enMetadataForms.dashboard?.fields ?? {})).toContain('refreshIntervalSeconds');
136+
});
137+
});

packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -950,6 +950,30 @@ export const enMetadataForms: NonNullable<TranslationData['metadataForms']> = {
950950
label: "Header",
951951
helpText: "Dashboard header config (title, subtitle, actions)"
952952
},
953+
"header.showTitle": {
954+
label: "Show Title",
955+
helpText: "Show dashboard title in header"
956+
},
957+
"header.showDescription": {
958+
label: "Show Description",
959+
helpText: "Show dashboard description in header"
960+
},
961+
"header.actions": {
962+
label: "Actions",
963+
helpText: "Header action buttons"
964+
},
965+
"header.actions.label": {
966+
label: "Label"
967+
},
968+
"header.actions.actionUrl": {
969+
label: "Action URL"
970+
},
971+
"header.actions.actionType": {
972+
label: "Action Type"
973+
},
974+
"header.actions.icon": {
975+
label: "Icon"
976+
},
953977
widgets: {
954978
label: "Widgets",
955979
helpText: "Dashboard widgets with position and sizing"

packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -950,6 +950,30 @@ export const esESMetadataForms: NonNullable<TranslationData['metadataForms']> =
950950
label: "Encabezado",
951951
helpText: "Configuración de cabecera del panel (title, subtitle, actions)"
952952
},
953+
"header.showTitle": {
954+
label: "Mostrar título",
955+
helpText: "Mostrar el título del panel en la cabecera"
956+
},
957+
"header.showDescription": {
958+
label: "Mostrar descripción",
959+
helpText: "Mostrar la descripción del panel en la cabecera"
960+
},
961+
"header.actions": {
962+
label: "Botones de acción",
963+
helpText: "Botones de acción mostrados en la cabecera"
964+
},
965+
"header.actions.label": {
966+
label: "Etiqueta"
967+
},
968+
"header.actions.actionUrl": {
969+
label: "URL de la acción"
970+
},
971+
"header.actions.actionType": {
972+
label: "Tipo de acción"
973+
},
974+
"header.actions.icon": {
975+
label: "Icono"
976+
},
953977
widgets: {
954978
label: "Widgets del panel",
955979
helpText: "Widgets del panel con posición y tamaño"

packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -950,6 +950,30 @@ export const jaJPMetadataForms: NonNullable<TranslationData['metadataForms']> =
950950
label: "ヘッダー",
951951
helpText: "ダッシュボードヘッダー設定(title, subtitle, actions)"
952952
},
953+
"header.showTitle": {
954+
label: "タイトルを表示",
955+
helpText: "ヘッダーにダッシュボードのタイトルを表示"
956+
},
957+
"header.showDescription": {
958+
label: "説明を表示",
959+
helpText: "ヘッダーにダッシュボードの説明を表示"
960+
},
961+
"header.actions": {
962+
label: "操作ボタン",
963+
helpText: "ヘッダーに表示する操作ボタン"
964+
},
965+
"header.actions.label": {
966+
label: "ラベル"
967+
},
968+
"header.actions.actionUrl": {
969+
label: "操作 URL"
970+
},
971+
"header.actions.actionType": {
972+
label: "操作タイプ"
973+
},
974+
"header.actions.icon": {
975+
label: "アイコン"
976+
},
953977
widgets: {
954978
label: "ウィジェット",
955979
helpText: "位置とサイズを持つダッシュボードウィジェット"

packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -950,6 +950,30 @@ export const zhCNMetadataForms: NonNullable<TranslationData['metadataForms']> =
950950
label: "页眉",
951951
helpText: "仪表板页眉配置(title、subtitle、actions)"
952952
},
953+
"header.showTitle": {
954+
label: "显示标题",
955+
helpText: "在页眉中显示仪表板标题"
956+
},
957+
"header.showDescription": {
958+
label: "显示描述",
959+
helpText: "在页眉中显示仪表板描述"
960+
},
961+
"header.actions": {
962+
label: "操作按钮",
963+
helpText: "页眉中的操作按钮"
964+
},
965+
"header.actions.label": {
966+
label: "标签"
967+
},
968+
"header.actions.actionUrl": {
969+
label: "操作地址"
970+
},
971+
"header.actions.actionType": {
972+
label: "操作类型"
973+
},
974+
"header.actions.icon": {
975+
label: "图标"
976+
},
953977
widgets: {
954978
label: "组件",
955979
helpText: "包含位置和尺寸的仪表板组件"

0 commit comments

Comments
 (0)