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
9 changes: 9 additions & 0 deletions .changeset/11755-translation-preview-global-actions.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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<string, unknown>) {
const parsed = TranslationDataSchema.safeParse(data);
expect(parsed.success, JSON.stringify(parsed.error?.issues)).toBe(true);
render(<TranslationPreview type="translation" name="fr" draft={{ locale: 'fr-FR', data }} locale={LOCALE} />);
}

/** 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 }),
);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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[] = [
Expand All @@ -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 },
Expand Down Expand Up @@ -173,7 +187,7 @@ function CategoryCard({
{cat.sample.map(([k, v]) => (
<li key={k} className="flex items-baseline gap-2 truncate">
<code className="font-mono text-muted-foreground shrink-0">{k}</code>
<span className="truncate text-foreground/80">{renderSampleValue(v, cat.flat, locale)}</span>
<span className="truncate text-foreground/80">{renderSampleValue(v, cat, locale)}</span>
</li>
))}
{cat.count > cat.sample.length && (
Expand All @@ -188,13 +202,20 @@ function CategoryCard({
);
}

function renderSampleValue(v: unknown, flat: boolean, locale: string | undefined): string {
if (flat) {
function renderSampleValue(
v: unknown,
cat: Pick<CategoryDef, 'flat' | 'nodeLabel'>,
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 });
}
Expand Down
Loading