Skip to content

Commit 361ea24

Browse files
feat(spec): a page object-form's thank-you panel is translated through pages.PAGE.components.ID.submitBehavior (#28269)
domain:spec#1 · os-tesla · session_01L9qibwb6hBqszPTYJobitm <!-- claude-projects-attribution: {"github_login":"os-tesla"} --> _Requested by **Tesla** · [project thread](https://claude.ai/code/project/chan_01AXsQkLFKRdQCqQ3RTr6Jao?thread=cmsg_01AXsQkLFKRdQCqQ3RTr6Jao1Jx7WKoJNdFZFmj4TXFV34)_ Fixes #28214 Clause-②: yes Metadata-protocol: added Skills-docs: follow-up #28215 (skills/objectstack-i18n/SKILL.md: the page row gains `components.{component_id}.submitBehavior` beside the `_views.{view_name}` row #28195 already owes, in the same next i18n skill edit) ## 维护者速读 - 改了什么:页面里嵌入的 `object-form` 组件,提交后的感谢页(`submitBehavior` 为 `thank-you` 时的 `title` / `message`)有了翻译键 `pages.PAGE.components.ID.submitBehavior.title` / `.message`,`translatePage` 会把它套到下发的页面上;提取器会生成这两个键,lint 会拒收挂错组件的键。 - 为什么改:表单视图自己的感谢页在 #28195 已经能翻译,但页面里的表单组件引用的是同一个配置却没有键,showcase 的“新建项目(向导)”在任何语言下创建成功后都显示英文的 “Project created”。 - 风险与回滚:只放宽(新增可选键);规则与表单视图的感谢页共用同一个函数:只翻 thank-you、只翻作者写了的文案、缺键保留原文。回滚即 revert 本 PR。 - 先看:`packages/spec/src/system/i18n-resolver.ts` 的 `translateFormSubmitBehavior`(改为两处共用)和 `translatePage` 里新增的 `object-form` 分支,`packages/spec/src/system/translation.zod.ts` 的 `submitBehaviorTranslationSchema`,`packages/lint/src/validate-translation-references.ts` 的页面组件检查。 - 两分钟验证:`pnpm --filter @objectstack/spec exec vitest run src/system/i18n-resolver.page-form-thank-you.test.ts`,预期 12 条全绿。 - 需要你做的:无,席位落地(等契约复审记录)。 ## What changes A page `object-form` component takes `submitBehavior` from the form view by reference (`ObjectFormPropsSchema.submitBehavior`), so a page can author `{ kind: 'thank-you', title, message }` on it. #28195 gave a form view's panel its key and overlay. The page component's panel had neither: the strict `pages.PAGE.components.ID` entry declared no `submitBehavior`, and `translatePage` never read `properties.submitBehavior`. The showcase's `showcase_new_project_wizard` page confirmed a create with "Project created" in every locale. - **Spec, the key (`translation.zod.ts`).** `pages.PAGE.components.ID` gains `submitBehavior: { title?, message? }` (strict). The node is built by a new factory, `submitBehaviorTranslationSchema(surface)`, which the form view's `_views.VIEW.submitBehavior` now uses too, so the two addresses carry one shape: the same alias table (`heading`, `text`, `body`, `description`) and the same `url` / `kind` prescriptions. This follows the `actionTranslationSchema` precedent in the same file. The components node's alias table gains `thankYou: 'submitBehavior'`, as the `_views` node has. - **Spec, the overlay (`i18n-resolver.ts`).** `translateFormSubmitBehavior` is now the one comparison both carriers call. It takes a `lookup(attr)` instead of the view's object name and view key. `translateView` passes the `_views` lookup it used before. `translatePage` passes a new `lookupPageFormSubmitBehaviorText` (locale chain, key by key) for every addressed component whose type is the new exported `PAGE_FORM_COMPONENT_TYPE` (`'object-form'`), on the same `addressed` arbitration as the component copy, so a nested form is reached by the id route too. The rules are therefore the form view's, with no second copy: only a `kind: 'thank-you'` panel is touched, only an authored non-empty string is translated (a key cannot invent a `message`), and a missing key keeps the authored string. The component is returned unchanged when nothing applies. - **The extractor (`packages/cli/src/utils/i18n-extract.ts`).** A new `pushThankYouPanel(out, root, behavior, …)` emits the two slots and holds the "kind must be thank-you" reading once. `pushFormViewCopy` and `emitPageComponentCopy` both call it, so `os i18n extract` scaffolds `pages.PAGE.components.ID.submitBehavior.title` / `.message` for every `object-form` the page addresses, and the coverage report counts an authored panel a supported locale leaves untranslated. A non-thank-you kind records both keys unauthored, as on the view side. - **The lint (`packages/lint/src/validate-translation-references.ts`).** A `components.ID.submitBehavior` key is refused (`translation-target-unknown`) under a component the page declares with another type, and under an id the page does not declare, with the nearest `object-form` id offered. A page this stack does not declare is skipped, as for the tab items. - **Showcase.** The wizard's `object-form` had no `id`, so it had no address at all. It gets `id: 'new_project_form'` (`examples/app-showcase/src/ui/pages/new-project-wizard.page.ts`), and `src/system/translations/index.ts` gains its zh-CN `title`, `description` and panel `title` / `message`. The id also makes the form's `title` and `description` addressable, which the coverage ratchet counts, so all four strings are translated and `check:i18n-coverage` stays at its baseline. - **Generated.** `packages/spec/api-surface/system.json` and `export-origins/system.json` record the new `PAGE_FORM_COMPONENT_TYPE` export. `check:docs` and `check:strictness-ledger` stayed green with no regeneration (the generated translation reference renders the page node's `components` member collapsed). - **Changeset.** `.changeset/28214-page-form-thank-you-translation.md`: `@objectstack/spec`, `@objectstack/cli`, `@objectstack/lint`, all `minor`, with both declaration lines. **Address: route (a), under the page.** The key lives at `pages.PAGE.components.ID.submitBehavior`, the #6080 route `lookupPageComponentCopy` already serves for the same component's `title` and `description`. **Route not taken: (b), under the bound object** (the `_sections` precedent, `objects.OBJECT._sections`). The panel is copy the page authors, not the object. Two pages binding the same object can word their confirmation differently (a create wizard and a quick-add card), and an object-scoped key would make them share one translation or need a page-scoped sub-key, which is route (a) spelled longer. `_sections` is object-scoped because a form section's fields are the object's fields. A thank-you panel has no such tie. Route (b) would also need a second walk to find the bound object (`objectName`, or a `dataSource` binding), while (a) reuses the id addressing the extractor, lint and resolver already share through `walkAddressedPageComponents`. The cost of (a) is that an `object-form` without an `id` has no key, the same rule every other page component follows. The showcase form gains an id for that reason. **The page door runs the translator.** `translatePage` is the `page` entry of the translator table (`i18n-resolver.ts`, `page: translatePage`), which `/meta/page` reads go through (`packages/rest/src/meta-item-read-gate.ts`, `translateMetadataDocument`). The console's `object-form` renderers take `submitBehavior` from the component's props (`ui/packages/plugin-form/src/ObjectForm.tsx`, `WizardForm.tsx`), so the translated panel reaches the reader with no ui change. This is a reading of the code, not a live measure. - **Docs (seat-approved, commit `8837fd00`):** the `content/docs/ui/translations.mdx` key table gains both thank-you panel rows: the form view's `objects.NAME._views.VIEW.submitBehavior.title / .message` (which #28195 left out) and the page form's `pages.NAME.components.FORMID.submitBehavior.title / .message`. ## Tests - `packages/spec/src/system/i18n-resolver.page-form-thank-you.test.ts` (new, 12): parsed fixtures (the page through `PageSchema`, both bundle nodes through `TranslationDataSchema`). A zh-CN overlay through `translateMetadataDocument('page', …)`, with the form's own `title` translated beside it and a form nested in a `page:card` reached by id. The fallback chain (ja-JP over `[zh-CN]`). A missing key keeps the authored panel. Controls: a panel with no `message` gains none; a `redirect` behavior is untouched; a key under another component's id does not reach the form, and a `page:card` with a `submitBehavior` key gains no panel; the panel key and the component `title` key do not cross; an `object-form` without an id has no key; no mutation. The schema face: `url` refused as `unrecognized_keys` at the page path, and `kind` refused with the prescription. - `packages/cli/test/i18n-page-form-thank-you-coverage.test.ts` (new, 4): the walker emits both slots per addressed `object-form`, nested ones included, and none for an id-less form or a `page:card`; authored copy seeds, unauthored and non-thank-you copy does not; the extracted skeleton parses; coverage reports exactly the authored strings zh-CN leaves untranslated, and none once covered. - `packages/lint/src/validate-translation-references.test.ts` (+4): a panel over a declared `object-form`, nested included, reports nothing; refused on a `page:card` by name; refused under an undeclared id with `Did you mean "project_form"?`; a page the stack does not declare is not judged. - The existing `i18n-resolver.form-thank-you.test.ts` (11) stays green through the refactor of `translateFormSubmitBehavior`. <details><summary>Ablation: removing the page overlay branch turns the overlay cases red</summary> `node scripts/ablation-replace.mjs --file packages/spec/src/system/i18n-resolver.ts --anchor 'if (addressed && component.type === PAGE_FORM_COMPONENT_TYPE) {' --replacement 'if (false as boolean) { /* ABLATED-28214 */'`. The anchor hit once (1 → 0, blob `dfefaad6` → `8ebe517f`). The new spec test went to Tests 4 failed | 8 passed (12): the zh-CN overlay, the fallback chain, the no-message control and the title/panel crossing control. The other controls stayed green, as expected, since they assert that nothing is applied. The file was restored to its HEAD blob `dfefaad6`, `git diff HEAD` empty. The test imports the resolver from source, so no `dist` rebuild is involved. </details> ## Acceptance notes - **No packaged-base comparison on pages.** `translateFormSubmitBehavior` applies ADR-0029 D9.2a (an override beats the catalog) when its caller hands it the packaged config. `translatePage` takes `ResolveOptions` and no packaged base for any string (its `label`, `description`, header copy and component copy all apply the catalog unconditionally), so the page call passes `undefined` and the panel follows the page's existing rule. The comparison is still the one function, so a later card that hands `translatePage` its packaged base gets the panel for free. Noted, not filed. - **The wizard's form section labels** (`Basics`, `Health`, `Budget & Schedule`) and their descriptions stay untranslatable on a page form: `object-form`'s `properties.sections` has no page key, and `_sections` addresses an object's form sections, not a page component's. Outside this card. Noted, not filed. - **File surface.** `examples/app-showcase/src/ui/pages/new-project-wizard.page.ts` is outside the claim's surface. Route (a) needs the form to carry an `id`, and the showcase form had none, so the showcase strings could not land without it (4 added lines, the `id` and a comment). ## Verification - **Final head `8837fd00`** (seat note): one docs-only commit on top of `9e7da93f`. Re-deriving gives 108 families, the 88 below plus 20 docs families. The 20 new ones ran on `8837fd00` and all exit 0; `--ran`: 108 derived, 108 run, 0 NOT-MEASURED. `check:doc-authoring` exit 0. The readings below are on `9e7da93f` and carry, since the commit touches docs only. All readings are on head `9e7da93f` (the four feature commits through `2093ae5e`, merged with `origin/main` at `0027c4a2`), after `pnpm install --frozen-lockfile` and a declarations build of the touched closures, unless noted. - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 88 commands. All 88 were run with stdin from /dev/null and the exit code captured before any pipe: **86 exited 0 on the first pass, and 2 refused with exit 3** (PREREQUISITE NOT MET: `check:dual-build-cjs-loads` wants every published package built with declarations, `check:i18n-coverage` wants the example closure it names). After `turbo run build` over `./packages/**` and `./examples/**` (77 tasks), both exit 0: `check:dual-build-cjs-loads` "113 published require entry point(s) across 70 package(s) load", and `check:i18n-coverage` "OK (14 config(s), 527 baselined untranslated string(s), none new)". - `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --ran FILE`: "88 derived famil(ies) accounted for — 88 run, 0 NOT-MEASURED". - `pnpm --filter @objectstack/spec test` (verify lock): VERDICT command-exit 0, Test Files 765 passed, Tests 22638 passed, 1 todo. `pnpm --filter @objectstack/spec check:generated`: "All 14 generated artifacts are up to date". - `pnpm --filter @objectstack/lint test`: Test Files 181 passed, Tests 7676 passed. `pnpm --filter @objectstack/cli exec vitest run --project unit` (after `pnpm --filter @objectstack/cli build`): Test Files 355 passed, Tests 5109 passed. These two ran before the merge of `origin/main`, whose incoming commits touch one lint test (`validate-ai-tool-references.test.ts`) and one cli script (`scripts/check-app-nav-i18n.mjs`), neither the extractor nor the translation-reference check; the derived lint and cli gates ran on the merged head. - `typecheck` for `@objectstack/spec`, `@objectstack/lint`, `@objectstack/cli`: exit 0 (the first lint attempt hit TS7016 on a dependency closure built without declarations; rebuilt with declarations, exit 0). - `node scripts/check-nul-bytes.mjs`: exit 0. `node scripts/check-dev-prereqs.mjs`: exit 1, on box state only: the `ui/` package dists are not built in this worktree. It judges the local checkout, not the diff, and CI does not run its scan. - Dogfood: not owed. The title carries no `!` and `Clause-②` carries no `(narrowing)` arm. - CI on this PR: not yet read; the seat reads the check-runs. --- _Generated by [Claude Code](https://claude.ai/code/session_01L9qibyMTiYRPdRNbNBCGdF)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent f64724a commit 361ea24

13 files changed

Lines changed: 652 additions & 44 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/lint': minor
5+
---
6+
7+
feat(spec): a page `object-form`'s thank-you panel is translated through `pages.PAGE.components.ID.submitBehavior.title` / `.message`
8+
9+
Clause-②: yes
10+
Metadata-protocol: added
11+
12+
A page that embeds an `object-form` with `submitBehavior: { kind: 'thank-you', title, message }` used to confirm a create in the authoring language only: the strict `pages.PAGE.components.ID` translation entry declared no key for the panel, and `translatePage` never read `properties.submitBehavior`. A form view's own panel already had its key (`objects.OBJECT._views.VIEW.submitBehavior`); the page form, which takes the same member by reference, had none.
13+
14+
- **The key.** `pages.PAGE.components.ID.submitBehavior` takes `title` and `message`, under the form component's own `id`, beside its `title` and `description`. It is the page's copy, so two pages binding the same object can word their panels differently. The node is the form view's panel node, built by one factory, so a `thankYou` key is refused naming `submitBehavior`, and `url` / `kind` are refused with a prescription. An `object-form` without an `id` has no key: give it one.
15+
- **The overlay.** `translatePage` overlays the two strings onto the component's `properties.submitBehavior` through the same function `translateView` uses for a form view's panel: only a `kind: 'thank-you'` panel is translated, only copy the form authors (a key cannot invent a `message`), and a missing key keeps the authored string. A form nested in a container is reached by the id route, as every other component copy is.
16+
- **Extraction and coverage.** `os i18n extract` scaffolds `pages.PAGE.components.ID.submitBehavior.title` / `.message` for every `object-form` the page addresses, and the coverage report counts an authored panel a supported locale leaves untranslated.
17+
- **Lint.** `os lint` refuses a `submitBehavior` key under a component that is not an `object-form`, and under a component id the page does not declare, offering the nearest form id.

‎content/docs/ui/translations.mdx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ export default defineStack({
7272
| Inline child-grid heading of a master-detail field (drawn on the parent's form) | `objects.<child>.fields.<field>.inlineTitle` — keyed on the child object that owns the field; untranslated it keeps its authored text, with no fallback to the child's plural label |
7373
| Picklist option labels | `objects.<name>.fields.<field>.options.<value>` |
7474
| View titles, descriptions, empty states | `objects.<name>._views.<view>` |
75+
| A form view's thank-you panel (shown after a submit) | `objects.<name>._views.<view>.submitBehavior.title` / `.message` — only when the form's `submitBehavior` is `kind: 'thank-you'`, and only copy the form authors: a key cannot add a `message` the form leaves to the default wording |
7576
| Bulk-action copy on a list view (button, confirm prompt, dialog fields) | `objects.<name>._views.<view>.bulkActions.<def>.label` / `.confirmText` / `.confirmLabel` / `.params.<param>.label` / `.help` / `.placeholder` — a bulk param's hint is `help`, not `helpText` |
7677
| Action labels, confirm text, success messages, disabled reasons | `objects.<name>._actions.<action>` |
7778
| Action result dialogs (title / description / acknowledge / field labels) | `objects.<name>._actions.<action>.resultDialog` |
@@ -87,6 +88,7 @@ export default defineStack({
8788
| Page labels and `page:header` copy | `pages.<name>.label` / `description` / `title` / `subtitle` — on a `kind: 'slotted'` page the header under `slots.header` is the page's header |
8889
| Page component copy, by component id | `pages.<name>.components.<id>.title` / `description` / `label` / `placeholder` / `emptyText` — reached under `regions[].components[]` and `slots.<slot>`, through `properties.children` and a `page:tabs` / `page:accordion` panel's `items[].children` |
8990
| Page tab labels (`page:tabs` items) | `pages.<name>.components.<tabsId>.items.<value>.label` — keyed by the tabs component's `id` and the item's `value`; an item without a `value` has no key (localize it with an inline locale map), and `page:accordion` panels have none |
91+
| A page `object-form`'s thank-you panel | `pages.<name>.components.<formId>.submitBehavior.title` / `.message` — keyed by the `object-form` component's `id` (a form without an `id` has no key), by the same rules as a form view's panel |
9092
| Screen-flow wizards (flow label, screen headings and body text, screen field copy) | `flows.<flow>.label` / `flows.<flow>.screens.<node_id>.title` / `.description` / `.fields.<field>.label` / `.placeholder` / `.inlineHelpText` — a screen field's help text is `inlineHelpText`, the key the field itself declares, not `help`; see the boundary note below |
9193
| Hook refusal messages | `hooks.<hook>.refusals.<CODE>.message` — one per entry of the hook's `refusals` catalogue. A sandboxed hook body refuses by code, and the server answers the refusal with this message in the caller's locale, else the declared `message` |
9294
| Position label / description | `positions.<name>.label` / `description` — keyed by the position's `name`; overlaid on every served position, which is the list the console's position picker draws |

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

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1253,6 +1253,20 @@ export const ShowcaseTranslationBundle = {
12531253
project_picker: { label: '项目', placeholder: '选择项目…' },
12541254
},
12551255
},
1256+
// `ui/pages/new-project-wizard.page.ts` — the create wizard's form, its
1257+
// heading and the thank-you panel it swaps in after the create.
1258+
showcase_new_project_wizard: {
1259+
components: {
1260+
new_project_form: {
1261+
title: '创建项目',
1262+
description: '三步向导:先填基本信息,再看健康状况,最后是预算与排期。',
1263+
submitBehavior: {
1264+
title: '项目已创建',
1265+
message: '新项目已就绪——可在「项目」中找到它,或重新打开此向导再创建一个。',
1266+
},
1267+
},
1268+
},
1269+
},
12561270
// Nested per-component copy. These ids sit inside a container's
12571271
// `properties.children`, which `translatePage` resolves (#12961) but
12581272
// `os i18n extract` could not see until #13109 matched the extractor's

‎examples/app-showcase/src/ui/pages/new-project-wizard.page.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,10 @@ export const NewProjectWizardPage = definePage({
2323
name: 'main',
2424
components: [
2525
{
26+
// The id is the form's translation address: its `title`,
27+
// `description` and thank-you panel are keyed under
28+
// `pages.showcase_new_project_wizard.components.new_project_form`.
29+
id: 'new_project_form',
2630
type: 'object-form',
2731
properties: {
2832
objectName: 'showcase_project',

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

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,8 @@
113113
* pages.<page>.title / .subtitle (from the page's `page:header` component)
114114
* pages.<page>.components.<id>.<key> (per-component copy, #6080)
115115
* pages.<page>.components.<tabsId>.items.<value>.label (a `page:tabs` item label)
116+
* pages.<page>.components.<formId>.submitBehavior.title / .message
117+
* (an `object-form`'s thank-you panel)
116118
* flows.<flow>.label
117119
* flows.<flow>.screens.<node_id>.title (#7646 / #11287)
118120
* flows.<flow>.screens.<node_id>.description (#22507)
@@ -154,6 +156,7 @@ import {
154156
FLOW_SCREEN_FIELD_COPY_KEYS,
155157
FLOW_TERMINAL_MESSAGE_KEYS,
156158
PAGE_TABS_COMPONENT_TYPE,
159+
PAGE_FORM_COMPONENT_TYPE,
157160
globalFilterKey,
158161
pageTabItemKey,
159162
walkAddressedPageComponents,
@@ -489,9 +492,29 @@ function pushFormViewCopy(out: ExpectedEntry[], objectName: string, key: string,
489492
const form = config && typeof config === 'object' ? config as { title?: unknown; submitBehavior?: any } : undefined;
490493
const root = ['objects', objectName, '_views', key];
491494
pushOptional(out, [...root, 'title'], form?.title, 'view', { objectName });
492-
const thankYou = form?.submitBehavior?.kind === 'thank-you' ? form.submitBehavior : undefined;
493-
pushOptional(out, [...root, 'submitBehavior', 'title'], thankYou?.title, 'view', { objectName });
494-
pushOptional(out, [...root, 'submitBehavior', 'message'], thankYou?.message, 'view', { objectName });
495+
pushThankYouPanel(out, root, form?.submitBehavior, 'view', { objectName });
496+
}
497+
498+
/**
499+
* Emit `<root>.submitBehavior.title` / `.message` for one form's
500+
* `submitBehavior` — the panel's two slots, under whichever address its
501+
* carrier has: a form view's `_views.<key>` ({@link pushFormViewCopy}) or a
502+
* page `object-form` component's `pages.<page>.components.<id>`
503+
* ({@link emitPageComponentCopy}). Only a `kind: 'thank-you'` panel authors
504+
* copy; any other kind records both keys unauthored (`pushOptional`), so the
505+
* two carriers cannot read the kind differently.
506+
*/
507+
function pushThankYouPanel(
508+
out: ExpectedEntry[],
509+
root: string[],
510+
behavior: unknown,
511+
source: ExpectedEntry['source'],
512+
extra?: EntryScope,
513+
): void {
514+
const panel = behavior && typeof behavior === 'object' ? behavior as { kind?: unknown; title?: unknown; message?: unknown } : undefined;
515+
const thankYou = panel?.kind === 'thank-you' ? panel : undefined;
516+
pushOptional(out, [...root, 'submitBehavior', 'title'], thankYou?.title, source, extra);
517+
pushOptional(out, [...root, 'submitBehavior', 'message'], thankYou?.message, source, extra);
495518
}
496519

497520
/**
@@ -1348,6 +1371,11 @@ function emitPageComponentCopy(out: ExpectedEntry[], page: any, name: string): v
13481371
);
13491372
}
13501373
}
1374+
// An `object-form`'s thank-you panel, under the form component's id —
1375+
// the key `translatePage` reads, by the form view's panel's rules.
1376+
if (component.type === PAGE_FORM_COMPONENT_TYPE) {
1377+
pushThankYouPanel(out, ['pages', name, 'components', id as string], props.submitBehavior, 'page');
1378+
}
13511379
}
13521380
return component;
13531381
});
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
// Copyright (c) 2026 ObjectStack contributors. Apache-2.0 license.
2+
//
3+
// A page `object-form` component's thank-you panel (the `title` / `message`
4+
// of a `kind: 'thank-you'` `properties.submitBehavior`) is translated through
5+
// `pages.<page>.components.<id>.submitBehavior.{title,message}`
6+
// (`translatePage` overlays it, by the form view's panel's rules). The walker
7+
// emits both slots for every `object-form` the page addresses, so
8+
// `os i18n extract` scaffolds them and the coverage gate reports a locale
9+
// that leaves an authored panel untranslated.
10+
11+
import { describe, it, expect } from 'vitest';
12+
import { collectExpectedEntries, extractTranslations } from '../src/utils/i18n-extract';
13+
import { computeI18nCoverage } from '../src/utils/i18n-coverage';
14+
import { TranslationDataSchema } from '@objectstack/spec/system';
15+
16+
const panelKeys = (config: any) =>
17+
collectExpectedEntries(config)
18+
.map((e) => e.path.join('.'))
19+
.filter((p) => p.startsWith('pages.') && p.includes('.submitBehavior.'));
20+
21+
const form = (id: string | undefined, submitBehavior: unknown) => ({
22+
...(id ? { id } : {}),
23+
type: 'object-form',
24+
properties: { objectName: 'project', mode: 'create', submitBehavior },
25+
});
26+
27+
const config = (translations: unknown[] = []) => ({
28+
i18n: { defaultLocale: 'en', supportedLocales: ['en', 'zh-CN'] },
29+
objects: [{ name: 'project', label: 'Project', fields: { name: { type: 'text', label: 'Name' } } }],
30+
pages: [
31+
{
32+
name: 'project_wizard',
33+
label: 'New Project',
34+
type: 'app',
35+
regions: [
36+
{
37+
name: 'main',
38+
components: [
39+
form('project_form', { kind: 'thank-you', title: 'Project created', message: 'Your project is ready.' }),
40+
form('redirect_form', { kind: 'redirect', url: '/projects' }),
41+
// No id: no address, so no slot.
42+
form(undefined, { kind: 'thank-you', title: 'Anonymous' }),
43+
// Not an `object-form`: no panel slot under its id.
44+
{ id: 'summary_card', type: 'page:card', properties: { title: 'Summary' } },
45+
{
46+
id: 'side_card',
47+
type: 'page:card',
48+
properties: { title: 'Quick add', children: [form('quick_form', { kind: 'thank-you', message: 'Added.' })] },
49+
},
50+
],
51+
},
52+
],
53+
},
54+
],
55+
translations,
56+
});
57+
58+
describe("i18n walker — a page `object-form`'s thank-you panel, `components.<id>.submitBehavior`", () => {
59+
it('emits both slots for each addressed `object-form`, nested ones included, and none elsewhere', () => {
60+
expect(panelKeys(config()).sort()).toEqual([
61+
'pages.project_wizard.components.project_form.submitBehavior.message',
62+
'pages.project_wizard.components.project_form.submitBehavior.title',
63+
'pages.project_wizard.components.quick_form.submitBehavior.message',
64+
'pages.project_wizard.components.quick_form.submitBehavior.title',
65+
'pages.project_wizard.components.redirect_form.submitBehavior.message',
66+
'pages.project_wizard.components.redirect_form.submitBehavior.title',
67+
]);
68+
});
69+
70+
it('seeds authored panel copy and leaves unauthored copy — and every non-thank-you kind — unseeded', () => {
71+
const entries = collectExpectedEntries(config());
72+
const at = (p: string) => entries.find((e) => e.path.join('.') === p);
73+
const root = 'pages.project_wizard.components';
74+
expect(at(`${root}.project_form.submitBehavior.title`)).toMatchObject({ sourceValue: 'Project created', inline: 'Project created' });
75+
expect(at(`${root}.project_form.submitBehavior.message`)).toMatchObject({ sourceValue: 'Your project is ready.' });
76+
expect(at(`${root}.quick_form.submitBehavior.title`)?.inline).toBeUndefined();
77+
expect(at(`${root}.quick_form.submitBehavior.message`)).toMatchObject({ sourceValue: 'Added.' });
78+
expect(at(`${root}.redirect_form.submitBehavior.title`)?.inline).toBeUndefined();
79+
expect(at(`${root}.redirect_form.submitBehavior.message`)?.inline).toBeUndefined();
80+
});
81+
82+
it('`os i18n extract` writes a skeleton the translation schema accepts', () => {
83+
const page: any = extractTranslations(config(), { locales: ['en'] }).bundles.en?.pages?.project_wizard;
84+
expect(page?.components?.project_form?.submitBehavior).toEqual({ title: 'Project created', message: 'Your project is ready.' });
85+
const parsed = TranslationDataSchema.safeParse({ pages: { project_wizard: page } });
86+
expect(parsed.success, JSON.stringify(parsed.error?.issues)).toBe(true);
87+
});
88+
89+
it('the coverage gate reports authored panel copy a supported locale leaves untranslated', () => {
90+
const issuesOf = (translations: unknown[]) => computeI18nCoverage(config(translations))
91+
.issues.filter((i) => i.locale === 'zh-CN' && i.key.startsWith('pages.') && i.key.includes('.submitBehavior.'))
92+
.map((i) => i.key)
93+
.sort();
94+
expect(issuesOf([{ 'zh-CN': { objects: { project: { label: '项目' } } } }])).toEqual([
95+
'pages.project_wizard.components.project_form.submitBehavior.message',
96+
'pages.project_wizard.components.project_form.submitBehavior.title',
97+
'pages.project_wizard.components.quick_form.submitBehavior.message',
98+
]);
99+
expect(issuesOf([{
100+
'zh-CN': {
101+
pages: {
102+
project_wizard: {
103+
components: {
104+
project_form: { submitBehavior: { title: '项目已创建', message: '您的项目已就绪。' } },
105+
quick_form: { submitBehavior: { message: '已添加。' } },
106+
},
107+
},
108+
},
109+
},
110+
}])).toEqual([]);
111+
});
112+
});

‎packages/lint/src/validate-translation-references.test.ts‎

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2490,6 +2490,80 @@ describe('validateTranslationReferences — `page:tabs` item labels (`components
24902490
});
24912491
});
24922492

2493+
describe("validateTranslationReferences — an `object-form`'s thank-you panel (`components.<formId>.submitBehavior`)", () => {
2494+
/**
2495+
* The showcase wizard's shape: a page whose `object-form` authors a
2496+
* thank-you panel, beside a card that nests a second form, so each verdict
2497+
* below is judged against the same metadata as the clean run and differs
2498+
* only in the bundle.
2499+
*/
2500+
const wizardStack = {
2501+
pages: [
2502+
{
2503+
name: 'project_wizard',
2504+
label: 'New Project',
2505+
regions: [{
2506+
name: 'main',
2507+
components: [
2508+
{
2509+
type: 'object-form',
2510+
id: 'project_form',
2511+
properties: { objectName: 'project', submitBehavior: { kind: 'thank-you', title: 'Project created' } },
2512+
},
2513+
{
2514+
type: 'page:card',
2515+
id: 'side_card',
2516+
properties: {
2517+
title: 'Quick add',
2518+
children: [{ type: 'object-form', id: 'quick_form', properties: { objectName: 'project' } }],
2519+
},
2520+
},
2521+
],
2522+
}],
2523+
},
2524+
],
2525+
};
2526+
const bundle = (components: unknown) =>
2527+
({ ...wizardStack, translations: [{ 'zh-CN': { pages: { project_wizard: { components } } } }] });
2528+
2529+
it('reports nothing for a panel over a declared `object-form`, nested ones included', () => {
2530+
expect(validateTranslationReferences(bundle({
2531+
project_form: { title: '创建项目', submitBehavior: { title: '项目已创建' } },
2532+
quick_form: { submitBehavior: { message: '已添加。' } },
2533+
}))).toEqual([]);
2534+
});
2535+
2536+
it('refuses a panel on a component of another type, by name', () => {
2537+
const findings = validateTranslationReferences(bundle({ side_card: { submitBehavior: { title: 'x' } } }));
2538+
expect(findings).toHaveLength(1);
2539+
expect(findings[0].rule).toBe(TRANSLATION_TARGET_UNKNOWN);
2540+
expect(findings[0].severity).toBe('error');
2541+
expect(findings[0].path).toBe('translations[0]["zh-CN"].pages.project_wizard.components.side_card.submitBehavior');
2542+
expect(findings[0].message).toBe(
2543+
'Translations carry a thank-you panel for "side_card", which page "project_wizard" declares as a ' +
2544+
'`page:card`, not an `object-form`, so nothing resolves it.',
2545+
);
2546+
expect(findings[0].hint).toBe('Thank-you panel translations go under the `id` of an `object-form` component.');
2547+
});
2548+
2549+
it('refuses a panel under a component id the page does not declare, with the nearest form id offered', () => {
2550+
const findings = validateTranslationReferences(bundle({ project: { submitBehavior: { title: 'x' } } }));
2551+
expect(findings).toHaveLength(1);
2552+
expect(findings[0].path).toBe('translations[0]["zh-CN"].pages.project_wizard.components.project.submitBehavior');
2553+
expect(findings[0].message).toBe(
2554+
'Translations carry a thank-you panel for "project", a component id page "project_wizard" does not declare, ' +
2555+
'so nothing resolves it. Did you mean "project_form"?',
2556+
);
2557+
});
2558+
2559+
it('control: a page this stack does not declare is not judged by this check', () => {
2560+
expect(validateTranslationReferences({
2561+
...wizardStack,
2562+
translations: [{ 'zh-CN': { pages: { other_page: { components: { f: { submitBehavior: { title: 'x' } } } } } } }],
2563+
})).toEqual([]);
2564+
});
2565+
});
2566+
24932567
describe('validateTranslationReferences — flow toasts and screen-field options (#22507)', () => {
24942568
/**
24952569
* hotclm's measured shape, trimmed: a screen flow that authors a completion

0 commit comments

Comments
 (0)