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
11 changes: 11 additions & 0 deletions .changeset/11765-translation-preview-categories.md
Original file line number Diff line number Diff line change
@@ -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.
21 changes: 15 additions & 6 deletions packages/app-shell/src/views/metadata-admin/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1626,19 +1626,24 @@ const ENGINE_STRINGS_EN: Record<string, string> = {
'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
Expand Down Expand Up @@ -4645,18 +4650,22 @@ const ENGINE_STRINGS_ZH: Record<string, string> = {
'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':
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string, { data: Record<string, unknown>; 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<string, unknown>) {
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(<TranslationPreview type="translation" name="fr" draft={draft} locale={LOCALE} />);
}

/** 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(
<TranslationPreview
type="translation"
name="fr"
draft={{ name: 'fr', locale: 'fr-FR', messages: ONE_ENTRY.messages.data, ...refused }}
locale={LOCALE}
/>,
);
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é"');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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]');
});
Expand All @@ -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 }),
);
});
});
Loading
Loading