diff --git a/.changeset/11765-translation-preview-categories.md b/.changeset/11765-translation-preview-categories.md new file mode 100644 index 0000000000..34fa916897 --- /dev/null +++ b/.changeset/11765-translation-preview-categories.md @@ -0,0 +1,11 @@ +--- +'@object-ui/app-shell': patch +--- + +The translation designer's preview draws the groups of the schema it validates a bundle against (objectui#11765). + +The designer judges a translation draft by the per-app `TranslationDataSchema` groups, but the preview drew a hand-kept list that had drifted from them. It left out `picklists`, `datasets`, `pages`, `flows` and `settingsCommon`, so a spec-valid bundle of only one of those groups read as empty. It also listed `validationMessages`, which the spec removed, and `settings`, which only the platform schema declares. Both counted toward the coverage denominator, so a spec-valid bundle could never reach full coverage. + +The preview now draws one card per schema group, in the schema's order, and divides coverage by that count. A key the schema refuses is not drawn and not counted. A nested entry of a single key now reads `{1 key}` rather than `{1 keys}`. + +Nothing is added to the package entry: no export, prop, type member or language-pack key. The new headings are rows of the designer's own string table. diff --git a/packages/app-shell/src/views/metadata-admin/i18n.ts b/packages/app-shell/src/views/metadata-admin/i18n.ts index 87ec80a2ae..f82bfd79ab 100644 --- a/packages/app-shell/src/views/metadata-admin/i18n.ts +++ b/packages/app-shell/src/views/metadata-admin/i18n.ts @@ -1626,19 +1626,24 @@ const ENGINE_STRINGS_EN: Record = { 'The installed packages could not be read, so the extensions of this picklist are unknown.', 'engine.translationPreview.empty': 'This bundle is empty — add at least one translated string to see the coverage report.', + // One heading per group of the per-app `TranslationDataSchema`, in its order (objectui#11765). 'engine.translationPreview.category.objects': 'Objects', + 'engine.translationPreview.category.picklists': 'Picklists', 'engine.translationPreview.category.apps': 'Apps', 'engine.translationPreview.category.messages': 'Messages', - 'engine.translationPreview.category.validationMessages': 'Validation Messages', 'engine.translationPreview.category.globalActions': 'Global Actions', 'engine.translationPreview.category.dashboards': 'Dashboards', - 'engine.translationPreview.category.settings': 'Settings', + 'engine.translationPreview.category.datasets': 'Datasets', + 'engine.translationPreview.category.pages': 'Pages', + 'engine.translationPreview.category.flows': 'Flows', 'engine.translationPreview.category.metadataForms': 'Metadata Forms', + 'engine.translationPreview.category.settingsCommon': 'Settings UI', 'engine.translationPreview.coverage': 'Category coverage:', 'engine.translationPreview.totalKeys': '{count} total keys', 'engine.translationPreview.categoryEmpty': 'empty', 'engine.translationPreview.more': '+{count} more…', - 'engine.translationPreview.keyCount': '{{count} keys}', + 'engine.translationPreview.keyCountOne': '{{count} key}', + 'engine.translationPreview.keyCountOther': '{{count} keys}', // objectui#10862 (slice 2) — the two advisories `celAuthoring.lintCelPredicate` // words itself, shown beneath the CEL editors: the wrong-layer root (the // local instrument's sentence; a slot `@objectstack/lint` covers ships that @@ -4645,18 +4650,22 @@ const ENGINE_STRINGS_ZH: Record = { 'engine.picklistPreview.extensionsFailed': '无法读取已安装的软件包,因此不知道该选项列表有哪些扩展。', 'engine.translationPreview.empty': '此语言包为空 —— 至少添加一条译文,即可查看覆盖率报告。', 'engine.translationPreview.category.objects': '对象', + 'engine.translationPreview.category.picklists': '选项列表', 'engine.translationPreview.category.apps': '应用', 'engine.translationPreview.category.messages': '消息', - 'engine.translationPreview.category.validationMessages': '校验消息', 'engine.translationPreview.category.globalActions': '全局操作', 'engine.translationPreview.category.dashboards': '仪表盘', - 'engine.translationPreview.category.settings': '设置', + 'engine.translationPreview.category.datasets': '数据集', + 'engine.translationPreview.category.pages': '页面', + 'engine.translationPreview.category.flows': '流程', 'engine.translationPreview.category.metadataForms': '元数据表单', + 'engine.translationPreview.category.settingsCommon': '设置界面', 'engine.translationPreview.coverage': '分类覆盖率:', 'engine.translationPreview.totalKeys': '共 {count} 个键', 'engine.translationPreview.categoryEmpty': '空', 'engine.translationPreview.more': '另有 {count} 项…', - 'engine.translationPreview.keyCount': '{{count} 个键}', + 'engine.translationPreview.keyCountOne': '{{count} 个键}', + 'engine.translationPreview.keyCountOther': '{{count} 个键}', 'engine.celLint.notTheRow': '`{identifier}` 在此处不是记录行:行条件只把记录绑定为 `{canonical}`,别无其他。CEL 作用域词汇仍接受 `{identifier}`,所以这里不会阻止保存,但运行时表达式会以 `Unknown variable: {identifier}` 报错,规则永远不会触发。请改为以 `{canonical}` 为根引用。', 'engine.celLint.notPushdownable': diff --git a/packages/app-shell/src/views/metadata-admin/previews/TranslationPreview.categories-11765.test.tsx b/packages/app-shell/src/views/metadata-admin/previews/TranslationPreview.categories-11765.test.tsx new file mode 100644 index 0000000000..c038d5454f --- /dev/null +++ b/packages/app-shell/src/views/metadata-admin/previews/TranslationPreview.categories-11765.test.tsx @@ -0,0 +1,177 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectui#11765 — the translation preview's categories are the groups of the + * schema the designer validates against. + * + * The designer judges a translation draft with `validateMetadataDraft`, which + * binds `TranslationItemSchema`: the groups of the per-app + * `TranslationDataSchema` plus the item's own keys. The preview used to draw a + * hand-kept list that had drifted from those groups. It omitted `picklists`, + * `datasets`, `pages`, `flows` and `settingsCommon`, so a spec-valid bundle of + * only one of them read as empty. It listed `validationMessages` (removed by + * the spec) and `settings` (a platform-only group the per-app schema refuses), + * so both counted toward the coverage denominator and a spec-valid bundle could + * never reach full coverage. + * + * Every spec-valid fixture here is parsed by the installed spec's + * `TranslationDataSchema` and passed through the designer's own + * `validateMetadataDraft('translation', …)` before it is mounted. The expected + * groups, their order and their count are read from `TranslationDataSchema` + * itself, so a spec group added or dropped turns these pins red instead of + * drifting. Headings and count rows are read back through `t` / `tFormat` + * rather than restated. + */ + +import * as React from 'react'; +import { describe, it, expect, afterEach } from 'vitest'; +import { render, cleanup, screen } from '@testing-library/react'; +import { TranslationDataSchema } from '@objectstack/spec/system'; + +import { validateMetadataDraft } from '../clientValidation'; +import { t, tFormat } from '../i18n'; +import { TranslationPreview } from './TranslationPreview'; + +afterEach(cleanup); + +const LOCALE = 'en-US'; + +/** The groups the spec declares, in its own order. */ +const GROUPS = Object.keys(TranslationDataSchema.shape); + +/** One spec-valid entry per group, the entry's key, and how its sample row reads. */ +const ONE_ENTRY: Record; key: string; row: () => string }> = { + objects: { data: { account: { label: 'Compte' } }, key: 'account', row: () => keys(1) }, + picklists: { + data: { stage: { label: 'Étape', options: { open: 'Ouvert', won: 'Gagné' } } }, + key: 'stage', + row: () => keys(2), + }, + apps: { data: { crm: { label: 'CRM' } }, key: 'crm', row: () => keys(1) }, + messages: { data: { welcome: 'Bienvenue' }, key: 'welcome', row: () => '"Bienvenue"' }, + globalActions: { data: { portfolio_snapshot: { label: 'Instantané' } }, key: 'portfolio_snapshot', row: () => '"Instantané"' }, + dashboards: { data: { pipeline: { label: 'Pipeline' } }, key: 'pipeline', row: () => keys(1) }, + datasets: { + data: { revenue: { label: 'Revenu', measures: { amount: { label: 'Montant' } } } }, + key: 'revenue', + row: () => keys(2), + }, + pages: { data: { home: { label: 'Accueil' } }, key: 'home', row: () => keys(1) }, + flows: { data: { onboarding: { label: 'Intégration' } }, key: 'onboarding', row: () => keys(1) }, + metadataForms: { data: { object: { label: 'Objet' } }, key: 'object', row: () => keys(1) }, + // `settingsCommon` is one strict object, not a record of author-named nodes: + // its members (`sourceLabels`) are what the card counts and samples. + settingsCommon: { + data: { sourceLabels: { env: 'Environnement', tenant: 'Locataire' } }, + key: 'sourceLabels', + row: () => keys(2), + }, +}; + +/** The nested sample row for a node of `n` keys, in the row the preview picks for `n`. */ +function keys(n: number): string { + return tFormat(n === 1 ? 'engine.translationPreview.keyCountOne' : 'engine.translationPreview.keyCountOther', LOCALE, { + count: n, + }); +} + +function heading(group: string): string { + return t(`engine.translationPreview.category.${group}`, LOCALE); +} + +/** A bundle the spec and the designer both accept, mounted in the real preview as the designer hands it a draft. */ +async function mountSpecValid(data: Record) { + const parsed = TranslationDataSchema.safeParse(data); + expect(parsed.success, JSON.stringify(parsed.error?.issues)).toBe(true); + const draft = { name: 'fr', locale: 'fr-FR', ...data }; + const gate = await validateMetadataDraft('translation', draft); + expect(gate, 'the designer accepts the draft').toEqual({ ok: true, issues: [] }); + render(); +} + +/** The heading of every category card, in the order the preview draws them. */ +function cardHeadings(): string[] { + const grid = document.querySelector('.grid'); + expect(grid, 'the category grid').toBeTruthy(); + return Array.from(grid!.children).map((card) => card.querySelector('span')?.textContent ?? ''); +} + +/** The card headed by `group`'s row. */ +function card(group: string): HTMLElement { + const el = screen.getByText(heading(group)).closest('.rounded.border') as HTMLElement | null; + expect(el, `${group}: the category card`).toBeTruthy(); + return el!; +} + +/** The value text of the sample row for `key` in `group`'s card. */ +function sampleValue(group: string, key: string): string { + const row = Array.from(card(group).querySelectorAll('li')).find((li) => li.querySelector('code')?.textContent === key); + expect(row, `${group}: a sample row for ${key}`).toBeTruthy(); + return row!.querySelector('span')?.textContent ?? ''; +} + +function coverage(populated: number): string { + return `${populated}/${GROUPS.length} (${Math.round((populated / GROUPS.length) * 100)}%)`; +} + +describe('TranslationPreview draws the groups of the schema the designer validates against (objectui#11765)', () => { + it('every group the spec declares has a spec-valid fixture here', () => { + expect(Object.keys(ONE_ENTRY).sort()).toEqual([...GROUPS].sort()); + }); + + it('draws one card per schema group, in the schema order, and divides coverage by their count', async () => { + await mountSpecValid({ messages: ONE_ENTRY.messages.data }); + expect(cardHeadings()).toEqual(GROUPS.map(heading)); + expect(screen.getByText(coverage(1))).toBeTruthy(); + }); + + for (const group of GROUPS) { + it(`a spec-valid bundle of only ${group} reads non-empty and shows its card`, async () => { + const entry = ONE_ENTRY[group]; + await mountSpecValid({ [group]: entry.data }); + expect(screen.queryByText(t('engine.translationPreview.empty', LOCALE))).toBeNull(); + expect(sampleValue(group, entry.key)).toBe(entry.row()); + expect(screen.getByText(coverage(1))).toBeTruthy(); + }); + } + + it('a bundle carrying every group reaches full coverage', async () => { + await mountSpecValid(Object.fromEntries(GROUPS.map((g) => [g, ONE_ENTRY[g].data]))); + expect(screen.getByText(coverage(GROUPS.length))).toBeTruthy(); + expect(screen.queryByText(t('engine.translationPreview.categoryEmpty', LOCALE))).toBeNull(); + }); + + it('a group the schema refuses is neither drawn as a category nor counted', async () => { + const refused = { validationMessages: { required: 'Requis' }, settings: { theme: 'Thème' } }; + for (const [group, value] of Object.entries(refused)) { + expect(TranslationDataSchema.safeParse({ [group]: value }).success, `${group} is refused by the spec`).toBe(false); + const gate = await validateMetadataDraft('translation', { name: 'fr', locale: 'fr-FR', [group]: value }); + expect(gate.ok, `${group} is refused by the designer`).toBe(false); + } + render( + , + ); + expect(cardHeadings()).toEqual(GROUPS.map(heading)); + expect(screen.getByText(coverage(1))).toBeTruthy(); + expect(screen.queryByText('required')).toBeNull(); + expect(screen.queryByText('theme')).toBeNull(); + }); + + it('a nested node of one key reads the singular row, and of two keys the plural row', async () => { + expect(keys(1), 'the singular row is not the plural row read with 1').not.toBe(keys(2).replace('2', '1')); + await mountSpecValid({ pages: { home: { label: 'Accueil' }, about: { label: 'À propos', title: 'À propos de nous' } } }); + expect(sampleValue('pages', 'home')).toBe(keys(1)); + expect(sampleValue('pages', 'about')).toBe(keys(2)); + }); + + it('control: the messages card renders as before', async () => { + await mountSpecValid({ messages: { welcome: 'Bienvenue', saved: 'Enregistré' } }); + expect(sampleValue('messages', 'welcome')).toBe('"Bienvenue"'); + expect(sampleValue('messages', 'saved')).toBe('"Enregistré"'); + }); +}); diff --git a/packages/app-shell/src/views/metadata-admin/previews/TranslationPreview.globalActions-11755.test.tsx b/packages/app-shell/src/views/metadata-admin/previews/TranslationPreview.globalActions-11755.test.tsx index 92bc8f55c3..7eb0a32804 100644 --- a/packages/app-shell/src/views/metadata-admin/previews/TranslationPreview.globalActions-11755.test.tsx +++ b/packages/app-shell/src/views/metadata-admin/previews/TranslationPreview.globalActions-11755.test.tsx @@ -67,7 +67,7 @@ describe('TranslationPreview draws a globalActions node by its label (objectui#1 it('a node without a label renders its inner key count', () => { mountSpecValid({ globalActions: { archive: { confirmText: 'Archive?', successMessage: 'Archived' } } }); expect(sampleValue(GLOBAL_ACTIONS, 'archive')).toBe( - tFormat('engine.translationPreview.keyCount', LOCALE, { count: 2 }), + tFormat('engine.translationPreview.keyCountOther', LOCALE, { count: 2 }), ); expect(document.body.textContent).not.toContain('[object Object]'); }); @@ -80,7 +80,7 @@ describe('TranslationPreview draws a globalActions node by its label (objectui#1 expect(sampleValue('engine.translationPreview.category.messages', 'welcome')).toBe('"Bienvenue"'); // An object node keeps its key count: only the globalActions category reads a node's label. expect(sampleValue('engine.translationPreview.category.objects', 'account')).toBe( - tFormat('engine.translationPreview.keyCount', LOCALE, { count: 2 }), + tFormat('engine.translationPreview.keyCountOther', LOCALE, { count: 2 }), ); }); }); diff --git a/packages/app-shell/src/views/metadata-admin/previews/TranslationPreview.tsx b/packages/app-shell/src/views/metadata-admin/previews/TranslationPreview.tsx index ca2d00a4bc..06de276d84 100644 --- a/packages/app-shell/src/views/metadata-admin/previews/TranslationPreview.tsx +++ b/packages/app-shell/src/views/metadata-admin/previews/TranslationPreview.tsx @@ -10,18 +10,21 @@ * 1. "Which locale is this and how complete is it?" → header strip * with the locale badge, total-key count, and an overall coverage * bar (categories that have at least one entry). - * 2. "Where are the strings?" → a card per category in - * `TranslationDataSchema`: objects · apps · messages · - * validationMessages · globalActions · dashboards · settings · - * metadataForms. Each card shows the count of top-level keys and + * 2. "Where are the strings?" → a card per group of the per-app + * `TranslationDataSchema`, the groups the designer's + * `validateMetadataDraft('translation', …)` judges a draft by + * (objectui#11765). Each card shows the count of top-level keys and * a sample of up to 5 keys so the user can confirm the right - * bundle is loaded. + * bundle is loaded. A key the schema refuses (the removed + * `validationMessages`, the platform-only `settings`) is no group: + * it is neither drawn nor counted in the coverage denominator. * - * For flat string maps (messages, validationMessages, settings) we - * render a small key→value sample table. For nested objects (objects, - * apps, dashboards, metadataForms) we list the top-level keys with their - * inner key count. globalActions is nested too: each entry is an action - * translation node (`TranslationDataSchema.globalActions.NAME`), so its + * messages is the one flat string map, so its sample is a small key→value + * table. Every other group is nested, and its sample lists the top-level + * keys with their inner key count. settingsCommon is one strict object + * rather than a record of named nodes; its members (`sourceLabels`) are + * its top-level keys, read the same way. globalActions entries are action + * translation nodes (`TranslationDataSchema.globalActions.NAME`), so that * sample shows the node's `label`, quoted as a flat string is, and the * inner key count when the node carries no `label` (objectui#11755). */ @@ -30,6 +33,7 @@ import * as React from 'react'; import { AppWindow, ClipboardList, + Database, FileText, Gauge, Globe2, @@ -37,10 +41,13 @@ import { Languages, ListChecks, MessageCircle, + PanelsTopLeft, Settings2, - ShieldAlert, + SquareChevronDown, + Workflow, } from 'lucide-react'; import { EmptyDescription } from '@object-ui/components'; +import type { TranslationDataSchema } from '@objectstack/spec/system'; import type { MetadataPreviewProps } from '../preview-registry.js'; import { t as tr, tFormat } from '../i18n.js'; import { PreviewShell, PreviewMessage, PreviewErrorBoundary } from './PreviewShell.js'; @@ -61,27 +68,37 @@ interface CategoryDef { nodeLabel?: boolean; } -const CATEGORIES: CategoryDef[] = [ - { key: 'objects', label: 'engine.translationPreview.category.objects', icon: ListChecks, flat: false }, - { key: 'apps', label: 'engine.translationPreview.category.apps', icon: AppWindow, flat: false }, - { key: 'messages', label: 'engine.translationPreview.category.messages', icon: MessageCircle, flat: true }, - { - key: 'validationMessages', - label: 'engine.translationPreview.category.validationMessages', - icon: ShieldAlert, - flat: true, - }, - { - key: 'globalActions', +/** A group of the per-app `TranslationDataSchema`. */ +type TranslationGroup = keyof (typeof TranslationDataSchema)['shape']; + +/** + * One row per group of the per-app `TranslationDataSchema`, in the schema's + * order (objectui#11765). The schema cannot supply a group's icon or heading, + * so the rows are written here, and `satisfies` keeps them exhaustive both + * ways: a group the spec adds without a row here, or a row for a key the spec + * does not declare, fails type-check. `TranslationPreview.categories-11765.test.tsx` + * pins the same against the schema at runtime. + */ +const CATEGORY_DEFS = { + objects: { label: 'engine.translationPreview.category.objects', icon: ListChecks, flat: false }, + picklists: { label: 'engine.translationPreview.category.picklists', icon: SquareChevronDown, flat: false }, + apps: { label: 'engine.translationPreview.category.apps', icon: AppWindow, flat: false }, + messages: { label: 'engine.translationPreview.category.messages', icon: MessageCircle, flat: true }, + globalActions: { label: 'engine.translationPreview.category.globalActions', icon: ClipboardList, flat: false, nodeLabel: true, }, - { key: 'dashboards', label: 'engine.translationPreview.category.dashboards', icon: LayoutDashboard, flat: false }, - { key: 'settings', label: 'engine.translationPreview.category.settings', icon: Settings2, flat: true }, - { key: 'metadataForms', label: 'engine.translationPreview.category.metadataForms', icon: FileText, flat: false }, -]; + dashboards: { label: 'engine.translationPreview.category.dashboards', icon: LayoutDashboard, flat: false }, + datasets: { label: 'engine.translationPreview.category.datasets', icon: Database, flat: false }, + pages: { label: 'engine.translationPreview.category.pages', icon: PanelsTopLeft, flat: false }, + flows: { label: 'engine.translationPreview.category.flows', icon: Workflow, flat: false }, + metadataForms: { label: 'engine.translationPreview.category.metadataForms', icon: FileText, flat: false }, + settingsCommon: { label: 'engine.translationPreview.category.settingsCommon', icon: Settings2, flat: false }, +} satisfies Record>; + +const CATEGORIES: CategoryDef[] = Object.entries(CATEGORY_DEFS).map(([key, def]) => ({ key, ...def })); /** * `locale` is the designer's language, the one the preview's own words read @@ -217,7 +234,11 @@ function renderSampleValue( const nodeLabel = (v as Dict).label; if (cat.nodeLabel && typeof nodeLabel === 'string') return `"${nodeLabel}"`; const n = Object.keys(v as Dict).length; - return tFormat('engine.translationPreview.keyCount', locale, { count: n }); + return tFormat( + n === 1 ? 'engine.translationPreview.keyCountOne' : 'engine.translationPreview.keyCountOther', + locale, + { count: n }, + ); } return String(v ?? '∅'); } diff --git a/packages/app-shell/src/views/metadata-admin/previews/adminPreviews.i18n-10862-s2.test.tsx b/packages/app-shell/src/views/metadata-admin/previews/adminPreviews.i18n-10862-s2.test.tsx index 8266473f51..50fb14f69f 100644 --- a/packages/app-shell/src/views/metadata-admin/previews/adminPreviews.i18n-10862-s2.test.tsx +++ b/packages/app-shell/src/views/metadata-admin/previews/adminPreviews.i18n-10862-s2.test.tsx @@ -438,19 +438,22 @@ describe('TranslationPreview reads the designer locale (objectui#10862)', () => { key: 'engine.translationPreview.coverage' }, { key: 'engine.translationPreview.totalKeys', vars: { count: 7 } }, { key: 'engine.translationPreview.category.objects' }, + { key: 'engine.translationPreview.category.picklists' }, { key: 'engine.translationPreview.category.apps' }, { key: 'engine.translationPreview.category.messages' }, - { key: 'engine.translationPreview.category.validationMessages' }, { key: 'engine.translationPreview.category.globalActions' }, { key: 'engine.translationPreview.category.dashboards' }, - { key: 'engine.translationPreview.category.settings' }, + { key: 'engine.translationPreview.category.datasets' }, + { key: 'engine.translationPreview.category.pages' }, + { key: 'engine.translationPreview.category.flows' }, { key: 'engine.translationPreview.category.metadataForms' }, + { key: 'engine.translationPreview.category.settingsCommon' }, { key: 'engine.translationPreview.categoryEmpty' }, { key: 'engine.translationPreview.more', vars: { count: 1 } }, - { key: 'engine.translationPreview.keyCount', vars: { count: 1 } }, + { key: 'engine.translationPreview.keyCountOne', vars: { count: 1 } }, ]); // The bundle's own locale is author data, not the designer's language. - expectAsWritten(['French', 'fr-FR', 'account', '"Un"', '2/8 (25%)']); + expectAsWritten(['French', 'fr-FR', 'account', '"Un"', '2/11 (18%)']); }); } });