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
22 changes: 22 additions & 0 deletions .changeset/11100-app-preview-label-maps.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
---
'@object-ui/app-shell': patch
---

fix(app-shell): Studio's app preview resolves a locale-map app, nav-item and group label (objectui#11100)

The spec types an app's `label`, each navigation item's `label` and each group's `label` as
`I18nLabel`, a plain string or an inline locale map. The app preview read all three as strings,
so an author who localized the app's name or its navigation saw a wrong preview:

- the app label printed `[object Object]`, because the header ran `String()` over it;
- a childless navigation item with a map label vanished, target and all, because the reader kept
a label only when `typeof it.label === 'string'`;
- a group with a map label read `(unnamed)`, for the same reason.

All three now resolve through `resolveI18nLabel` in the designer locale, the way the areas list
beside them already does. A navigation item is kept whenever it names a record or holds children;
its label only decides the text, and `(unnamed)` now reads only for a label that is truly absent
(or a map that resolves to nothing). An app that authors no label still shows its own name.

What an author sees change: an entry that names a record but carries no label used to disappear
from the preview and now shows as `(unnamed)`.
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* objectui#11100: the Studio app preview resolves a locale-map app label,
* nav-item label and group label in the designer locale.
*
* The spec types all three as `I18nLabel` (a plain string or an inline locale
* map). Before this card the preview:
* - printed a map app label as `[object Object]` (`String(label)`);
* - dropped a childless nav item whose label was a map, target and all
* (`typeof it.label === 'string'` read the map as no label);
* - named a map-labelled group `(unnamed)`.
*
* The cases:
* - a map app label, a map nav-item label and a map group label each render
* the text resolved for the designer locale, under en and zh, with no
* `[object Object]` anywhere; the nav item keeps its target;
* - plain strings are the control, and read as they always did;
* - a label that is truly absent (or resolves to nothing) still reads
* `(unnamed)`, but never decides whether an entry with a target exists;
* - the app label resolves in design mode too.
*/

import { describe, it, expect, afterEach } from 'vitest';
import { render, screen, cleanup } from '@testing-library/react';
import { t } from '../i18n';
import { AppPreview } from './AppPreview';

afterEach(cleanup);

const APP_LABEL = { en: 'Customer Hub', 'zh-CN': '客户中心' };
const ITEM_LABEL = { en: 'Accounts', 'zh-CN': '客户' };
const GROUP_LABEL = { en: 'Administration', 'zh-CN': '系统管理' };
const DOCS = { id: 'docs', type: 'url', label: 'Docs', url: 'https://docs.example.com' };

function mapDraft(): Record<string, unknown> {
return {
name: 'crm',
label: APP_LABEL,
navigation: [
{ id: 'accounts', type: 'object', label: ITEM_LABEL, objectName: 'account' },
{ id: 'admin', type: 'group', label: GROUP_LABEL, children: [DOCS] },
],
};
}

function renderApp(draft: Record<string, unknown>, locale: string) {
return render(<AppPreview type="app" name="crm" draft={draft} locale={locale} />);
}

/** The nav list row that shows `label`. */
function navRow(label: string): HTMLElement {
const row = screen.getByText(label).parentElement;
expect(row, `no nav row shows ${JSON.stringify(label)}`).not.toBeNull();
return row as HTMLElement;
}

describe('AppPreview resolves locale-map labels in the designer locale (objectui#11100)', () => {
const cases: Array<[string, { app: string; item: string; group: string }]> = [
['en-US', { app: 'Customer Hub', item: 'Accounts', group: 'Administration' }],
['zh-CN', { app: '客户中心', item: '客户', group: '系统管理' }],
];

for (const [locale, want] of cases) {
it(`renders a map app label, nav-item label and group label as ${locale} text`, () => {
renderApp(mapDraft(), locale);

expect(screen.getByText(want.app)).toBeTruthy();
// The nav item is kept with its target, not dropped for a label it could not read.
expect(navRow(want.item).textContent).toContain('account');
expect(navRow(want.group).textContent).toContain('group');
// The group's child still renders beneath it.
expect(screen.getByText('Docs')).toBeTruthy();
// The landing line names the first reachable entry by its resolved label.
expect(screen.getByText(`→ ${want.item}`)).toBeTruthy();

for (const unnamed of [t('engine.appPreview.unnamed', 'en-US'), t('engine.appPreview.unnamed', 'zh-CN')]) {
expect(screen.queryByText(unnamed), 'a resolved label never reads as unnamed').toBeNull();
}
expect(document.body.textContent).not.toContain('[object Object]');
});
}

it('reads plain-string labels as before (the control)', () => {
renderApp(
{
name: 'crm',
label: 'Customer Hub',
navigation: [
{ id: 'accounts', type: 'object', label: 'Accounts', objectName: 'account' },
{ id: 'admin', type: 'group', label: 'Administration', children: [DOCS] },
],
},
'zh-CN',
);

expect(screen.getByText('Customer Hub')).toBeTruthy();
expect(navRow('Accounts').textContent).toContain('account');
expect(navRow('Administration').textContent).toContain('group');
expect(document.body.textContent).not.toContain('[object Object]');
});

it('falls back to the app name for an app with no label', () => {
renderApp({ name: 'crm', navigation: [] }, 'en-US');
// The name shows twice: as the header text and as the mono id beneath it.
expect(screen.getAllByText('crm')).toHaveLength(2);
});

for (const locale of ['en-US', 'zh-CN']) {
it(`keeps ${locale} "(unnamed)" for a truly absent label, but never drops an entry that has a target`, () => {
const unnamed = t('engine.appPreview.unnamed', locale);
renderApp(
{
name: 'crm',
label: 'Customer Hub',
navigation: [
// Absent label, holds children.
{ id: 'admin', type: 'group', children: [DOCS] },
// Absent label, names a record: kept.
{ id: 'leads', type: 'object', objectName: 'lead' },
// A map that resolves to nothing is no label either: kept.
{ id: 'deals', type: 'object', label: {}, objectName: 'deal' },
// No label, no children, no target: nothing to show.
{ id: 'ghost', type: 'object' },
],
},
locale,
);

expect(screen.getAllByText(unnamed)).toHaveLength(3);
expect(screen.getByText('lead')).toBeTruthy();
expect(screen.getByText('deal')).toBeTruthy();
expect(document.body.textContent).not.toContain('[object Object]');
});
}

it('resolves a map app label in design mode too', () => {
render(
<AppPreview
type="app"
name="crm"
draft={mapDraft()}
editing
selection={null}
onSelectionChange={() => {}}
locale="zh-CN"
/>,
);

expect(screen.getByText('客户中心')).toBeTruthy();
expect(screen.getByText('→ 客户')).toBeTruthy();
expect(document.body.textContent).not.toContain('[object Object]');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -121,10 +121,16 @@ function navTarget(it: Record<string, unknown>, kind?: NavKind): string | undefi
}

/**
* `unnamed` is the designer's word for an entry with children and no label
* Every nav label is `I18nLabel` (a plain string or an inline locale map), so
* it resolves through `resolveI18nLabel` in the designer `locale` and never
* through a `typeof === 'string'` test or `String()` (objectui#11100): a map
* label would read as no label, or as `[object Object]`. A label only decides
* an entry's text, never whether the entry exists: an entry that names a
* record or holds children is kept, and reads `unnamed` when it has no label
* to show. `unnamed` is the designer's word for that
* (`engine.appPreview.unnamed`, in the preview's locale — objectui#10862).
*/
function normalizeNav(raw: unknown, appName: string, unnamed: string): NavItem[] {
function normalizeNav(raw: unknown, appName: string, unnamed: string, locale: string | undefined): NavItem[] {
if (!Array.isArray(raw)) return [];
return raw
.map((it: any): NavItem | null => {
Expand All @@ -134,9 +140,9 @@ function normalizeNav(raw: unknown, appName: string, unnamed: string): NavItem[]
if (kind === 'separator') {
return { id: typeof it.id === 'string' ? it.id : undefined, label: '', kind };
}
const label = typeof it.label === 'string' ? it.label.trim() : '';
if (!label && !it.children) return null;
const label = (resolveI18nLabel(it.label, locale) ?? '').trim();
const target = navTarget(it, kind);
if (!label && !it.children && !target) return null;
// Delegate to the shell's own mapping so the preview cannot invent a
// route the runtime would not produce. Needs a `type`, so an item
// missing the discriminator gets no link — which is the truth.
Expand All @@ -153,7 +159,7 @@ function normalizeNav(raw: unknown, appName: string, unnamed: string): NavItem[]
href = undefined;
}
}
const children = Array.isArray(it.children) ? normalizeNav(it.children, appName, unnamed) : undefined;
const children = Array.isArray(it.children) ? normalizeNav(it.children, appName, unnamed, locale) : undefined;
return { id: typeof it.id === 'string' ? it.id : undefined, label: label || unnamed, kind, target, href, external, children };
})
.filter((x): x is NavItem => x !== null);
Expand Down Expand Up @@ -236,7 +242,9 @@ function findFirstLanding(items: NavItem[]): NavItem | undefined {

export function AppPreview({ name, draft, editing, selection, onSelectionChange, onPatch, locale }: MetadataPreviewProps) {
const appName = String((draft as any).name ?? name ?? '');
const label = (draft as any).label ?? appName;
// `label` is `I18nLabel` too: resolved in the designer locale, the app's own
// name only when it authored none (objectui#11100).
const label = resolveI18nLabel(draft.label as I18nText, locale) ?? appName;
const unnamed = tr('engine.appPreview.unnamed', locale);
// The landing page is DERIVED, never authored: it is the first navigation
// item that actually addresses something. The app used to be able to pin it
Expand All @@ -254,10 +262,10 @@ export function AppPreview({ name, draft, editing, selection, onSelectionChange,
['menu', (draft as any).menu],
];
for (const [k, c] of candidates) {
if (Array.isArray(c) && c.length) return { rootKey: k, navItems: normalizeNav(c, appName, unnamed) };
if (Array.isArray(c) && c.length) return { rootKey: k, navItems: normalizeNav(c, appName, unnamed, locale) };
}
return { rootKey: null, navItems: [] };
}, [draft, appName, unnamed]);
}, [draft, appName, unnamed, locale]);

// Resolve the landing entry the same way `resolveLandingRoute` does in the
// console shell, so the author sees WHICH entry the app will open on:
Expand Down Expand Up @@ -301,7 +309,7 @@ export function AppPreview({ name, draft, editing, selection, onSelectionChange,
<PreviewErrorBoundary>
<div className="p-3 space-y-3">
<div className="rounded border bg-muted/30 p-3">
<div className="text-sm font-medium text-foreground">{String(label)}</div>
<div className="text-sm font-medium text-foreground">{label}</div>
<div className="text-xs text-muted-foreground font-mono mt-0.5">{appName}</div>
<div className="text-xs text-muted-foreground mt-1">
{homeItem ? (
Expand Down
Loading