diff --git a/.changeset/24969-form-view-title-translation-key.md b/.changeset/24969-form-view-title-translation-key.md new file mode 100644 index 00000000000..96b4a7eeb69 --- /dev/null +++ b/.changeset/24969-form-view-title-translation-key.md @@ -0,0 +1,17 @@ +--- +'@objectstack/spec': minor +'@objectstack/rest': minor +'@objectstack/lint': minor +'@objectstack/cli': minor +--- + +feat(spec): a form view's `title` is translated through `objects.._views..title`, and `GET /forms/:slug` serves it in the visitor's language + +Clause-②: yes (widening) + +- **Before.** A form view's `title` (`FormViewSchema.title`) had no translation key. The `_views.` entry named `title` as a rename onto `label`, `translateView` never read `config.title`, and `GET /forms/:slug` answered the top-level `label` from the untranslated view. A public form that declared a title served it in the authoring language to every visitor, so declaring one replaced a translated heading (the object label) with an untranslated one in every other locale. +- **Now.** `objects.._views..title` is a declared key, under the same `_views` node and view key as the view's `label`. `translateView` overlays it onto a served form item's `config.title`, on the same locale chain and override rule as every other view string: only a form that authors a title is translated, a missing key keeps the authored title, and a title edited on top of the packaged view keeps the edit. `heading` and `formTitle` are named as renames onto `title`. +- **`GET /forms/:slug`.** The served form's `title` is the translated one, and the top-level `label` is taken from the translated view when the view authors a `label`. A view that authors none is still answered without one, so the console heading (`label`, then `form.title`, then the translated object label) is unchanged for it. +- **`objectstack validate`.** `@objectstack/lint`'s translation-reference check accepts a `title` key over a form view (a `formViews.` entry, the default `form`, or a `viewKind: 'form'` item) and refuses it, as `translation-target-unknown`, over a list view, pointing at the view's `label` key. +- **`os i18n extract`.** The CLI extractor emits `_views..title` for each form a view container serves (keyed by the composer's runtime name) and for a `viewKind: 'form'` item, so the skeleton carries the slot and the i18n coverage gate reports an authored title a supported locale leaves untranslated. +- **Unchanged.** Every other `_views` key, the view `label` and `description` reads, and a form without a title. diff --git a/content/docs/references/system/translation.mdx b/content/docs/references/system/translation.mdx index 03873930696..7b056a60c7e 100644 --- a/content/docs/references/system/translation.mdx +++ b/content/docs/references/system/translation.mdx @@ -91,7 +91,7 @@ Translation data for a single object | **pluralLabel** | `string` | optional | Translated plural label | | **description** | `string` | optional | Translated object description | | **fields** | `Record }>` | optional | Field-level translations | -| **_views** | `Record }>` | optional | View translations keyed by view name | +| **_views** | `Record` | optional | View translations keyed by view name | | **_actions** | `Record` | optional | Action translations keyed by action name | | **_sections** | `Record` | optional | Section translations keyed by section name | | **_tabs** | `Record` | optional | Filter-preset tab translations keyed by tab name | @@ -114,6 +114,7 @@ Translation data for a single field | :--- | :--- | :--- | :--- | | **label** | `string` | optional | Translated view label | | **description** | `string` | optional | Translated view description | +| **title** | `string` | optional | Translated form view title — overlays a form view's own `title` (`FormViewSchema.title`); only a form that authors one is translated | | **emptyState** | `{ title?: string; message?: string }` | optional | Translated empty-state copy shown when the view has no rows | | **bulkActions** | `Record }>` | optional | Selection-bar translations keyed by bulk-action def name (`BulkActionDefSchema.name`) | @@ -191,7 +192,7 @@ Translation data for a single object | **pluralLabel** | `string` | optional | Translated plural label | | **description** | `string` | optional | Translated object description | | **fields** | `Record }>` | optional | Field-level translations | -| **_views** | `Record }>` | optional | View translations keyed by view name | +| **_views** | `Record` | optional | View translations keyed by view name | | **_actions** | `Record` | optional | Action translations keyed by action name | | **_sections** | `Record` | optional | Section translations keyed by section name | | **_tabs** | `Record` | optional | Filter-preset tab translations keyed by tab name | @@ -394,7 +395,7 @@ Translation data for a single object | **pluralLabel** | `string` | optional | Translated plural label | | **description** | `string` | optional | Translated object description | | **fields** | `Record }>` | optional | Field-level translations | -| **_views** | `Record }>` | optional | View translations keyed by view name | +| **_views** | `Record` | optional | View translations keyed by view name | | **_actions** | `Record` | optional | Action translations keyed by action name | | **_sections** | `Record` | optional | Section translations keyed by section name | | **_tabs** | `Record` | optional | Filter-preset tab translations keyed by tab name | @@ -557,7 +558,7 @@ Translation data for a single object | **pluralLabel** | `string` | optional | Translated plural label | | **description** | `string` | optional | Translated object description | | **fields** | `Record }>` | optional | Field-level translations | -| **_views** | `Record }>` | optional | View translations keyed by view name | +| **_views** | `Record` | optional | View translations keyed by view name | | **_actions** | `Record` | optional | Action translations keyed by action name | | **_sections** | `Record` | optional | Section translations keyed by section name | | **_tabs** | `Record` | optional | Filter-preset tab translations keyed by tab name | diff --git a/examples/app-showcase/src/system/translations/index.ts b/examples/app-showcase/src/system/translations/index.ts index f285c33d01a..955165bd6e4 100644 --- a/examples/app-showcase/src/system/translations/index.ts +++ b/examples/app-showcase/src/system/translations/index.ts @@ -602,8 +602,14 @@ export const ShowcaseTranslationBundle = { // "Contacts", so the object's own `pluralLabel` above is the right // word; the `全部…` spelling the task/project lists use belongs to // labels that actually say "All …". + // + // `_views.create.title` — the sparse `formViews.create` form's own + // heading (`title: 'New contact'`), which `translateView` overlays + // onto the served form's `config.title`. 新建联系人 follows the + // 新建… shape a create heading takes. _views: { default: { label: '联系人' }, + create: { title: '新建联系人' }, }, // `_sections` — the four groups of the default edit form in // `ui/views/contact.view.ts`, plus the sparse `formViews.create` diff --git a/packages/cli/src/utils/i18n-extract.ts b/packages/cli/src/utils/i18n-extract.ts index 72b2eb71860..5458ec59db5 100644 --- a/packages/cli/src/utils/i18n-extract.ts +++ b/packages/cli/src/utils/i18n-extract.ts @@ -421,6 +421,39 @@ function pushViewEntries(out: ExpectedEntry[], objectName: string, viewName: str pushBulkActionDefs(out, root, view, objectName); } +/** + * Emit `_views..title` for every FORM view a view container serves — + * each `formViews.` entry and the default `form` — the key + * `translateView` overlays a form's `title` (`FormViewSchema.title`) from. + * + * **The key is the composer's, asked, never re-derived.** A container form is + * served as the item `expandViewContainer` expands it to, and `translateView` + * keys its title by that item's registry name with the object prefix + * stripped: `formViews.` is `` (renamed on a collision, e.g. + * `default_2` beside a default list), the default `form` is its `name` or + * `form`. Reading the expanded items is the same discipline + * {@link defaultListViewKey} keeps for the default list, so the key written + * here is the key the runtime reads. + * + * **Title only.** The other `_views` tails stay unemitted for a form: a + * container's form entry carries no `label`, and its `description` is not + * overlaid by anything a form renders. A form that authors no title still + * records the key (`pushOptional`), so the coverage gate notices a bundle + * that translates one the source never wrote. + */ +function pushContainerFormTitles(out: ExpectedEntry[], containerObject: string, container: any): void { + if (!container?.form && !container?.formViews) return; + const prefix = `${containerObject}.`; + for (const item of expandViewContainer(containerObject, container)) { + if (item.viewKind !== 'form') continue; + const key = item.name.startsWith(prefix) ? item.name.slice(prefix.length) : item.name; + const config = item.config as { title?: unknown } | undefined; + pushOptional(out, ['objects', containerObject, '_views', key, 'title'], config?.title, 'view', { + objectName: containerObject, + }); + } +} + /** * Emit `_views..bulkActions..*` for a list view's authored * `bulkActionDefs[]` (#14253's resolver, #14376's walk). @@ -1315,9 +1348,11 @@ export function collectExpectedEntries( // `i18n/missing-view` had zero producers repo-wide while the ratchet // reported green (#4123). // - // `formViews` stays uncovered: form views have no counterpart in the - // `viewLabel` / `_views.*` resolver convention, so emitting keys for them - // would expect translations nothing reads. + // A container's form views (`formViews.*`, the default `form`) emit one + // `_views` tail, `title` — the form heading `translateView` overlays (see + // `pushContainerFormTitles`). Their `label` / `description` stay + // unemitted: a container form entry carries no label, and nothing a form + // renders reads a `_views` description. const views: any[] = Array.isArray(config?.views) ? config.views : []; for (const view of views) { const containerObject = viewObjectName(view); @@ -1325,9 +1360,17 @@ export function collectExpectedEntries( if (view.name) { pushViewEntries(out, containerObject, view.name, view); + // A `viewKind: 'form'` item carries its form under `config`, title included. + if (view.viewKind === 'form' && view.config && typeof view.config === 'object') { + pushOptional(out, ['objects', containerObject, '_views', view.name, 'title'], view.config.title, 'view', { + objectName: containerObject, + }); + } continue; } + pushContainerFormTitles(out, containerObject, view); + // The container's default list, keyed exactly as the runtime registry keys // it — see `defaultListViewKey`. `undefined` means the container declares // no default list; a key the container's own `listViews` also declares diff --git a/packages/cli/test/i18n-form-title-coverage.test.ts b/packages/cli/test/i18n-form-title-coverage.test.ts new file mode 100644 index 00000000000..699ae7b834d --- /dev/null +++ b/packages/cli/test/i18n-form-title-coverage.test.ts @@ -0,0 +1,103 @@ +// Copyright (c) 2026 ObjectStack contributors. Apache-2.0 license. +// +// A form view's title (`FormViewSchema.title`) is translated through +// `objects.._views..title` (`translateView` overlays it onto the +// served form item's `config.title`). The walker emits that slot for every +// form a view container serves, keyed by the composer's runtime identity, so +// `os i18n extract` scaffolds it and the coverage gate reports a locale that +// leaves an authored title untranslated. + +import { describe, it, expect } from 'vitest'; +import { collectExpectedEntries, extractTranslations } from '../src/utils/i18n-extract'; +import { computeI18nCoverage } from '../src/utils/i18n-coverage'; +import { ObjectTranslationDataSchema } from '@objectstack/spec/system'; + +const titleKeys = (config: any) => + collectExpectedEntries(config) + .map((e) => e.path.join('.')) + .filter((p) => p.endsWith('.title') && p.includes('._views.')); + +const leadForm = (extra: Record = {}) => ({ + type: 'simple', + data: { provider: 'object', object: 'crm_lead' }, + sections: [{ name: 'basics', label: 'Basics', fields: ['name'] }], + ...extra, +}); + +const config = (translations: unknown[] = []) => ({ + i18n: { defaultLocale: 'en', supportedLocales: ['en', 'zh-CN'] }, + objects: [{ name: 'crm_lead', label: 'Lead', fields: { name: { type: 'text', label: 'Name' } } }], + views: [ + { + list: { type: 'grid', name: 'all_leads', label: 'All Leads', data: { provider: 'object', object: 'crm_lead' } }, + form: leadForm({ title: 'New lead' }), + formViews: { + web_to_lead: leadForm({ title: 'Contact us' }), + quick: leadForm(), + }, + }, + { + name: 'referral', + object: 'crm_lead', + viewKind: 'form', + config: leadForm({ title: 'Refer a friend' }), + }, + ], + translations, +}); + +describe('i18n walker — a form view\'s title, `_views..title`', () => { + it('emits the slot for each form a container serves and for a form item, keyed by the runtime identity', () => { + expect(titleKeys(config()).sort()).toEqual([ + 'objects.crm_lead._views.form.title', + 'objects.crm_lead._views.quick.title', + 'objects.crm_lead._views.referral.title', + 'objects.crm_lead._views.web_to_lead.title', + ]); + }); + + it('seeds an authored title and leaves a form without one unseeded', () => { + const entries = collectExpectedEntries(config()); + const at = (p: string) => entries.find((e) => e.path.join('.') === p); + expect(at('objects.crm_lead._views.web_to_lead.title')).toMatchObject({ sourceValue: 'Contact us', inline: 'Contact us' }); + expect(at('objects.crm_lead._views.form.title')).toMatchObject({ sourceValue: 'New lead' }); + expect(at('objects.crm_lead._views.quick.title')?.inline).toBeUndefined(); + }); + + it('CONTROL: the list view emits no title slot, and the container forms emit no `label` slot', () => { + const paths = collectExpectedEntries(config()).map((e) => e.path.join('.')); + expect(paths).toContain('objects.crm_lead._views.all_leads.label'); + expect(paths).not.toContain('objects.crm_lead._views.all_leads.title'); + expect(paths).not.toContain('objects.crm_lead._views.web_to_lead.label'); + }); + + it('`os i18n extract` writes a skeleton the translation schema accepts', () => { + const lead: any = extractTranslations(config(), { locales: ['en'] }).bundles.en?.objects?.crm_lead; + expect(lead?._views?.web_to_lead?.title).toBe('Contact us'); + const parsed = ObjectTranslationDataSchema.safeParse(lead); + expect(parsed.success, JSON.stringify(parsed.error?.issues)).toBe(true); + }); + + it('the coverage gate reports an authored title a supported locale leaves untranslated', () => { + const missing = computeI18nCoverage(config([{ 'zh-CN': { objects: { crm_lead: { label: '线索' } } } }])) + .issues.filter((i) => i.locale === 'zh-CN' && i.key.endsWith('.title')) + .map((i) => i.key) + .sort(); + expect(missing).toEqual([ + 'objects.crm_lead._views.form.title', + 'objects.crm_lead._views.referral.title', + 'objects.crm_lead._views.web_to_lead.title', + ]); + const covered = computeI18nCoverage(config([{ + 'zh-CN': { + objects: { + crm_lead: { + label: '线索', + _views: { web_to_lead: { title: '联系我们' }, form: { title: '新建线索' }, referral: { title: '推荐朋友' } }, + }, + }, + }, + }])).issues.filter((i) => i.locale === 'zh-CN' && i.key.endsWith('.title')); + expect(covered).toEqual([]); + }); +}); diff --git a/packages/lint/src/validate-translation-references.test.ts b/packages/lint/src/validate-translation-references.test.ts index 455ef802895..7bfcc7ffc00 100644 --- a/packages/lint/src/validate-translation-references.test.ts +++ b/packages/lint/src/validate-translation-references.test.ts @@ -2890,6 +2890,70 @@ describe('validateTranslationReferences — the canonical view-record shape', () }); }); +/** + * `_views..title` — a FORM view's heading. `translateView` overlays it + * onto the served form item's `config.title`, keyed by the item's runtime + * identity: a `formViews.` entry, the default `form` (`form`, or its + * `name`), or a `viewKind: 'form'` item. A list view has no title, so the key + * over one resolves nothing — refused, pointing at the tab's `label`. + */ +describe('validateTranslationReferences — a form view\'s title (`_views..title`)', () => { + const stack = (views: Record) => ({ + objects: [{ name: 'crm_lead', fields: { name: { type: 'text' } } }], + views: [ + { + list: { type: 'grid', name: 'all_leads', label: 'All Leads', data: { provider: 'object', object: 'crm_lead' } }, + form: { type: 'simple', title: 'New lead', data: { provider: 'object', object: 'crm_lead' } }, + formViews: { + web_to_lead: { type: 'simple', title: 'Contact us', data: { provider: 'object', object: 'crm_lead' } }, + }, + }, + { + name: 'quick_lead', + object: 'crm_lead', + viewKind: 'form', + config: { type: 'simple', title: 'Quick lead', data: { provider: 'object', object: 'crm_lead' } }, + }, + ], + translations: [{ 'zh-CN': { objects: { crm_lead: { label: '线索', _views: views } } } }], + }); + + it('accepts a title over a named form view, the default form and a form item', () => { + const findings = validateTranslationReferences(stack({ + web_to_lead: { title: '联系我们' }, + form: { title: '新建线索' }, + quick_lead: { title: '快速线索' }, + })); + expect(findings).toEqual([]); + }); + + it('the same fixture still reports a key nothing declares (the green above is not an empty run)', () => { + const findings = validateTranslationReferences(stack({ + web_to_lead: { title: '联系我们' }, + ghost_form: { title: 'x' }, + })); + expect(findings.map((f) => f.path)).toEqual(['translations[0]["zh-CN"].objects.crm_lead._views.ghost_form']); + }); + + it('refuses a title over a LIST view by name, pointing at its `label`', () => { + const findings = validateTranslationReferences(stack({ all_leads: { title: '全部线索' } })); + expect(findings.map((f) => [f.rule, f.severity, f.path])).toEqual([ + [TRANSLATION_TARGET_UNKNOWN, 'error', 'translations[0]["zh-CN"].objects.crm_lead._views.all_leads.title'], + ]); + expect(findings[0].message).toBe('View "all_leads" of object "crm_lead" is not a form view, so no title resolves this key.'); + expect(findings[0].hint).toContain('`_views.all_leads.label`'); + }); + + it('CONTROL: a list view\'s `label` key is accepted as before', () => { + expect(validateTranslationReferences(stack({ all_leads: { label: '全部线索' } }))).toEqual([]); + }); + + it('the default form\'s key stays a non-key for every tail but `title`', () => { + const findings = validateTranslationReferences(stack({ form: { label: '表单' } })); + expect(findings.map((f) => f.path)).toEqual(['translations[0]["zh-CN"].objects.crm_lead._views.form']); + }); +}); + describe('validateTranslationReferences — section anchors', () => { it('resolves a section named on a form view or a record:details page', () => { const stack = { @@ -4027,6 +4091,7 @@ describe('[#22161] one-line verdicts — translation-target-unknown, translation '`screen` node; its heading is keyed', 'declares no node for, so the step keeps', '`approval` node; its step label is keyed', + 'is not a form view, so no title resolves this key', 'Translations are keyed to parameter "', 'does not declare in `outcomeMessages`', 'declares no `outcomeMessages` at all', diff --git a/packages/lint/src/validate-translation-references.ts b/packages/lint/src/validate-translation-references.ts index e8329170008..1428cb964cb 100644 --- a/packages/lint/src/validate-translation-references.ts +++ b/packages/lint/src/validate-translation-references.ts @@ -345,6 +345,16 @@ const IMPLICIT_FIELDS: ReadonlySet = new Set([ interface ObjectFacts { fields: Map; views: Set; + /** + * The `_views` keys of the FORM views this object's containers serve — each + * `formViews.` entry, the default `form`, and a `viewKind: 'form'` + * item — the views a `_views..title` key addresses (`translateView` + * overlays it onto the form's `config.title`). A list view carries no + * title, so a `title` key over one resolves nothing. The default form's key + * is legal for `title` alone: its other `_views` tails stay non-keys (see + * {@link collectViewRecord}). + */ + forms: Set; actions: Map; sections: Set; /** @@ -467,6 +477,7 @@ function emptyFacts(): ObjectFacts { return { fields: new Map(), views: new Set(), + forms: new Set(), actions: new Map(), sections: new Set(), validations: new Set(), @@ -525,6 +536,9 @@ function collectViewRecord(view: AnyRec, factsFor: (objectName: string) => Objec const addView = (objectName: string | undefined, name: string | undefined) => { if (objectName && name) factsFor(objectName).views.add(name); }; + const addForm = (objectName: string | undefined, name: string | undefined) => { + if (objectName && name) factsFor(objectName).forms.add(name); + }; /** * Register the `_sections` names one form-ish container declares. @@ -545,6 +559,7 @@ function collectViewRecord(view: AnyRec, factsFor: (objectName: string) => Objec const listBinding = isRec(view.list) ? bindingOf(view.list) : undefined; if (isRec(view.list)) addView(listBinding, defaultListViewKey(listBinding, view)); addView(recordObject ?? listBinding, strName(view.name)); + if (view.viewKind === 'form') addForm(recordObject ?? listBinding, strName(view.name)); const named = namedViewKeys(view); for (const family of ['listViews', 'formViews'] as const) { @@ -560,6 +575,7 @@ function collectViewRecord(view: AnyRec, factsFor: (objectName: string) => Objec if (!isRec(sub)) continue; const binding = bindingOf(sub) ?? listBinding; addView(binding, registryKey); + if (family === 'formViews') addForm(binding, registryKey); addSections(sub, binding); } } @@ -578,7 +594,16 @@ function collectViewRecord(view: AnyRec, factsFor: (objectName: string) => Objec // A `_views` name registered here for the default form would make a key // legal that no consumer ever reads. Its SECTIONS are a different matter — // `ObjectForm` resolves those, which is exactly what #5415 established. - if (isRec(view.form)) addSections(view.form, bindingOf(view.form) ?? listBinding); + // + // One `_views` tail of the default form IS read: its `title`, which + // `translateView` overlays onto the served `.form` item's + // `config.title`. So its runtime key is registered as a FORM key only + // (`forms`), legal for `title` and nothing else. + if (isRec(view.form)) { + const formBinding = bindingOf(view.form) ?? listBinding; + addSections(view.form, formBinding); + addForm(formBinding, defaultFormViewKey(formBinding, view)); + } addSections(view, recordObject ?? listBinding); } @@ -680,6 +705,22 @@ function defaultListViewKey(object: string | undefined, container: AnyRec): stri return item.name.startsWith(prefix) ? item.name.slice(prefix.length) : item.name; } +/** + * The bare `_views` key the RUNTIME assigns to a container's default `form` — + * `form`, or the author's `form.name`, renamed on a collision. Asked of the + * composer, like {@link defaultListViewKey}: the default form is the one form + * item the composer marks `isDefault`. + */ +function defaultFormViewKey(object: string | undefined, container: AnyRec): string | undefined { + if (!object || !isRec(container.form)) return undefined; + const item = expandViewContainer(object, container).find( + (i) => i.viewKind === 'form' && i.isDefault, + ); + if (!item) return undefined; + const prefix = `${object}.`; + return item.name.startsWith(prefix) ? item.name.slice(prefix.length) : item.name; +} + /** * The bare `_views` keys the RUNTIME assigns to a container's NAMED entries — * `listViews.` / `formViews.` — in authoring order per family. @@ -1656,8 +1697,24 @@ export function validateTranslationReferences(stack: AnyRec): TranslationRefFind } // _views. - for (const viewName of Object.keys(asRecord(rawNode._views))) { + for (const [viewName, rawView] of Object.entries(asRecord(rawNode._views))) { + const hasTitle = isRec(rawView) && 'title' in rawView; + const isForm = facts.forms.has(viewName); + // A `title` key translates a FORM view's heading only: over a list + // view it resolves nothing (a list view has no title), where the + // author most likely meant the tab's `label`. + if (hasTitle && !isForm && facts.views.has(viewName)) { + orphan( + `${inLocale} · object "${objectName}" · view "${viewName}"`, + `${objPath}._views.${viewName}.title`, + `View "${viewName}" of object "${objectName}" is not a form view, so no title resolves this key.`, + `A view's tab label is keyed \`_views.${viewName}.label\`; only a form view has a \`title\`.`, + ); + continue; + } if (facts.views.has(viewName)) continue; + // The default form's key is legal for its `title` alone. + if (isForm && isRec(rawView) && Object.keys(rawView).every((k) => k === 'title')) continue; orphan( `${inLocale} · object "${objectName}" · view "${viewName}"`, `${objPath}._views.${viewName}`, diff --git a/packages/rest/src/form-routes.ts b/packages/rest/src/form-routes.ts index d1bb32ecbf5..9d6ebc8a81e 100644 --- a/packages/rest/src/form-routes.ts +++ b/packages/rest/src/form-routes.ts @@ -256,6 +256,12 @@ export function registerFormEndpoints(this: FormRouteHost, basePath: string): vo // sections' `label` / `description` localized beside the // schema's field labels (see the translation block). let servedForm: any = match.form; + // The top-level `label`, served from the TRANSLATED view + // when the view authors one (see the translation block). A + // view that authors none keeps the route's own reading, so + // the console's `label ?? form.title ?? objectSchema.label` + // still reaches the form's (translated) title. + let servedLabel: any = match.view?.label ?? match.form?.label; try { const p = await this.resolveProtocol(environmentId, req); if (typeof (p as any).getMetaItems === 'function') { @@ -334,15 +340,23 @@ export function registerFormEndpoints(this: FormRouteHost, basePath: string): vo // never swapped for the item's `config`. It runs // BEFORE the strip below and returns each section's // `fields` untouched, so it cannot re-introduce a - // stripped field. The top-level `label` keeps its - // own reading: the translator falls back to the - // view's machine name where this route falls back - // to the form's label. + // stripped field. The same call translates the + // form's `title` (`objects.OBJECT._views.VIEW.title`, + // at `config.title`), so the served form carries it. + // + // The top-level `label` is taken from the translated + // view only when the view AUTHORS one: the translator + // falls back to the view's machine name, which must + // never shadow the form's title in the console's + // `label ?? form.title ?? objectSchema.label`. const view = translateMetadataDocument('view', match.view, bundle, { ...this.translateOptionsFor(i18n, locale), packagedBase: this.packagedObjectBase(p, 'view', match.view?.name), }); if (match.form === match.view?.config) servedForm = view?.config; + if (match.view?.label !== undefined && view?.label !== undefined) { + servedLabel = view.label; + } } } catch (e: any) { logError('[REST] Public form schema translation failed:', e); @@ -389,7 +403,7 @@ export function registerFormEndpoints(this: FormRouteHost, basePath: string): vo res.json({ slug, object: match.object, - label: match.view?.label ?? match.form?.label, + label: servedLabel, form: safeForm, objectSchema, }); diff --git a/packages/rest/src/public-form-title-i18n.test.ts b/packages/rest/src/public-form-title-i18n.test.ts new file mode 100644 index 00000000000..462be014dab --- /dev/null +++ b/packages/rest/src/public-form-title-i18n.test.ts @@ -0,0 +1,251 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// `GET /forms/:slug` localizes the served form's TITLE (`form.title`) and the +// top-level `label` under `Accept-Language`. +// +// Before this, a public form could not be titled in more than one language: +// the title had no translation key, `translateView` never read `config.title`, +// and the route answered `label` from the view it matched, before +// translation. A form that declared a title served it in the authoring +// language to every visitor, so declaring one regressed every other locale +// from a translated heading (the object label) to an untranslated one. +// +// The console's public form page draws its heading as +// `payload.label ?? form.title ?? objectSchema.label`. All three now reach it +// translated, and the fall-through order is unchanged: a view that authors no +// `label` is still answered without one, so a form's title is not shadowed by +// the view's machine name. +// +// The RULES live in `@objectstack/spec/system` (`translateView`): the key is +// `objects.OBJECT._views.VIEW.title`, only an authored title is translated, a +// missing key keeps the authored text, and an explicit override beats the +// catalog against the packaged view. What is pinned here is the ROUTE's half. + +import { describe, it, expect, vi } from 'vitest'; +import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; +import { SchemaRegistry } from '@objectstack/objectql'; +import { expandViewContainer } from '@objectstack/spec/ui'; +import { RestServer } from './rest-server'; + +// [#10126] Pay the first transform of the dist-resolved workspace dep the +// handler reaches through a dynamic `import()` at MODULE LOAD, not inside a +// clocked `it()` body (`scripts/check-test-source-alias.mjs`). +import '@objectstack/spec/system'; + +const TITLE_EN = 'Contact us'; +const TITLE_ZH = '联系我们'; + +const ticketObject = { + name: 'ticket', + label: 'Ticket', + fields: { + subject: { type: 'text', label: 'Subject' }, + email: { type: 'text', label: 'Email' }, + }, +}; + +const publicForm = (slug: string, title?: string) => ({ + type: 'simple', + data: { provider: 'object', object: 'ticket' }, + sharing: { enabled: true, allowAnonymous: true, publicLink: `/forms/${slug}` }, + ...(title !== undefined ? { title } : {}), + sections: [{ name: 'about', label: 'About', fields: ['subject', 'email'] }], +}); + +/** + * Three public forms on one container: `contact` authors a title the bundle + * translates, `untitled` authors none (the bundle carries a key for it that + * must not apply), and `referral` authors a title the bundle has no key for. + */ +const ticketContainer = () => ({ + object: 'ticket', + formViews: { + contact: publicForm('contact-us', TITLE_EN), + untitled: publicForm('untitled'), + referral: publicForm('referral', 'Refer a friend'), + }, +}); + +const BUNDLE: Record = { + en: { objects: { ticket: { label: 'Ticket' } } }, + 'zh-CN': { + objects: { + ticket: { + label: '工单', + fields: { subject: { label: '主题' } }, + _views: { + contact: { title: TITLE_ZH }, + untitled: { title: 'MUST-NOT-APPLY' }, + labelled: { label: '工单表单', title: '提交工单' }, + }, + }, + }, + }, +}; + +const i18nService = { + getLocales: () => ['en', 'zh-CN'], + getTranslations: (locale: string) => BUNDLE[locale], + getDefaultLocale: () => '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), + }; +} + +/** The real `GET /forms/:slug` route, mounted over `protocol`. `get` answers one anonymous request. */ +function routeOver(protocol: unknown) { + 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, + // i18nServiceProvider — the 14th constructor argument. + async () => i18nService as any, + ); + (rest as any).resolveExecCtx = async () => ({ userId: 'test-user' }); + rest.registerRoutes(); + const resolve = rest.getRoutes().find((r) => r.method === 'GET' && r.path.endsWith('/forms/:slug'))!; + return async (locale: string, slug: string) => { + const res: any = { statusCode: 200, body: undefined, headers: {} as Record }; + res.status = vi.fn((c: number) => { res.statusCode = c; return res; }); + res.json = vi.fn((b: any) => { res.body = b; return res; }); + res.header = vi.fn((k: string, v: string) => { res.headers[k] = v; return res; }); + await resolve.handler({ params: { slug }, query: {}, headers: { 'accept-language': locale } } as any, res); + expect(res.statusCode, JSON.stringify(res.body)).toBe(200); + return res; + }; +} + +const matchesWhere = (row: Record, where: Record) => + Object.entries(where ?? {}).every(([k, v]) => { + if (k.startsWith('$')) throw new Error(`fake driver: unsupported operator ${k}`); + if (v === null) return row[k] === null || row[k] === undefined; + return row[k] === v; + }); + +const PKG = 'com.example.helpdesk'; + +/** + * The REAL protocol over a real `SchemaRegistry`, the container registered the + * way the boot registers a `defineView` container (the bare object key, then + * every item `expandViewContainer` expands it to), over a READ-ONLY in-memory + * `sys_metadata` the overlay rows are seeded into. + */ +function realProtocolSession() { + const registry = new SchemaRegistry({ multiTenant: false }); + registry.logLevel = 'silent'; + const rows: Record[] = []; + const engine: any = { + registry, + async find(table: string, o: { where: Record; limit?: number }) { + if (table !== 'sys_metadata') return []; + const matched = rows.filter((r) => matchesWhere(r, o.where)); + return typeof o?.limit === 'number' ? matched.slice(0, o.limit) : matched; + }, + }; + const protocol: any = new ObjectStackProtocolImplementation(engine, undefined, 'env_test'); + registry.registerObject(ticketObject as any, PKG); + const container = ticketContainer(); + registry.registerItem('view', { name: 'ticket', ...container }, 'name', PKG); + for (const item of expandViewContainer('ticket', container)) registry.registerItem('view', item, 'name', PKG); + return { rows, get: routeOver(protocol) }; +} + +/** The route over a protocol double that serves `view` as the one view body. */ +function overDouble(view: Record) { + const protocol: any = { + getDiscovery: vi.fn().mockResolvedValue({ version: 'v0', routes: { data: '', metadata: '' } }), + getMetaTypes: vi.fn().mockResolvedValue([]), + getMetaItems: vi.fn(async ({ type }: { type: string }) => { + if (type === 'view') return [view]; + if (type === 'object') return [ticketObject]; + return []; + }), + }; + return routeOver(protocol); +} + +describe('GET /forms/:slug — the form title is localized under Accept-Language', () => { + it('zh-CN: a form whose title the bundle keys under `_views.VIEW.title` is served the translated title', async () => { + const { get } = realProtocolSession(); + const res = await get('zh-CN', 'contact-us'); + expect(res.body.form.title).toBe(TITLE_ZH); + // The field labels are translated beside it, as before. + expect(res.body.objectSchema.fields.subject.label).toBe('主题'); + }); + + it('CONTROL — en: the same form is served its authored title', async () => { + const { get } = realProtocolSession(); + const res = await get('en', 'contact-us'); + expect(res.body.form.title).toBe(TITLE_EN); + }); + + it('zh-CN: a title the bundle has no key for is served as authored', async () => { + const { get } = realProtocolSession(); + const res = await get('zh-CN', 'referral'); + expect(res.body.form.title).toBe('Refer a friend'); + }); + + it('CONTROL — a form with no title gains none, and the heading still falls back to the translated object label', async () => { + const { get } = realProtocolSession(); + const res = await get('zh-CN', 'untitled'); + expect(res.body.form.title).toBeUndefined(); + // A container form item carries no `label`: the route answers none, so + // the console's `payload.label ?? form.title ?? objectSchema.label` + // reaches the object label, translated. + expect(res.body.label).toBeUndefined(); + expect(res.body.objectSchema.label).toBe('工单'); + }); + + it('CONTROL — `label` is not filled with the view\'s machine name when the view authors none', async () => { + const { get } = realProtocolSession(); + const res = await get('zh-CN', 'contact-us'); + expect(res.body.label).toBeUndefined(); + expect(res.body.form.title).toBe(TITLE_ZH); + }); + + it('zh-CN: an environment overlay that edits the title keeps the edit against the packaged view', async () => { + const { rows, get } = realProtocolSession(); + const shipped: any = expandViewContainer('ticket', ticketContainer()).find((v) => v.name === 'ticket.contact'); + const edited = 'Talk to us (edited)'; + rows.push({ + id: 'r_1', type: 'view', name: 'ticket.contact', package_id: null, organization_id: null, + state: 'active', metadata: JSON.stringify({ ...shipped, config: { ...shipped.config, title: edited } }), + }); + const res = await get('zh-CN', 'contact-us'); + expect(res.body.form.title).toBe(edited); + }); +}); + +describe('GET /forms/:slug — the top-level `label` is served from the TRANSLATED view', () => { + /** A `viewKind: 'form'` item that authors both a `label` and a form `title`. */ + const labelledItem = () => ({ + name: 'labelled', + object: 'ticket', + viewKind: 'form', + label: 'Ticket form', + config: publicForm('labelled', 'File a ticket'), + }); + + it('zh-CN: an authored `label` with a `_views.VIEW.label` key is served translated, and so is the title', async () => { + const res = await overDouble(labelledItem())('zh-CN', 'labelled'); + expect(res.body.label).toBe('工单表单'); + expect(res.body.form.title).toBe('提交工单'); + }); + + it('CONTROL — en: both are served as authored', async () => { + const res = await overDouble(labelledItem())('en', 'labelled'); + expect(res.body.label).toBe('Ticket form'); + expect(res.body.form.title).toBe('File a ticket'); + }); + + it('CONTROL: the stored view is not mutated', async () => { + const view = labelledItem(); + await overDouble(view)('zh-CN', 'labelled'); + expect(view.label).toBe('Ticket form'); + expect(view.config.title).toBe('File a ticket'); + }); +}); diff --git a/packages/spec/src/system/i18n-resolver.form-title.test.ts b/packages/spec/src/system/i18n-resolver.form-title.test.ts new file mode 100644 index 00000000000..262b84d13f4 --- /dev/null +++ b/packages/spec/src/system/i18n-resolver.form-title.test.ts @@ -0,0 +1,154 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Pin: `translateView` localizes a served FORM view's `title` + * (`FormViewSchema.title`, served at `config.title`) through + * `objects.._views..title`, on the shared locale chain and under + * the same catalog-vs-explicit-override rule as every other string the view + * translator overlays (ADR-0029 D9.2a). + * + * Before this, the title had no translation key at all: the `_views` entry + * named `title` as a rename onto `label`, and `translateView` never read + * `config.title`. A public form that declared a title served it in the + * authoring language to every visitor, so an app could not give a public form + * a localized title. + * + * `title` is its own key, not the view's `label` key, because they are two + * authored strings: a view item's `label` names the view (and a form-family + * container entry carries none), while the form's `title` is the heading the + * form shows. One translation cannot stand for two source strings. + * + * The served documents below are what `expandViewContainer` emits from an + * authored container, and both the container and the bundle's object node are + * PARSED by the authoring schemas, so an address neither schema carries cannot + * pass. + */ + +import { describe, it, expect } from 'vitest'; +import { translateView, translateMetadataDocument } from './i18n-resolver'; +import { ObjectTranslationDataSchema, type TranslationBundle } from './translation.zod'; +import { ViewSchema, expandViewContainer } from '../ui/view.zod'; + +const clone = (v: T): T => JSON.parse(JSON.stringify(v)); + +const CONTAINER = { + form: { + type: 'simple' as const, + data: { provider: 'object' as const, object: 'crm_lead' }, + title: 'New lead', + sections: [{ name: 'basics', label: 'Basics', fields: ['name'] }], + }, + formViews: { + web_to_lead: { + type: 'simple' as const, + data: { provider: 'object' as const, object: 'crm_lead' }, + title: 'Contact us', + sections: [{ name: 'basics', label: 'Basics', fields: ['name'] }], + }, + // Authors no title: the bundle's key must not invent one. + quick: { + type: 'simple' as const, + data: { provider: 'object' as const, object: 'crm_lead' }, + sections: [{ name: 'basics', label: 'Basics', fields: ['name'] }], + }, + // Authors a title the bundle carries no key for. + referral: { + type: 'simple' as const, + data: { provider: 'object' as const, object: 'crm_lead' }, + title: 'Refer a friend', + sections: [{ name: 'basics', label: 'Basics', fields: ['name'] }], + }, + }, +}; + +const served = (name: string): any => { + const item = expandViewContainer('crm_lead', CONTAINER).find((v) => v.name === name); + if (!item) throw new Error(`fixture missing ${name}`); + return item; +}; + +const LEAD_ZH = { + label: '线索', + _views: { + web_to_lead: { title: '联系我们' }, + form: { title: '新建线索' }, + quick: { title: 'MUST-NOT-APPLY' }, + }, +}; +const LEAD_EN = { _views: { web_to_lead: { title: 'Contact us' } } }; + +const BUNDLE: TranslationBundle = { + en: { objects: { crm_lead: LEAD_EN } } as any, + 'zh-CN': { objects: { crm_lead: LEAD_ZH } } as any, +}; + +describe('translateView — a form view\'s title, keyed `_views..title`', () => { + it('composes the identity this test is pinned to — legal fixtures, the title lives under `config`', () => { + expect(ViewSchema.safeParse(CONTAINER).success).toBe(true); + for (const node of [LEAD_ZH, LEAD_EN]) { + const parsed = ObjectTranslationDataSchema.safeParse(node); + expect(parsed.success, JSON.stringify(parsed.error?.issues)).toBe(true); + } + const item = served('crm_lead.web_to_lead'); + expect(item.viewKind).toBe('form'); + expect(item.label).toBeUndefined(); + expect(item.config.title).toBe('Contact us'); + }); + + it('a zh-CN pack with a `_views..title` key yields the translated title', () => { + const out = translateMetadataDocument('view', clone(served('crm_lead.web_to_lead')), BUNDLE, { locale: 'zh-CN' }); + expect(out.config.title).toBe('联系我们'); + // The default form is keyed by its runtime identity, `form`. + const def = translateMetadataDocument('view', clone(served('crm_lead.form')), BUNDLE, { locale: 'zh-CN' }); + expect(def.config.title).toBe('新建线索'); + }); + + it('a locale the bundle lacks reads the declared fallback chain — ja-JP over `[zh-CN]`', () => { + const out = translateView(clone(served('crm_lead.web_to_lead')), BUNDLE, { locale: 'ja-JP', fallbackChain: ['zh-CN'] }) as any; + expect(out.config.title).toBe('联系我们'); + }); + + it('a missing key yields the authored title', () => { + const out = translateView(clone(served('crm_lead.referral')), BUNDLE, { locale: 'zh-CN' }) as any; + expect(out.config.title).toBe('Refer a friend'); + }); + + it('CONTROL: a form that authors no title gains none — the bundle key does not invent one', () => { + const doc = clone(served('crm_lead.quick')); + const out = translateView(doc, BUNDLE, { locale: 'zh-CN' }) as any; + expect(out.config.title).toBeUndefined(); + expect('title' in out.config).toBe(false); + }); + + it('CONTROL: the title key does not leak into the view `label`, and the `label` key does not reach the title', () => { + const out = translateView(clone(served('crm_lead.web_to_lead')), BUNDLE, { locale: 'zh-CN' }) as any; + // No `_views.web_to_lead.label` key: the label falls back to the item's + // name, exactly as before this change. + expect(out.label).toBe('crm_lead.web_to_lead'); + const labelOnly: TranslationBundle = { + 'zh-CN': { objects: { crm_lead: { _views: { web_to_lead: { label: '网页线索' } } } } } as any, + }; + const viaLabel = translateView(clone(served('crm_lead.web_to_lead')), labelOnly, { locale: 'zh-CN' }) as any; + expect(viaLabel.label).toBe('网页线索'); + expect(viaLabel.config.title).toBe('Contact us'); + }); + + it('an override beats the catalog — an edited title keeps the edit against the packaged view', () => { + const packaged = served('crm_lead.web_to_lead'); + const edited = clone(packaged); + edited.config.title = 'Talk to sales (edited)'; + const out = translateView(edited, BUNDLE, { locale: 'zh-CN', packagedBase: packaged }) as any; + expect(out.config.title).toBe('Talk to sales (edited)'); + // CONTROL: the unedited title, judged against the same base, translates. + const same = translateView(clone(packaged), BUNDLE, { locale: 'zh-CN', packagedBase: packaged }) as any; + expect(same.config.title).toBe('联系我们'); + }); + + it('the served document is not mutated, and a form with nothing to translate keeps its `config` reference', () => { + const doc = clone(served('crm_lead.web_to_lead')); + translateView(doc, BUNDLE, { locale: 'zh-CN' }); + expect(doc.config.title).toBe('Contact us'); + const quick = clone(served('crm_lead.quick')); + expect((translateView(quick, BUNDLE, { locale: 'zh-CN' }) as any).config).toBe(quick.config); + }); +}); diff --git a/packages/spec/src/system/i18n-resolver.ts b/packages/spec/src/system/i18n-resolver.ts index 998bc298868..e7c34b919b7 100644 --- a/packages/spec/src/system/i18n-resolver.ts +++ b/packages/spec/src/system/i18n-resolver.ts @@ -458,7 +458,7 @@ function lookupViewText( bundle: TranslationBundle | undefined, objectName: string, viewKey: string, - attr: 'label' | 'description', + attr: 'label' | 'description' | 'title', opts?: ResolveOptions, ): string | undefined { if (!bundle) return undefined; @@ -1105,6 +1105,39 @@ function translateFormSections( return changed ? next : sections; } +/** + * A served form view's translated `title` (`FormViewSchema.title`, at + * `config.title`), read from `objects.._views..title` — + * the SAME `_views` node, object and view key the view's own `label` is read + * from ({@link lookupViewText}), so the two strings of one view share one + * address and never a second key convention. + * + * Returns `title` itself (the same value) when nothing applies, so the caller + * can tell "unchanged" by identity: + * + * - only an AUTHORED title is translated — a non-empty string, which is all + * `FormViewSchema.title` admits. A form without one gains none, so its + * heading keeps falling back to the object label (the anonymous form + * page's `label ?? form.title ?? objectSchema.label`); + * - an explicit override beats the catalog (ADR-0029 D9.2a): when the + * packaged view's `config` is known and its `title` differs from the + * served one, the served title was authored on top of the package, and the + * catalog — the translation of the PACKAGED title — does not overwrite it; + * - a missing key keeps the authored title. + */ +function translateFormTitle( + title: unknown, + bundle: TranslationBundle, + objectName: string, + viewKey: string, + opts: ResolveOptions | undefined, + packagedConfig: Record | undefined, +): unknown { + if (typeof title !== 'string' || title.length === 0) return title; + if (valueOverridesPackagedBase(packagedConfig, 'title', title)) return title; + return lookupViewText(bundle, objectName, viewKey, 'title', opts) ?? title; +} + /** * Apply the active locale to a view metadata document by overwriting `label` * and `description` with translated values when available, and overlaying the @@ -1174,6 +1207,19 @@ function translateFormSections( * locale chain and the same override rule as every other string here; a * container's form slot receives it through the same `config` projection its * bulk-action defs do. + * + * ## A form view's `title` is translated HERE too + * + * A served form view carries its heading at `config.title` + * (`FormViewSchema.title`), and it is addressed at + * `objects.._views..title` — the view's own `_views` node, + * beside its `label`, not the `label` key itself: a view item's `label` names + * the view and a container's form entries carry none, while `title` is what + * the form draws, so they are two strings with two keys. Before this the + * title had no key, and the anonymous public form — whose visitor cannot load + * the bundle — served it in the authoring language beside translated section + * headings and field labels. {@link translateFormTitle} applies it, on the + * same locale chain and the same override rule as every other string here. */ export function translateView( view: T, @@ -1209,11 +1255,20 @@ export function translateView( opts, packagedConfig, ); - if (defs !== config.bulkActionDefs || sections !== config.sections) { + const title = translateFormTitle( + config.title, + bundle, + objectName, + viewTranslationKey(view, objectName), + opts, + packagedConfig, + ); + if (defs !== config.bulkActionDefs || sections !== config.sections || title !== config.title) { config = { ...config, ...(defs !== config.bulkActionDefs ? { bulkActionDefs: defs } : {}), ...(sections !== config.sections ? { sections } : {}), + ...(title !== config.title ? { title } : {}), }; } } diff --git a/packages/spec/src/system/translation.test.ts b/packages/spec/src/system/translation.test.ts index 0cff6ae7536..3b29db64690 100644 --- a/packages/spec/src/system/translation.test.ts +++ b/packages/spec/src/system/translation.test.ts @@ -1975,3 +1975,31 @@ describe('retired translation.validationMessages', () => { })).not.toThrow(); }); }); + +// ────────────────────────────────────────────────────────────────────────── +// `_views..title` — a form view's heading (`FormViewSchema.title`) +// ────────────────────────────────────────────────────────────────────────── +describe('`objects.._views..title`, the key a form view\'s title is translated through', () => { + const parse = (view: unknown) => + ObjectTranslationDataSchema.safeParse({ label: 'Lead', _views: { web_to_lead: view } }); + + it('accepts `title` as its own key, beside the view\'s `label`', () => { + const result = parse({ label: '网页线索', title: '联系我们' }); + expect(result.success, JSON.stringify(result.error?.issues)).toBe(true); + expect(result.success && result.data._views?.web_to_lead).toEqual({ label: '网页线索', title: '联系我们' }); + }); + + it('a heading-shaped neighbour is named as a rename onto `title`, not onto `label`', () => { + const result = parse({ heading: '联系我们' }); + expect(result.success).toBe(false); + const issue = result.error?.issues.find((i) => i.code === 'unrecognized_keys'); + expect(issue?.message).toContain('this view translation'); + expect(issue?.message).toContain('`heading` → `title`'); + }); + + it('CONTROL: the entry stays closed — a key it does not declare is still refused', () => { + const result = parse({ subtitle: 'x' }); + expect(result.success).toBe(false); + expect(result.error?.issues.some((i) => i.code === 'unrecognized_keys')).toBe(true); + }); +}); diff --git a/packages/spec/src/system/translation.zod.ts b/packages/spec/src/system/translation.zod.ts index d0bc611f495..ffa90b09685 100644 --- a/packages/spec/src/system/translation.zod.ts +++ b/packages/spec/src/system/translation.zod.ts @@ -244,6 +244,7 @@ export const ObjectTranslationDataSchema = lazySchema(() => strictObject({ * Convention (auto-resolved by `resolveViewLabel`): * objects.._views..label * objects.._views..description + * objects.._views..title (a form view's heading) * objects.._views..emptyState.title * objects.._views..emptyState.message * objects.._views..bulkActions..* @@ -252,7 +253,10 @@ export const ObjectTranslationDataSchema = lazySchema(() => strictObject({ surface: 'this view translation', history: TRANSLATION_HISTORY, aliases: { - name: 'label', title: 'label', empty: 'emptyState', emptyMessage: 'emptyState', + // `title` is a declared key below (a form view's heading), so it is no + // longer named as a rename onto `label`; the heading-shaped neighbours + // are named as renames onto it. + name: 'label', heading: 'title', formTitle: 'title', empty: 'emptyState', emptyMessage: 'emptyState', // The authoring key is `bulkActionDefs`; the translation group drops the // `Defs` suffix the way `_actions` drops nothing and `_tabs` drops // `tabs[]`'s bracket — one address per surface, and the authoring @@ -269,6 +273,24 @@ export const ObjectTranslationDataSchema = lazySchema(() => strictObject({ }, { label: z.string().optional().describe('Translated view label'), description: z.string().optional().describe('Translated view description'), + /** + * A FORM view's heading — overlays `FormViewSchema.title`, which a served + * form item carries at `config.title` (the title an anonymous public form + * shows above its sections). + * + * **Its own key, not the view's `label` key.** They are two authored + * strings on two layers: a view item's `label` names the view, and a + * form-family container entry (`formViews.`, the default `form`) + * carries no `label` at all, while `title` is the heading the form itself + * draws. One translation cannot stand for two source strings, so the key + * mirrors the authored one, the way `emptyState.title` does. + * + * Applied by `translateView` only where the form AUTHORS a title: a form + * without one gains none, and its heading keeps falling back to the + * object label. A list view carries no title, so the key over one + * resolves nothing (`objectstack validate` refuses it). + */ + title: z.string().optional().describe("Translated form view title — overlays a form view's own `title` (`FormViewSchema.title`); only a form that authors one is translated"), emptyState: strictObject({ surface: 'this view empty-state translation', history: TRANSLATION_HISTORY,