diff --git a/.changeset/25459-form-entry-pack-keys.md b/.changeset/25459-form-entry-pack-keys.md new file mode 100644 index 00000000000..31ddd1656cd --- /dev/null +++ b/.changeset/25459-form-entry-pack-keys.md @@ -0,0 +1,31 @@ +--- +'@objectstack/spec': minor +'@objectstack/cli': minor +'@objectstack/lint': minor +--- + +feat(spec,cli,lint): a form section entry's `label`, `placeholder` and `helpText` are translatable at `objects.._sections.
.fields.` + +Clause-②: yes (widening) +Metadata-protocol: added + +A form section entry (`{ field, label, placeholder, helpText }` in a form view's `sections[].fields`) can override the object field's copy for that form. Before this change a translation pack had no key for those three strings, so a form's entry overrides stayed in the authoring language for every reader, the anonymous `GET /forms/:slug` visitor included. + +- **`@objectstack/spec`:** the `_sections.
` node of an object translation gains `fields`, keyed by the entry's `field`, each carrying `label`, `placeholder` and `helpText` (strict; `help`, `hint` and `tooltip` are refused with the rename to `helpText`). `translateView` overlays them onto a served form view's `config.sections[].fields[]` under the section rules: the key is the section's `name` and the entry's `field`, only copy the entry authors is translated, a missing key keeps the authored string, and a string edited on top of the packaged view keeps the edit (ADR-0029 D9.2a). An entry in a section with no `name`, and a bare field-name entry, have no key. +- **`@objectstack/cli`:** `os i18n extract` scaffolds the key for every entry string a form view's named section authors, and `os i18n check` / `os lint` coverage report a locale that leaves one untranslated, in the `section` bucket. +- **`@objectstack/lint`:** `translation-target-unknown` reports a `_sections.
.fields.` key that no form view section of that name declares as an entry object. + +Example (`zh-CN` bundle): + +```ts +objects: { + crm_case: { + _sections: { + submitter: { + label: '关于您', + fields: { contact_name: { label: '您的姓名', placeholder: '张三', helpText: '与证件一致' } }, + }, + }, + }, +} +``` diff --git a/content/docs/references/system/translation.mdx b/content/docs/references/system/translation.mdx index 7b056a60c7e..3f2ffabd6b3 100644 --- a/content/docs/references/system/translation.mdx +++ b/content/docs/references/system/translation.mdx @@ -93,7 +93,7 @@ Translation data for a single object | **fields** | `Record }>` | optional | Field-level translations | | **_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 | +| **_sections** | `Record }>` | optional | Section translations keyed by section name | | **_tabs** | `Record` | optional | Filter-preset tab translations keyed by tab name | | **_validations** | `Record` | optional | Custom validation-rule messages keyed by rule name (`ValidationRuleSchema.name`) | @@ -136,6 +136,7 @@ Translation data for a single field | :--- | :--- | :--- | :--- | | **label** | `string` | optional | Translated section label | | **description** | `string` | optional | Translated section description | +| **fields** | `Record` | optional | Form section entry translations keyed by the entry's `field` | ### Nested Shape: `ObjectTranslationData._tabs[string]` @@ -194,7 +195,7 @@ Translation data for a single object | **fields** | `Record }>` | optional | Field-level translations | | **_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 | +| **_sections** | `Record }>` | optional | Section translations keyed by section name | | **_tabs** | `Record` | optional | Filter-preset tab translations keyed by tab name | | **_validations** | `Record` | optional | Custom validation-rule messages keyed by rule name (`ValidationRuleSchema.name`) | @@ -397,7 +398,7 @@ Translation data for a single object | **fields** | `Record }>` | optional | Field-level translations | | **_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 | +| **_sections** | `Record }>` | optional | Section translations keyed by section name | | **_tabs** | `Record` | optional | Filter-preset tab translations keyed by tab name | | **_validations** | `Record` | optional | Custom validation-rule messages keyed by rule name (`ValidationRuleSchema.name`) | @@ -560,7 +561,7 @@ Translation data for a single object | **fields** | `Record }>` | optional | Field-level translations | | **_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 | +| **_sections** | `Record }>` | optional | Section translations keyed by section name | | **_tabs** | `Record` | optional | Filter-preset tab translations keyed by tab name | | **_validations** | `Record` | optional | Custom validation-rule messages keyed by rule name (`ValidationRuleSchema.name`) | diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/system.md b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/system.md index 4951ca0e4b6..7ac492a8074 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/system.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/system.md @@ -19,4 +19,4 @@ hand-patch a number here** — fix the code or the verdict and regenerate. | Dir | Sites | |---|---| -| `system/` | 377 | +| `system/` | 378 | diff --git a/packages/cli/src/utils/i18n-extract.ts b/packages/cli/src/utils/i18n-extract.ts index d72f1cb8391..2ff0fc70164 100644 --- a/packages/cli/src/utils/i18n-extract.ts +++ b/packages/cli/src/utils/i18n-extract.ts @@ -69,6 +69,7 @@ * objects..fields..placeholder * objects..fields..options. * objects.._sections.
.label + * objects.._sections.
.fields..{label,placeholder,helpText} * objects.._views..label * objects.._views..description * objects.._views..emptyState.title / .message @@ -953,12 +954,61 @@ function addSectionList(index: SectionIndex, sections: unknown, objectName: unkn } } +/** + * object name → section name → entry `field` → the override copy that entry + * authors, RAW — the `objects.._sections..fields..*` keys. + */ +type SectionEntryIndex = Map>>>>; + +/** The override copy a form section entry carries (`FormFieldSchema`). */ +type SectionEntryAttr = 'label' | 'placeholder' | 'helpText'; +const SECTION_ENTRY_ATTRS: readonly SectionEntryAttr[] = ['label', 'placeholder', 'helpText']; + +/** + * Read the override copy of a FORM VIEW's section entries into the index — + * the `label`, `placeholder` and `helpText` an entry object authors over the + * object field it renders, addressed under its section's `name` by its own + * `field`, which is where `translateView` reads them. + * + * Form views only: a `record:details` section and a `fieldGroups` group have + * no entry overrides the resolver reads. A nameless section has no address, + * and a bare field-name entry authors no copy, so neither contributes. One + * string, one key: the first authored value wins, as for a heading. + */ +function addSectionEntries(index: SectionEntryIndex, sections: unknown, objectName: unknown): void { + if (!Array.isArray(sections)) return; + if (typeof objectName !== 'string' || objectName.length === 0) return; + for (const section of sections) { + if (!section || typeof section !== 'object') continue; + const s = section as Record; + if (typeof s.name !== 'string' || s.name.length === 0 || !Array.isArray(s.fields)) continue; + for (const raw of s.fields) { + if (!raw || typeof raw !== 'object') continue; + const entry = raw as Record; + if (typeof entry.field !== 'string' || entry.field.length === 0) continue; + let byObject = index.get(objectName); + if (!byObject) index.set(objectName, (byObject = new Map())); + let bySection = byObject.get(s.name); + if (!bySection) byObject.set(s.name, (bySection = new Map())); + let copy = bySection.get(entry.field); + if (!copy) bySection.set(entry.field, (copy = {})); + for (const attr of SECTION_ENTRY_ATTRS) { + const authored = asAuthoredLabel(entry[attr]); + if (copy[attr] === undefined && authored !== undefined) copy[attr] = authored; + } + } + } +} + /** * Emit `objects.._sections.
.label` for every section the - * stack renders, from both authoring surfaces. + * stack renders, from both authoring surfaces, and + * `objects.._sections.
.fields..{label,placeholder,helpText}` + * for the override copy a form view's section entries author. */ function walkObjectSections(config: any, out: ExpectedEntry[]): void { const index: SectionIndex = new Map(); + const entries: SectionEntryIndex = new Map(); // (a) `fieldGroups` × field `group` membership. `deriveFieldGroupLayout` is // the shared derivation the renderers themselves consume, so "which @@ -991,13 +1041,18 @@ function walkObjectSections(config: any, out: ExpectedEntry[]): void { for (const view of views) { const containerObject = viewObjectName(view); addSectionList(index, view?.sections, containerObject); + addSectionEntries(entries, view?.sections, containerObject); if (view?.form && typeof view.form === 'object') { - addSectionList(index, view.form.sections, viewObjectName(view.form) ?? containerObject); + const formObject = viewObjectName(view.form) ?? containerObject; + addSectionList(index, view.form.sections, formObject); + addSectionEntries(entries, view.form.sections, formObject); } if (view?.formViews && typeof view.formViews === 'object') { for (const form of Object.values(view.formViews)) { if (!form || typeof form !== 'object') continue; - addSectionList(index, form.sections, viewObjectName(form) ?? containerObject); + const formObject = viewObjectName(form) ?? containerObject; + addSectionList(index, form.sections, formObject); + addSectionEntries(entries, form.sections, formObject); } } } @@ -1045,6 +1100,25 @@ function walkObjectSections(config: any, out: ExpectedEntry[]): void { ); } } + + // Authored copy only (`pushEntry`): the resolver translates an entry string + // only where the entry authors one, so a key for a string nobody wrote is + // one nothing reads. + for (const [objectName, sections] of entries) { + for (const [sectionName, fields] of sections) { + for (const [fieldName, copy] of fields) { + for (const attr of SECTION_ENTRY_ATTRS) { + pushEntry( + out, + ['objects', objectName, '_sections', sectionName, 'fields', fieldName, attr], + copy[attr], + 'section', + { objectName }, + ); + } + } + } + } } // ─── Object filter-preset tabs (`objects.._tabs..label`) ─────── diff --git a/packages/cli/test/i18n-form-section-entry-coverage.test.ts b/packages/cli/test/i18n-form-section-entry-coverage.test.ts new file mode 100644 index 00000000000..77275142568 --- /dev/null +++ b/packages/cli/test/i18n-form-section-entry-coverage.test.ts @@ -0,0 +1,123 @@ +// Copyright (c) 2026 ObjectStack contributors. Apache-2.0 license. +// +// A form section entry's override copy — the `label`, `placeholder` and +// `helpText` an entry object authors over the object field it renders — is +// translated through `objects.._sections.
.fields.` +// (`translateView` overlays it onto the served section's entries). The walker +// emits that slot for every authored string of a form view's named sections, +// so `os i18n extract` scaffolds it and the coverage gate reports a locale +// that leaves one 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 entryKeys = (config: any) => + collectExpectedEntries(config) + .map((e) => e.path.join('.')) + .filter((p) => p.includes('._sections.') && p.includes('.fields.')); + +const caseForm = (sections: unknown[]) => ({ + type: 'simple', + data: { provider: 'object', object: 'crm_case' }, + sections, +}); + +const config = (translations: unknown[] = []) => ({ + i18n: { defaultLocale: 'en', supportedLocales: ['en', 'zh-CN'] }, + objects: [{ + name: 'crm_case', + label: 'Case', + fields: { + contact_name: { type: 'text', label: 'Contact name' }, + contact_email: { type: 'email', label: 'Contact email' }, + subject: { type: 'text', label: 'Subject' }, + }, + }], + views: [ + { + list: { type: 'grid', name: 'all_cases', label: 'All Cases', data: { provider: 'object', object: 'crm_case' } }, + formViews: { + support: caseForm([ + { + name: 'submitter', + label: 'About you', + fields: [ + { field: 'contact_name', label: 'Your name', placeholder: 'Jane Doe', helpText: 'As on your ID' }, + { field: 'contact_email', label: 'Your email' }, + 'subject', + ], + }, + // No `name`: its entries have no address. + { label: 'Details', fields: [{ field: 'subject', label: 'What happened' }] }, + ]), + }, + }, + ], + pages: [{ + name: 'case_record', + type: 'record', + object: 'crm_case', + regions: [{ + name: 'main', + components: [{ + type: 'record:details', + properties: { sections: [{ name: 'page_only', label: 'Page', fields: [{ field: 'subject', label: 'Page label' }] }] }, + }], + }], + }], + translations, +}); + +describe('i18n walker — a form section entry\'s copy, `_sections.
.fields.`', () => { + it('emits one slot per AUTHORED entry string of a named form-view section', () => { + expect(entryKeys(config()).sort()).toEqual([ + 'objects.crm_case._sections.submitter.fields.contact_email.label', + 'objects.crm_case._sections.submitter.fields.contact_name.helpText', + 'objects.crm_case._sections.submitter.fields.contact_name.label', + 'objects.crm_case._sections.submitter.fields.contact_name.placeholder', + ]); + }); + + it('seeds each slot with the authored string, under the `section` bucket', () => { + const at = (p: string) => collectExpectedEntries(config()).find((e) => e.path.join('.') === p); + expect(at('objects.crm_case._sections.submitter.fields.contact_name.placeholder')) + .toMatchObject({ sourceValue: 'Jane Doe', inline: 'Jane Doe', source: 'section', objectName: 'crm_case' }); + }); + + it('`os i18n extract` writes a skeleton the translation schema accepts', () => { + const node: any = extractTranslations(config(), { locales: ['en'] }).bundles.en?.objects?.crm_case; + expect(node?._sections?.submitter?.fields?.contact_name).toEqual({ + label: 'Your name', placeholder: 'Jane Doe', helpText: 'As on your ID', + }); + const parsed = ObjectTranslationDataSchema.safeParse(node); + expect(parsed.success, JSON.stringify(parsed.error?.issues)).toBe(true); + }); + + it('the coverage gate reports an authored entry string a supported locale leaves untranslated', () => { + const missing = computeI18nCoverage(config([{ 'zh-CN': { objects: { crm_case: { label: '工单' } } } }])) + .issues.filter((i) => i.locale === 'zh-CN' && i.key.includes('.fields.') && i.key.includes('._sections.')) + .map((i) => i.key) + .sort(); + expect(missing).toEqual(entryKeys(config()).sort()); + const covered = computeI18nCoverage(config([{ + 'zh-CN': { + objects: { + crm_case: { + label: '工单', + _sections: { + submitter: { + fields: { + contact_name: { label: '您的姓名', placeholder: '张三', helpText: '与证件一致' }, + contact_email: { label: '您的邮箱' }, + }, + }, + }, + }, + }, + }, + }])).issues.filter((i) => i.locale === 'zh-CN' && i.key.includes('.fields.') && i.key.includes('._sections.')); + expect(covered).toEqual([]); + }); +}); diff --git a/packages/lint/src/validate-translation-references.test.ts b/packages/lint/src/validate-translation-references.test.ts index ab61ca01b9c..3a0de1f0e65 100644 --- a/packages/lint/src/validate-translation-references.test.ts +++ b/packages/lint/src/validate-translation-references.test.ts @@ -144,6 +144,42 @@ describe('validateTranslationReferences — orphan keys', () => { expect(findings).toEqual([]); }); + it('accepts a form section entry\'s copy keyed by its `field`, and flags an entry no section of that name declares', () => { + const stack = (sections: unknown) => ({ + ...leadStack([ + { + 'zh-CN': { + objects: { + crm_lead: { + label: '线索', + _sections: { + contact: { label: '联系方式', fields: { name: { label: '您的姓名', placeholder: '张三', helpText: '全名' } } }, + basics: { fields: { name: { label: 'x' } } }, + }, + }, + }, + }, + }, + ]), + views: [{ + name: 'lead_forms', + objectName: 'crm_lead', + formViews: { web: { type: 'simple', data: { provider: 'object', object: 'crm_lead' }, sections } }, + }], + }); + const findings = validateTranslationReferences(stack([ + { name: 'contact', label: 'Contact', fields: [{ field: 'name', label: 'Your name', placeholder: 'Jane', helpText: 'Full' }, 'status'] }, + ])); + // `contact.fields.name` resolves; `basics` is a `fieldGroups` group, which has no entries. + expect(findings.map((f) => f.path)).toEqual([ + 'translations[0]["zh-CN"].objects.crm_lead._sections.basics.fields.name', + ]); + expect(findings[0].severity).toBe('error'); + // A bare field-name entry authors no copy, so its `field` addresses nothing. + const bare = validateTranslationReferences(stack([{ name: 'contact', label: 'Contact', fields: ['name'] }])); + expect(bare.map((f) => f.path)).toContain('translations[0]["zh-CN"].objects.crm_lead._sections.contact.fields.name'); + }); + it('does not flag implicit audit/system fields', () => { const findings = validateTranslationReferences( leadStack([ diff --git a/packages/lint/src/validate-translation-references.ts b/packages/lint/src/validate-translation-references.ts index 9ebd2b879f8..326bc00c5f7 100644 --- a/packages/lint/src/validate-translation-references.ts +++ b/packages/lint/src/validate-translation-references.ts @@ -369,6 +369,14 @@ interface ObjectFacts { forms: Set; actions: Map; sections: Set; + /** + * The `_sections.
.fields` keys — per section name, the `field` of + * every entry object a FORM VIEW's section of that name declares. Those are + * the entries whose `label` / `placeholder` / `helpText` `translateView` + * overlays; a `fieldGroups` group or a `record:details` section contributes + * a section name and no entry. + */ + sectionEntries: Map>; /** * `_validations` names — the custom validation rules this object declares * (`objects[].validations[].name`, including a nested `conditional` rule's @@ -508,6 +516,7 @@ function emptyFacts(): ObjectFacts { forms: new Set(), actions: new Map(), sections: new Set(), + sectionEntries: new Map(), validations: new Set(), tabs: new Set(), }; @@ -580,7 +589,17 @@ function collectViewRecord(view: AnyRec, factsFor: (objectName: string) => Objec if (!binding) return; for (const section of recordsOf(container.sections)) { const sectionName = strName(section.name); - if (sectionName) factsFor(binding).sections.add(sectionName); + if (!sectionName) continue; + const facts = factsFor(binding); + facts.sections.add(sectionName); + // A section's entry objects are the `_sections..fields` keys; a + // bare field-name entry authors no copy, so it addresses nothing. + let entries = facts.sectionEntries.get(sectionName); + if (!entries) facts.sectionEntries.set(sectionName, (entries = new Set())); + for (const entry of recordsOf(section.fields)) { + const field = strName(entry.field); + if (field) entries.add(field); + } } }; @@ -1790,8 +1809,25 @@ export function validateTranslationReferences(stack: AnyRec): TranslationRefFind } // _sections. - for (const sectionName of Object.keys(asRecord(rawNode._sections))) { - if (facts.sections.has(sectionName)) continue; + for (const [sectionName, rawSection] of Object.entries(asRecord(rawNode._sections))) { + if (facts.sections.has(sectionName)) { + // _sections..fields. — a form section entry's copy. + const entries = facts.sectionEntries.get(sectionName) ?? new Set(); + for (const fieldName of Object.keys(asRecord(asRecord(rawSection).fields))) { + if (entries.has(fieldName)) continue; + orphan( + `${inLocale} · object "${objectName}" · section "${sectionName}" · entry "${fieldName}"`, + `${objPath}._sections.${sectionName}.fields.${fieldName}`, + `Translations are keyed to entry "${fieldName}" of section "${sectionName}", which no ` + + `form view section of that name on object "${objectName}" declares, so nothing reads them.` + + suggest(fieldName, entries), + `Key the entry by its \`field\` under a form view section's \`name\`, or drop the ` + + `translation. A bare field name authors no copy: translate the field itself at ` + + `\`objects.${objectName}.fields.${fieldName}\`.`, + ); + } + continue; + } orphan( `${inLocale} · object "${objectName}" · section "${sectionName}"`, `${objPath}._sections.${sectionName}`, diff --git a/packages/rest/src/public-form-section-i18n.test.ts b/packages/rest/src/public-form-section-i18n.test.ts index 964e5672d13..b5c41092164 100644 --- a/packages/rest/src/public-form-section-i18n.test.ts +++ b/packages/rest/src/public-form-section-i18n.test.ts @@ -92,7 +92,12 @@ const BUNDLE: Record = { ticket: { fields: { subject: { label: '主题' }, email: { label: '邮箱' }, status: { label: '状态' } }, _sections: { - about: { label: ABOUT_ZH, description: ABOUT_DESC_ZH }, + about: { + label: ABOUT_ZH, + description: ABOUT_DESC_ZH, + // A section entry's override copy (read only by the entry pins below). + fields: { subject: { label: '您的问题', placeholder: '一句话概括', helpText: '我们据此分派工单' } }, + }, more: { label: '更多' }, }, }, @@ -358,3 +363,38 @@ describe('[#23497] a form matched outside `config` is served as matched', () => }); } }); + +describe('GET /forms/:slug — a section entry\'s label, placeholder and helpText are localized under Accept-Language', () => { + /** The form view with an entry object overriding the `subject` field's copy in `about`, and one in `details` the bundle has no key for. */ + const entryFormView = () => { + const view = formView(); + view.config.sections[0].fields = [ + { field: 'subject', label: 'Your question', placeholder: 'One line, please', helpText: 'We route the ticket by it' }, + 'contact_id', + ] as any; + view.config.sections[1].fields = [{ field: 'email', label: 'Your email', placeholder: 'you@example.com' }] as any; + return view; + }; + const entry = (res: any, index: number) => res.body.form.sections[index].fields[0]; + + it('zh-CN: a pack with `_sections.
.fields.` serves the translated entry label, placeholder and helpText', async () => { + const res = await buildServer(entryFormView()).get('zh-CN'); + expect(entry(res, 0)).toEqual({ + field: 'subject', label: '您的问题', placeholder: '一句话概括', helpText: '我们据此分派工单', + }); + // The strip still ran over the translated section: its lookup is gone. + expect(renderedFields(res)[0]).toEqual(['subject']); + }); + + it('zh-CN: an entry the pack has no key for is served its authored copy', async () => { + const res = await buildServer(entryFormView()).get('zh-CN'); + expect(entry(res, 1)).toEqual({ field: 'email', label: 'Your email', placeholder: 'you@example.com' }); + }); + + it('CONTROL — en: the entry is served its authored copy', async () => { + const res = await buildServer(entryFormView()).get('en'); + expect(entry(res, 0)).toEqual({ + field: 'subject', label: 'Your question', placeholder: 'One line, please', helpText: 'We route the ticket by it', + }); + }); +}); diff --git a/packages/spec/src/system/i18n-resolver.form-sections.test.ts b/packages/spec/src/system/i18n-resolver.form-sections.test.ts index 8440f0d0748..a00fb05a0b8 100644 --- a/packages/spec/src/system/i18n-resolver.form-sections.test.ts +++ b/packages/spec/src/system/i18n-resolver.form-sections.test.ts @@ -187,3 +187,120 @@ describe('translateView — a form section\'s copy loses to an explicit override expect(section(translateView(doc, BUNDLE, { locale: 'zh-CN' }), 'basics').label).toBe('基本信息'); }); }); + +/** + * A section's ENTRIES override an object field's `label`, `placeholder` and + * `helpText` for that form alone, and those three are addressed under the + * section's own node: `objects.._sections.
.fields.`. + */ +const ENTRY_CONTAINER = { + form: { + type: 'simple' as const, + data: { provider: 'object' as const, object: 'crm_case' }, + sections: [ + { + name: 'submitter', + label: 'About you', + fields: [ + { field: 'contact_name', label: 'Your name', placeholder: 'Jane Doe', helpText: 'As on your ID' }, + // Authors only a label: the catalog never adds a placeholder or help text. + { field: 'contact_email', label: 'Your email' }, + // A bare name authors no copy at all. + 'subject', + // No bundle entry — the negative control for a missing key. + { field: 'phone', label: 'Your phone', placeholder: '+1 555', helpText: 'Optional' }, + ], + }, + // No `name`: its entries have no address, whatever the bundle carries. + { label: 'Details', fields: [{ field: 'description', label: 'What happened' }] }, + ], + }, +}; + +const ENTRY_PACKAGED: any = expandViewContainer('crm_case', ENTRY_CONTAINER).find((v) => v.name === 'crm_case.form'); + +const CASE_ZH = { + _sections: { + submitter: { + label: '关于您', + fields: { + contact_name: { label: '您的姓名', placeholder: '张三', helpText: '与证件一致' }, + contact_email: { label: '您的邮箱', placeholder: 'MUST-NOT-APPLY', helpText: 'MUST-NOT-APPLY' }, + subject: { label: 'MUST-NOT-APPLY' }, + }, + }, + details: { fields: { description: { label: 'MUST-NOT-APPLY' } } }, + }, +}; + +const ENTRY_BUNDLE: TranslationBundle = { 'zh-CN': { objects: { crm_case: CASE_ZH } } as any }; + +const entry = (view: any, sectionIndex: number, field: string): any => + view.config.sections[sectionIndex].fields.find((f: any) => (typeof f === 'string' ? f : f.field) === field); + +describe('translateView — a form section entry\'s copy, keyed under `_sections.
.fields.`', () => { + it('composes legal fixtures — the container and the pack node both parse', () => { + expect(ViewSchema.safeParse(ENTRY_CONTAINER).success).toBe(true); + expect(ObjectTranslationDataSchema.safeParse(CASE_ZH).success).toBe(true); + expect(ENTRY_PACKAGED.config.sections[0].fields).toHaveLength(4); + }); + + it('a zh-CN pack yields the translated entry `label`', () => { + const out = translateView(clone(ENTRY_PACKAGED), ENTRY_BUNDLE, { locale: 'zh-CN' }); + expect(entry(out, 0, 'contact_name').label).toBe('您的姓名'); + }); + + it('a zh-CN pack yields the translated entry `placeholder`', () => { + const out = translateView(clone(ENTRY_PACKAGED), ENTRY_BUNDLE, { locale: 'zh-CN' }); + expect(entry(out, 0, 'contact_name').placeholder).toBe('张三'); + }); + + it('a zh-CN pack yields the translated entry `helpText`', () => { + const out = translateView(clone(ENTRY_PACKAGED), ENTRY_BUNDLE, { locale: 'zh-CN' }); + expect(entry(out, 0, 'contact_name').helpText).toBe('与证件一致'); + // The section heading beside it is translated by the same reading. + expect(out.config.sections[0].label).toBe('关于您'); + }); + + it('a missing key yields the authored entry copy', () => { + const out = translateView(clone(ENTRY_PACKAGED), ENTRY_BUNDLE, { locale: 'zh-CN' }); + expect(entry(out, 0, 'phone')).toEqual(ENTRY_PACKAGED.config.sections[0].fields[3]); + }); + + it('only authored copy is translated — the catalog never adds a placeholder or help text, nor copy to a bare name', () => { + const out = translateView(clone(ENTRY_PACKAGED), ENTRY_BUNDLE, { locale: 'zh-CN' }); + expect(entry(out, 0, 'contact_email')).toEqual({ field: 'contact_email', label: '您的邮箱' }); + expect(entry(out, 0, 'subject')).toBe('subject'); + }); + + it('an entry of a section with no `name` is left as authored', () => { + const doc = clone(ENTRY_PACKAGED); + const out = translateView(doc, ENTRY_BUNDLE, { locale: 'zh-CN' }) as any; + expect(out.config.sections[1]).toBe(doc.config.sections[1]); + }); + + it('an edited entry string keeps its value against the packaged base, judged apart from its siblings', () => { + const doc = clone(ENTRY_PACKAGED); + doc.config.sections[0].fields[0].label = 'Full name (edited)'; + const out = translateView(doc, ENTRY_BUNDLE, { locale: 'zh-CN', packagedBase: ENTRY_PACKAGED }); + expect(entry(out, 0, 'contact_name')).toMatchObject({ + label: 'Full name (edited)', placeholder: '张三', helpText: '与证件一致', + }); + // NO packaged base supplied → the catalog applies (the control). + expect(entry(translateView(doc, ENTRY_BUNDLE, { locale: 'zh-CN' }), 0, 'contact_name').label).toBe('您的姓名'); + }); + + it('an entry the packaged section does not carry was authored after the fact and keeps its strings', () => { + const base = clone(ENTRY_PACKAGED); + base.config.sections[0].fields = base.config.sections[0].fields.slice(1); + const out = translateView(clone(ENTRY_PACKAGED), ENTRY_BUNDLE, { locale: 'zh-CN', packagedBase: base }); + expect(entry(out, 0, 'contact_name').label).toBe('Your name'); + }); + + it('never mutates its input', () => { + const doc = clone(ENTRY_PACKAGED); + const before = clone(doc); + translateView(doc, ENTRY_BUNDLE, { locale: 'zh-CN' }); + expect(doc).toEqual(before); + }); +}); diff --git a/packages/spec/src/system/i18n-resolver.ts b/packages/spec/src/system/i18n-resolver.ts index 2956a32125a..5942acf6f63 100644 --- a/packages/spec/src/system/i18n-resolver.ts +++ b/packages/spec/src/system/i18n-resolver.ts @@ -23,10 +23,15 @@ * objects.._tabs..label * objects.._sections..label * objects.._sections..description + * objects.._sections..fields..label + * objects.._sections..fields..placeholder + * objects.._sections..fields..helpText * * `` 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. + * `name` has no key and keeps its authored text, its entries' copy included. + * `` is a section entry's own `field` — the override copy one entry of + * that section authors over the object field it renders. * * `` is the BARE authoring key (`listViews.`, or the default * list/form key) — never the `.` identity the registry assigns a @@ -147,9 +152,28 @@ interface FormSectionLike { name?: unknown; label?: unknown; description?: unknown; + /** `FormSectionSchema.fields` — a bare field name, or an entry object. */ + fields?: unknown; [key: string]: unknown; } +/** + * Minimal form-section entry shape consumed by {@link translateView} — + * `FormFieldSchema` (`ui/view.zod.ts`) narrowed to the override copy the + * overlay reads. The three are `I18nLabel`s, so they stay `unknown`. + */ +interface FormSectionEntryLike { + /** `FormFieldSchema.field` — the `_sections.
.fields` key. */ + field?: unknown; + label?: unknown; + placeholder?: unknown; + helpText?: unknown; + [key: string]: unknown; +} + +/** The override copy a section entry carries, each one keyed by its own name. */ +type FormSectionEntryAttr = 'label' | 'placeholder' | 'helpText'; + /** * Minimal bulk-action-def shape consumed by {@link translateView} — * `BulkActionDefSchema` (`ui/bulk-action.zod.ts`) narrowed to the copy this @@ -1036,6 +1060,28 @@ function lookupSectionText( return undefined; } +/** + * One string off a form section entry's + * `objects.._sections.
.fields.` node, across the + * locale chain — `undefined` when no locale carries it, and the caller then + * leaves the authored value in place. + */ +function lookupSectionEntryText( + bundle: TranslationBundle, + objectName: string, + sectionName: string, + fieldName: string, + attr: FormSectionEntryAttr, + opts: ResolveOptions | undefined, +): string | undefined { + for (const code of localeChain(opts)) { + const candidate = pickData(bundle, code)?.objects?.[objectName]?._sections?.[sectionName] + ?.fields?.[fieldName]?.[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. @@ -1062,9 +1108,11 @@ function formSectionObjectName(config: Record, viewObject: stri /** * 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). + * form view's authored `sections[]`, and + * `objects.._sections.
.fields..{label,placeholder,helpText}` + * onto each entry of a section's `fields[]`, returning the SAME array + * reference when nothing matched (so {@link translateView} rebuilds `config` + * only when a section moved). * * Three rules. The first and the third are the ones its sibling translators * already follow ({@link translateBulkActionDefs} keys each def by its own @@ -1086,6 +1134,12 @@ function formSectionObjectName(config: Record, viewObject: stri * 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. + * + * A section's entries follow the same three rules, one level down + * ({@link translateFormSectionEntries}): the key is the entry's own `field` + * under its section's `name`, only the copy the entry authors is translated, + * and a string that diverged from the packaged section's entry for the same + * `field` keeps its value. */ function translateFormSections( sections: unknown, @@ -1108,17 +1162,78 @@ function translateFormSections( const label = text('label', section.label); const description = text('description', section.description); - if (label === undefined && description === undefined) return raw; + const fields = translateFormSectionEntries( + section.fields, + bundle, + objectName, + sectionName, + opts, + packagedSection, + ); + if (label === undefined && description === undefined && fields === section.fields) return raw; changed = true; return { ...section, ...(label !== undefined ? { label } : {}), ...(description !== undefined ? { description } : {}), + ...(fields !== section.fields ? { fields } : {}), }; }); return changed ? next : sections; } +/** + * Overlay `objects.._sections.
.fields..{label,placeholder,helpText}` + * onto one named section's `fields[]`, returning the SAME array reference when + * nothing matched. The three rules are {@link translateFormSections}' own, one + * level down: + * + * - **The key is the entry's own `field`** under its section's `name`. A bare + * field-name entry authors no copy (the object field's own label renders, + * translated through `objects..fields.`), so only an entry + * object is read; sub-`fields` of a composite entry are not addressed. + * - **Only authored copy is translated.** An entry that overrides no + * `placeholder` keeps the field's own; the catalog never adds one. + * - **The catalog loses to an explicit override** (ADR-0029 D9.2a): each + * entry is found in the packaged section's `fields` by its `field` + * ({@link packagedPart}), and a string that diverged from its packaged + * counterpart keeps its value ({@link valueOverridesPackagedBase}). + */ +function translateFormSectionEntries( + entries: unknown, + bundle: TranslationBundle, + objectName: string, + sectionName: string, + opts: ResolveOptions | undefined, + packagedSection: Record | undefined, +): unknown { + if (!Array.isArray(entries)) return entries; + let changed = false; + const next = entries.map((raw) => { + const entry = asRecord(raw) as FormSectionEntryLike | undefined; + if (entry === undefined || typeof entry.field !== 'string' || entry.field.length === 0) return raw; + const fieldName = entry.field; + const packagedEntry = packagedPart(packagedSection, 'fields', (candidate) => candidate.field === fieldName); + const text = (attr: FormSectionEntryAttr): string | undefined => + !sectionCopyAuthored(entry[attr]) || valueOverridesPackagedBase(packagedEntry, attr, entry[attr]) + ? undefined + : lookupSectionEntryText(bundle, objectName, sectionName, fieldName, attr, opts); + + const label = text('label'); + const placeholder = text('placeholder'); + const helpText = text('helpText'); + if (label === undefined && placeholder === undefined && helpText === undefined) return raw; + changed = true; + return { + ...entry, + ...(label !== undefined ? { label } : {}), + ...(placeholder !== undefined ? { placeholder } : {}), + ...(helpText !== undefined ? { helpText } : {}), + }; + }); + return changed ? next : entries; +} + /** * A served form view's translated `title` (`FormViewSchema.title`, at * `config.title`), read from `objects.._views..title` — @@ -1222,6 +1337,12 @@ function translateFormTitle( * container's form slot receives it through the same `config` projection its * bulk-action defs do. * + * A section's entries override an object field's `label`, `placeholder` and + * `helpText` for that form alone, and those three are addressed under the + * same node, at `objects.._sections..fields.` — so an + * entry's copy is served in the reader's language beside its section heading, + * by the same reading and the same rules. + * * ## A form view's `title` is translated HERE too * * A served form view carries its heading at `config.title` @@ -1611,7 +1732,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), or a form view's section (#23632) — + * bulk-action def or its param (#20731), a form view's section (#23632) or one + * of that section's entries — * 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: @@ -1630,7 +1752,7 @@ function isOptionValue(value: unknown): value is string | number | boolean { */ function packagedPart( parent: Record | undefined, - key: 'widgets' | 'globalFilters' | 'options' | 'bulkActionDefs' | 'params' | 'sections', + key: 'widgets' | 'globalFilters' | 'options' | 'bulkActionDefs' | 'params' | 'sections' | 'fields', matches: (candidate: Record) => boolean, ): Record | undefined { if (parent === undefined) return undefined; @@ -3367,8 +3489,9 @@ 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`, its params' `help` and a form - * section's `label` / `description` (#23632). One + * asks it of a bulk-action def's `confirmText`, its params' `help`, a form + * section's `label` / `description` (#23632) and its entries' `label` / + * `placeholder` / `helpText`. One * implementation, so the three conservative edges documented on the exported * predicate hold for every caller alike; ⛔ never a second copy of the * comparison. diff --git a/packages/spec/src/system/translation.test.ts b/packages/spec/src/system/translation.test.ts index e9acfe7e49f..b2c55529b47 100644 --- a/packages/spec/src/system/translation.test.ts +++ b/packages/spec/src/system/translation.test.ts @@ -2024,3 +2024,34 @@ describe('`objects.._views..title`, the key a form view\'s title i expect(result.error?.issues.some((i) => i.code === 'unrecognized_keys')).toBe(true); }); }); + +// ============================================================================ +// `objects.._sections.
.fields.` — a form section +// entry's override copy +// ============================================================================ + +describe('form section entry copy, addressable under its section by the entry\'s `field`', () => { + const parse = (fields: unknown) => + ObjectTranslationDataSchema.safeParse({ _sections: { submitter: { label: '关于您', fields } } }); + + it('accepts `label`, `placeholder` and `helpText` keyed by the entry\'s `field`', () => { + const result = parse({ contact_name: { label: '您的姓名', placeholder: '张三', helpText: '与证件一致' } }); + expect(result.success, JSON.stringify(result.error?.issues)).toBe(true); + expect(result.success && result.data._sections?.submitter.fields?.contact_name).toEqual({ + label: '您的姓名', placeholder: '张三', helpText: '与证件一致', + }); + }); + + it('stays `.strict()` — an invented key is refused', () => { + const result = parse({ contact_name: { lable: 'x' } }); + expect(result.success).toBe(false); + expect(result.error?.issues.find((i) => i.code === 'unrecognized_keys')?.message).toContain('→ `label`'); + }); + + it('sends `help` to `helpText`, the spelling a section entry declares', () => { + const result = parse({ contact_name: { help: 'x' } }); + expect(result.success).toBe(false); + expect(result.error?.issues.find((i) => i.code === 'unrecognized_keys')?.message) + .toContain('`help` → `helpText`'); + }); +}); diff --git a/packages/spec/src/system/translation.zod.ts b/packages/spec/src/system/translation.zod.ts index d172dbe1b54..7b403ee4cf4 100644 --- a/packages/spec/src/system/translation.zod.ts +++ b/packages/spec/src/system/translation.zod.ts @@ -415,6 +415,9 @@ export const ObjectTranslationDataSchema = lazySchema(() => strictObject({ * Convention: * objects.._sections..label * objects.._sections..description + * objects.._sections..fields..label + * objects.._sections..fields..placeholder + * objects.._sections..fields..helpText * Three authoring surfaces name a section under this key (e.g. * "Opportunity Information" → "商机信息"): an object-bound form view's * `sections[].name` (`FormSectionSchema.name`), a `record:details` @@ -427,14 +430,35 @@ export const ObjectTranslationDataSchema = lazySchema(() => strictObject({ * overlays a served form view's `config.sections`). A metadata-editing * form's sections are keyed elsewhere, at * `metadataForms..sections.`. + * + * `fields` carries the override copy of one object-bound form view + * section's entries (`FormSectionSchema.fields[]`), keyed by each entry's + * own `field`: the `label`, `placeholder` and `helpText` an entry authors + * over the object field it renders, for that form alone. `translateView` + * overlays them onto the served section's entries; an entry that authors + * none of the three keeps the object field's own copy, translated at + * `objects..fields.`. */ _sections: z.record(z.string(), strictObject({ surface: 'this section translation', history: TRANSLATION_HISTORY, - aliases: { name: 'label', title: 'label', heading: 'label' }, + aliases: { name: 'label', title: 'label', heading: 'label', entries: 'fields' }, }, { label: z.string().optional().describe('Translated section label'), description: z.string().optional().describe('Translated section description'), + fields: z.record(z.string(), strictObject({ + surface: 'this form section entry translation', + history: TRANSLATION_HISTORY, + // `helpText` is the spelling a form section entry declares + // (`FormFieldSchema.helpText`); `help` is the FIELD translation's, one + // level up at `objects..fields.`. Neighbouring-surface + // borrowing, which edit distance reads as a legitimate word. + aliases: { help: 'helpText', hint: 'helpText', tooltip: 'helpText', name: 'label', title: 'label' }, + }, { + label: z.string().optional().describe('Translated entry label (overlays the section entry\'s `label` override)'), + placeholder: z.string().optional().describe('Translated entry placeholder (overlays the section entry\'s `placeholder` override)'), + helpText: z.string().optional().describe('Translated entry help text (overlays the section entry\'s `helpText` override)'), + })).optional().describe('Form section entry translations keyed by the entry\'s `field`'), })).optional().describe('Section translations keyed by section name'), /**