Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .changeset/28214-page-form-thank-you-translation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
'@objectstack/spec': minor
'@objectstack/cli': minor
'@objectstack/lint': minor
---

feat(spec): a page `object-form`'s thank-you panel is translated through `pages.PAGE.components.ID.submitBehavior.title` / `.message`

Clause-②: yes
Metadata-protocol: added

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.

- **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.
- **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.
- **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.
- **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.
2 changes: 2 additions & 0 deletions content/docs/ui/translations.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ export default defineStack({
| 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 |
| Picklist option labels | `objects.<name>.fields.<field>.options.<value>` |
| View titles, descriptions, empty states | `objects.<name>._views.<view>` |
| 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 |
| 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` |
| Action labels, confirm text, success messages, disabled reasons | `objects.<name>._actions.<action>` |
| Action result dialogs (title / description / acknowledge / field labels) | `objects.<name>._actions.<action>.resultDialog` |
Expand All @@ -87,6 +88,7 @@ export default defineStack({
| 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 |
| 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` |
| 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 |
| 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 |
| 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 |
| 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` |
| 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 |
Expand Down
14 changes: 14 additions & 0 deletions examples/app-showcase/src/system/translations/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1253,6 +1253,20 @@ export const ShowcaseTranslationBundle = {
project_picker: { label: '项目', placeholder: '选择项目…' },
},
},
// `ui/pages/new-project-wizard.page.ts` — the create wizard's form, its
// heading and the thank-you panel it swaps in after the create.
showcase_new_project_wizard: {
components: {
new_project_form: {
title: '创建项目',
description: '三步向导:先填基本信息,再看健康状况,最后是预算与排期。',
submitBehavior: {
title: '项目已创建',
message: '新项目已就绪——可在「项目」中找到它,或重新打开此向导再创建一个。',
},
},
},
},
// Nested per-component copy. These ids sit inside a container's
// `properties.children`, which `translatePage` resolves (#12961) but
// `os i18n extract` could not see until #13109 matched the extractor's
Expand Down
4 changes: 4 additions & 0 deletions examples/app-showcase/src/ui/pages/new-project-wizard.page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@ export const NewProjectWizardPage = definePage({
name: 'main',
components: [
{
// The id is the form's translation address: its `title`,
// `description` and thank-you panel are keyed under
// `pages.showcase_new_project_wizard.components.new_project_form`.
id: 'new_project_form',
type: 'object-form',
properties: {
objectName: 'showcase_project',
Expand Down
34 changes: 31 additions & 3 deletions packages/cli/src/utils/i18n-extract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,8 @@
* pages.<page>.title / .subtitle (from the page's `page:header` component)
* pages.<page>.components.<id>.<key> (per-component copy, #6080)
* pages.<page>.components.<tabsId>.items.<value>.label (a `page:tabs` item label)
* pages.<page>.components.<formId>.submitBehavior.title / .message
* (an `object-form`'s thank-you panel)
* flows.<flow>.label
* flows.<flow>.screens.<node_id>.title (#7646 / #11287)
* flows.<flow>.screens.<node_id>.description (#22507)
Expand Down Expand Up @@ -154,6 +156,7 @@ import {
FLOW_SCREEN_FIELD_COPY_KEYS,
FLOW_TERMINAL_MESSAGE_KEYS,
PAGE_TABS_COMPONENT_TYPE,
PAGE_FORM_COMPONENT_TYPE,
globalFilterKey,
pageTabItemKey,
walkAddressedPageComponents,
Expand Down Expand Up @@ -489,9 +492,29 @@ function pushFormViewCopy(out: ExpectedEntry[], objectName: string, key: string,
const form = config && typeof config === 'object' ? config as { title?: unknown; submitBehavior?: any } : undefined;
const root = ['objects', objectName, '_views', key];
pushOptional(out, [...root, 'title'], form?.title, 'view', { objectName });
const thankYou = form?.submitBehavior?.kind === 'thank-you' ? form.submitBehavior : undefined;
pushOptional(out, [...root, 'submitBehavior', 'title'], thankYou?.title, 'view', { objectName });
pushOptional(out, [...root, 'submitBehavior', 'message'], thankYou?.message, 'view', { objectName });
pushThankYouPanel(out, root, form?.submitBehavior, 'view', { objectName });
}

/**
* Emit `<root>.submitBehavior.title` / `.message` for one form's
* `submitBehavior` — the panel's two slots, under whichever address its
* carrier has: a form view's `_views.<key>` ({@link pushFormViewCopy}) or a
* page `object-form` component's `pages.<page>.components.<id>`
* ({@link emitPageComponentCopy}). Only a `kind: 'thank-you'` panel authors
* copy; any other kind records both keys unauthored (`pushOptional`), so the
* two carriers cannot read the kind differently.
*/
function pushThankYouPanel(
out: ExpectedEntry[],
root: string[],
behavior: unknown,
source: ExpectedEntry['source'],
extra?: EntryScope,
): void {
const panel = behavior && typeof behavior === 'object' ? behavior as { kind?: unknown; title?: unknown; message?: unknown } : undefined;
const thankYou = panel?.kind === 'thank-you' ? panel : undefined;
pushOptional(out, [...root, 'submitBehavior', 'title'], thankYou?.title, source, extra);
pushOptional(out, [...root, 'submitBehavior', 'message'], thankYou?.message, source, extra);
}

/**
Expand Down Expand Up @@ -1348,6 +1371,11 @@ function emitPageComponentCopy(out: ExpectedEntry[], page: any, name: string): v
);
}
}
// An `object-form`'s thank-you panel, under the form component's id —
// the key `translatePage` reads, by the form view's panel's rules.
if (component.type === PAGE_FORM_COMPONENT_TYPE) {
pushThankYouPanel(out, ['pages', name, 'components', id as string], props.submitBehavior, 'page');
}
}
return component;
});
Expand Down
112 changes: 112 additions & 0 deletions packages/cli/test/i18n-page-form-thank-you-coverage.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
// Copyright (c) 2026 ObjectStack contributors. Apache-2.0 license.
//
// A page `object-form` component's thank-you panel (the `title` / `message`
// of a `kind: 'thank-you'` `properties.submitBehavior`) is translated through
// `pages.<page>.components.<id>.submitBehavior.{title,message}`
// (`translatePage` overlays it, by the form view's panel's rules). The walker
// emits both slots for every `object-form` the page addresses, so
// `os i18n extract` scaffolds them and the coverage gate reports a locale
// that leaves an authored panel untranslated.

import { describe, it, expect } from 'vitest';
import { collectExpectedEntries, extractTranslations } from '../src/utils/i18n-extract';
import { computeI18nCoverage } from '../src/utils/i18n-coverage';
import { TranslationDataSchema } from '@objectstack/spec/system';

const panelKeys = (config: any) =>
collectExpectedEntries(config)
.map((e) => e.path.join('.'))
.filter((p) => p.startsWith('pages.') && p.includes('.submitBehavior.'));

const form = (id: string | undefined, submitBehavior: unknown) => ({
...(id ? { id } : {}),
type: 'object-form',
properties: { objectName: 'project', mode: 'create', submitBehavior },
});

const config = (translations: unknown[] = []) => ({
i18n: { defaultLocale: 'en', supportedLocales: ['en', 'zh-CN'] },
objects: [{ name: 'project', label: 'Project', fields: { name: { type: 'text', label: 'Name' } } }],
pages: [
{
name: 'project_wizard',
label: 'New Project',
type: 'app',
regions: [
{
name: 'main',
components: [
form('project_form', { kind: 'thank-you', title: 'Project created', message: 'Your project is ready.' }),
form('redirect_form', { kind: 'redirect', url: '/projects' }),
// No id: no address, so no slot.
form(undefined, { kind: 'thank-you', title: 'Anonymous' }),
// Not an `object-form`: no panel slot under its id.
{ id: 'summary_card', type: 'page:card', properties: { title: 'Summary' } },
{
id: 'side_card',
type: 'page:card',
properties: { title: 'Quick add', children: [form('quick_form', { kind: 'thank-you', message: 'Added.' })] },
},
],
},
],
},
],
translations,
});

describe("i18n walker — a page `object-form`'s thank-you panel, `components.<id>.submitBehavior`", () => {
it('emits both slots for each addressed `object-form`, nested ones included, and none elsewhere', () => {
expect(panelKeys(config()).sort()).toEqual([
'pages.project_wizard.components.project_form.submitBehavior.message',
'pages.project_wizard.components.project_form.submitBehavior.title',
'pages.project_wizard.components.quick_form.submitBehavior.message',
'pages.project_wizard.components.quick_form.submitBehavior.title',
'pages.project_wizard.components.redirect_form.submitBehavior.message',
'pages.project_wizard.components.redirect_form.submitBehavior.title',
]);
});

it('seeds authored panel copy and leaves unauthored copy — and every non-thank-you kind — unseeded', () => {
const entries = collectExpectedEntries(config());
const at = (p: string) => entries.find((e) => e.path.join('.') === p);
const root = 'pages.project_wizard.components';
expect(at(`${root}.project_form.submitBehavior.title`)).toMatchObject({ sourceValue: 'Project created', inline: 'Project created' });
expect(at(`${root}.project_form.submitBehavior.message`)).toMatchObject({ sourceValue: 'Your project is ready.' });
expect(at(`${root}.quick_form.submitBehavior.title`)?.inline).toBeUndefined();
expect(at(`${root}.quick_form.submitBehavior.message`)).toMatchObject({ sourceValue: 'Added.' });
expect(at(`${root}.redirect_form.submitBehavior.title`)?.inline).toBeUndefined();
expect(at(`${root}.redirect_form.submitBehavior.message`)?.inline).toBeUndefined();
});

it('`os i18n extract` writes a skeleton the translation schema accepts', () => {
const page: any = extractTranslations(config(), { locales: ['en'] }).bundles.en?.pages?.project_wizard;
expect(page?.components?.project_form?.submitBehavior).toEqual({ title: 'Project created', message: 'Your project is ready.' });
const parsed = TranslationDataSchema.safeParse({ pages: { project_wizard: page } });
expect(parsed.success, JSON.stringify(parsed.error?.issues)).toBe(true);
});

it('the coverage gate reports authored panel copy a supported locale leaves untranslated', () => {
const issuesOf = (translations: unknown[]) => computeI18nCoverage(config(translations))
.issues.filter((i) => i.locale === 'zh-CN' && i.key.startsWith('pages.') && i.key.includes('.submitBehavior.'))
.map((i) => i.key)
.sort();
expect(issuesOf([{ 'zh-CN': { objects: { project: { label: '项目' } } } }])).toEqual([
'pages.project_wizard.components.project_form.submitBehavior.message',
'pages.project_wizard.components.project_form.submitBehavior.title',
'pages.project_wizard.components.quick_form.submitBehavior.message',
]);
expect(issuesOf([{
'zh-CN': {
pages: {
project_wizard: {
components: {
project_form: { submitBehavior: { title: '项目已创建', message: '您的项目已就绪。' } },
quick_form: { submitBehavior: { message: '已添加。' } },
},
},
},
},
}])).toEqual([]);
});
});
74 changes: 74 additions & 0 deletions packages/lint/src/validate-translation-references.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2490,6 +2490,80 @@ describe('validateTranslationReferences — `page:tabs` item labels (`components
});
});

describe("validateTranslationReferences — an `object-form`'s thank-you panel (`components.<formId>.submitBehavior`)", () => {
/**
* The showcase wizard's shape: a page whose `object-form` authors a
* thank-you panel, beside a card that nests a second form, so each verdict
* below is judged against the same metadata as the clean run and differs
* only in the bundle.
*/
const wizardStack = {
pages: [
{
name: 'project_wizard',
label: 'New Project',
regions: [{
name: 'main',
components: [
{
type: 'object-form',
id: 'project_form',
properties: { objectName: 'project', submitBehavior: { kind: 'thank-you', title: 'Project created' } },
},
{
type: 'page:card',
id: 'side_card',
properties: {
title: 'Quick add',
children: [{ type: 'object-form', id: 'quick_form', properties: { objectName: 'project' } }],
},
},
],
}],
},
],
};
const bundle = (components: unknown) =>
({ ...wizardStack, translations: [{ 'zh-CN': { pages: { project_wizard: { components } } } }] });

it('reports nothing for a panel over a declared `object-form`, nested ones included', () => {
expect(validateTranslationReferences(bundle({
project_form: { title: '创建项目', submitBehavior: { title: '项目已创建' } },
quick_form: { submitBehavior: { message: '已添加。' } },
}))).toEqual([]);
});

it('refuses a panel on a component of another type, by name', () => {
const findings = validateTranslationReferences(bundle({ side_card: { submitBehavior: { title: 'x' } } }));
expect(findings).toHaveLength(1);
expect(findings[0].rule).toBe(TRANSLATION_TARGET_UNKNOWN);
expect(findings[0].severity).toBe('error');
expect(findings[0].path).toBe('translations[0]["zh-CN"].pages.project_wizard.components.side_card.submitBehavior');
expect(findings[0].message).toBe(
'Translations carry a thank-you panel for "side_card", which page "project_wizard" declares as a ' +
'`page:card`, not an `object-form`, so nothing resolves it.',
);
expect(findings[0].hint).toBe('Thank-you panel translations go under the `id` of an `object-form` component.');
});

it('refuses a panel under a component id the page does not declare, with the nearest form id offered', () => {
const findings = validateTranslationReferences(bundle({ project: { submitBehavior: { title: 'x' } } }));
expect(findings).toHaveLength(1);
expect(findings[0].path).toBe('translations[0]["zh-CN"].pages.project_wizard.components.project.submitBehavior');
expect(findings[0].message).toBe(
'Translations carry a thank-you panel for "project", a component id page "project_wizard" does not declare, ' +
'so nothing resolves it. Did you mean "project_form"?',
);
});

it('control: a page this stack does not declare is not judged by this check', () => {
expect(validateTranslationReferences({
...wizardStack,
translations: [{ 'zh-CN': { pages: { other_page: { components: { f: { submitBehavior: { title: 'x' } } } } } } }],
})).toEqual([]);
});
});

describe('validateTranslationReferences — flow toasts and screen-field options (#22507)', () => {
/**
* hotclm's measured shape, trimmed: a screen flow that authors a completion
Expand Down
Loading
Loading