Skip to content

Commit e3b56b2

Browse files
feat(spec): a lookup or master-detail field's related-list title is translatable (#29989)
domain:spec#3 · os-support-ai · session_01WGufzfgvGexKn9vLHsy89K <!-- claude-projects-attribution: {"github_login":"os-support-ai"} --> _Requested by **Yibo Wang** · [project thread](https://claude.ai/code/project/chan_01BVUyyPie8gHwoPM18iABwB?thread=cmsg_01BVUyyPie8gHwoPM18iABwBSZHMhd8ZVScXL6iCiz5fjS)_ Fixes #29942 Clause-②: yes Metadata-protocol: added Skills-docs: follow-up owed #29990 — skills/objectstack-i18n/SKILL.md (its `fields.{field_name}` row lists the field slots and stops at `inlineTitle`) ## 维护者速读 - **改了什么:** 翻译包新增字段槽位 `objects.CHILD.fields.FIELD.relatedListTitle`,`translateObject` 把它覆盖到下发的子对象字段上(与 `inlineTitle` 同一规则,含 packaged-base 让位)。`os i18n extract` 生成该键,`os i18n check` 计入字段桶;对象保存门把下发的译文还原成源文;showcase 的自引用 `showcase_business_unit.parent` 写了标题和 zh-CN 译文,showcase 原有的 3 个已写标题也补了 zh-CN 译文。 - **为什么改:** 记录页 v3 的关联列表标题只能来自 `relatedListTitle`(纯字符串)或子对象标签。自引用 lookup(合同续签自合同)在 zh-CN 下侧卡标题是「合同」,和记录本身同名;写英文 `relatedListTitle` 又会把英文发给每个中文用户,i18n 门禁也看不见。 - **风险与回滚:** 纯加宽:一个可选的翻译包键,无默认值,未写该键的包与对象行为不变;没有条目的已写标题保留原文,不会拿子对象标签替换。`@objectstack/spec`、`@objectstack/cli`、`@objectstack/metadata-protocol` minor。回滚 = revert 本 PR。 - **先看:** `packages/spec/src/system/i18n-resolver.ts` 的 `translateField` 中 `relatedListTitle` 一段;`packages/spec/src/system/translation.zod.ts` 的 `FieldTranslationSchema.relatedListTitle`;`packages/metadata-protocol/src/protocol-translated-echo.ts` 的 `FIELD_TEXT_SLOTS`。 - **两分钟验证:** `pnpm --filter @objectstack/spec exec vitest run src/system/i18n-field-related-list-title.test.ts`,预期 `8 passed`。 - **需要你做的:** 无,席位落地。 ## What changes - **`@objectstack/spec`.** `FieldTranslationSchema` declares `relatedListTitle` (optional string), addressed on the child: `objects.CHILD.fields.FIELD.relatedListTitle`, because the lookup or master-detail field that declares the title lives on the child object. `translateObject` overlays it on the served child field exactly as it overlays `inlineTitle`, including the packaged-base rule (`valueOverridesPackagedBase`, ADR-0029 D9.2a): a title an org re-authored over the packaged field keeps its text. There is no fallback to the child's translated label: an authored `relatedListTitle` with no bundle entry keeps its authored text, and a field with neither keeps today's child-label title (no key is added). - **`@objectstack/cli`.** `i18n-extract.ts` emits `objects.CHILD.fields.FIELD.relatedListTitle` for a field that authors one (kind `field`). `i18n-coverage.ts` needs no edit: it maps the `field` kind onto its field bucket, so `os i18n check` counts the key, which the new CLI test pins (reported untranslated, cleared once translated, not demanded when the field authors none). - **`@objectstack/metadata-protocol`.** `FIELD_TEXT_SLOTS` is documented as "the field slots the object translator writes", and the translator now writes `relatedListTitle`, so the key joins it. Measured: without it, a save of an object as a zh-CN `/meta` read served it would store `续签合同` over the source `Renewals`; with it the save door restores the source and reports `fields.renewed_from.relatedListTitle` in its notices. Pinned in `protocol-translated-echo.test.ts`. - **Example.** `showcase_business_unit.parent` (a lookup from the object back to itself, the card's shape) authors `relatedListTitle: 'Sub-units'`, and the showcase zh-CN bundle translates it `下级单元` beside the field's label. `test/zh-cn-self-lookup-related-list-title.test.ts` pins the zh-CN overlay through `translateMetadataDocument('object', …)`, distinct from the object's own translated label `业务单元`. - **Debt the new walk surfaced.** The showcase already authored three related-list titles (`showcase_task.project` "Tasks", `showcase_project.account` "Projects", `showcase_invoice.account` "Invoices"). Once `os i18n extract` walks the key, `check:i18n-coverage` measured them as zh-CN debt (untranslated declared strings 320 → 323), so the zh-CN bundle now translates each beside the field's label (任务 / 项目 / 发票); the gate reads `none new` again. No other example config moved (14 configs). - **Generated.** `content/docs/references/system/translation.mdx` (two rows) and `packages/spec/authorable-surface/system.json` (`system/FieldTranslation:relatedListTitle`). Nothing new is exported, so `api-surface`, `export-origins` and `check:spec-parsed-alias` do not move. No liveness row: the `translation` ledger drills one level and the field-level conventions sit below that boundary (the `inlineTitle` precedent added none either); `check:liveness` is green and its state counts are unchanged. No new schema node, so the strictness ledger counts do not move. ### Before / after: the zh-CN self-lookup case A `contract` object whose `renewed_from` lookup points back at `contract` and authors `relatedListTitle: 'Renewals'`, with the zh-CN bundle entry `objects.contract.fields.renewed_from.relatedListTitle: '续签合同'`, probed with the spec sources of each tree (`tsx`): | | `origin/main` `c92b8872` | this branch | |---|---|---| | `TranslationDataSchema.safeParse(zh-CN)` | `unrecognized_keys` `relatedListTitle` at `objects.contract.fields.renewed_from` | ok | | served `label` | `合同` | `合同` | | served `fields.renewed_from.relatedListTitle` | `Renewals` | `续签合同` | With no `relatedListTitle` authored, the console titles the list with the child's plural label, which for a self-lookup is the record's own object (「合同」 on hotclm, 「业务单元」 on the showcase); that is unchanged when nothing is authored, and an author can now write a title that is translated. ## Why Card #29942 (hotclm 18.3 upgrade): since 18.3 retired `page.slots.tabs`, a contract page draws `clm_contract.renewed_from` as a derived related list, titled by the child label 「合同」. The derived list's only title key is `FieldSchema.relatedListTitle`, and the bundle had no slot for it, so the app could not fix it without shipping English to every zh-CN reader. ## The ui reader (measured, not edited) No ui slice is owed. The console derives the title from the served child object, and every read that object arrives through is translated by `translateObject`: - `ui/packages/app-shell/src/utils/deriveRelatedLists.ts:388-389` copies the child field's `relatedListTitle` onto the descriptor's `title`. - `ui/packages/app-shell/src/views/RecordDetailView.tsx:2245` calls `deriveRelatedLists(objectDef, objects, …)` with the view's `objects` prop; `RecordDetailView.tsx:3995` reads `title` as `titleOverride`, and `:4009` uses it before the plural-label fallback. That one descriptor feeds both the side card and the body table (`isPrimary` / `isBody` on the same entry, `:4005` and `:4045-4048`). - The `objects` prop is `allObjects` from `ui/packages/app-shell/src/console/AppContent.tsx:551`: the app scope's objects (the app bundle) or the registry's `/meta/object` list. - The bundle's members are answered through `createMetaItemAnswer`, the item chain `GET /meta/:type/:name` uses, translation included (`packages/rest/src/meta-app-bundle-routes.ts`, module docblock). - The list is translated element by element by `translateMetaList` (`packages/rest/src/meta-item-read-gate.ts:3024`) through `translateMetadataDocument`, i.e. `translateObject`. - The client sends the locale on every request (`packages/client/src/index.ts:7836`, `Accept-Language`). The card's alternative, a field group's translated `label` drawn as the body table's title, is out of scope and is not drawn today: the title above is the descriptor's `title` or the child's plural label, and no field-group label reaches it. ## ADR-0087 `not-required (no-migration-prescription)`: a new optional bundle key, no stored row changes shape and nothing is renamed or refused. ## Verification All readings at head `a2ca53a2`, branch off `origin/main` `c92b8872`. - **New tests.** - `packages/spec/src/system/i18n-field-related-list-title.test.ts` (8): the slot parses at the bundle and per-object doors and refuses a non-string; `translateMetadataDocument('object', …)` serves `续签合同` on the self-lookup with no `unrecognized_keys` on the served field; an authored title with no entry keeps `Amendments`; a field with neither gains no key; BCP-47 ladder and fallback chain; the packaged-base rule both ways. - `packages/cli/test/i18n-related-list-title-walk.test.ts` (3): extract walks the key only where authored; the extracted bundle parses; `computeI18nCoverage` reports it untranslated, clears it once translated, and never demands it for a field that authors none. - `packages/metadata-protocol/src/protocol-translated-echo.test.ts` (+1): a save of the zh-CN echo restores `Renewals` and reports `fields.renewed_from.relatedListTitle`. - `examples/app-showcase/test/zh-cn-self-lookup-related-list-title.test.ts` (3): the showcase self-lookup is served `下级单元` in zh-CN, `Sub-units` in en. - **Ablation**, once, through `scripts/ablation-replace.mjs` under the lock: the overlay line `if (translatedRelatedListTitle) next.relatedListTitle = translatedRelatedListTitle;` replaced by a no-op (anchor 1 → 0, blob `76f843de` → `c73d857f`): 4 failed / 4 passed in the new spec file (the four that read the served title). Restored: blob == HEAD, `git diff HEAD` empty; the file then reads 8 passed. - **Suites (under `os-verify-lock`, shared box).** - `@objectstack/spec` `pnpm test`: 809 files, 24285 passed / 1 todo, exit 0. - Closure build `@objectstack/cli...` + `@objectstack/metadata-protocol...` + `@objectstack/example-showcase^...`: 66/66 tasks, exit 0. - `@objectstack/metadata-protocol` `pnpm test`: 312 passed / 4 skipped files, 22340 passed / 26 skipped tests, exit 0. - `@objectstack/cli` `vitest run --project unit`: 378 files, 5311 tests, exit 0. The integration tier is declared to CI (no integration-tier or spawn-entry file is touched). - showcase: the new test plus `zh-cn-report-blocks-inline-titles`, `record-action-location-hosts`, `seed`: 4 files, 69 tests, exit 0. - `check:test-typecheck` (spec): OK, 52 files / 240 errors / 131 signatures held. - **Derived gates.** `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`: 108 commands, run serially. First round: 102 exit 0; `check:i18n-coverage` exit 1 (the showcase debt above, fixed in `a2ca53a2`); `spec check:generated` exit 1 (its internal test-typecheck re-measure ran out of heap on the shared box; `gen:test-typecheck-debt` at an 8192 heap re-records the ledger byte-identical, and `check:generated` at that heap is green, 14/14 artifacts); three exit 3 (prerequisites): `check-plugin-teardown-shape --self-test` (fixture commit `621a4876` not in the shallow clone; fetched), `check:skill-examples` and `check:dual-build-cjs-loads` (dists; built the seven packages, 47/47 tasks). Each of the five rerun on `a2ca53a2`: exit 0. `check:type-check-debt` (it builds under the lock itself): exit 0. `dispatch-gates --ran`: `✓ 108 derived famil(ies) accounted for — 108 run, 0 NOT-MEASURED`. - Not run locally: `pnpm lint` (repo-wide, CI's), the CLI integration tier, the dogfood battery (no `!` in the title and no `(narrowing)` arm), and the ui suites (no `ui/**` file is touched). ## Acceptance notes - `skills/objectstack-i18n/SKILL.md:162` lists the field translation slots and stops at `inlineTitle`; adding `relatedListTitle` is a Tier H edit left to the follow-up the `Skills-docs:` line names, as #26757 did with #26759. ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic (the overlay sits beside its `inlineTitle` sibling in the existing resolver). - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran. - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: its generated artifacts are regenerated and committed. - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English (the `维护者速读` section is the maintainer summary in Chinese). - [x] The *Before Submitting* list in CONTRIBUTING.md is done. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01WGufzfgYCAmrdTsjL4CVF6 Body refreshed by spec seat 3 (Skills-docs follow-up filed as #29990): 2026-11-03T23:59Z --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 88cd0ab commit e3b56b2

13 files changed

Lines changed: 330 additions & 7 deletions

File tree

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/cli': minor
4+
'@objectstack/metadata-protocol': minor
5+
---
6+
7+
feat(spec): a lookup or master-detail field's related-list title is translatable
8+
9+
Clause-②: yes
10+
Metadata-protocol: added
11+
12+
A related list the record page derives from a lookup or master-detail field is titled by the field's `relatedListTitle`, or by the child object's label when it has none. That title had no translation slot, so a zh-CN reader saw it in the authoring language, and `os i18n check` could not see the gap. A lookup from an object back to itself was the plainest case: with no title of its own, a contract's record page listed its renewals under 「合同」, the same word as the contract beside it.
13+
14+
- `FieldTranslationSchema` declares `relatedListTitle`: `objects.<child>.fields.<field>.relatedListTitle`, on the child object whose field declares the title. `translateObject` overlays it on the served child object (`GET /meta/object`, and the app bundle's object members), which is what the console derives the related list's title from, on the side card and on the body table alike.
15+
- It follows the packaged-base rule (ADR-0029 D9.2a): a title an org re-authored on top of the packaged declaration keeps its text. An authored `relatedListTitle` with no entry keeps its authored text; the child object's label is never substituted for it.
16+
- `os i18n extract` scaffolds the key and `os i18n check` counts it under the field bucket. A field without a `relatedListTitle` is recorded without being demanded.
17+
- The object save door puts a served (translated) `relatedListTitle` back to its source text, as it does for the field's other translated slots.

‎content/docs/references/system/translation.mdx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ Translation data for a single field
6767
| **placeholder** | `string` | optional | Translated placeholder text for form inputs |
6868
| **options** | `Record<string, string>` | optional | Option value to translated label map |
6969
| **inlineTitle** | `string` | optional | Translated inline master-detail grid title — overlays the field's own `inlineTitle` |
70+
| **relatedListTitle** | `string` | optional | Translated detail-page related list title — overlays the field's own `relatedListTitle` |
7071

7172

7273
---
@@ -109,6 +110,7 @@ Translation data for a single field
109110
| **placeholder** | `string` | optional | Translated placeholder text for form inputs |
110111
| **options** | `Record<string, string>` | optional | Option value to translated label map |
111112
| **inlineTitle** | `string` | optional | Translated inline master-detail grid title — overlays the field's own `inlineTitle` |
113+
| **relatedListTitle** | `string` | optional | Translated detail-page related list title — overlays the field's own `relatedListTitle` |
112114

113115
### Nested Shape: `ObjectTranslationData._views[string]`
114116

‎examples/app-showcase/src/data/objects/business-unit.object.ts‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,11 @@ export const BusinessUnit = ObjectSchema.create({
2121

2222
fields: {
2323
name: Field.text({ label: 'Name', required: true, searchable: true, maxLength: 120 }),
24-
parent: Field.lookup('showcase_business_unit', { label: 'Parent Unit', allowCreate: true }),
24+
// A lookup back to this object, so a unit's record page lists its child
25+
// units. Without a title of its own that list would be headed by this
26+
// object's own label ("Business Units") beside the unit itself; the title
27+
// is translated at `objects.showcase_business_unit.fields.parent.relatedListTitle`.
28+
parent: Field.lookup('showcase_business_unit', { label: 'Parent Unit', allowCreate: true, relatedListTitle: 'Sub-units' }),
2529
kind: Field.select({
2630
label: 'Type',
2731
options: [

‎examples/app-showcase/src/system/translations/index.ts‎

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -382,7 +382,8 @@ export const ShowcaseTranslationBundle = {
382382
// hence 任务数; `total_estimate` reuses showcase_task's 预计工时.
383383
fields: {
384384
name: { label: '项目名称' },
385-
account: { label: '客户' },
385+
// The account page's Projects list, titled by this lookup.
386+
account: { label: '客户', relatedListTitle: '项目' },
386387
owner: { label: '负责人邮箱' },
387388
status: {
388389
label: '状态',
@@ -449,7 +450,8 @@ export const ShowcaseTranslationBundle = {
449450
pluralLabel: '任务',
450451
fields: {
451452
title: { label: '标题' },
452-
project: { label: '项目' },
453+
// The project page's Tasks list, titled by this master-detail.
454+
project: { label: '项目', relatedListTitle: '任务' },
453455
assignee: { label: '负责人' },
454456
status: {
455457
label: '状态',
@@ -730,7 +732,8 @@ export const ShowcaseTranslationBundle = {
730732
pluralLabel: '发票',
731733
fields: {
732734
name: { label: '发票号' },
733-
account: { label: '客户' },
735+
// The account page's Invoices table, titled by this lookup.
736+
account: { label: '客户', relatedListTitle: '发票' },
734737
contact: { label: '联系人' },
735738
owner: { label: '负责人邮箱' },
736739
status: { label: '状态' },
@@ -876,7 +879,9 @@ export const ShowcaseTranslationBundle = {
876879
description: '组织架构层级——在自引用对象上演示树形 / 树形表格视图。',
877880
fields: {
878881
name: { label: '名称' },
879-
parent: { label: '上级单元' },
882+
// The self-lookup's related-list title on a unit's record page,
883+
// translated at birth like the label beside it.
884+
parent: { label: '上级单元', relatedListTitle: '下级单元' },
880885
kind: {
881886
label: '类型',
882887
options: { company: '公司', division: '事业部', department: '部门', team: '团队' },
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* Under zh-CN, the title of a self-lookup's related list is served translated.
5+
*
6+
* `showcase_business_unit.parent` points back at the same object, so a unit's
7+
* record page lists its child units. The console derives that list (the side
8+
* card, or the body table of a `relatedList: 'primary'` list) from the served
9+
* CHILD field's `relatedListTitle`, read off `GET /meta/object`; with no
10+
* translatable title it was headed by the object's own label, the same word
11+
* as the record it sits beside. So the pin is the translator applied to the
12+
* app's own object with the app's own bundle, exactly what the REST boundary
13+
* does per request locale.
14+
*/
15+
import { describe, expect, it } from 'vitest';
16+
import { translateMetadataDocument, type TranslationBundle } from '@objectstack/spec/system';
17+
import { BusinessUnit } from '../src/data/objects/business-unit.object.js';
18+
import { ShowcaseTranslationBundle } from '../src/system/translations/index.js';
19+
20+
const bundle = ShowcaseTranslationBundle as unknown as TranslationBundle;
21+
22+
describe('showcase under zh-CN — a self-lookup\'s related-list title', () => {
23+
it('is a lookup from the object back to itself that authors a title', () => {
24+
const parent = BusinessUnit.fields.parent as Record<string, unknown>;
25+
expect(parent.type).toBe('lookup');
26+
expect(parent.reference).toBe(BusinessUnit.name);
27+
expect(parent.relatedListTitle).toBe('Sub-units');
28+
});
29+
30+
it('serves the title translated, distinct from the object\'s own translated label', () => {
31+
const out = translateMetadataDocument('object', BusinessUnit, bundle, { locale: 'zh-CN' });
32+
expect(out.fields.parent.relatedListTitle).toBe('下级单元');
33+
expect(out.label).toBe('业务单元');
34+
});
35+
36+
it('leaves the authored English in place for a locale the bundle does not carry', () => {
37+
const out = translateMetadataDocument('object', BusinessUnit, bundle, { locale: 'en' });
38+
expect(out.fields.parent.relatedListTitle).toBe('Sub-units');
39+
});
40+
});

‎packages/cli/src/utils/i18n-extract.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1504,6 +1504,9 @@ export function collectExpectedEntries(
15041504
// The inline child grid's heading, drawn on the PARENT's form but
15051505
// addressed by the child field that declares it (`translateObject`).
15061506
pushOptional(out, ['objects', objectName, 'fields', fieldName, 'inlineTitle'], field.inlineTitle, 'field', { objectName });
1507+
// The related list's title on the parent's record page, addressed the
1508+
// same way: by the child field that declares it.
1509+
pushOptional(out, ['objects', objectName, 'fields', fieldName, 'relatedListTitle'], field.relatedListTitle, 'field', { objectName });
15071510

15081511
// Options — accept either `{value, label}[]` arrays or a record map.
15091512
//
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
// Copyright (c) 2026 ObjectStack contributors. Apache-2.0 license.
2+
//
3+
// A lookup / master-detail field's related-list title
4+
// (`objects.<child>.fields.<field>.relatedListTitle`) is an authored,
5+
// user-visible label: the parent record page heads the derived related list
6+
// with it. It is served through `translateObject`, so without this walk
7+
// `os i18n extract` scaffolded no key for it and `os i18n check` measured a
8+
// debt of zero while a zh-CN user read it in English.
9+
10+
import { describe, it, expect } from 'vitest';
11+
import { collectExpectedEntries, extractTranslations } from '../src/utils/i18n-extract.js';
12+
import { computeI18nCoverage } from '../src/utils/i18n-coverage.js';
13+
import { TranslationDataSchema } from '@objectstack/spec/system';
14+
15+
const config = () => ({
16+
objects: [
17+
{
18+
name: 'contract',
19+
label: 'Contract',
20+
fields: {
21+
// A lookup from the object back to itself: its related list sits on a
22+
// contract's own record page.
23+
renewed_from: { type: 'lookup', label: 'Renewed From', reference: 'contract', relatedListTitle: 'Renewals' },
24+
// No relatedListTitle: the list is headed by the child's label, nothing to demand.
25+
amended_from: { type: 'lookup', label: 'Amended From', reference: 'contract' },
26+
},
27+
},
28+
],
29+
});
30+
31+
const byKey = () => new Map(collectExpectedEntries(config()).map((e) => [e.path.join('.'), e]));
32+
33+
describe('i18n extract — a field\'s relatedListTitle', () => {
34+
it('walks the title on the child field that declares it, and only there', () => {
35+
const entries = byKey();
36+
expect(entries.get('objects.contract.fields.renewed_from.relatedListTitle'))
37+
.toMatchObject({ sourceValue: 'Renewals', inline: 'Renewals', source: 'field' });
38+
expect(entries.get('objects.contract.fields.amended_from.relatedListTitle')?.inline).toBeUndefined();
39+
});
40+
41+
it('`TranslationDataSchema` accepts a bundle written at the extracted path', () => {
42+
const out = extractTranslations(config(), { locales: ['zh-CN'] });
43+
const zh = out.bundles['zh-CN'] as any;
44+
expect(zh?.objects?.contract?.fields?.renewed_from?.relatedListTitle).toBeDefined();
45+
expect(TranslationDataSchema.safeParse({ objects: zh.objects }).success).toBe(true);
46+
});
47+
});
48+
49+
describe('i18n check — a field\'s relatedListTitle', () => {
50+
it('reports an untranslated title, and clears it once translated', () => {
51+
const coverage = (translations: unknown[]) => computeI18nCoverage({
52+
...config(),
53+
i18n: { supportedLocales: ['en', 'zh-CN'] },
54+
translations,
55+
} as any).issues.filter((i) => i.locale === 'zh-CN').map((i) => i.key);
56+
57+
const before = coverage([{ 'zh-CN': { objects: { contract: { label: '合同' } } } }]);
58+
expect(before).toContain('objects.contract.fields.renewed_from.relatedListTitle');
59+
// Nothing authored ⇒ nothing to translate.
60+
expect(before).not.toContain('objects.contract.fields.amended_from.relatedListTitle');
61+
62+
const after = coverage([{
63+
'zh-CN': { objects: { contract: { label: '合同', fields: { renewed_from: { relatedListTitle: '续签合同' } } } } },
64+
}]);
65+
expect(after).not.toContain('objects.contract.fields.renewed_from.relatedListTitle');
66+
});
67+
});

‎packages/metadata-protocol/src/protocol-translated-echo.test.ts‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,4 +131,23 @@ describe('[#29673] restoreTranslatedEchoes', () => {
131131
const echoed = restoreTranslatedEchoes(typed, { source: packagedBase, bundle, packagedBase });
132132
expect(echoed.item.fields.amount.label).toBe('Amount');
133133
});
134+
135+
it('a self-lookup\'s translated relatedListTitle is put back to its source like every other field slot the read writes', () => {
136+
const selfBundle = {
137+
'zh-CN': { objects: { clm_contract: { fields: { renewed_from: { relatedListTitle: '续签合同' } } } } },
138+
} as unknown as TranslationBundle;
139+
const source = () => ({
140+
name: 'clm_contract',
141+
label: 'Contract',
142+
fields: {
143+
renewed_from: { type: 'lookup', label: 'Renewed From', reference: 'clm_contract', relatedListTitle: 'Renewals' },
144+
},
145+
});
146+
const echo: any = translateObject(source(), selfBundle, { locale: 'zh-CN' });
147+
// The precondition: the read really translated the slot.
148+
expect(echo.fields.renewed_from.relatedListTitle).toBe('续签合同');
149+
const { item, restored } = restoreTranslatedEchoes(echo, { source: source(), bundle: selfBundle });
150+
expect((item as any).fields.renewed_from.relatedListTitle).toBe('Renewals');
151+
expect(restored.map((r) => r.path)).toEqual(['fields.renewed_from.relatedListTitle']);
152+
});
134153
});

‎packages/metadata-protocol/src/protocol-translated-echo.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,7 @@ export function translatedEchoNotices(name: string, restored: readonly RestoredT
120120
}
121121

122122
/** The field slots the object translator writes. */
123-
const FIELD_TEXT_SLOTS = ['label', 'description', 'inlineHelpText', 'inlineTitle'] as const;
123+
const FIELD_TEXT_SLOTS = ['label', 'description', 'inlineHelpText', 'inlineTitle', 'relatedListTitle'] as const;
124124
/** The object scalars the object translator writes. */
125125
const OBJECT_TEXT_SLOTS = ['label', 'pluralLabel', 'description'] as const;
126126

‎packages/spec/authorable-surface/system.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -457,6 +457,7 @@
457457
"system/FieldTranslation:label",
458458
"system/FieldTranslation:options",
459459
"system/FieldTranslation:placeholder",
460+
"system/FieldTranslation:relatedListTitle",
460461
"system/FileDestinationConfig:append",
461462
"system/FileDestinationConfig:encoding",
462463
"system/FileDestinationConfig:path",

0 commit comments

Comments
 (0)