diff --git a/.changeset/11755-translation-preview-global-actions.md b/.changeset/11755-translation-preview-global-actions.md new file mode 100644 index 0000000000..440e635b83 --- /dev/null +++ b/.changeset/11755-translation-preview-global-actions.md @@ -0,0 +1,9 @@ +--- +'@object-ui/app-shell': patch +--- + +The translation designer's preview names each global action by its translated label (objectui#11755). + +`TranslationDataSchema` declares every `globalActions` entry as an action translation node (`label`, `description`, `confirmText`, and the rest), not a string. The preview treated the category as a flat string map and drew each value with `String(v)`, so every entry the spec accepts read as `NAME[object Object]`. The category's sample now shows the node's `label`, quoted as a flat string is. A node without a `label` shows its key count, as the other nested categories do. + +The other categories render as before. Nothing is added to the package entry: no export, prop, type member or language-pack key. 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 new file mode 100644 index 0000000000..92bc8f55c3 --- /dev/null +++ b/packages/app-shell/src/views/metadata-admin/previews/TranslationPreview.globalActions-11755.test.tsx @@ -0,0 +1,86 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectui#11755 — the translation designer's preview draws a `globalActions` + * entry by its node's `label`. + * + * `TranslationDataSchema` declares `globalActions.NAME` as an action + * translation node (`label`, `description`, `confirmText`, …), not a string. + * The preview used to mark the category flat and draw each value with + * `String(v)`, so every entry the spec accepts rendered as + * `NAME[object Object]`. The category is now nested, and its sample shows the + * node's `label`, quoted as a flat string is; a node without a `label` (the key + * is optional) shows its inner key count, as the other nested categories do. + * + * Every fixture here is parsed by the installed spec's `TranslationDataSchema` + * first, so no case passes on a bundle the spec would refuse. The pins read + * the rendered row of the real component; the count row is read back through + * `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 { t, tFormat } from '../i18n'; +import { TranslationPreview } from './TranslationPreview'; + +afterEach(cleanup); + +const LOCALE = 'en-US'; + +/** A bundle the installed spec accepts, mounted in the real preview. */ +function mountSpecValid(data: Record) { + const parsed = TranslationDataSchema.safeParse(data); + expect(parsed.success, JSON.stringify(parsed.error?.issues)).toBe(true); + render(); +} + +/** The sample row's value text for `key` inside the card headed by the category row `labelKey`. */ +function sampleValue(labelKey: string, key: string): string { + const heading = screen.getByText(t(labelKey, LOCALE)); + const card = heading.closest('.rounded.border') as HTMLElement | null; + expect(card, `${labelKey}: the category card`).toBeTruthy(); + const row = Array.from(card!.querySelectorAll('li')).find((li) => li.querySelector('code')?.textContent === key); + expect(row, `${labelKey}: a sample row for ${key}`).toBeTruthy(); + return row!.querySelector('span')?.textContent ?? ''; +} + +const GLOBAL_ACTIONS = 'engine.translationPreview.category.globalActions'; + +describe('TranslationPreview draws a globalActions node by its label (objectui#11755)', () => { + it('a spec-valid globalActions node renders its label, and no [object Object]', () => { + mountSpecValid({ globalActions: { portfolio_snapshot: { label: 'Snapshot' } } }); + expect(sampleValue(GLOBAL_ACTIONS, 'portfolio_snapshot')).toBe('"Snapshot"'); + expect(document.body.textContent).not.toContain('[object Object]'); + }); + + it('a node with its other translated fields still renders its label', () => { + mountSpecValid({ + globalActions: { portfolio_snapshot: { label: 'Snapshot', confirmText: 'Take a snapshot?', successMessage: 'Done' } }, + }); + expect(sampleValue(GLOBAL_ACTIONS, 'portfolio_snapshot')).toBe('"Snapshot"'); + expect(document.body.textContent).not.toContain('[object Object]'); + }); + + 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 }), + ); + expect(document.body.textContent).not.toContain('[object Object]'); + }); + + it('control: the flat and the other nested categories render as before', () => { + mountSpecValid({ + messages: { welcome: 'Bienvenue' }, + objects: { account: { label: 'Compte', _actions: { close: { label: 'Fermer' } } } }, + }); + 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 }), + ); + }); +}); 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 70b07e05d1..ca2d00a4bc 100644 --- a/packages/app-shell/src/views/metadata-admin/previews/TranslationPreview.tsx +++ b/packages/app-shell/src/views/metadata-admin/previews/TranslationPreview.tsx @@ -17,10 +17,13 @@ * a sample of up to 5 keys so the user can confirm the right * bundle is loaded. * - * For flat string maps (messages, validationMessages, globalActions, - * 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. + * 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 + * 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). */ import * as React from 'react'; @@ -51,6 +54,11 @@ interface CategoryDef { icon: React.ComponentType<{ className?: string }>; /** When true, the value is { key: string } (flat). Otherwise { key: nestedObject }. */ flat: boolean; + /** + * Nested categories only: each value is a translation node whose `label` the + * sample shows in place of its inner key count (objectui#11755). + */ + nodeLabel?: boolean; } const CATEGORIES: CategoryDef[] = [ @@ -63,7 +71,13 @@ const CATEGORIES: CategoryDef[] = [ icon: ShieldAlert, flat: true, }, - { key: 'globalActions', label: 'engine.translationPreview.category.globalActions', icon: ClipboardList, flat: true }, + { + key: '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 }, @@ -173,7 +187,7 @@ function CategoryCard({ {cat.sample.map(([k, v]) => (
  • {k} - {renderSampleValue(v, cat.flat, locale)} + {renderSampleValue(v, cat, locale)}
  • ))} {cat.count > cat.sample.length && ( @@ -188,13 +202,20 @@ function CategoryCard({ ); } -function renderSampleValue(v: unknown, flat: boolean, locale: string | undefined): string { - if (flat) { +function renderSampleValue( + v: unknown, + cat: Pick, + locale: string | undefined, +): string { + if (cat.flat) { if (typeof v === 'string') return `"${v}"`; if (v == null) return '∅'; return String(v); } if (v && typeof v === 'object') { + // `label` is optional on a translation node, so a node without one keeps the key count. + 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 }); }