diff --git a/.changeset/23632-form-section-labels.md b/.changeset/23632-form-section-labels.md new file mode 100644 index 00000000000..8878376e8ca --- /dev/null +++ b/.changeset/23632-form-section-labels.md @@ -0,0 +1,19 @@ +--- +'@objectstack/spec': patch +--- + +fix(spec): `translateView` localizes a form view's `sections` through `objects.OBJECT._sections.NAME` (#23632) + +A served form view (`viewKind: 'form'`) carries its sections at `config.sections`. `translateView`, and `translateMetadataDocument('view', …)` that dispatches to it, translated the view's `label`, `description` and `config.bulkActionDefs`, and never a section. So a reader that translates on the caller's behalf served every section heading as authored, beside translated field labels. The anonymous public form is one: its visitor cannot load the translation bundle. + +Each named section's `label` and `description` now resolve through `objects.OBJECT._sections.NAME.label` / `.description`, the key `TranslationDataSchema` already declares and `os i18n extract` already scaffolds. They use the same locale chain as every other resolver here (requested locale, then the declared `fallbackChain`, never walked for the `defaultLocale`): + +- A missing key keeps the authored text. +- A section with no `name` has no key and is served exactly as authored. +- Only authored copy is translated. A section that authors no `label` or no `description` gains neither from the bundle. +- The form's own `data.object` binding decides the object, before the view's, as the extractor and the translation linter already bind it. +- An explicit override beats the catalog, by the rule the view's other strings follow (ADR-0029 D9.2a). Given `packagedBase`, a section whose string diverged from the packaged section of the same `name` keeps its string. A section the base does not carry keeps its strings. + +No export, parameter or type changes. Every caller of `translateView` / `translateMetadataDocument('view', …)` now receives a form view's section copy in the requested locale. + +Clause-②: no diff --git a/packages/spec/src/system/i18n-resolver.form-sections.test.ts b/packages/spec/src/system/i18n-resolver.form-sections.test.ts new file mode 100644 index 00000000000..8440f0d0748 --- /dev/null +++ b/packages/spec/src/system/i18n-resolver.form-sections.test.ts @@ -0,0 +1,189 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Pin: `translateView` localizes a served FORM view's `sections` — each + * section's `label` and `description` — through + * `objects.._sections.`, 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, `TranslationDataSchema` declared the key, the i18n extractor + * scaffolded it and objectui's form renderers resolved it client-side, but no + * exported resolver read it: a server-side reader that has to translate on the + * caller's behalf — the anonymous public form, whose visitor cannot load the + * bundle — served every section heading as authored, beside translated field + * labels. + * + * The served documents below are what `expandViewContainer` emits from an + * authored container (the composer the registrars run), and both the + * container and the bundle's object node are PARSED by the authoring schemas, + * so a section address or a catalog key 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)); + +/** The container as the package ships it. */ +const CONTAINER = { + form: { + type: 'simple' as const, + data: { provider: 'object' as const, object: 'crm_lead' }, + sections: [ + { name: 'basics', label: 'Basics', description: 'Who is asking', fields: ['name'] }, + // No bundle entry — the negative control for a missing key. + { name: 'notes', label: 'Notes', description: 'Anything else', fields: ['note'] }, + // No `name`: no `_sections` key, whatever the bundle carries — including a + // key equal to the slug of its label, which must NOT be read. + { label: 'Contact Details', description: 'How to reach them', fields: ['email'] }, + // Named, but authors neither a heading nor a description. + { name: 'extra', fields: ['phone'] }, + ], + }, + formViews: { + // Retargeted at another object: its sections key under the form's OWN + // binding, the object the extractor and the linter bind them to. + referral: { + type: 'simple' as const, + data: { provider: 'object' as const, object: 'crm_contact' }, + 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 PACKAGED = served('crm_lead.form'); + +const LEAD_ZH = { + _sections: { + basics: { label: '基本信息', description: '咨询人是谁' }, + contact_details: { label: 'MUST-NOT-APPLY', description: 'MUST-NOT-APPLY' }, + extra: { label: 'MUST-NOT-APPLY', description: 'MUST-NOT-APPLY' }, + }, +}; +const LEAD_EN = { + _sections: { basics: { label: 'Basics', description: 'Who is asking' } }, +}; +const CONTACT_ZH = { + _sections: { basics: { label: '联系人基本信息' } }, +}; + +const BUNDLE: TranslationBundle = { + en: { objects: { crm_lead: LEAD_EN } } as any, + 'zh-CN': { objects: { crm_lead: LEAD_ZH, crm_contact: CONTACT_ZH } } as any, +}; + +const section = (view: any, name: string): any => view.config.sections.find((s: any) => s.name === name); + +describe('translateView — a form view\'s sections, keyed under `_sections`', () => { + it('composes the identity this test is pinned to — legal fixtures, sections live under `config`', () => { + expect(ViewSchema.safeParse(CONTAINER).success).toBe(true); + for (const node of [LEAD_ZH, LEAD_EN, CONTACT_ZH]) { + expect(ObjectTranslationDataSchema.safeParse(node).success).toBe(true); + } + expect(PACKAGED.viewKind).toBe('form'); + expect(PACKAGED.object).toBe('crm_lead'); + expect(PACKAGED.sections).toBeUndefined(); + expect(PACKAGED.config.sections.map((s: any) => s.name)).toEqual(['basics', 'notes', undefined, 'extra']); + }); + + it('a zh-CN pack with a `_sections` key yields the translated label and description', () => { + const out = translateMetadataDocument('view', clone(PACKAGED), BUNDLE, { locale: 'zh-CN' }); + expect(section(out, 'basics')).toMatchObject({ label: '基本信息', description: '咨询人是谁', fields: ['name'] }); + }); + + it('a missing key yields the authored text', () => { + const out = translateMetadataDocument('view', clone(PACKAGED), BUNDLE, { locale: 'zh-CN' }); + expect(section(out, 'notes')).toEqual(PACKAGED.config.sections[1]); + // A key that carries a label but no description leaves the description as authored. + const label = translateMetadataDocument('view', clone(served('crm_lead.referral')), BUNDLE, { locale: 'zh-CN' }); + expect(section(label, 'basics').label).toBe('联系人基本信息'); + expect(section(label, 'basics').description).toBeUndefined(); + }); + + it('a section with no `name` is left as authored — it has no key, and its label is never slugged into one', () => { + const doc = clone(PACKAGED); + const out = translateView(doc, BUNDLE, { locale: 'zh-CN' }) as any; + expect(out.config.sections[2]).toBe(doc.config.sections[2]); + expect(out.config.sections[2]).toMatchObject({ label: 'Contact Details', description: 'How to reach them' }); + }); + + it('the catalog translates authored copy only — it never adds a heading or a description', () => { + const out = translateView(clone(PACKAGED), BUNDLE, { locale: 'zh-CN' }) as any; + expect(section(out, 'extra').label).toBeUndefined(); + expect(section(out, 'extra').description).toBeUndefined(); + }); + + it('an inline locale map heading is authored text and takes the catalog, as a view label does', () => { + const doc = clone(PACKAGED); + doc.config.sections[0].label = { en: 'Basics', 'zh-CN': '基础' }; + const out = translateView(doc, BUNDLE, { locale: 'zh-CN' }) as any; + expect(section(out, 'basics').label).toBe('基本信息'); + }); + + it('keys a retargeted form\'s sections under the form\'s own `data.object`', () => { + const referral = served('crm_lead.referral'); + expect(referral.object).toBe('crm_lead'); + const out = translateView(clone(referral), BUNDLE, { locale: 'zh-CN' }) as any; + // `crm_lead._sections.basics` would read 基本信息; the form binds crm_contact. + expect(section(out, 'basics').label).toBe('联系人基本信息'); + }); + + it('walks the shared locale chain — BCP-47 ladder, declared fallback chain, default locale', () => { + expect(section(translateView(clone(PACKAGED), BUNDLE, { locale: 'zh' }), 'basics').label).toBe('基本信息'); + const viaChain = translateView(clone(PACKAGED), BUNDLE, { locale: 'fr-FR', fallbackChain: ['zh-CN'] }); + expect(section(viaChain, 'basics').label).toBe('基本信息'); + // A request for the default locale never walks the chain: the authored text answers. + const asDefault = translateView(clone(PACKAGED), BUNDLE, { + locale: 'fr-FR', defaultLocale: 'fr-FR', fallbackChain: ['zh-CN'], + }); + expect(section(asDefault, 'basics').label).toBe('Basics'); + }); + + it('returns the SAME `config` reference when no section gained a translation, and never mutates its input', () => { + const doc = clone(PACKAGED); + expect((translateView(doc, { 'ja-JP': {} }, { locale: 'ja-JP' }) as any).config).toBe(doc.config); + const before = clone(doc); + translateView(doc, BUNDLE, { locale: 'zh-CN' }); + expect(doc).toEqual(before); + }); +}); + +describe('translateView — a form section\'s copy loses to an explicit override (ADR-0029 D9.2a)', () => { + it('an unedited form view gets the catalog for every section string', () => { + const out = translateView(clone(PACKAGED), BUNDLE, { locale: 'zh-CN', packagedBase: PACKAGED }); + expect(section(out, 'basics')).toMatchObject({ label: '基本信息', description: '咨询人是谁' }); + }); + + it('an edited heading is served as edited — in zh-CN and in the source locale — and judged apart from its description', () => { + const doc = clone(PACKAGED); + doc.config.sections[0].label = 'Basics (edited)'; + for (const locale of ['zh-CN', 'en']) { + const out = translateView(doc, BUNDLE, { locale, packagedBase: PACKAGED }); + expect(section(out, 'basics').label, locale).toBe('Basics (edited)'); + } + expect(section(translateView(doc, BUNDLE, { locale: 'zh-CN', packagedBase: PACKAGED }), 'basics').description) + .toBe('咨询人是谁'); + }); + + it('a section the package does not ship was authored after the fact and keeps its strings', () => { + const doc = clone(PACKAGED); + doc.config.sections[0].name = 'contact_details'; + doc.config.sections[0].label = 'Added by the org'; + const out = translateView(doc, BUNDLE, { locale: 'zh-CN', packagedBase: PACKAGED }); + expect(section(out, 'contact_details').label).toBe('Added by the org'); + }); + + it('NO packaged base supplied → the catalog applies (the control)', () => { + const doc = clone(PACKAGED); + doc.config.sections[0].label = 'Basics (edited)'; + expect(section(translateView(doc, BUNDLE, { locale: 'zh-CN' }), 'basics').label).toBe('基本信息'); + }); +}); diff --git a/packages/spec/src/system/i18n-resolver.ts b/packages/spec/src/system/i18n-resolver.ts index 2e8baa02561..ec043f0c739 100644 --- a/packages/spec/src/system/i18n-resolver.ts +++ b/packages/spec/src/system/i18n-resolver.ts @@ -21,6 +21,12 @@ * objects.._actions..params..placeholder * objects.._actions..params..options. * objects.._tabs..label + * objects.._sections..label + * objects.._sections..description + * + * `` is a form section's own `name` — the served form view's + * `config.sections[]`, see {@link translateView}. A section authored without a + * `name` has no key and keeps its authored text. * * `` is the BARE authoring key (`listViews.`, or the default * list/form key) — never the `.` identity the registry assigns a @@ -121,10 +127,28 @@ export interface ViewLike { * puts the whole `ListViewSchema` under `config`, and `expandViewContainer` * does the same for a container-authored view, so `config.bulkActionDefs` is * the ONE address a served def has. See {@link translateView}. + * + * A served FORM view's `sections[]` live here too, for the same reason: + * `ViewItemSchema`'s form arm puts the whole `FormViewSchema` under + * `config`, so `config.sections` is where a section's heading is served. */ config?: { data?: { object?: string }; bulkActionDefs?: unknown; [key: string]: unknown }; } +/** + * Minimal form-section shape consumed by {@link translateView} — + * `FormSectionSchema` (`ui/view.zod.ts`) narrowed to what the overlay reads. + * `label` stays `unknown` because the schema declares it an `I18nLabel`: a + * plain string or an inline locale map, both of them authored text. + */ +interface FormSectionLike { + /** `FormSectionSchema.name` — the `_sections` key. */ + name?: unknown; + label?: unknown; + description?: unknown; + [key: string]: unknown; +} + /** * Minimal bulk-action-def shape consumed by {@link translateView} — * `BulkActionDefSchema` (`ui/bulk-action.zod.ts`) narrowed to the copy this @@ -979,6 +1003,104 @@ function translateBulkActionDefs( return changed ? next : defs; } +/** + * One string off a form section's `objects.._sections.
` node, + * across the locale chain — `undefined` when no locale carries it, and the + * caller then leaves the authored value in place. + */ +function lookupSectionText( + bundle: TranslationBundle, + objectName: string, + sectionName: string, + attr: 'label' | 'description', + opts: ResolveOptions | undefined, +): string | undefined { + for (const code of localeChain(opts)) { + const candidate = pickData(bundle, code)?.objects?.[objectName]?._sections?.[sectionName]?.[attr]; + if (typeof candidate === 'string' && candidate.length > 0) return candidate; + } + return undefined; +} + +/** + * Whether a section authored this piece of copy — a non-empty string, or (for + * an `I18nLabel` heading) an inline locale map. + */ +function sectionCopyAuthored(value: unknown): boolean { + return (typeof value === 'string' && value.length > 0) || asRecord(value) !== undefined; +} + +/** + * The object a form view's section headings are keyed under: the form's OWN + * binding (`config.data.object`) first, then the view's. + * + * That is the order the two surfaces that WRITE and CHECK `_sections` keys + * already use — the i18n extractor (`walkObjectSections`, + * `packages/cli/src/utils/i18n-extract.ts`) and the translation linter + * (`collectViewRecord`, `packages/lint/src/validate-translation-references.ts`) + * both bind a form's sections to the form's own `data.object` before the + * container's — so the key read here is the key they scaffold and accept. + */ +function formSectionObjectName(config: Record, viewObject: string): string { + const own = asRecord(config.data)?.object; + return typeof own === 'string' && own.length > 0 ? own : viewObject; +} + +/** + * Overlay `objects.._sections.
.{label,description}` onto a + * form view's authored `sections[]`, returning the SAME array reference when + * nothing matched (so {@link translateView} rebuilds `config` only when a + * section moved). + * + * Three rules, each the one its sibling translators already follow: + * + * - **The key is the section's own `name`**, ⛔ never a slug of its label: a + * section without a `name` has no `_sections` key (`FormSectionSchema.name` + * exists for that lookup), so it is returned exactly as authored. + * - **Only authored copy is translated.** A section that authors no `label` + * is an untitled section and one that authors no `description` draws + * nothing under its heading, so the catalog never ADDS either: the public + * form renderer draws a heading only where one was authored, and a heading + * conjured here would draw on the anonymous route alone. + * - **The catalog loses to an explicit override** (#20731, ADR-0029 D9.2a): + * each section is found in the packaged base's `config.sections` by the + * `name` the bundle addresses it by ({@link packagedPart}), and a string + * that diverged from its packaged counterpart keeps its value + * ({@link valueOverridesPackagedBase}; ⛔ never a second comparison). A + * section the base does not carry was authored after the fact. + */ +function translateFormSections( + sections: unknown, + bundle: TranslationBundle, + objectName: string, + opts: ResolveOptions | undefined, + packagedConfig: Record | undefined, +): unknown { + if (!Array.isArray(sections)) return sections; + let changed = false; + const next = sections.map((raw) => { + const section = asRecord(raw) as FormSectionLike | undefined; + if (section === undefined || typeof section.name !== 'string' || section.name.length === 0) return raw; + const sectionName = section.name; + const packagedSection = packagedPart(packagedConfig, 'sections', (candidate) => candidate.name === sectionName); + const text = (attr: 'label' | 'description', authored: unknown): string | undefined => + !sectionCopyAuthored(authored) || valueOverridesPackagedBase(packagedSection, attr, authored) + ? undefined + : lookupSectionText(bundle, objectName, sectionName, attr, opts); + + const label = text('label', section.label); + const description = text('description', section.description); + if (label === undefined && description === undefined) return raw; + changed = true; + return { + ...section, + ...(label !== undefined ? { label } : {}), + ...(description !== undefined ? { description } : {}), + }; + }); + return changed ? next : sections; +} + /** * Apply the active locale to a view metadata document by overwriting `label` * and `description` with translated values when available, and overlaying the @@ -1033,6 +1155,21 @@ function translateBulkActionDefs( * param the base does not carry was authored after the fact and counts as * diverged. No base supplied is the pre-#20731 behaviour, unchanged: the * catalog applies. + * + * ## [#23632] A form view's `sections` are translated HERE too + * + * A served form view carries its sections at `config.sections` — the form arm + * of `ViewItemSchema` nests the whole `FormViewSchema` under `config`, as the + * list arm nests its `bulkActionDefs` — and each section's heading and + * description are addressed at `objects.._sections.`, the + * convention `TranslationDataSchema` declares, the i18n extractor scaffolds and + * objectui's form renderers resolve client-side. A caller that cannot load the + * bundle itself — the anonymous public form — had no exported reading of that + * key, so its section titles stayed in the source language beside translated + * field labels. {@link translateFormSections} is that reading, on the same + * 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. */ export function translateView( view: T, @@ -1051,7 +1188,7 @@ export function translateView( const objectName = viewObjectName(view); if (config && typeof config === 'object' && bundle && objectName) { // The base's own `config` — `{}` when the base is known but carries none, - // so every def on the served view then counts as authored. + // so every def and section on the served view then counts as authored. const packagedConfig = base === undefined ? undefined : asRecord(base.config) ?? {}; const defs = translateBulkActionDefs( config.bulkActionDefs, @@ -1061,7 +1198,20 @@ export function translateView( opts, packagedConfig, ); - if (defs !== config.bulkActionDefs) config = { ...config, bulkActionDefs: defs }; + const sections = translateFormSections( + config.sections, + bundle, + formSectionObjectName(config, objectName), + opts, + packagedConfig, + ); + if (defs !== config.bulkActionDefs || sections !== config.sections) { + config = { + ...config, + ...(defs !== config.bulkActionDefs ? { bulkActionDefs: defs } : {}), + ...(sections !== config.sections ? { sections } : {}), + }; + } } return { @@ -1388,8 +1538,8 @@ function isOptionValue(value: unknown): value is string | number | boolean { /** * [#20680] The packaged counterpart of one addressable part of a translated * document — a dashboard's widget, global filter or filter option; a view's - * bulk-action def or its param (#20731) — found in `parent[key]` (an array) by - * the key the bundle addresses it by. + * bulk-action def or its param (#20731), or a form view's section (#23632) — + * found in `parent[key]` (an array) by the key the bundle addresses it by. * * Three answers, and the difference between the first two is the point: * @@ -1407,7 +1557,7 @@ function isOptionValue(value: unknown): value is string | number | boolean { */ function packagedPart( parent: Record | undefined, - key: 'widgets' | 'globalFilters' | 'options' | 'bulkActionDefs' | 'params', + key: 'widgets' | 'globalFilters' | 'options' | 'bulkActionDefs' | 'params' | 'sections', matches: (candidate: Record) => boolean, ): Record | undefined { if (parent === undefined) return undefined; @@ -3012,7 +3162,8 @@ export function scalarOverridesPackagedBase( * object scalars its two callers ask about, and the dashboard translator * ({@link translateDashboard}) asks the same question of `title`, which that * union does not carry; the view translator ({@link translateView}, #20731) - * asks it of a bulk-action def's `confirmText` and its params' `help`. One + * asks it of a bulk-action def's `confirmText`, its params' `help` and a form + * section's `label` / `description` (#23632). One * implementation, so the three conservative edges documented on the exported * predicate hold for every caller alike; ⛔ never a second copy of the * comparison.