From 68045c286e61e47bf2b681914f85b446fac4b3e4 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 9 Sep 2026 13:39:27 +0000 Subject: [PATCH 1/5] 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..fields..label onto a derived JSON Schema (stepping through an array's items so a repeater row property is .); 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 Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH --- .../dashboard-item-level-property-names.md | 49 +++++ .../docs/protocol/kernel/i18n-standard.mdx | 54 ++++++ .../dashboard-header-children.test.ts | 91 ++++++++++ .../rest/src/meta-types-schema-titles.test.ts | 167 ++++++++++++++++++ packages/rest/src/rest-server.ts | 10 +- .../spec/src/system/i18n-resolver.test.ts | 135 ++++++++++++++ packages/spec/src/system/i18n-resolver.ts | 144 ++++++++++++++- packages/spec/src/system/translation.zod.ts | 12 +- packages/spec/src/ui/dashboard.form.ts | 34 +++- packages/spec/src/ui/dashboard.test.ts | 63 +++++++ packages/spec/src/ui/dashboard.zod.ts | 22 ++- 11 files changed, 770 insertions(+), 11 deletions(-) create mode 100644 .changeset/dashboard-item-level-property-names.md create mode 100644 packages/platform-objects/src/apps/translations/dashboard-header-children.test.ts create mode 100644 packages/rest/src/meta-types-schema-titles.test.ts diff --git a/.changeset/dashboard-item-level-property-names.md b/.changeset/dashboard-item-level-property-names.md new file mode 100644 index 0000000000..638e514e68 --- /dev/null +++ b/.changeset/dashboard-item-level-property-names.md @@ -0,0 +1,49 @@ +--- +"@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..fields..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..fields..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 `.` (`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)` — +`defineStack` parses in strict mode by default and the console renderer +branches positioned-vs-auto-flow on `columns != null`, so materialising the +default would change how a `columns`-less dashboard lays out; see #16458. diff --git a/content/docs/protocol/kernel/i18n-standard.mdx b/content/docs/protocol/kernel/i18n-standard.mdx index ceddb3c218..10423b2399 100644 --- a/content/docs/protocol/kernel/i18n-standard.mdx +++ b/content/docs/protocol/kernel/i18n-standard.mdx @@ -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.`: + +```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 `.` — 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 diff --git a/packages/platform-objects/src/apps/translations/dashboard-header-children.test.ts b/packages/platform-objects/src/apps/translations/dashboard-header-children.test.ts new file mode 100644 index 0000000000..3bcd3b152e --- /dev/null +++ b/packages/platform-objects/src/apps/translations/dashboard-header-children.test.ts @@ -0,0 +1,91 @@ +// 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.` 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 `.` 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 }, + { name: 'zh-CN', forms: zhCNMetadataForms as Record }, + { name: 'ja-JP', forms: jaJPMetadataForms as Record }, + { name: 'es-ES', forms: esESMetadataForms as Record }, +]; + +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( + (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); + } + } + }); +}); diff --git a/packages/rest/src/meta-types-schema-titles.test.ts b/packages/rest/src/meta-types-schema-titles.test.ts new file mode 100644 index 0000000000..03bd77566d --- /dev/null +++ b/packages/rest/src/meta-types-schema-titles.test.ts @@ -0,0 +1,167 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #16458 — `GET /meta` localises the derived JSON `schema` of every type + * entry, not only its `form`. + * + * The RULE lives in `@objectstack/spec/system` (`resolveMetadataFormSchemaTitles`, + * pinned in `i18n-resolver.test.ts`): a `metadataForms..fields..label` + * becomes the `title` of the JSON Schema node the path addresses, stepping + * through an array's `items` so a repeater ROW property is `.`. + * What can only be tested here is the PLUMBING: `translateMetaTypesResponse` + * hands `entry.schema` through that overlay with the same bundle, locale and + * options it already hands `entry.form`. Before this seam existed the served + * schema carried the zod `title` only, so a repeater's column headers — which + * the console reads from `items.properties[k].title` and from NOTHING on the + * form — stayed English in every locale a bundle named them for. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { RestServer } from './rest-server.js'; + +/** The served shape `getMetaTypes()` derives for a `dashboard` entry, trimmed to the header. */ +const DASHBOARD_SCHEMA = () => ({ + type: 'object', + properties: { + columns: { type: 'integer', description: 'Number of grid columns (default 12)' }, + header: { + type: 'object', + properties: { + showTitle: { type: 'boolean' }, + actions: { + type: 'array', + items: { + type: 'object', + properties: { + label: { type: 'string', title: 'Label' }, + actionUrl: { type: 'string', title: 'Action URL' }, + }, + }, + }, + }, + }, + }, +}); + +const FORM = () => ({ + schemaId: 'dashboard', + type: 'simple', + sections: [{ name: 'layout', label: 'Layout', fields: [{ field: 'columns', type: 'number' }] }], +}); + +const ZH_DATA = { + metadataForms: { + dashboard: { + label: '仪表板', + fields: { + columns: { label: '列数' }, + 'header.showTitle': { label: '显示标题' }, + 'header.actions': { label: '操作按钮' }, + 'header.actions.label': { label: '标签' }, + 'header.actions.actionUrl': { label: '操作地址' }, + }, + }, + }, +}; + +function i18nFor(bundles: Record) { + return { + getLocales: () => Object.keys(bundles), + getTranslations: (locale: string) => bundles[locale] ?? {}, + getDefaultLocale: () => 'en', + getFallbackLocale: () => 'en', + }; +} + +function mockServer() { + return { + get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn(), patch: vi.fn(), + use: vi.fn(), listen: vi.fn().mockResolvedValue(undefined), close: vi.fn().mockResolvedValue(undefined), + }; +} + +function mockRes() { + return { json: vi.fn(), status: vi.fn().mockReturnThis(), header: vi.fn(), send: vi.fn() }; +} + +function protocol() { + return { + getDiscovery: vi.fn().mockResolvedValue({ + version: 'v0', + routes: { data: '', metadata: '', ui: '', auth: '/auth' }, + }), + getMetaTypes: vi.fn(async () => ({ + entries: [{ type: 'dashboard', label: 'Dashboard', form: FORM(), schema: DASHBOARD_SCHEMA() }], + types: ['dashboard'], + registered: ['dashboard'], + })), + getMetaItems: vi.fn(async () => []), + getMetaItem: vi.fn(async () => undefined), + getMetaItemCached: undefined as any, + findData: vi.fn().mockResolvedValue([]), + }; +} + +function makeRest(i18n: any) { + const rest = new RestServer( + mockServer() as any, protocol() as any, { api: { requireAuth: false } } as any, + undefined, undefined, undefined, undefined, undefined, + undefined, undefined, undefined, undefined, undefined, + async () => i18n, + ); + (rest as any).resolveExecCtx = async () => ({ userId: 'u1', systemPermissions: [] }); + rest.registerRoutes(); + return rest; +} + +async function readTypes(rest: RestServer, locale: string | undefined): Promise { + const route = (rest as any).getRoutes().find((r: any) => r.method === 'GET' && r.path === '/api/v1/meta'); + if (!route) throw new Error('route not registered: GET /api/v1/meta'); + const res = mockRes(); + await route.handler( + { method: 'GET', params: {}, query: {}, body: {}, headers: locale ? { 'accept-language': locale } : {} }, + res, + ); + const calls = res.json.mock.calls; + return calls.length ? calls[calls.length - 1][0] : undefined; +} + +const rowProps = (entry: any) => entry.schema.properties.header.properties.actions.items.properties; + +describe('#16458 — GET /meta serves a localised `schema` beside the localised `form`', () => { + const bundles = () => ({ en: {}, 'zh-CN': ZH_DATA }); + + it('a zh-CN request names the repeater row columns through the schema `title`', async () => { + const body = await readTypes(makeRest(i18nFor(bundles())), 'zh-CN'); + const entry = body.entries.find((e: any) => e.type === 'dashboard'); + expect(entry.label).toBe('仪表板'); + expect(rowProps(entry).label.title).toBe('标签'); + expect(rowProps(entry).actionUrl.title).toBe('操作地址'); + // Composite child and top-level field — the same overlay, one rule. + expect(entry.schema.properties.header.properties.showTitle.title).toBe('显示标题'); + expect(entry.schema.properties.header.properties.actions.title).toBe('操作按钮'); + expect(entry.schema.properties.columns.title).toBe('列数'); + // The form is still translated by the sibling seam. + expect(entry.form.sections[0].fields[0].label).toBe('列数'); + // The overlay wrote `title` and nothing else on the node. + expect(rowProps(entry).label.type).toBe('string'); + expect(entry.schema.properties.columns.description).toBe('Number of grid columns (default 12)'); + }); + + it('control — an en request leaves the authored titles in place', async () => { + const body = await readTypes(makeRest(i18nFor(bundles())), 'en'); + const entry = body.entries.find((e: any) => e.type === 'dashboard'); + expect(rowProps(entry).label.title).toBe('Label'); + expect(rowProps(entry).actionUrl.title).toBe('Action URL'); + expect(entry.schema.properties.header.properties.showTitle.title).toBeUndefined(); + expect(entry.schema.properties.columns.title).toBeUndefined(); + }); + + it('control — the served schema is not mutated in place across requests', async () => { + const rest = makeRest(i18nFor(bundles())); + await readTypes(rest, 'zh-CN'); + const body = await readTypes(rest, 'en'); + const entry = body.entries.find((e: any) => e.type === 'dashboard'); + expect(rowProps(entry).label.title).toBe('Label'); + }); +}); diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index 9b5f473315..18963aa36f 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -3892,9 +3892,11 @@ export class RestServer { /** * Translate the `entries` payload returned by `getMetaTypes()` — applies - * the active locale to each entry's `label`, `description`, and the + * the active locale to each entry's `label`, `description`, the * nested `form` layout (section labels, field labels, helpText, - * placeholders) via `metadataForms.` translation namespace. + * placeholders) and the derived JSON `schema` (a `title` per node the + * bundle names — the only channel that reaches a repeater row's column + * headers, #16458) via the `metadataForms.` translation namespace. * * No-ops when no i18n service / locale / matching bundle entry exists, * so this is safe to call unconditionally from the `/meta` handler. @@ -3910,6 +3912,7 @@ export class RestServer { resolveMetadataTypeLabel, resolveMetadataTypeDescription, resolveMetadataFormLabels, + resolveMetadataFormSchemaTitles, } = await import('@objectstack/spec/system'); const opts = RestServer.translateOptionsFor(i18n, locale); const entries = payload.entries.map((entry: any) => { @@ -3921,6 +3924,9 @@ export class RestServer { if (entry.form) { next.form = resolveMetadataFormLabels(entry.form, entry.type, bundle, opts); } + if (entry.schema && typeof entry.schema === 'object') { + next.schema = resolveMetadataFormSchemaTitles(entry.schema, entry.type, bundle, opts); + } return next; }); return { ...payload, entries }; diff --git a/packages/spec/src/system/i18n-resolver.test.ts b/packages/spec/src/system/i18n-resolver.test.ts index 18efe90343..9e7a815068 100644 --- a/packages/spec/src/system/i18n-resolver.test.ts +++ b/packages/spec/src/system/i18n-resolver.test.ts @@ -682,6 +682,7 @@ import { resolveMetadataTypeLabel, resolveMetadataTypeDescription, resolveMetadataFormLabels, + resolveMetadataFormSchemaTitles, } from './i18n-resolver'; describe('TranslationDataSchema metadataForms', () => { @@ -4121,3 +4122,137 @@ describe('#14882 — a declared fallback chain, at the resolver', () => { .toBe('Entry Sheet'); }); }); + +describe('resolveMetadataFormSchemaTitles (#16458)', () => { + const bundle: TranslationBundle = { + 'zh-CN': { + metadataForms: { + dashboard: { + fields: { + columns: { label: '列数' }, + 'header.showTitle': { label: '显示标题', helpText: '在页眉中显示仪表板标题' }, + 'header.actions': { label: '操作按钮' }, + 'header.actions.label': { label: '标签' }, + 'header.actions.actionUrl': { label: '操作地址' }, + 'ghost.child': { label: '幽灵' }, + }, + }, + }, + }, + 'zh-TW': { + metadataForms: { + dashboard: { + fields: { + 'header.actions.icon': { label: '圖示' }, + }, + }, + }, + }, + }; + + // The shape `z.toJSONSchema(DashboardSchema)` derives for the header, + // trimmed: a composite, a repeater whose row properties carry the authored + // English title, and an `I18nLabel` union on one of them. + const schema = () => ({ + type: 'object', + properties: { + columns: { type: 'integer', description: 'Number of grid columns (default 12)' }, + header: { + type: 'object', + properties: { + showTitle: { type: 'boolean', description: 'Show dashboard title in header' }, + actions: { + type: 'array', + items: { + type: 'object', + properties: { + label: { anyOf: [{ type: 'string' }, { type: 'object' }], title: 'Label' }, + actionUrl: { type: 'string', title: 'Action URL' }, + actionType: { type: 'string', title: 'Action Type', enum: ['url', 'modal'] }, + icon: { type: 'string', title: 'Icon' }, + }, + }, + }, + }, + }, + }, + }); + + it('names a repeater ROW property through the array\'s `items`, with no `items` segment in the path', () => { + const out: any = resolveMetadataFormSchemaTitles(schema(), 'dashboard', bundle, { locale: 'zh-CN' }); + const row = out.properties.header.properties.actions.items.properties; + expect(row.label.title).toBe('标签'); + expect(row.actionUrl.title).toBe('操作地址'); + // A union node takes the title at the node, where the renderer reads it. + expect(row.label.anyOf).toHaveLength(2); + // The authored English title stays where no locale names the property. + expect(row.actionType.title).toBe('Action Type'); + }); + + it('names a composite child and a top-level field by the same rule; only `label` crosses over', () => { + const out: any = resolveMetadataFormSchemaTitles(schema(), 'dashboard', bundle, { locale: 'zh-CN' }); + expect(out.properties.header.properties.showTitle.title).toBe('显示标题'); + expect(out.properties.header.properties.actions.title).toBe('操作按钮'); + expect(out.properties.columns.title).toBe('列数'); + // `helpText` is a FormView attribute — the schema `description` is untouched. + expect(out.properties.header.properties.showTitle.description).toBe('Show dashboard title in header'); + expect(out.properties.columns.description).toBe('Number of grid columns (default 12)'); + }); + + it('walks the fallback chain per key', () => { + const out: any = resolveMetadataFormSchemaTitles(schema(), 'dashboard', bundle, { + locale: 'zh-TW', fallbackChain: ['zh-CN'], + }); + const row = out.properties.header.properties.actions.items.properties; + expect(row.icon.title).toBe('圖示'); // the requested locale's own entry + expect(row.label.title).toBe('标签'); // reached through the chain + }); + + it('leaves a path the schema cannot place alone — an overlay, not a fork', () => { + const out: any = resolveMetadataFormSchemaTitles(schema(), 'dashboard', bundle, { locale: 'zh-CN' }); + expect(out.properties.ghost).toBeUndefined(); + expect(Object.keys(out.properties).sort()).toEqual(['columns', 'header']); + }); + + it('is pure: the input is not mutated, and the untouched spine is shared by identity', () => { + const input: any = schema(); + const before = JSON.stringify(input); + const out: any = resolveMetadataFormSchemaTitles(input, 'dashboard', bundle, { locale: 'zh-CN' }); + expect(JSON.stringify(input)).toBe(before); + expect(out).not.toBe(input); + // `actionType` is renamed by no locale: its node is the input's own object. + expect(out.properties.header.properties.actions.items.properties.actionType) + .toBe(input.properties.header.properties.actions.items.properties.actionType); + }); + + it('returns the input object itself when no bundle entry for the type exists at any locale of the chain', () => { + const input: any = schema(); + expect(resolveMetadataFormSchemaTitles(input, 'dashboard', bundle, { locale: 'en' })).toBe(input); + expect(resolveMetadataFormSchemaTitles(input, 'report', bundle, { locale: 'zh-CN' })).toBe(input); + expect(resolveMetadataFormSchemaTitles(input, 'dashboard', undefined, { locale: 'zh-CN' })).toBe(input); + }); + + it('a literal `items` segment addresses a declared property of that name, never the row', () => { + const declaredItems = { + type: 'object', + properties: { + fields: { + type: 'array', + items: { + type: 'object', + properties: { + items: { type: 'object', properties: { label: { type: 'string' } } }, + label: { type: 'string' }, + }, + }, + }, + }, + }; + const b: TranslationBundle = { + 'zh-CN': { metadataForms: { object: { fields: { 'fields.items.label': { label: '字段标签' }, 'fields.label': { label: '行标签' } } } } }, + }; + const out: any = resolveMetadataFormSchemaTitles(declaredItems, 'object', b, { locale: 'zh-CN' }); + expect(out.properties.fields.items.properties.items.properties.label.title).toBe('字段标签'); + expect(out.properties.fields.items.properties.label.title).toBe('行标签'); + }); +}); diff --git a/packages/spec/src/system/i18n-resolver.ts b/packages/spec/src/system/i18n-resolver.ts index 956b72dd5b..16d915643a 100644 --- a/packages/spec/src/system/i18n-resolver.ts +++ b/packages/spec/src/system/i18n-resolver.ts @@ -2872,10 +2872,30 @@ export function resolveSettingsSourceLabel( // // `field_path` is dot-notation for nested fields. Top-level form fields use // just the field name (e.g. `"name"`, `"description"`). Composite and -// repeater children are addressed via parent path: +// repeater children are addressed via parent path — the SAME path the +// extractor (`os i18n extract`, `walkFormField`) emits from a form's declared +// `fields`, so a repeater ROW property is `.` with no +// literal `items` segment in between: // // "capabilities.trackHistory" // composite "capabilities" → "trackHistory" -// "fields.items.label" // repeater "fields" → row → "label" +// "header.actions.label" // repeater "header.actions" → row → "label" +// "fields.items.label" // repeater "fields" → a DECLARED child +// // named `items` → "label" (not a reserved word) +// +// TWO objects consume these keys, and a localised name reaches the panel only +// through the one the renderer reads for that node (#16458): +// +// - the FormView (`FormFieldSpec.label` / `helpText` / `placeholder`) — +// `resolveMetadataFormLabels`, which also SYNTHESIZES a composite / +// repeater's `fields[]` from the bundle when the form enumerates none; +// - the JSON Schema derived from the metadata type's zod schema +// (`properties..properties..title`, and through an array's `items`) +// — `resolveMetadataFormSchemaTitles`. The console renders a repeater's +// rows as a table whose column headers read `items.properties[k].title`, +// never a `FormFieldSpec`, so an ITEM-level property name is authored as +// a `.meta({ title })` on the zod item schema (the English name) and +// localised by this overlay. Only `label` → `title` crosses over; +// `helpText` / `placeholder` stay on the FormView route. // // All helpers are pure (immutable) — they return a new form object with // the translated branches when matches exist, or the input unchanged when @@ -3123,6 +3143,126 @@ export function resolveMetadataFormLabels>( return next as T; } +/** + * Every `metadataForms..fields.` key recorded at any locale of the + * chain — the union, so a key one locale names and another does not is still + * visited (the per-key lookup then walks the chain in order). + */ +function listMetadataFormFieldPaths( + bundle: TranslationBundle | undefined, + type: string, + opts?: ResolveOptions, +): string[] { + if (!bundle) return []; + const seen = new Set(); + for (const code of localeChain(opts)) { + const fields = pickData(bundle, code)?.metadataForms?.[type]?.fields; + if (!fields || typeof fields !== 'object') continue; + for (const key of Object.keys(fields)) if (key.length > 0) seen.add(key); + } + return Array.from(seen); +} + +/** + * Write `title` onto the JSON Schema node a dot path addresses, copying only + * the spine of the tree that changes. Returns the SAME node when the path + * addresses nothing — an overlay, not a fork: a key the schema cannot place is + * left alone rather than invented. + * + * Addressing rules, shared with the extractor's `walkFormField` and with + * `translateFormField` above: + * + * - a segment names an entry of `properties`; + * - an ARRAY node is stepped through into its row schema (`items`) without + * consuming a segment — a repeater row property is `.`; + * a literal `items` segment is only ever a declared property of that name; + * - a union (`anyOf` / `oneOf`) applies the path to every branch that can + * take it (an `I18nLabel` is a `string | locale-map` union; an optional + * array authored beside a scalar is a union of shapes); + * - a `$ref` is not followed: a shared definition cannot carry a per-path + * name, and the metadata-type schemas are emitted inlined. + */ +function setSchemaTitleAtPath(node: any, segments: readonly string[], title: string): any { + if (!node || typeof node !== 'object' || Array.isArray(node)) return node; + if (segments.length === 0) { + return node.title === title ? node : { ...node, title }; + } + const [head, ...rest] = segments; + const props = node.properties; + if (props && typeof props === 'object' && props[head] && typeof props[head] === 'object') { + const child = setSchemaTitleAtPath(props[head], rest, title); + return child === props[head] ? node : { ...node, properties: { ...props, [head]: child } }; + } + const items = node.items; + if (items && typeof items === 'object' && !Array.isArray(items)) { + const row = setSchemaTitleAtPath(items, segments, title); + return row === items ? node : { ...node, items: row }; + } + for (const key of ['anyOf', 'oneOf'] as const) { + const branches = node[key]; + if (!Array.isArray(branches)) continue; + let changed = false; + const next = branches.map((branch: any) => { + const out = setSchemaTitleAtPath(branch, segments, title); + if (out !== branch) changed = true; + return out; + }); + if (changed) return { ...node, [key]: next }; + } + return node; +} + +/** + * Overlay a locale bundle's `metadataForms..fields..label` entries + * onto a JSON Schema derived from the metadata type's zod schema, as `title` + * on the node each path addresses (#16458). + * + * This is the SECOND half of the metadata-form localisation channel. + * {@link resolveMetadataFormLabels} decorates the FormView the panel lays + * out with; this function decorates the JSON Schema the panel reads field + * shapes from — and for one class of node the schema is the ONLY object the + * renderer consults: a repeater's rows render as a table whose column headers + * are `items.properties[k].title`, with no `FormFieldSpec` in reach. An + * item-level property is therefore named in English by a `.meta({ title })` + * on the zod item schema, and named in every other locale by a bundle entry at + * the row-property path (`header.actions.label`), which lands here. + * + * Every `fields.` key of the bundle is applied — top-level and composite + * paths too — so a consumer reading `properties..title` sees the same name + * the FormView carries. Only `label` crosses over; `helpText` and + * `placeholder` are FormView attributes and stay on that route. + * + * Pure: returns a new schema with the changed spine copied, or the INPUT + * OBJECT ITSELF when no bundle entry for `type` exists at any locale of the + * chain or no entry addresses a node — a caller may memoise on identity. + * Lookup order per key is the resolver's usual chain (requested locale → + * `fallbackChain` → nothing; the authored `title` is left in place when no + * locale names the node). + * + * @example + * ```ts + * const schema = z.toJSONSchema(DashboardSchema, { io: 'input' }); + * const localized = resolveMetadataFormSchemaTitles(schema, 'dashboard', bundle, { locale: 'zh-CN' }); + * localized.properties.header.properties.actions.items.properties.label.title; // '标签' + * ``` + */ +export function resolveMetadataFormSchemaTitles>( + schema: T, + type: string, + bundle: TranslationBundle | undefined, + opts?: ResolveOptions, +): T { + if (!schema || typeof schema !== 'object') return schema; + if (!lookupMetadataForm(bundle, type, opts)) return schema; + let next: any = schema; + for (const path of listMetadataFormFieldPaths(bundle, type, opts)) { + const title = lookupMetadataFormField(bundle, type, path, 'label', opts); + if (!title) continue; + next = setSchemaTitleAtPath(next, path.split('.'), title); + } + return next as T; +} + // ──────────────────────────────────────────────────────────────────────────── // Screen-flow metadata resolvers (#7646 / #11287) — flows..… // ──────────────────────────────────────────────────────────────────────────── diff --git a/packages/spec/src/system/translation.zod.ts b/packages/spec/src/system/translation.zod.ts index b6663c982e..92e76a54e7 100644 --- a/packages/spec/src/system/translation.zod.ts +++ b/packages/spec/src/system/translation.zod.ts @@ -1210,8 +1210,16 @@ const translationDataShape = () => ({ * metadataForms..fields..placeholder * * `field_path` uses dot-notation for nested composite/repeater fields, - * e.g. `"name"`, `"capabilities.trackHistory"`, - * `"fields.items.label"` (a repeater "fields" → row → "label" sub-field). + * e.g. `"name"`, `"capabilities.trackHistory"`, and a repeater ROW property + * is `"."` with no `items` segment — + * `"header.actions.label"` names the `label` column of each + * `dashboard.header.actions[]` row (`"fields.items.label"` addresses a + * declared child that happens to be named `items`). + * + * A row property is rendered from the JSON Schema (`items.properties[k].title`), + * not from a form-field spec, so its `label` here is applied by + * `resolveMetadataFormSchemaTitles` as the schema node's `title`; the zod + * item schema's own `.meta({ title })` is the English name it overlays. * * @example * ```ts diff --git a/packages/spec/src/ui/dashboard.form.ts b/packages/spec/src/ui/dashboard.form.ts index e116b29a98..4e3489c4c2 100644 --- a/packages/spec/src/ui/dashboard.form.ts +++ b/packages/spec/src/ui/dashboard.form.ts @@ -24,7 +24,39 @@ export const dashboardForm = defineForm({ { field: 'columns', type: 'number', colSpan: 1, helpText: 'Grid columns (default 12)' }, { field: 'gap', type: 'number', colSpan: 1, helpText: 'Space between widgets, in steps of 0.25rem (4 = 1rem)' }, { field: 'refreshIntervalSeconds', type: 'number', colSpan: 1, helpText: 'Auto-refresh (seconds)' }, - { field: 'header', type: 'composite', colSpan: 3, helpText: 'Dashboard header config (title, subtitle, actions)' }, + { + field: 'header', + type: 'composite', + colSpan: 3, + helpText: 'Dashboard header config (title, subtitle, actions)', + // The children are ENUMERATED (#16458) so `os i18n extract` emits a + // `metadataForms.dashboard.fields['header.']` key for each one + // and the platform catalogs can name them. All three — the console + // prefers a declared `fields` list over the schema-derived one, so + // naming two of three would drop the third from the panel. + fields: [ + { field: 'showTitle', type: 'boolean', helpText: 'Show dashboard title in header' }, + { field: 'showDescription', type: 'boolean', helpText: 'Show dashboard description in header' }, + { + field: 'actions', + type: 'repeater', + helpText: 'Header action buttons', + // Item-level names. A row's columns are rendered from the JSON + // Schema (`items.properties[k].title`), not from these specs, so + // each `label` here MUST equal the `title` authored on + // `DashboardHeaderActionSchema` — the extractor takes the English + // source from here, the panel reads it from there, and + // `dashboard.test.ts` pins the two equal. No `type`: the row + // widgets stay schema-derived. + fields: [ + { field: 'label', label: 'Label' }, + { field: 'actionUrl', label: 'Action URL' }, + { field: 'actionType', label: 'Action Type' }, + { field: 'icon', label: 'Icon' }, + ], + }, + ], + }, ], }, { diff --git a/packages/spec/src/ui/dashboard.test.ts b/packages/spec/src/ui/dashboard.test.ts index c67e50f14e..c65805ea31 100644 --- a/packages/spec/src/ui/dashboard.test.ts +++ b/packages/spec/src/ui/dashboard.test.ts @@ -15,6 +15,7 @@ import { DATE_RANGE_PRESETS, DATE_RANGE_DEFAULT_RANGES, } from './dashboard.zod'; +import { dashboardForm } from './dashboard.form'; /** * ADR-0021 single-form: every dashboard widget binds a `dataset` and selects @@ -733,3 +734,65 @@ describe('dashboard.refreshInterval carries its unit (#15680)', () => { } }); }); + +// ──────────────────────────────────────────────────────────────────────────── +// #16458 — item-level property names: the header-action row schema carries a +// `title` per field, and the form declares the same children with the same +// labels +// ──────────────────────────────────────────────────────────────────────────── + +describe('#16458 — DashboardHeaderAction fields carry an item-level `title`', () => { + const ROW_TITLES: Record = { + label: 'Label', + actionUrl: 'Action URL', + actionType: 'Action Type', + icon: 'Icon', + }; + + // The console derives the panel schema with `io: 'input'`; `GET /meta` + // derives it in output mode. The name must survive both. + for (const io of ['input', 'output'] as const) { + it(`z.toJSONSchema(DashboardSchema, { io: '${io}' }) names every header.actions[] column`, () => { + const js = z.toJSONSchema(DashboardSchema, { io, unrepresentable: 'any' }) as any; + const props = js.properties.header.properties.actions.items.properties; + expect(Object.keys(props).sort()).toEqual(Object.keys(ROW_TITLES).sort()); + for (const [key, title] of Object.entries(ROW_TITLES)) { + expect(props[key].title, `items.properties.${key}.title`).toBe(title); + // The title is an annotation beside the existing description, not in + // place of it. + expect(typeof props[key].description).toBe('string'); + } + // Control — a sibling item property with no authored title has none: + // the pin above is reading a title, not a default the emitter invents. + const widgetProps = js.properties.widgets.items.properties; + expect(widgetProps.id.title).toBeUndefined(); + }); + } + + it('dashboardForm enumerates the header children and labels the row properties as the schema titles', () => { + const header = (dashboardForm.sections as any[]) + .flatMap((s) => s.fields ?? []) + .find((f: any) => f?.field === 'header'); + expect(header?.type).toBe('composite'); + // ALL of the composite's children — the panel prefers a declared list over + // the schema-derived one, so a partial enumeration would drop a child. + const headerKeys = Object.keys( + (z.toJSONSchema(DashboardHeaderSchema, { io: 'input', unrepresentable: 'any' }) as any).properties, + ).sort(); + expect((header.fields as any[]).map((f) => f.field).sort()).toEqual(headerKeys); + const actions = (header.fields as any[]).find((f) => f.field === 'actions'); + expect(actions.type).toBe('repeater'); + // The extractor takes the English source from these labels; the panel + // reads the schema `title`. One name, two spellings, pinned equal. + const declared = Object.fromEntries((actions.fields as any[]).map((f) => [f.field, f.label])); + expect(declared).toEqual(ROW_TITLES); + }); + + it('control — `columns` still declares no default and parses to undefined when absent (#16458 item ④ deliberately not landed)', () => { + const js = z.toJSONSchema(DashboardSchema, { io: 'input', unrepresentable: 'any' }) as any; + expect(js.properties.columns.default).toBeUndefined(); + const parsed = DashboardSchema.parse({ name: 'dash_x', label: 'D', widgets: [] }); + expect(parsed.columns).toBeUndefined(); + expect('columns' in parsed).toBe(false); + }); +}); diff --git a/packages/spec/src/ui/dashboard.zod.ts b/packages/spec/src/ui/dashboard.zod.ts index c2e7086b9e..4cd9d43e2e 100644 --- a/packages/spec/src/ui/dashboard.zod.ts +++ b/packages/spec/src/ui/dashboard.zod.ts @@ -78,6 +78,20 @@ const DASHBOARD_HISTORY = /** * Dashboard Header Action Schema * An action button displayed in the dashboard header area. + * + * Each field carries a JSON Schema `title` (#16458): the ITEM-level authoring + * name. A repeater row is not a form field — the Studio property panel renders + * `header.actions[]` as a table whose column headers read + * `items.properties[k].title ?? k` from the JSON Schema derived by + * `z.toJSONSchema(DashboardSchema)`, never from a `FormFieldSpec` label. With + * no `title`, the fallback arm runs for every locale, English included, and the + * machine key is what the maker sees. The title is the English name; a locale + * bundle names the same column through + * `metadataForms.dashboard.fields['header.actions.'].label`, which + * `resolveMetadataFormSchemaTitles` (`system/i18n-resolver.ts`) overlays onto + * the derived schema. `dashboard.form.ts` declares the same four children with + * the same labels so the extractor emits those keys; `dashboard.test.ts` pins + * the two spellings equal. */ export const DashboardHeaderActionSchema = lazySchema(() => strictObject({ surface: 'this dashboard header action', @@ -85,16 +99,16 @@ export const DashboardHeaderActionSchema = lazySchema(() => strictObject({ aliases: { title: 'label', text: 'label', name: 'label', url: 'actionUrl', href: 'actionUrl', link: 'actionUrl', target: 'actionUrl', type: 'actionType', kind: 'actionType' }, }, { /** Action label */ - label: I18nLabelSchema.describe('Action button label'), + label: I18nLabelSchema.describe('Action button label').meta({ title: 'Label' }), /** Action URL or target */ - actionUrl: z.string().describe('URL or target for the action'), + actionUrl: z.string().describe('URL or target for the action').meta({ title: 'Action URL' }), /** Action type */ - actionType: WidgetActionTypeSchema.optional().describe('Type of action'), + actionType: WidgetActionTypeSchema.optional().describe('Type of action').meta({ title: 'Action Type' }), /** Icon identifier */ - icon: z.string().optional().describe('Icon identifier for the action button'), + icon: z.string().optional().describe('Icon identifier for the action button').meta({ title: 'Icon' }), }).describe('Dashboard header action')); /** From 7a17a70e7fb3c784eaa4104677533e7d927b3fca Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 9 Sep 2026 13:57:28 +0000 Subject: [PATCH 2/5] chore(spec): regenerate api-surface and export-origins for resolveMetadataFormSchemaTitles (#16458) Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH --- packages/spec/api-surface/system.json | 1 + packages/spec/export-origins/system.json | 1 + 2 files changed, 2 insertions(+) diff --git a/packages/spec/api-surface/system.json b/packages/spec/api-surface/system.json index 7740243b35..53cf97271b 100644 --- a/packages/spec/api-surface/system.json +++ b/packages/spec/api-surface/system.json @@ -810,6 +810,7 @@ "resolveDocLocale (function)", "resolveFlowScreenTitle (function)", "resolveMetadataFormLabels (function)", + "resolveMetadataFormSchemaTitles (function)", "resolveMetadataTypeDescription (function)", "resolveMetadataTypeLabel (function)", "resolveObjectFieldLabels (function)", diff --git a/packages/spec/export-origins/system.json b/packages/spec/export-origins/system.json index 97d21f11a6..0b7ee5c432 100644 --- a/packages/spec/export-origins/system.json +++ b/packages/spec/export-origins/system.json @@ -771,6 +771,7 @@ "resolveDocLocale": "src/system/doc.zod.ts#resolveDocLocale (function)", "resolveFlowScreenTitle": "src/system/i18n-resolver.ts#resolveFlowScreenTitle (function)", "resolveMetadataFormLabels": "src/system/i18n-resolver.ts#resolveMetadataFormLabels (function)", + "resolveMetadataFormSchemaTitles": "src/system/i18n-resolver.ts#resolveMetadataFormSchemaTitles (function)", "resolveMetadataTypeDescription": "src/system/i18n-resolver.ts#resolveMetadataTypeDescription (function)", "resolveMetadataTypeLabel": "src/system/i18n-resolver.ts#resolveMetadataTypeLabel (function)", "resolveObjectFieldLabels": "src/system/i18n-resolver.ts#resolveObjectFieldLabels (function)", From db7853cf47761e3e8e5b29829a28574030cbc905 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 9 Sep 2026 14:20:28 +0000 Subject: [PATCH 3/5] 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 Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH --- .../en.metadata-forms.generated.ts | 24 +++++++++++++++++++ .../es-ES.metadata-forms.generated.ts | 24 +++++++++++++++++++ .../ja-JP.metadata-forms.generated.ts | 24 +++++++++++++++++++ .../zh-CN.metadata-forms.generated.ts | 24 +++++++++++++++++++ 4 files changed, 96 insertions(+) diff --git a/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts index ddbe31055f..0fa7e43b08 100644 --- a/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts @@ -950,6 +950,30 @@ export const enMetadataForms: NonNullable = { 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" diff --git a/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts index 023e6f5d54..1bb4cf1bfb 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts @@ -950,6 +950,30 @@ export const esESMetadataForms: NonNullable = 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" diff --git a/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts index 1901c20487..004730fb28 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts @@ -950,6 +950,30 @@ export const jaJPMetadataForms: NonNullable = 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: "位置とサイズを持つダッシュボードウィジェット" diff --git a/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts index b6a40c4697..ac0c3898ca 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts @@ -950,6 +950,30 @@ export const zhCNMetadataForms: NonNullable = 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: "包含位置和尺寸的仪表板组件" From 6fa3f71ca6f7cf358e1a8b69c4e8e8a8f440666b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 9 Sep 2026 18:11:05 +0000 Subject: [PATCH 4/5] test(platform-objects): pin the catalogs to the LIVE refresh key, against the card's inverted acceptance (#16458) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH --- .../dashboard-header-children.test.ts | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/packages/platform-objects/src/apps/translations/dashboard-header-children.test.ts b/packages/platform-objects/src/apps/translations/dashboard-header-children.test.ts index 3bcd3b152e..b3a4ca5bfc 100644 --- a/packages/platform-objects/src/apps/translations/dashboard-header-children.test.ts +++ b/packages/platform-objects/src/apps/translations/dashboard-header-children.test.ts @@ -89,3 +89,49 @@ describe('#16458 — dashboard header children and row properties in every catal } }); }); + +// --------------------------------------------------------------------------- +// #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(); + 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'); + }); +}); From 24402bf3600e9b2697489836c3986a6b48fee639 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 9 Sep 2026 19:31:54 +0000 Subject: [PATCH 5/5] =?UTF-8?q?docs(changeset):=20record=20the=20MEASURED?= =?UTF-8?q?=20reason=20item=20=E2=91=A3=20stayed=20out=20(#16458)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH --- .../dashboard-item-level-property-names.md | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/.changeset/dashboard-item-level-property-names.md b/.changeset/dashboard-item-level-property-names.md index 638e514e68..adc9e3e349 100644 --- a/.changeset/dashboard-item-level-property-names.md +++ b/.changeset/dashboard-item-level-property-names.md @@ -43,7 +43,17 @@ console shipped a private overlay for exactly those three. `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)` — -`defineStack` parses in strict mode by default and the console renderer -branches positioned-vs-auto-flow on `columns != null`, so materialising the -default would change how a `columns`-less dashboard lays out; see #16458. + +`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.