diff --git a/examples/app-showcase/src/system/translations/index.ts b/examples/app-showcase/src/system/translations/index.ts index 301cf7843de..6a5de8ced0e 100644 --- a/examples/app-showcase/src/system/translations/index.ts +++ b/examples/app-showcase/src/system/translations/index.ts @@ -1243,6 +1243,14 @@ export const ShowcaseTranslationBundle = { showcase_active_projects: { label: '进行中的项目' }, // `ui/pages/invoice-document.page.ts` — the invoice print page. showcase_invoice_document: { label: '发票' }, + // `ui/pages/task-detail.page.ts` — the banner a task in review shows. + // Only its `title` has a key on this face; its `body` carries its + // zh-CN text inline on the page (see the comment on the node there). + showcase_task_detail: { + components: { + review_alert: { title: '待评审' }, + }, + }, showcase_contact_form: { components: { field_name: { label: '姓名', placeholder: '艾达·洛夫莱斯' }, diff --git a/examples/app-showcase/src/ui/pages/task-detail.page.ts b/examples/app-showcase/src/ui/pages/task-detail.page.ts index b7ba5a98dbe..d5a78c16663 100644 --- a/examples/app-showcase/src/ui/pages/task-detail.page.ts +++ b/examples/app-showcase/src/ui/pages/task-detail.page.ts @@ -91,15 +91,35 @@ export const TaskDetailPage = definePage({ // hence no `visible` beside it: a node `visibleWhen` and // `properties.visible` compose as AND, so leaving both would keep the // legacy predicate load-bearing. + // + // The banner's copy reaches a zh-CN reader by two routes, one per key. + // `title` is translated by the bundle at + // `pages.showcase_task_detail.components.review_alert.title`, which is + // why the node carries an `id`: the page-component route addresses a + // component by its id, so an id-less node has no bundle key at all. + // `body` has no bundle key on that face (`PAGE_COMPONENT_COPY_KEYS` in + // `@objectstack/spec/system` is title / description / label / + // placeholder / emptyText, and the bundle schema refuses any other), + // so it carries its translation at the authoring site, as the inline + // locale map `RecordAlertProps.body` accepts. `dismissKey` keeps the + // dismissal under one name in every locale: its default is the English + // resolution of `title`, and a bundle-translated title would resolve + // to the translation, so a banner dismissed in one locale would come + // back in the other. { + id: 'review_alert', type: 'record:alert', visibleWhen: "has(record.status) && record.status == 'in_review'", properties: { severity: 'warning', icon: 'eye', title: 'Awaiting review', - body: 'This task is in review — confirm the work before marking it done.', + body: { + en: 'This task is in review — confirm the work before marking it done.', + 'zh-CN': '此任务正在评审中——请先确认工作成果,再标记为完成。', + }, dismissible: true, + dismissKey: 'showcase_task_review', }, }, { diff --git a/examples/app-showcase/test/task-detail-review-alert-zh-cn.test.ts b/examples/app-showcase/test/task-detail-review-alert-zh-cn.test.ts new file mode 100644 index 00000000000..ba0bea4c8d4 --- /dev/null +++ b/examples/app-showcase/test/task-detail-review-alert-zh-cn.test.ts @@ -0,0 +1,59 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Under zh-CN, the Task Detail page's "Awaiting review" banner + * (`record:alert`, shown while a task is in review) is served in Chinese + * like the rest of the page. + * + * The banner's two strings travel by two routes. `title` is translated by the + * bundle through the page-component route + * (`pages.showcase_task_detail.components.review_alert.title`), which needs + * the node's `id` — before it had one the route could not address it and the + * title stayed English. `body` has no key on that face, so it is an inline + * locale map on the page; the renderer picks the reader's locale from it + * (`pickLocalized`), so the served document must still carry that map + * intact. The pin is the translator applied to the page with the app's own + * bundle, which is what the REST boundary does per request locale. + */ +import { describe, expect, it } from 'vitest'; +import { + translateMetadataDocument, + TranslationBundleSchema, + type TranslationBundle, +} from '@objectstack/spec/system'; +import { TaskDetailPage } from '../src/ui/pages/task-detail.page.js'; +import { ShowcaseTranslationBundle } from '../src/system/translations/index.js'; + +const bundle = ShowcaseTranslationBundle as unknown as TranslationBundle; + +type Node = { id?: string; type: string; properties?: Record }; +const reviewAlert = (page: { regions?: Array<{ components: Node[] }> }): Node | undefined => + page.regions?.flatMap((r) => r.components).find((c) => c.type === 'record:alert'); + +describe('showcase Task Detail — the review banner under zh-CN', () => { + it('serves the banner title from the bundle and keeps the body\'s locale map', () => { + const out = translateMetadataDocument('page', TaskDetailPage, bundle, { locale: 'zh-CN' }); + const alert = reviewAlert(out); + expect(alert?.id).toBe('review_alert'); + expect(alert?.properties?.title).toBe('待评审'); + expect(alert?.properties?.body).toEqual({ + en: 'This task is in review — confirm the work before marking it done.', + 'zh-CN': '此任务正在评审中——请先确认工作成果,再标记为完成。', + }); + // The dismissal key does not move with the translated title. + expect(alert?.properties?.dismissKey).toBe('showcase_task_review'); + }); + + it('leaves the authored English title in place for the default locale', () => { + const out = translateMetadataDocument('page', TaskDetailPage, bundle, { locale: 'en' }); + expect(reviewAlert(out)?.properties?.title).toBe('Awaiting review'); + }); + + it('carries the title entry in a bundle the spec accepts', () => { + // The bundle schema is strict on `components.`: a key outside the + // page-component copy face (such as `body`) is refused, so the entry + // this page relies on must parse as written. + const zh = (ShowcaseTranslationBundle as Record)['zh-CN']; + expect(TranslationBundleSchema.safeParse({ 'zh-CN': zh }).success).toBe(true); + }); +});