Skip to content

Commit eddffc3

Browse files
feat(spec,rest,lint,cli): a form view's title is translated through objects.OBJECT._views.VIEW.title, and GET /forms/:slug serves it in the visitor's language (#25035)
Closes #24969 Clause-②: yes (widening) ## What changes A public form could not be titled in more than one language. `FormViewSchema.title` had no translation key: the `_views.VIEW` translation entry named `title` as a rename onto `label`, `translateView` never read `config.title`, and `GET /forms/:slug` answered the top-level `label` from the matched view before translation. A form that declared a title served it in the authoring language, so declaring one replaced a translated heading (the object label) with an untranslated one in every other locale. - **Spec** (`packages/spec/src/system/translation.zod.ts`): `objects.OBJECT._views.VIEW.title` is a declared key. The `title -> label` alias on that entry is removed, and `heading` and `formTitle` are named as renames onto `title`. - **Resolver** (`packages/spec/src/system/i18n-resolver.ts`): `translateView` overlays the key onto a served form item's `config.title` (new `translateFormTitle`), through the same one `_views` read (`lookupViewText`) as the view's `label` and `description`. It follows the rules every other view string follows: only an authored title is translated (a form without one gains none), a missing key keeps the authored title, and an explicit override beats the catalog against the packaged view (ADR-0029 D9.2a, `valueOverridesPackagedBase`). - **REST** (`packages/rest/src/form-routes.ts`): the served `form.title` is the translated one, because the route already serves the translated view's `config`. The top-level `label` is now taken from the translated view when the view authors a `label`. When the view authors none, the route still answers none, because the translator falls back to the view's machine name and that must not shadow the title. The console's `payload.label ?? form.title ?? objectSchema.label` stays meaningful: all three arrive translated, in the same order. - **`objectstack validate`** (`packages/lint/src/validate-translation-references.ts`): a new `forms` fact set holds the `_views` keys of every form the composer serves: `formViews.KEY` entries, the default `form` (`form` or its `name`, asked of `expandViewContainer`), and `viewKind: 'form'` items. A `title` key over a form resolves. Over a list view it is refused, as `translation-target-unknown`, with: `View "all_leads" of object "crm_lead" is not a form view, so no title resolves this key.` The fix line points at `_views.VIEW.label`. The default form's key is legal for `title` alone, and its other tails stay non-keys as before. - **CLI extractor** (`packages/cli/src/utils/i18n-extract.ts`): it emits `_views.VIEW.title` for every form a container serves, keyed by the composer's runtime name (new `pushContainerFormTitles`), and for a `viewKind: 'form'` item. So `os i18n extract` scaffolds the slot, and `check:i18n-coverage` reports an authored title that a supported locale leaves untranslated. - **Showcase**: `examples/app-showcase` authors `title: 'New contact'` on `formViews.create`. The new slot raised its untranslated count by one (414 to 415), so the zh-CN bundle now carries `_views.create.title`. ## Why its own key, not the view's `label` key The card asked for one of two options: reuse `_views.VIEW.label` if `title` aliases `label`, or add a key. I read the schemas. The `title -> label` alias exists only on the TRANSLATION entry (a "did you mean" for a refused key). The authored shapes keep them separate: - `ViewItem.label` names the view. - `FormViewSchema.title` is the heading the form draws. - A container's form entries (`formViews.KEY`, `form`) carry no `label` at all, because `FormViewSchema` declares none. `expandViewContainer` stamps `label: v.label`, which is undefined. - A `viewKind: 'form'` item can author both, with different text, and the console prefers `label`. So they are two source strings. If both used one key, one translation would stand for two different strings. The new key mirrors the authored key, the way `emptyState.title` does. It sits in the same `_views` node and uses the same view key, so it adds no second key convention. ## Tests Measured at head `6f43b6b2e0`, which is merged with `origin/main` `73432299c0`. - **Repro first.** Commit `87d4974eab` adds the tests before the fix. - **Reverse verification.** After committing the fix, I restored the five source files to base `7532ea3c08`, in the working tree only. The mutation was confirmed on disk: `translateFormTitle` count 0, `forms.add` count 0, `pushContainerFormTitles` count 0, `servedLabel` count 0. I rebuilt spec, and `ablation-dist-preflight --absent` found the marker in 0 of 232 built files. Results: - spec: `6 failed | 162 passed`. The new resolver cases failed, and so did the `title` and `heading` schema cases. - rest: `3 failed | 6 passed`. These were the translated title, the label-not-machine-name case and the authored-label case. The "no title" controls stayed green. - lint: `3 failed | 188 passed`. - cli: `4 failed | 1 passed`. - Restored with `git checkout HEAD -- PATHS`. `git diff HEAD` was empty and `git status --porcelain` was clean. After the spec rebuild, the preflight found the marker in 6 built files. - **Controls.** - A form with no title gains none, and `/forms/:slug` still answers no `label`, so the heading falls back to the translated object label (`工单`). - The `label` key does not reach the title, and the title key does not reach the `label`. - An `en` request is served as authored. - A title edited by an environment overlay keeps the edit. This runs on the real `ObjectStackProtocolImplementation` over a real `SchemaRegistry`. - A list view's `label` key is still accepted. - The `_views` entry stays closed. - **Package runs.** - Pre-merge, at `32124e9b0a` plus the showcase commit: - `pnpm --filter @objectstack/spec test`: 704 files, 21381 passed. `pnpm --filter @objectstack/spec typecheck` passed too. - `pnpm --filter @objectstack/rest test`: 308 files, 6166 passed. - `pnpm --filter @objectstack/lint test`: 152 files, 7030 passed. - `pnpm --filter @objectstack/cli exec vitest run --project unit`: 4654 of 4655 passed. The one failure was `boot-preparation-parity.test.ts`, which hit its 5000 ms timeout under shared-box load. It passes alone, and this diff does not touch `serve.ts` or that file. - After the merge, re-run on `6f43b6b2e0`: - the touched suites passed: spec 3 files/181, rest 2 files/23, lint 1 file/191, and cli 5 files/102 (including `boot-preparation-parity`); - `typecheck` passed for rest, lint and cli. - The cli `integration` tier is declared to CI. - **Generated artifacts.** `pnpm --filter @objectstack/spec check:generated` reports all 14 up to date. `--fix` regenerated `content/docs/references/system/translation.mdx`. `check:authorable-surface`, `check:liveness` and `check:strictness-ledger` were already green; the `objects` liveness row is `live` and undrilled. - **i18n gates.** - `pnpm check:i18n`: OK, 10 packages in sync. - `pnpm check:i18n-coverage`: OK, 14 configs, 621 baselined, none new. - `pnpm check:i18n-walk-parity`: OK. - `pnpm check:i18n-stale-fill`: OK. - `pnpm check:nul-bytes`: OK. - **Derived gates.** `dispatch-gates --commands` derived 117 commands on `6f43b6b2e0`, the same list as before the merge. 116 exited 0. `pnpm check:type-check-debt` is NOT MEASURED: its closure build queued on the shared verify lock behind a sibling run and timed out (exit 99, then exit 3). `check:type-check-coverage` exited 0, and the rest, lint and cli `typecheck` runs above passed. - **eslint**, narrowed to the 11 touched source and test files (`--no-inline-config --format json`): 11 files, 0 errors, 0 warnings, and no file was ignored. The config declares no type-aware linting (no `parserOptions.project`), so this diff cannot change the verdict on any untouched file. ## Review tier `packages/spec` is a published schema, so this PR owes a `CONTRACT_REVIEW_TIER` review of record before it enters the queue (pm-dispatch `references/contract-review.md`). It touches no governed surface, so no Tier S or Tier H landing rule applies. ## Acceptance notes - **Open PRs on overlapping files.** I checked these after the merge. Only #24947 touches `i18n-extract.ts`, in `pushBulkActionDefs` (`confirmText`), and the hunks do not overlap. No other open PR touches `translation.zod.ts`, `i18n-resolver.ts`, `form-routes.ts` or `validate-translation-references.ts`. #24968 is not open as a PR yet. - **Inference, not measured: a form `formViews.KEY` that retargets another object** (its own `data.object`). The lint rule files its `_views` key under the form's own binding, as it already does for that form's `label`. `translateView` and the new extractor slot key it under the container object (`ExpandedViewItem.object`). The `label` key already has the same split. No shipped example retargets a form view. - **Inference, not measured: a `viewKind: 'form'` item.** It is a `ViewItem` whose `name` is spec-qualified as `OBJECT.KEY`. The extractor's `view.name` branch and the lint rule both use the raw `name` as the `_views` key, while `translateView` strips the object prefix. The new title slot follows that branch's existing key, so it inherits the behaviour of `label` there. No shipped example authors one. - **The default form's `_views.form.label` and `description`** are read by `translateView` for the served `OBJECT.form` item, but lint still calls them non-keys. Its documented reason is that `_views.*` is a list-view convention. This PR registers that key for `title` only and leaves the earlier decision alone. - `FormViewSchema.title`'s own `.describe()` was not edited to name the key. The translation face documents the key, and editing the describe would churn the JSON schema and docs for no behaviour change. --- _Generated by [Claude Code](https://claude.ai/code/session_01D6F9axfoPzR7t8G1J7Ytzz)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 30d21f6 commit eddffc3

13 files changed

Lines changed: 833 additions & 17 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/rest': minor
4+
'@objectstack/lint': minor
5+
'@objectstack/cli': minor
6+
---
7+
8+
feat(spec): a form view's `title` is translated through `objects.<object>._views.<view>.title`, and `GET /forms/:slug` serves it in the visitor's language
9+
10+
Clause-②: yes (widening)
11+
12+
- **Before.** A form view's `title` (`FormViewSchema.title`) had no translation key. The `_views.<view>` entry named `title` as a rename onto `label`, `translateView` never read `config.title`, and `GET /forms/:slug` answered the top-level `label` from the untranslated view. A public form that declared a title served it in the authoring language to every visitor, so declaring one replaced a translated heading (the object label) with an untranslated one in every other locale.
13+
- **Now.** `objects.<object>._views.<view>.title` is a declared key, under the same `_views` node and view key as the view's `label`. `translateView` overlays it onto a served form item's `config.title`, on the same locale chain and override rule as every other view string: only a form that authors a title is translated, a missing key keeps the authored title, and a title edited on top of the packaged view keeps the edit. `heading` and `formTitle` are named as renames onto `title`.
14+
- **`GET /forms/:slug`.** The served form's `title` is the translated one, and the top-level `label` is taken from the translated view when the view authors a `label`. A view that authors none is still answered without one, so the console heading (`label`, then `form.title`, then the translated object label) is unchanged for it.
15+
- **`objectstack validate`.** `@objectstack/lint`'s translation-reference check accepts a `title` key over a form view (a `formViews.<key>` entry, the default `form`, or a `viewKind: 'form'` item) and refuses it, as `translation-target-unknown`, over a list view, pointing at the view's `label` key.
16+
- **`os i18n extract`.** The CLI extractor emits `_views.<view>.title` for each form a view container serves (keyed by the composer's runtime name) and for a `viewKind: 'form'` item, so the skeleton carries the slot and the i18n coverage gate reports an authored title a supported locale leaves untranslated.
17+
- **Unchanged.** Every other `_views` key, the view `label` and `description` reads, and a form without a title.

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

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ Translation data for a single object
9191
| **pluralLabel** | `string` | optional | Translated plural label |
9292
| **description** | `string` | optional | Translated object description |
9393
| **fields** | `Record<string, { label?: string; help?: string; placeholder?: string; options?: Record<string, string> }>` | optional | Field-level translations |
94-
| **_views** | `Record<string, { label?: string; description?: string; emptyState?: object; bulkActions?: Record<string, object> }>` | optional | View translations keyed by view name |
94+
| **_views** | `Record<string, { label?: string; description?: string; title?: string; emptyState?: object; … }>` | optional | View translations keyed by view name |
9595
| **_actions** | `Record<string, { label?: string; description?: string; confirmText?: string; successMessage?: string; … }>` | optional | Action translations keyed by action name |
9696
| **_sections** | `Record<string, { label?: string; description?: string }>` | optional | Section translations keyed by section name |
9797
| **_tabs** | `Record<string, { label?: string }>` | optional | Filter-preset tab translations keyed by tab name |
@@ -114,6 +114,7 @@ Translation data for a single field
114114
| :--- | :--- | :--- | :--- |
115115
| **label** | `string` | optional | Translated view label |
116116
| **description** | `string` | optional | Translated view description |
117+
| **title** | `string` | optional | Translated form view title — overlays a form view's own `title` (`FormViewSchema.title`); only a form that authors one is translated |
117118
| **emptyState** | `{ title?: string; message?: string }` | optional | Translated empty-state copy shown when the view has no rows |
118119
| **bulkActions** | `Record<string, { label?: string; confirmText?: string; confirmLabel?: string; params?: Record<string, object> }>` | optional | Selection-bar translations keyed by bulk-action def name (`BulkActionDefSchema.name`) |
119120

@@ -191,7 +192,7 @@ Translation data for a single object
191192
| **pluralLabel** | `string` | optional | Translated plural label |
192193
| **description** | `string` | optional | Translated object description |
193194
| **fields** | `Record<string, { label?: string; help?: string; placeholder?: string; options?: Record<string, string> }>` | optional | Field-level translations |
194-
| **_views** | `Record<string, { label?: string; description?: string; emptyState?: object; bulkActions?: Record<string, object> }>` | optional | View translations keyed by view name |
195+
| **_views** | `Record<string, { label?: string; description?: string; title?: string; emptyState?: object; … }>` | optional | View translations keyed by view name |
195196
| **_actions** | `Record<string, { label?: string; description?: string; confirmText?: string; successMessage?: string; … }>` | optional | Action translations keyed by action name |
196197
| **_sections** | `Record<string, { label?: string; description?: string }>` | optional | Section translations keyed by section name |
197198
| **_tabs** | `Record<string, { label?: string }>` | optional | Filter-preset tab translations keyed by tab name |
@@ -394,7 +395,7 @@ Translation data for a single object
394395
| **pluralLabel** | `string` | optional | Translated plural label |
395396
| **description** | `string` | optional | Translated object description |
396397
| **fields** | `Record<string, { label?: string; help?: string; placeholder?: string; options?: Record<string, string> }>` | optional | Field-level translations |
397-
| **_views** | `Record<string, { label?: string; description?: string; emptyState?: object; bulkActions?: Record<string, object> }>` | optional | View translations keyed by view name |
398+
| **_views** | `Record<string, { label?: string; description?: string; title?: string; emptyState?: object; … }>` | optional | View translations keyed by view name |
398399
| **_actions** | `Record<string, { label?: string; description?: string; confirmText?: string; successMessage?: string; … }>` | optional | Action translations keyed by action name |
399400
| **_sections** | `Record<string, { label?: string; description?: string }>` | optional | Section translations keyed by section name |
400401
| **_tabs** | `Record<string, { label?: string }>` | optional | Filter-preset tab translations keyed by tab name |
@@ -557,7 +558,7 @@ Translation data for a single object
557558
| **pluralLabel** | `string` | optional | Translated plural label |
558559
| **description** | `string` | optional | Translated object description |
559560
| **fields** | `Record<string, { label?: string; help?: string; placeholder?: string; options?: Record<string, string> }>` | optional | Field-level translations |
560-
| **_views** | `Record<string, { label?: string; description?: string; emptyState?: object; bulkActions?: Record<string, object> }>` | optional | View translations keyed by view name |
561+
| **_views** | `Record<string, { label?: string; description?: string; title?: string; emptyState?: object; … }>` | optional | View translations keyed by view name |
561562
| **_actions** | `Record<string, { label?: string; description?: string; confirmText?: string; successMessage?: string; … }>` | optional | Action translations keyed by action name |
562563
| **_sections** | `Record<string, { label?: string; description?: string }>` | optional | Section translations keyed by section name |
563564
| **_tabs** | `Record<string, { label?: string }>` | optional | Filter-preset tab translations keyed by tab name |

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -602,8 +602,14 @@ export const ShowcaseTranslationBundle = {
602602
// "Contacts", so the object's own `pluralLabel` above is the right
603603
// word; the `全部…` spelling the task/project lists use belongs to
604604
// labels that actually say "All …".
605+
//
606+
// `_views.create.title` — the sparse `formViews.create` form's own
607+
// heading (`title: 'New contact'`), which `translateView` overlays
608+
// onto the served form's `config.title`. 新建联系人 follows the
609+
// 新建… shape a create heading takes.
605610
_views: {
606611
default: { label: '联系人' },
612+
create: { title: '新建联系人' },
607613
},
608614
// `_sections` — the four groups of the default edit form in
609615
// `ui/views/contact.view.ts`, plus the sparse `formViews.create`

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

Lines changed: 46 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -424,6 +424,39 @@ function pushViewEntries(out: ExpectedEntry[], objectName: string, viewName: str
424424
pushBulkActionDefs(out, root, view, objectName);
425425
}
426426

427+
/**
428+
* Emit `_views.<view>.title` for every FORM view a view container serves —
429+
* each `formViews.<key>` entry and the default `form` — the key
430+
* `translateView` overlays a form's `title` (`FormViewSchema.title`) from.
431+
*
432+
* **The key is the composer's, asked, never re-derived.** A container form is
433+
* served as the item `expandViewContainer` expands it to, and `translateView`
434+
* keys its title by that item's registry name with the object prefix
435+
* stripped: `formViews.<key>` is `<key>` (renamed on a collision, e.g.
436+
* `default_2` beside a default list), the default `form` is its `name` or
437+
* `form`. Reading the expanded items is the same discipline
438+
* {@link defaultListViewKey} keeps for the default list, so the key written
439+
* here is the key the runtime reads.
440+
*
441+
* **Title only.** The other `_views` tails stay unemitted for a form: a
442+
* container's form entry carries no `label`, and its `description` is not
443+
* overlaid by anything a form renders. A form that authors no title still
444+
* records the key (`pushOptional`), so the coverage gate notices a bundle
445+
* that translates one the source never wrote.
446+
*/
447+
function pushContainerFormTitles(out: ExpectedEntry[], containerObject: string, container: any): void {
448+
if (!container?.form && !container?.formViews) return;
449+
const prefix = `${containerObject}.`;
450+
for (const item of expandViewContainer(containerObject, container)) {
451+
if (item.viewKind !== 'form') continue;
452+
const key = item.name.startsWith(prefix) ? item.name.slice(prefix.length) : item.name;
453+
const config = item.config as { title?: unknown } | undefined;
454+
pushOptional(out, ['objects', containerObject, '_views', key, 'title'], config?.title, 'view', {
455+
objectName: containerObject,
456+
});
457+
}
458+
}
459+
427460
/**
428461
* Emit `_views.<view>.bulkActions.<def>.*` for a list view's authored
429462
* `bulkActionDefs[]` (#14253's resolver, #14376's walk).
@@ -1334,19 +1367,29 @@ export function collectExpectedEntries(
13341367
// `i18n/missing-view` had zero producers repo-wide while the ratchet
13351368
// reported green (#4123).
13361369
//
1337-
// `formViews` stays uncovered: form views have no counterpart in the
1338-
// `viewLabel` / `_views.*` resolver convention, so emitting keys for them
1339-
// would expect translations nothing reads.
1370+
// A container's form views (`formViews.*`, the default `form`) emit one
1371+
// `_views` tail, `title` — the form heading `translateView` overlays (see
1372+
// `pushContainerFormTitles`). Their `label` / `description` stay
1373+
// unemitted: a container form entry carries no label, and nothing a form
1374+
// renders reads a `_views` description.
13401375
const views: any[] = Array.isArray(config?.views) ? config.views : [];
13411376
for (const view of views) {
13421377
const containerObject = viewObjectName(view);
13431378
if (!containerObject) continue;
13441379

13451380
if (view.name) {
13461381
pushViewEntries(out, containerObject, view.name, view);
1382+
// A `viewKind: 'form'` item carries its form under `config`, title included.
1383+
if (view.viewKind === 'form' && view.config && typeof view.config === 'object') {
1384+
pushOptional(out, ['objects', containerObject, '_views', view.name, 'title'], view.config.title, 'view', {
1385+
objectName: containerObject,
1386+
});
1387+
}
13471388
continue;
13481389
}
13491390

1391+
pushContainerFormTitles(out, containerObject, view);
1392+
13501393
// The container's default list, keyed exactly as the runtime registry keys
13511394
// it — see `defaultListViewKey`. `undefined` means the container declares
13521395
// no default list; a key the container's own `listViews` also declares
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
// Copyright (c) 2026 ObjectStack contributors. Apache-2.0 license.
2+
//
3+
// A form view's title (`FormViewSchema.title`) is translated through
4+
// `objects.<object>._views.<view>.title` (`translateView` overlays it onto the
5+
// served form item's `config.title`). The walker emits that slot for every
6+
// form a view container serves, keyed by the composer's runtime identity, so
7+
// `os i18n extract` scaffolds it and the coverage gate reports a locale that
8+
// leaves an authored title untranslated.
9+
10+
import { describe, it, expect } from 'vitest';
11+
import { collectExpectedEntries, extractTranslations } from '../src/utils/i18n-extract';
12+
import { computeI18nCoverage } from '../src/utils/i18n-coverage';
13+
import { ObjectTranslationDataSchema } from '@objectstack/spec/system';
14+
15+
const titleKeys = (config: any) =>
16+
collectExpectedEntries(config)
17+
.map((e) => e.path.join('.'))
18+
.filter((p) => p.endsWith('.title') && p.includes('._views.'));
19+
20+
const leadForm = (extra: Record<string, unknown> = {}) => ({
21+
type: 'simple',
22+
data: { provider: 'object', object: 'crm_lead' },
23+
sections: [{ name: 'basics', label: 'Basics', fields: ['name'] }],
24+
...extra,
25+
});
26+
27+
const config = (translations: unknown[] = []) => ({
28+
i18n: { defaultLocale: 'en', supportedLocales: ['en', 'zh-CN'] },
29+
objects: [{ name: 'crm_lead', label: 'Lead', fields: { name: { type: 'text', label: 'Name' } } }],
30+
views: [
31+
{
32+
list: { type: 'grid', name: 'all_leads', label: 'All Leads', data: { provider: 'object', object: 'crm_lead' } },
33+
form: leadForm({ title: 'New lead' }),
34+
formViews: {
35+
web_to_lead: leadForm({ title: 'Contact us' }),
36+
quick: leadForm(),
37+
},
38+
},
39+
{
40+
name: 'referral',
41+
object: 'crm_lead',
42+
viewKind: 'form',
43+
config: leadForm({ title: 'Refer a friend' }),
44+
},
45+
],
46+
translations,
47+
});
48+
49+
describe('i18n walker — a form view\'s title, `_views.<view>.title`', () => {
50+
it('emits the slot for each form a container serves and for a form item, keyed by the runtime identity', () => {
51+
expect(titleKeys(config()).sort()).toEqual([
52+
'objects.crm_lead._views.form.title',
53+
'objects.crm_lead._views.quick.title',
54+
'objects.crm_lead._views.referral.title',
55+
'objects.crm_lead._views.web_to_lead.title',
56+
]);
57+
});
58+
59+
it('seeds an authored title and leaves a form without one unseeded', () => {
60+
const entries = collectExpectedEntries(config());
61+
const at = (p: string) => entries.find((e) => e.path.join('.') === p);
62+
expect(at('objects.crm_lead._views.web_to_lead.title')).toMatchObject({ sourceValue: 'Contact us', inline: 'Contact us' });
63+
expect(at('objects.crm_lead._views.form.title')).toMatchObject({ sourceValue: 'New lead' });
64+
expect(at('objects.crm_lead._views.quick.title')?.inline).toBeUndefined();
65+
});
66+
67+
it('CONTROL: the list view emits no title slot, and the container forms emit no `label` slot', () => {
68+
const paths = collectExpectedEntries(config()).map((e) => e.path.join('.'));
69+
expect(paths).toContain('objects.crm_lead._views.all_leads.label');
70+
expect(paths).not.toContain('objects.crm_lead._views.all_leads.title');
71+
expect(paths).not.toContain('objects.crm_lead._views.web_to_lead.label');
72+
});
73+
74+
it('`os i18n extract` writes a skeleton the translation schema accepts', () => {
75+
const lead: any = extractTranslations(config(), { locales: ['en'] }).bundles.en?.objects?.crm_lead;
76+
expect(lead?._views?.web_to_lead?.title).toBe('Contact us');
77+
const parsed = ObjectTranslationDataSchema.safeParse(lead);
78+
expect(parsed.success, JSON.stringify(parsed.error?.issues)).toBe(true);
79+
});
80+
81+
it('the coverage gate reports an authored title a supported locale leaves untranslated', () => {
82+
const missing = computeI18nCoverage(config([{ 'zh-CN': { objects: { crm_lead: { label: '线索' } } } }]))
83+
.issues.filter((i) => i.locale === 'zh-CN' && i.key.endsWith('.title'))
84+
.map((i) => i.key)
85+
.sort();
86+
expect(missing).toEqual([
87+
'objects.crm_lead._views.form.title',
88+
'objects.crm_lead._views.referral.title',
89+
'objects.crm_lead._views.web_to_lead.title',
90+
]);
91+
const covered = computeI18nCoverage(config([{
92+
'zh-CN': {
93+
objects: {
94+
crm_lead: {
95+
label: '线索',
96+
_views: { web_to_lead: { title: '联系我们' }, form: { title: '新建线索' }, referral: { title: '推荐朋友' } },
97+
},
98+
},
99+
},
100+
}])).issues.filter((i) => i.locale === 'zh-CN' && i.key.endsWith('.title'));
101+
expect(covered).toEqual([]);
102+
});
103+
});

0 commit comments

Comments
 (0)