Repository navigation
Commit e3b56b2
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
- .changeset
- content/docs/references/system
- examples/app-showcase
- src
- data/objects
- system/translations
- test
- packages
- cli
- src/utils
- test
- metadata-protocol/src
- spec
- authorable-surface
- src/system
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
67 | 67 | | |
68 | 68 | | |
69 | 69 | | |
| 70 | + | |
70 | 71 | | |
71 | 72 | | |
72 | 73 | | |
| |||
109 | 110 | | |
110 | 111 | | |
111 | 112 | | |
| 113 | + | |
112 | 114 | | |
113 | 115 | | |
114 | 116 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
21 | 21 | | |
22 | 22 | | |
23 | 23 | | |
24 | | - | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
25 | 29 | | |
26 | 30 | | |
27 | 31 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
382 | 382 | | |
383 | 383 | | |
384 | 384 | | |
385 | | - | |
| 385 | + | |
| 386 | + | |
386 | 387 | | |
387 | 388 | | |
388 | 389 | | |
| |||
449 | 450 | | |
450 | 451 | | |
451 | 452 | | |
452 | | - | |
| 453 | + | |
| 454 | + | |
453 | 455 | | |
454 | 456 | | |
455 | 457 | | |
| |||
730 | 732 | | |
731 | 733 | | |
732 | 734 | | |
733 | | - | |
| 735 | + | |
| 736 | + | |
734 | 737 | | |
735 | 738 | | |
736 | 739 | | |
| |||
876 | 879 | | |
877 | 880 | | |
878 | 881 | | |
879 | | - | |
| 882 | + | |
| 883 | + | |
| 884 | + | |
880 | 885 | | |
881 | 886 | | |
882 | 887 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1504 | 1504 | | |
1505 | 1505 | | |
1506 | 1506 | | |
| 1507 | + | |
| 1508 | + | |
| 1509 | + | |
1507 | 1510 | | |
1508 | 1511 | | |
1509 | 1512 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
131 | 131 | | |
132 | 132 | | |
133 | 133 | | |
| 134 | + | |
| 135 | + | |
| 136 | + | |
| 137 | + | |
| 138 | + | |
| 139 | + | |
| 140 | + | |
| 141 | + | |
| 142 | + | |
| 143 | + | |
| 144 | + | |
| 145 | + | |
| 146 | + | |
| 147 | + | |
| 148 | + | |
| 149 | + | |
| 150 | + | |
| 151 | + | |
| 152 | + | |
134 | 153 | | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
120 | 120 | | |
121 | 121 | | |
122 | 122 | | |
123 | | - | |
| 123 | + | |
124 | 124 | | |
125 | 125 | | |
126 | 126 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
457 | 457 | | |
458 | 458 | | |
459 | 459 | | |
| 460 | + | |
460 | 461 | | |
461 | 462 | | |
462 | 463 | | |
| |||
0 commit comments