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
59 changes: 59 additions & 0 deletions .changeset/dashboard-item-level-property-names.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
---
"@objectstack/spec": minor
"@objectstack/rest": patch
"@objectstack/platform-objects": patch
---

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)

## What was wrong

The Studio property panel renders `dashboard.header.actions[]` as a table whose
column headers read `items.properties[k].title ?? k` from the JSON Schema
derived by `z.toJSONSchema(DashboardSchema)`. None of the four item fields
(`label`, `actionUrl`, `actionType`, `icon`) carried a `title`, so the fallback
arm ran for every locale, English included, and the maker saw machine keys.
Nothing could localise them either: the only channel, `resolveMetadataFormLabels`,
decorates the `FormFieldSpec` tree, which the table never reads. And the platform
catalogs carried `dashboard.fields.header` alone — `dashboard.form.ts` declared
no children under the composite, so `os i18n extract` emitted no
`header.showTitle` / `header.showDescription` / `header.actions` key and the
console shipped a private overlay for exactly those three.

## What changed

- **`@objectstack/spec`** — `DashboardHeaderActionSchema`'s four fields author
`.meta({ title })` (`Label`, `Action URL`, `Action Type`, `Icon`), so the
derived JSON Schema names each column. New export
`resolveMetadataFormSchemaTitles(schema, type, bundle, opts)` in
`@objectstack/spec/system`: every `metadataForms.<type>.fields.<path>.label`
at any locale of the chain becomes the `title` of the node the path addresses,
stepping through an array's `items` so a repeater ROW property is addressed
as `<repeater>.<property>` (`header.actions.label`) — the same path the
extractor emits. Pure; returns the input object itself when nothing applies.
`dashboardForm` enumerates the `header` composite's children
(`showTitle`, `showDescription`, `actions` with its four row properties) with
labels equal to the schema titles, pinned equal in `dashboard.test.ts`.
The mechanism is written down in `content/docs/protocol/kernel/i18n-standard.mdx`
→ "Metadata authoring forms".
- **`@objectstack/rest`** — `GET /api/v1/meta` localises each entry's derived
`schema` beside its `form`, through that overlay.
- **`@objectstack/platform-objects`** — the four generated `metadata-forms`
catalogs carry the seven new `dashboard.fields` keys, translated in `zh-CN`,
`ja-JP` and `es-ES`.

Additive: no key removed, no accept set changed, no parsed output moved.

`DashboardSchema.columns` deliberately still declares no `.default(12)`, and
the reason is stronger than the one #16458 assumed. The card reasoned that the
renderer already falls back to 12, which would make `.default(12)`
behaviour-preserving. Measured at objectui `origin/main`
(`packages/plugin-dashboard/src/DashboardRenderer.tsx`), it does not: a
`columns`-less dashboard is INFERRED from the widget spans — `maxSpan > 4`
yields 12 and everything else yields **4** — and the next line switches the
whole layout on that value (`hasExplicitColumns = schema.columns != null ||
inferredColumns !== 4`, positioned grid vs responsive auto-flow). Declaring the
default would therefore both retire the inference and flip every auto-flow
dashboard into the positioned grid. A default that silently materialises a key
is expensive to take back, so the round stopped at the declared condition and
left the key alone; see #16458.
54 changes: 54 additions & 0 deletions content/docs/protocol/kernel/i18n-standard.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -341,6 +341,60 @@ my-plugin/
}
```

### Metadata authoring forms (`metadataForms`)

The Studio property panels that author a metadata document (an object, a
dashboard, a flow, …) are laid out by the type's authoring form
(`dashboardForm`, …, `METADATA_FORM_REGISTRY`) and render field shapes from
the JSON Schema derived from its zod schema. Those forms are authored in
English; a bundle localises them under `metadataForms.<type>`:

```typescript
metadataForms: {
dashboard: {
label: '仪表板',
sections: { layout: { label: '布局' } }, // section by its slugged label
fields: {
columns: { label: '列数', helpText: '栅格列数' }, // a top-level form field
'header.showTitle': { label: '显示标题' }, // a composite's child
'header.actions': { label: '操作按钮' }, // a repeater
'header.actions.label': { label: '标签' }, // a property of each repeater ROW
},
},
}
```

A field path is the dot path from the form root, and a repeater **row**
property is `<repeater>.<property>` — no `items` segment (`fields.items.label`
would name a declared child called `items`). The keys are emitted by
`os i18n extract` from the children the form **declares** under a composite or
repeater (`fields: [...]`), so a child the form does not enumerate has no key;
enumerate all of a composite's children or none, because the panel prefers a
declared list over the schema-derived one.

Two objects consume these entries, and a localised name reaches the panel only
through the one the renderer reads for that node:

| Node | What the panel reads | Resolver |
|:---|:---|:---|
| a form field, a composite's child | `FormFieldSpec.label` / `helpText` / `placeholder` | `resolveMetadataFormLabels(form, type, bundle, opts)` |
| a property of a repeater row (a table column header) | the JSON Schema `items.properties[k].title` | `resolveMetadataFormSchemaTitles(schema, type, bundle, opts)` |

Both run in `GET /api/v1/meta`, which serves every type's `form` and `schema`
already localised for the request's locale; a client deriving the schema itself
with `z.toJSONSchema` applies the second one to its own copy. Only `label`
crosses over to the schema (as `title`); `helpText` and `placeholder` stay on
the form.

**Naming an item-level property** therefore has three parts, none of which is
a locale-catalog entry alone: the English name is a `.meta({ title: 'Action URL' })`
on the zod item schema (`DashboardHeaderActionSchema`), because that is the
object the table header reads and its fallback — the raw key — runs in every
locale, English included; the form declares the child with the same `label`,
so the extractor emits `fields['header.actions.actionUrl']` and the platform
catalogs carry a translation for it; and a locale bundle's entry at that path is
what the overlay writes back as the node's `title`.

## Translation API

### Basic Translation
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
//
// #16458 — the platform catalogs name the `dashboard.header` composite's
// children and the `header.actions[]` ROW properties, in every locale.
//
// Before this pin the four generated catalogs carried `header` alone: the
// extractor walks a form field's DECLARED `fields`, `dashboard.form.ts`
// declared none under `header`, so no `header.<child>` key was ever emitted
// and the only localisation of those three children was a private overlay in
// objectui. The row properties had no channel at all — a repeater's column
// headers are read from the JSON Schema `title`, which the bundle overlays
// through the `<repeater>.<property>` path pinned here.
//
// The English source of a row property's name lives in TWO places by
// construction — the zod `.meta({ title })` the panel reads and the form's
// declared `label` the extractor emits — and `packages/spec`'s
// `dashboard.test.ts` pins those two equal. This file pins the catalog side:
// the `en` leaf equals the form's declared label, and each translated locale
// carries its own text rather than a copy of the source.

import { describe, it, expect } from 'vitest';
import { dashboardForm } from '@objectstack/spec/ui';
import { enMetadataForms } from './en.metadata-forms.generated.js';
import { zhCNMetadataForms } from './zh-CN.metadata-forms.generated.js';
import { jaJPMetadataForms } from './ja-JP.metadata-forms.generated.js';
import { esESMetadataForms } from './es-ES.metadata-forms.generated.js';

const LOCALES = [
{ name: 'en', forms: enMetadataForms as Record<string, any> },
{ name: 'zh-CN', forms: zhCNMetadataForms as Record<string, any> },
{ name: 'ja-JP', forms: jaJPMetadataForms as Record<string, any> },
{ name: 'es-ES', forms: esESMetadataForms as Record<string, any> },
];

const HEADER_CHILDREN = ['header.showTitle', 'header.showDescription', 'header.actions'];
const ROW_PROPERTIES = ['label', 'actionUrl', 'actionType', 'icon'];
const ROW_KEYS = ROW_PROPERTIES.map((p) => `header.actions.${p}`);

/** The `actions` repeater as `dashboard.form.ts` declares it, children included. */
function declaredActionsRepeater(): any {
for (const section of (dashboardForm as any).sections ?? []) {
for (const field of section.fields ?? []) {
if (field?.field === 'header') {
return (field.fields ?? []).find((f: any) => f?.field === 'actions');
}
}
}
return undefined;
}

describe('#16458 — dashboard header children and row properties in every catalog', () => {
for (const { name, forms } of LOCALES) {
it(`${name}: carries the three header children and the four row-property keys`, () => {
const fields = forms.dashboard?.fields ?? {};
for (const key of [...HEADER_CHILDREN, ...ROW_KEYS]) {
expect(typeof fields[key]?.label, `${name} dashboard.fields['${key}'].label`).toBe('string');
expect(fields[key].label.length, `${name} dashboard.fields['${key}'].label is empty`).toBeGreaterThan(0);
}
// The three composite children carry a hint too — the overlay objectui
// shipped for them had one, and this is what makes it redundant.
for (const key of HEADER_CHILDREN) {
expect(typeof fields[key]?.helpText, `${name} dashboard.fields['${key}'].helpText`).toBe('string');
}
// Control — a neighbouring key known to exist, so an empty `fields` map
// cannot pass by vacuity.
expect(typeof fields.header?.label).toBe('string');
});
}

it('en: each row-property leaf is the form\'s declared label, the English name the panel reads', () => {
const repeater = declaredActionsRepeater();
expect(repeater, 'dashboard.form.ts declares header.actions with children').toBeDefined();
const declared = new Map<string, string>(
(repeater.fields as any[]).map((f) => [String(f.field), String(f.label)]),
);
expect([...declared.keys()]).toEqual(ROW_PROPERTIES);
for (const prop of ROW_PROPERTIES) {
expect(enMetadataForms.dashboard?.fields?.[`header.actions.${prop}`]?.label).toBe(declared.get(prop));
}
});

it('translated locales carry their own text for every new leaf, not a copy of the source', () => {
for (const { name, forms } of LOCALES) {
if (name === 'en') continue;
for (const key of [...HEADER_CHILDREN, ...ROW_KEYS]) {
const en = (enMetadataForms as any).dashboard.fields[key].label;
expect(forms.dashboard.fields[key].label, `${name} dashboard.fields['${key}'].label still reads the en source`).not.toBe(en);
}
}
});
});

// ---------------------------------------------------------------------------
// #16458 item ③, first half — the catalogs were ALREADY correct, and this pin
// exists so the next reader cannot "repair" them backwards.
//
// The card and its triage both prescribe the opposite of the truth: "every
// generated catalog names `refreshInterval`, not `refreshIntervalSeconds`".
// That direction is inverted. `refreshInterval` was RENAMED to
// `refreshIntervalSeconds` in @objectstack/spec 17 (#15680, ruling B on
// #14478) and is now a `retiredKey` tombstone — authoring it is a parse error
// (`packages/spec/src/ui/dashboard.test.ts` pins the refusal). The live
// authorable key is `refreshIntervalSeconds`, which is what these catalogs and
// `dashboard.form.ts` already name.
//
// The card's reading came from a substring: `refreshInterval` "occurs" in
// `dashboard.zod.ts` only inside `refreshIntervalSeconds`, in the rename
// comment and in the tombstone's own prose. Under `grep -P '\brefreshInterval\b'`
// there is no live field by that name at all.
//
// So carrying out that acceptance literally would have written the tombstoned
// key into all four catalogs and created exactly the never-matching entry the
// card set out to remove.
describe('#16458 item ③ — the catalogs name the LIVE refresh key, not the tombstone', () => {
for (const { name, forms } of LOCALES) {
it(`${name}: names \`refreshIntervalSeconds\` and never the retired \`refreshInterval\``, () => {
const fields = forms.dashboard?.fields ?? {};
expect(typeof fields.refreshIntervalSeconds?.label, `${name} names the live key`).toBe('string');
expect(
Object.keys(fields),
`${name} carries the tombstoned \`refreshInterval\` — it is a parse error in the spec, so the entry could never match`,
).not.toContain('refreshInterval');
});
}

it('the key the catalogs name is the key the form declares — one source, not two', () => {
const declared = new Set<string>();
for (const section of (dashboardForm as any).sections ?? []) {
for (const field of section.fields ?? []) if (field?.field) declared.add(String(field.field));
}
// Control — the form really was walked, so an empty set cannot pass by vacuity.
expect(declared.has('columns'), 'dashboardForm declares the neighbouring `columns`').toBe(true);
expect(declared.has('refreshIntervalSeconds')).toBe(true);
expect(declared.has('refreshInterval')).toBe(false);
expect(Object.keys(enMetadataForms.dashboard?.fields ?? {})).toContain('refreshIntervalSeconds');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -950,6 +950,30 @@ export const enMetadataForms: NonNullable<TranslationData['metadataForms']> = {
label: "Header",
helpText: "Dashboard header config (title, subtitle, actions)"
},
"header.showTitle": {
label: "Show Title",
helpText: "Show dashboard title in header"
},
"header.showDescription": {
label: "Show Description",
helpText: "Show dashboard description in header"
},
"header.actions": {
label: "Actions",
helpText: "Header action buttons"
},
"header.actions.label": {
label: "Label"
},
"header.actions.actionUrl": {
label: "Action URL"
},
"header.actions.actionType": {
label: "Action Type"
},
"header.actions.icon": {
label: "Icon"
},
widgets: {
label: "Widgets",
helpText: "Dashboard widgets with position and sizing"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -950,6 +950,30 @@ export const esESMetadataForms: NonNullable<TranslationData['metadataForms']> =
label: "Encabezado",
helpText: "Configuración de cabecera del panel (title, subtitle, actions)"
},
"header.showTitle": {
label: "Mostrar título",
helpText: "Mostrar el título del panel en la cabecera"
},
"header.showDescription": {
label: "Mostrar descripción",
helpText: "Mostrar la descripción del panel en la cabecera"
},
"header.actions": {
label: "Botones de acción",
helpText: "Botones de acción mostrados en la cabecera"
},
"header.actions.label": {
label: "Etiqueta"
},
"header.actions.actionUrl": {
label: "URL de la acción"
},
"header.actions.actionType": {
label: "Tipo de acción"
},
"header.actions.icon": {
label: "Icono"
},
widgets: {
label: "Widgets del panel",
helpText: "Widgets del panel con posición y tamaño"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -950,6 +950,30 @@ export const jaJPMetadataForms: NonNullable<TranslationData['metadataForms']> =
label: "ヘッダー",
helpText: "ダッシュボードヘッダー設定(title, subtitle, actions)"
},
"header.showTitle": {
label: "タイトルを表示",
helpText: "ヘッダーにダッシュボードのタイトルを表示"
},
"header.showDescription": {
label: "説明を表示",
helpText: "ヘッダーにダッシュボードの説明を表示"
},
"header.actions": {
label: "操作ボタン",
helpText: "ヘッダーに表示する操作ボタン"
},
"header.actions.label": {
label: "ラベル"
},
"header.actions.actionUrl": {
label: "操作 URL"
},
"header.actions.actionType": {
label: "操作タイプ"
},
"header.actions.icon": {
label: "アイコン"
},
widgets: {
label: "ウィジェット",
helpText: "位置とサイズを持つダッシュボードウィジェット"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -950,6 +950,30 @@ export const zhCNMetadataForms: NonNullable<TranslationData['metadataForms']> =
label: "页眉",
helpText: "仪表板页眉配置(title、subtitle、actions)"
},
"header.showTitle": {
label: "显示标题",
helpText: "在页眉中显示仪表板标题"
},
"header.showDescription": {
label: "显示描述",
helpText: "在页眉中显示仪表板描述"
},
"header.actions": {
label: "操作按钮",
helpText: "页眉中的操作按钮"
},
"header.actions.label": {
label: "标签"
},
"header.actions.actionUrl": {
label: "操作地址"
},
"header.actions.actionType": {
label: "操作类型"
},
"header.actions.icon": {
label: "图标"
},
widgets: {
label: "组件",
helpText: "包含位置和尺寸的仪表板组件"
Expand Down
Loading
Loading