Skip to content

Commit 1ac24f2

Browse files
fix(spec): translateView localizes a form view's sections through objects.OBJECT._sections.NAME (#23749)
Fixes #23632 Clause-②: no ## What this does `translateView` (and `translateMetadataDocument('view', …)`, which dispatches to it) now localizes a served form view's `sections`. Each named section's `label` and `description` resolve through `objects.OBJECT._sections.NAME.label` / `.description`, on the same `localeChain` / `pickData` every other resolver in `packages/spec/src/system/i18n-resolver.ts` uses. No new export: the card's first option ("`translateView` covers a form view's `sections`"). - A missing key keeps the authored text, as the sibling resolvers do. - A section with no `name` has no key and is returned as the same object. Its label is never slugged into a key: the slug-of-label fallback in the `metadataForms.TYPE.sections` walk belongs to a different namespace and a different rule, and the translation linter refuses that spelling for `_sections`. - An explicit override beats the catalog (#20731, ADR-0029 D9.2a). Each section is found in `packagedBase.config.sections` by the `name` the bundle addresses it by, through the existing `packagedPart` finder (its key union gains `'sections'`). The comparison is the existing `valueOverridesPackagedBase`, with no second copy. A section the base does not carry was authored after the fact and keeps its strings. With no base supplied, the catalog applies. - Only authored copy is translated (see the decision note below). - The object is the form's own binding (`config.data.object`) first, then the view's. That is the order the i18n extractor (`walkObjectSections`) and the translation linter (`collectViewRecord`) already use for form sections, so the key read here is the key they scaffold and accept. `config` is rebuilt only when a def or a section actually moved. A view with no section translation keeps its `config` reference, as before. ## Measured readings (dispatch Zone 2) All against `origin/main` `534ca13eb` (the branch base). 1. **`translateView` never read `sections`: confirmed.** From `translateView` to `translateMetadataDocument`, the base holds 0 occurrences of `sections`. The resolver's only other `sections` walk is `resolveMetadataFormLabels` (`metadataForms.TYPE.sections`). It is a different namespace with a slug-of-label fallback, so it is not the same rule and was not reused. 2. **The key is declared.** `translation.zod.ts` declares `_sections` (`label`, `description`) on `ObjectTranslationDataSchema`, and `component.zod.ts` declares section `name` as the `_sections` anchor (`record:details` and `object-form` sections). The form view's own `FormSectionSchema.name` (`ui/view.zod.ts`) says only "Stable section identifier for i18n lookup". Its TSDoc points at `metadataForms.TYPE.sections.NAME`, which see the acceptance notes below. 3. **Where a served form view carries its sections: `config.sections`.** `getMetaItems({ type: 'view' })` filters every aggregated container out (`metadata-protocol` `protocol.ts`, "Never surface the aggregated … container"). So every element `GET /forms/:slug` scans is a ViewItem, and `anonymousFormIntakeCandidates`'s `config` arm (`viewKind === 'form'`) is the one that matches there: `match.form === match.view.config`. `ListViewSchema` refuses a `sections` key (`unrecognized_keys`, measured against the built package), so `config.sections` exists only on the form arm, and no `viewKind` gate is needed. - **The call #23497 makes:** `translateMetadataDocument('view', match.view, bundle, { ...RestServer.translateOptionsFor(i18n, locale), packagedBase: this.packagedObjectBase(p, 'view', match.view.name) })`. The route then serves the translated view's `config` as `form`, before its existing field strip. That is the same pair of arguments the object half of the same handler already passes. 4. **Override rule: holds, with a stable identity.** A section's packaged counterpart is found by `name`, the key the bundle addresses. A nameless section is never translated, so it needs no identity. 5. **The `metadataForms` walk: a different rule**, see 1. 6. **Consumers** (`git grep -l "translateMetadataDocument\|translateView" -- packages ':!packages/spec'`): the runtime callers are in `packages/rest/src` (`meta-item-read-gate.ts`, `rest-server.ts`). `metadata-protocol/src/protocol.ts` names `translateView` in a comment only. The rest are tests (`objectql`, `platform-objects`, `plugin-approvals`, `service-i18n`, `cli`). Readings are under Tests. ## Decision: the catalog never adds a heading or a description A section that authors no `label` stays without one, and the same holds for `description`, even when the bundle carries `_sections.NAME.label`. The card's pins do not decide this case, so here is the reason. The public form renderer (objectui `FormPage`) draws a heading only under `sec.label &&`, and its comment rules that "a translation cannot conjure a heading the author did not write". A label added here would draw a heading on the anonymous route only. The authenticated `FormPage` path would draw none for the same form. Pinned, and ablated below. The alternative is to overlay whatever the bundle carries, the way the view's own `description` is overlaid. That would be a one-line change if ruled otherwise. ## Tests - New `packages/spec/src/system/i18n-resolver.form-sections.test.ts`, 13 cases. The fixtures are spec-legal: the container passes `ViewSchema.safeParse` and each bundle object node passes `ObjectTranslationDataSchema.safeParse`. The served documents come from `expandViewContainer`. Cases: the card's three pins, the only-authored-copy rule, an inline locale-map heading, the form's own binding, the locale chain (BCP-47 ladder, declared fallback chain, default locale), `config` reference identity and no mutation, and four override-rule cases (unedited, edited per string, not in base, no base). - Readings, all at head `4abffa0fb`, each command run through `scripts/pm/os-verify-lock.sh`: - `pnpm --filter @objectstack/spec exec vitest run --project local --maxWorkers=2`: 680 files, 20769 passed, 1 todo. - `pnpm --filter @objectstack/spec typecheck`: exit 0. `check:test-typecheck` OK, and the new test file adds no signature to `test-typecheck-debt.json`. - `pnpm --filter @objectstack/spec check:generated` (after a real `pnpm --filter @objectstack/spec build`): all 14 artifacts up to date, `check:api-surface` included. So no export, parameter or type widens, and `Clause-②: no` holds as measured. - Consumer tests that read translated views, built against this head's `dist` (`turbo run build --filter='@objectstack/rest^...'`). The checks below ran per package, by file: - `@objectstack/rest`, 9 files, 166 passed: `meta-view-container-i18n`, `meta-dashboard-view-i18n-explicit-override`, `meta-i18n-declared-fallback-chain`, `meta-plural-i18n`, and the five `public-form-*` suites. - `@objectstack/objectql`, 2 files, 16 passed: `protocol-packaged-view-base`, `engine-validation-locale-negotiation`. - `@objectstack/metadata-protocol`, 2 files, 242 passed: `protocol.graft-folded-form-sections`, `view-container-runtime-expansion`. Its source has no runtime caller of `translateView`, only a comment. - `@objectstack/platform-objects` `invite-user-action-copy`, 34 passed. `@objectstack/service-i18n` `authored-translations`, 16 passed. - No consumer fixture asserted an untranslated section. ## Gates The local subset is `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` on this diff: 84 families. Each exit code was captured before any pipe, and the record was reconciled with `--ran`: `84 derived famil(ies) accounted for — 83 run, 1 NOT-MEASURED`. 83 exit 0. The one NOT MEASURED is `pnpm check:dual-build-cjs-loads`, exit 3, PREREQUISITE NOT MET: it reads every package's `dist/`, and this worktree built only the closure of `@objectstack/rest`. CI runs it. Two families first answered exit 3 and gave their verdict, exit 0, once their prerequisites were met: `check-plugin-teardown-shape --self-test` after fetching its pinned fixture commit, and `check:doc-formula-expressions` / `check:lean-entry-closure` once their packages were built. The local-only `check:dev-prereqs` gives exit 0. Every family CI runs is read from this PR's check runs, not re-run here. Not done before opening: `origin/main` is 7 commits past this branch's base. None of them touches a file in this diff or `i18n-resolver.ts` / `view.zod.ts`: the spec files they change are migration entries and `api/protocol.zod.ts`. This diff changes no generated artifact either. The PR's CI runs on the merge ref. ## Ablation (one per rule, each restored and proven by blob hash == HEAD and an empty `git diff HEAD`) The suite imports `./i18n-resolver` relatively (source, not `dist/`), so no rebuild sits between mutation and run. Each mutation went through `scripts/ablation-replace.mjs`, whose anchor must hit exactly once. | mutation | result | |:---|:---| | `translateFormSections` returns its input (`return changed ? next : sections;` → `return sections;`) | 8 failed / 5 passed. The 5 that stay green are the ones whose expectation is "unchanged". | | object binding ignores `config.data.object` | 2 failed (the retargeted-form case and the label-only-key case) / 11 passed | | only-authored-copy guard removed | 1 failed (`never adds a heading or a description`) / 12 passed | ## Acceptance notes (observations, not filed) - **`FormSectionSchema.name` TSDoc names the wrong namespace for an object-bound form view.** It says a bundle targets the section via `metadataForms.TYPE.sections.NAME`. That holds for a metadata-editing form (`defineForm({ schemaId })`). The renderers, the extractor, the linter and now this resolver all address an object-bound form view's section under `objects.OBJECT._sections.NAME`. The `.describe()` string names no namespace. Doc drift outside this card's file surface. Carrier: none. - **Neither the extractor nor the linter walks a ViewItem record's `config.sections`.** `walkObjectSections` and `collectViewRecord` read a view record's top-level `sections`, `form` and `formViews`, never `config.sections` of an authored `{ name, object, viewKind: 'form', config }` record. No source producer of such a form record exists in this tree (`git grep "viewKind: 'form'"` over `examples`, `packages/platform-objects`, `packages/plugins`, `apps`: zero hits outside tests), so nothing reaches it. Read-only inference, not filed. Carrier: none. - **A localized `/meta/view` read now serves section copy translated.** This is the intended reach. An editor that read a view with a locale and saved that body back would persist the translation. The view `label` and the bulk-action defs already have that exposure on the same read, so this widens it to one more key and does not change its kind. - **The other half of the card.** `GET /forms/:slug` still serves `form` untranslated until #23497 makes the call above. #23497 is not addressed here. --- _Generated by [Claude Code](https://claude.ai/code/session_01HjR8RUwTPQ3TgbfKihmxp9)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent d176b0a commit 1ac24f2

3 files changed

Lines changed: 365 additions & 6 deletions

File tree

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
'@objectstack/spec': patch
3+
---
4+
5+
fix(spec): `translateView` localizes a form view's `sections` through `objects.OBJECT._sections.NAME` (#23632)
6+
7+
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.
8+
9+
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`):
10+
11+
- A missing key keeps the authored text.
12+
- A section with no `name` has no key and is served exactly as authored.
13+
- Only authored copy is translated. A section that authors no `label` or no `description` gains neither from the bundle.
14+
- The form's own `data.object` binding decides the object, before the view's, as the extractor and the translation linter already bind it.
15+
- 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.
16+
17+
No export, parameter or type changes. Every caller of `translateView` / `translateMetadataDocument('view', …)` now receives a form view's section copy in the requested locale.
18+
19+
Clause-②: no
Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* Pin: `translateView` localizes a served FORM view's `sections` — each
5+
* section's `label` and `description` — through
6+
* `objects.<object>._sections.<name>`, on the shared locale chain and under
7+
* the same catalog-vs-explicit-override rule as every other string the view
8+
* translator overlays (ADR-0029 D9.2a).
9+
*
10+
* Before this, `TranslationDataSchema` declared the key, the i18n extractor
11+
* scaffolded it and objectui's form renderers resolved it client-side, but no
12+
* exported resolver read it: a server-side reader that has to translate on the
13+
* caller's behalf — the anonymous public form, whose visitor cannot load the
14+
* bundle — served every section heading as authored, beside translated field
15+
* labels.
16+
*
17+
* The served documents below are what `expandViewContainer` emits from an
18+
* authored container (the composer the registrars run), and both the
19+
* container and the bundle's object node are PARSED by the authoring schemas,
20+
* so a section address or a catalog key neither schema carries cannot pass.
21+
*/
22+
23+
import { describe, it, expect } from 'vitest';
24+
import { translateView, translateMetadataDocument } from './i18n-resolver';
25+
import { ObjectTranslationDataSchema, type TranslationBundle } from './translation.zod';
26+
import { ViewSchema, expandViewContainer } from '../ui/view.zod';
27+
28+
const clone = <T,>(v: T): T => JSON.parse(JSON.stringify(v));
29+
30+
/** The container as the package ships it. */
31+
const CONTAINER = {
32+
form: {
33+
type: 'simple' as const,
34+
data: { provider: 'object' as const, object: 'crm_lead' },
35+
sections: [
36+
{ name: 'basics', label: 'Basics', description: 'Who is asking', fields: ['name'] },
37+
// No bundle entry — the negative control for a missing key.
38+
{ name: 'notes', label: 'Notes', description: 'Anything else', fields: ['note'] },
39+
// No `name`: no `_sections` key, whatever the bundle carries — including a
40+
// key equal to the slug of its label, which must NOT be read.
41+
{ label: 'Contact Details', description: 'How to reach them', fields: ['email'] },
42+
// Named, but authors neither a heading nor a description.
43+
{ name: 'extra', fields: ['phone'] },
44+
],
45+
},
46+
formViews: {
47+
// Retargeted at another object: its sections key under the form's OWN
48+
// binding, the object the extractor and the linter bind them to.
49+
referral: {
50+
type: 'simple' as const,
51+
data: { provider: 'object' as const, object: 'crm_contact' },
52+
sections: [{ name: 'basics', label: 'Basics', fields: ['name'] }],
53+
},
54+
},
55+
};
56+
57+
const served = (name: string): any => {
58+
const item = expandViewContainer('crm_lead', CONTAINER).find((v) => v.name === name);
59+
if (!item) throw new Error(`fixture missing ${name}`);
60+
return item;
61+
};
62+
const PACKAGED = served('crm_lead.form');
63+
64+
const LEAD_ZH = {
65+
_sections: {
66+
basics: { label: '基本信息', description: '咨询人是谁' },
67+
contact_details: { label: 'MUST-NOT-APPLY', description: 'MUST-NOT-APPLY' },
68+
extra: { label: 'MUST-NOT-APPLY', description: 'MUST-NOT-APPLY' },
69+
},
70+
};
71+
const LEAD_EN = {
72+
_sections: { basics: { label: 'Basics', description: 'Who is asking' } },
73+
};
74+
const CONTACT_ZH = {
75+
_sections: { basics: { label: '联系人基本信息' } },
76+
};
77+
78+
const BUNDLE: TranslationBundle = {
79+
en: { objects: { crm_lead: LEAD_EN } } as any,
80+
'zh-CN': { objects: { crm_lead: LEAD_ZH, crm_contact: CONTACT_ZH } } as any,
81+
};
82+
83+
const section = (view: any, name: string): any => view.config.sections.find((s: any) => s.name === name);
84+
85+
describe('translateView — a form view\'s sections, keyed under `_sections`', () => {
86+
it('composes the identity this test is pinned to — legal fixtures, sections live under `config`', () => {
87+
expect(ViewSchema.safeParse(CONTAINER).success).toBe(true);
88+
for (const node of [LEAD_ZH, LEAD_EN, CONTACT_ZH]) {
89+
expect(ObjectTranslationDataSchema.safeParse(node).success).toBe(true);
90+
}
91+
expect(PACKAGED.viewKind).toBe('form');
92+
expect(PACKAGED.object).toBe('crm_lead');
93+
expect(PACKAGED.sections).toBeUndefined();
94+
expect(PACKAGED.config.sections.map((s: any) => s.name)).toEqual(['basics', 'notes', undefined, 'extra']);
95+
});
96+
97+
it('a zh-CN pack with a `_sections` key yields the translated label and description', () => {
98+
const out = translateMetadataDocument('view', clone(PACKAGED), BUNDLE, { locale: 'zh-CN' });
99+
expect(section(out, 'basics')).toMatchObject({ label: '基本信息', description: '咨询人是谁', fields: ['name'] });
100+
});
101+
102+
it('a missing key yields the authored text', () => {
103+
const out = translateMetadataDocument('view', clone(PACKAGED), BUNDLE, { locale: 'zh-CN' });
104+
expect(section(out, 'notes')).toEqual(PACKAGED.config.sections[1]);
105+
// A key that carries a label but no description leaves the description as authored.
106+
const label = translateMetadataDocument('view', clone(served('crm_lead.referral')), BUNDLE, { locale: 'zh-CN' });
107+
expect(section(label, 'basics').label).toBe('联系人基本信息');
108+
expect(section(label, 'basics').description).toBeUndefined();
109+
});
110+
111+
it('a section with no `name` is left as authored — it has no key, and its label is never slugged into one', () => {
112+
const doc = clone(PACKAGED);
113+
const out = translateView(doc, BUNDLE, { locale: 'zh-CN' }) as any;
114+
expect(out.config.sections[2]).toBe(doc.config.sections[2]);
115+
expect(out.config.sections[2]).toMatchObject({ label: 'Contact Details', description: 'How to reach them' });
116+
});
117+
118+
it('the catalog translates authored copy only — it never adds a heading or a description', () => {
119+
const out = translateView(clone(PACKAGED), BUNDLE, { locale: 'zh-CN' }) as any;
120+
expect(section(out, 'extra').label).toBeUndefined();
121+
expect(section(out, 'extra').description).toBeUndefined();
122+
});
123+
124+
it('an inline locale map heading is authored text and takes the catalog, as a view label does', () => {
125+
const doc = clone(PACKAGED);
126+
doc.config.sections[0].label = { en: 'Basics', 'zh-CN': '基础' };
127+
const out = translateView(doc, BUNDLE, { locale: 'zh-CN' }) as any;
128+
expect(section(out, 'basics').label).toBe('基本信息');
129+
});
130+
131+
it('keys a retargeted form\'s sections under the form\'s own `data.object`', () => {
132+
const referral = served('crm_lead.referral');
133+
expect(referral.object).toBe('crm_lead');
134+
const out = translateView(clone(referral), BUNDLE, { locale: 'zh-CN' }) as any;
135+
// `crm_lead._sections.basics` would read 基本信息; the form binds crm_contact.
136+
expect(section(out, 'basics').label).toBe('联系人基本信息');
137+
});
138+
139+
it('walks the shared locale chain — BCP-47 ladder, declared fallback chain, default locale', () => {
140+
expect(section(translateView(clone(PACKAGED), BUNDLE, { locale: 'zh' }), 'basics').label).toBe('基本信息');
141+
const viaChain = translateView(clone(PACKAGED), BUNDLE, { locale: 'fr-FR', fallbackChain: ['zh-CN'] });
142+
expect(section(viaChain, 'basics').label).toBe('基本信息');
143+
// A request for the default locale never walks the chain: the authored text answers.
144+
const asDefault = translateView(clone(PACKAGED), BUNDLE, {
145+
locale: 'fr-FR', defaultLocale: 'fr-FR', fallbackChain: ['zh-CN'],
146+
});
147+
expect(section(asDefault, 'basics').label).toBe('Basics');
148+
});
149+
150+
it('returns the SAME `config` reference when no section gained a translation, and never mutates its input', () => {
151+
const doc = clone(PACKAGED);
152+
expect((translateView(doc, { 'ja-JP': {} }, { locale: 'ja-JP' }) as any).config).toBe(doc.config);
153+
const before = clone(doc);
154+
translateView(doc, BUNDLE, { locale: 'zh-CN' });
155+
expect(doc).toEqual(before);
156+
});
157+
});
158+
159+
describe('translateView — a form section\'s copy loses to an explicit override (ADR-0029 D9.2a)', () => {
160+
it('an unedited form view gets the catalog for every section string', () => {
161+
const out = translateView(clone(PACKAGED), BUNDLE, { locale: 'zh-CN', packagedBase: PACKAGED });
162+
expect(section(out, 'basics')).toMatchObject({ label: '基本信息', description: '咨询人是谁' });
163+
});
164+
165+
it('an edited heading is served as edited — in zh-CN and in the source locale — and judged apart from its description', () => {
166+
const doc = clone(PACKAGED);
167+
doc.config.sections[0].label = 'Basics (edited)';
168+
for (const locale of ['zh-CN', 'en']) {
169+
const out = translateView(doc, BUNDLE, { locale, packagedBase: PACKAGED });
170+
expect(section(out, 'basics').label, locale).toBe('Basics (edited)');
171+
}
172+
expect(section(translateView(doc, BUNDLE, { locale: 'zh-CN', packagedBase: PACKAGED }), 'basics').description)
173+
.toBe('咨询人是谁');
174+
});
175+
176+
it('a section the package does not ship was authored after the fact and keeps its strings', () => {
177+
const doc = clone(PACKAGED);
178+
doc.config.sections[0].name = 'contact_details';
179+
doc.config.sections[0].label = 'Added by the org';
180+
const out = translateView(doc, BUNDLE, { locale: 'zh-CN', packagedBase: PACKAGED });
181+
expect(section(out, 'contact_details').label).toBe('Added by the org');
182+
});
183+
184+
it('NO packaged base supplied → the catalog applies (the control)', () => {
185+
const doc = clone(PACKAGED);
186+
doc.config.sections[0].label = 'Basics (edited)';
187+
expect(section(translateView(doc, BUNDLE, { locale: 'zh-CN' }), 'basics').label).toBe('基本信息');
188+
});
189+
});

0 commit comments

Comments
 (0)