Skip to content

Commit a8c5c70

Browse files
Copilothotlong
andcommitted
fix: use I18nLabel objects in CRM metadata and resolve via API for spec-aligned i18n
Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
1 parent efc4b35 commit a8c5c70

File tree

14 files changed

+178
-65
lines changed

14 files changed

+178
-65
lines changed

apps/console/src/__tests__/AppSidebar.test.tsx

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,21 @@ vi.mock('../hooks/useNavPins', () => ({
8383
}));
8484

8585
vi.mock('../utils', () => ({
86-
resolveI18nLabel: (label: any) => (typeof label === 'string' ? label : label?.en || ''),
86+
resolveI18nLabel: (label: any) => {
87+
if (typeof label === 'string') return label;
88+
if (label && typeof label === 'object') return label.defaultValue || label.key || '';
89+
return '';
90+
},
91+
}));
92+
93+
vi.mock('@object-ui/i18n', () => ({
94+
useObjectTranslation: () => ({
95+
t: (key: string, opts?: any) => opts?.defaultValue ?? key,
96+
language: 'en',
97+
changeLanguage: vi.fn(),
98+
direction: 'ltr',
99+
i18n: {},
100+
}),
87101
}));
88102

89103
// Mock @object-ui/components to keep most components but simplify some

apps/console/src/__tests__/app-creation-integration.test.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -235,7 +235,11 @@ vi.mock('../hooks/useNavPins', () => ({
235235
}));
236236

237237
vi.mock('../utils', () => ({
238-
resolveI18nLabel: (label: any) => (typeof label === 'string' ? label : label?.en || ''),
238+
resolveI18nLabel: (label: any) => {
239+
if (typeof label === 'string') return label;
240+
if (label && typeof label === 'object') return label.defaultValue || label.key || '';
241+
return '';
242+
},
239243
}));
240244

241245
// Mock i18n

apps/console/src/components/AppSidebar.tsx

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ import { useRecentItems } from '../hooks/useRecentItems';
6060
import { useFavorites } from '../hooks/useFavorites';
6161
import { useNavPins } from '../hooks/useNavPins';
6262
import { resolveI18nLabel } from '../utils';
63+
import { useObjectTranslation } from '@object-ui/i18n';
6364

6465
// ---------------------------------------------------------------------------
6566
// useNavOrder – localStorage-persisted drag-and-drop reorder for nav items
@@ -155,6 +156,7 @@ export function AppSidebar({ activeAppName, onAppChange }: { activeAppName: stri
155156
const { isMobile } = useSidebar();
156157
const { user, signOut } = useAuth();
157158
const navigate = useNavigate();
159+
const { t } = useObjectTranslation();
158160

159161
// Swipe-from-left-edge gesture to open sidebar on mobile
160162
React.useEffect(() => {
@@ -267,15 +269,15 @@ export function AppSidebar({ activeAppName, onAppChange }: { activeAppName: stri
267269
style={primaryColor ? { backgroundColor: primaryColor } : undefined}
268270
>
269271
{logo ? (
270-
<img src={logo} alt={resolveI18nLabel(activeApp.label)} className="size-6 object-contain" />
272+
<img src={logo} alt={resolveI18nLabel(activeApp.label, t)} className="size-6 object-contain" />
271273
) : (
272274
React.createElement(getIcon(activeApp.icon), { className: "size-4" })
273275
)}
274276
</div>
275277
<div className="grid flex-1 text-left text-sm leading-tight">
276-
<span className="truncate font-semibold">{resolveI18nLabel(activeApp.label)}</span>
278+
<span className="truncate font-semibold">{resolveI18nLabel(activeApp.label, t)}</span>
277279
<span className="truncate text-xs">
278-
{resolveI18nLabel(activeApp.description) || `${activeApps.length} Apps Available`}
280+
{resolveI18nLabel(activeApp.description, t) || `${activeApps.length} Apps Available`}
279281
</span>
280282
</div>
281283
<ChevronsUpDown className="ml-auto" />

apps/console/src/components/DashboardView.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import { SkeletonDashboard } from './skeletons';
3030
import { useMetadata } from '../context/MetadataProvider';
3131
import { resolveI18nLabel } from '../utils';
3232
import { useAdapter } from '../context/AdapterProvider';
33+
import { useObjectTranslation } from '@object-ui/i18n';
3334
import type { DashboardSchema, DashboardWidgetSchema } from '@object-ui/types';
3435

3536
// ---------------------------------------------------------------------------
@@ -130,6 +131,7 @@ export function DashboardView({ dataSource }: { dataSource?: any }) {
130131
const { dashboardName } = useParams<{ dashboardName: string }>();
131132
const { showDebug, toggleDebug } = useMetadataInspector();
132133
const adapter = useAdapter();
134+
const { t } = useObjectTranslation();
133135
const [isLoading, setIsLoading] = useState(true);
134136
const [configPanelOpen, setConfigPanelOpen] = useState(false);
135137
const [selectedWidgetId, setSelectedWidgetId] = useState<string | null>(null);
@@ -381,9 +383,9 @@ export function DashboardView({ dataSource }: { dataSource?: any }) {
381383
{/* ── Header ───────────────────────────────────────────────── */}
382384
<div className="flex flex-col sm:flex-row justify-between sm:items-center gap-3 sm:gap-4 p-4 sm:p-6 border-b shrink-0">
383385
<div className="min-w-0 flex-1">
384-
<h1 className="text-lg sm:text-xl md:text-2xl font-bold tracking-tight truncate">{resolveI18nLabel(dashboard.label) || dashboard.name}</h1>
386+
<h1 className="text-lg sm:text-xl md:text-2xl font-bold tracking-tight truncate">{resolveI18nLabel(dashboard.label, t) || dashboard.name}</h1>
385387
{dashboard.description && (
386-
<p className="text-sm text-muted-foreground mt-1 line-clamp-2">{resolveI18nLabel(dashboard.description)}</p>
388+
<p className="text-sm text-muted-foreground mt-1 line-clamp-2">{resolveI18nLabel(dashboard.description, t)}</p>
387389
)}
388390
</div>
389391
<div className="shrink-0 flex items-center gap-1.5">

apps/console/src/main.tsx

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,20 @@ import '@object-ui/plugin-dashboard';
2727
import '@object-ui/plugin-report';
2828
import '@object-ui/plugin-markdown';
2929

30+
/**
31+
* Load application-specific translations for a given language from the API.
32+
* Falls back gracefully when translations are unavailable.
33+
*/
34+
async function loadLanguage(lang: string): Promise<Record<string, unknown>> {
35+
try {
36+
const res = await fetch(`/api/v1/i18n/${lang}`);
37+
if (!res.ok) return {};
38+
return await res.json();
39+
} catch {
40+
return {};
41+
}
42+
}
43+
3044
// Start MSW before rendering the app
3145
async function bootstrap() {
3246
// Initialize Mock Service Worker if enabled (lazy-loaded to keep production bundle lean)
@@ -39,7 +53,7 @@ async function bootstrap() {
3953
ReactDOM.createRoot(document.getElementById('root')!).render(
4054
<React.StrictMode>
4155
<MobileProvider pwa={{ enabled: true, name: 'ObjectUI Console', shortName: 'Console' }}>
42-
<I18nProvider>
56+
<I18nProvider loadLanguage={loadLanguage}>
4357
<App />
4458
</I18nProvider>
4559
</MobileProvider>

apps/console/src/mocks/browser.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
* This pattern follows @objectstack/studio — see https://github.com/objectstack-ai/spec
1010
*/
1111

12+
import { http, HttpResponse } from 'msw';
1213
import { ObjectKernel } from '@objectstack/runtime';
1314
import { InMemoryDriver } from '@objectstack/driver-memory';
1415
import type { MSWPlugin } from '@objectstack/plugin-msw';
@@ -19,6 +20,22 @@ let kernel: ObjectKernel | null = null;
1920
let driver: InMemoryDriver | null = null;
2021
let mswPlugin: MSWPlugin | null = null;
2122

23+
/**
24+
* Lazy-load CRM locale bundles for the i18n API endpoint.
25+
* Returns a flat translation resource for the given language code.
26+
*/
27+
async function loadCrmLocale(lang: string): Promise<Record<string, unknown>> {
28+
try {
29+
const { crmLocales } = await import('@object-ui/example-crm');
30+
const translations = (crmLocales as Record<string, any>)[lang];
31+
if (!translations) return {};
32+
// Nest under `crm.*` namespace so keys like `crm.navigation.dashboard` resolve
33+
return { crm: translations };
34+
} catch {
35+
return {};
36+
}
37+
}
38+
2239
export async function startMockServer() {
2340
// Polyfill process.on for ObjectKernel in browser environment
2441
try {
@@ -42,6 +59,14 @@ export async function startMockServer() {
4259
enableBrowser: true,
4360
baseUrl: '/api/v1',
4461
logRequests: import.meta.env.DEV,
62+
customHandlers: [
63+
// Serve i18n translation bundles via API
64+
http.get('/api/v1/i18n/:lang', async ({ params }) => {
65+
const lang = params.lang as string;
66+
const resources = await loadCrmLocale(lang);
67+
return HttpResponse.json(resources);
68+
}),
69+
],
4570
},
4671
});
4772
kernel = result.kernel;

apps/console/src/mocks/server.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
* This pattern follows @objectstack/studio — see https://github.com/objectstack-ai/spec
1010
*/
1111

12+
import { http, HttpResponse } from 'msw';
1213
import { ObjectKernel } from '@objectstack/runtime';
1314
import { InMemoryDriver } from '@objectstack/driver-memory';
1415
import { setupServer } from 'msw/node';
@@ -21,6 +22,20 @@ let driver: InMemoryDriver | null = null;
2122
let mswPlugin: MSWPlugin | null = null;
2223
let server: ReturnType<typeof setupServer> | null = null;
2324

25+
/**
26+
* Lazy-load CRM locale bundles for the i18n API endpoint.
27+
*/
28+
async function loadCrmLocale(lang: string): Promise<Record<string, unknown>> {
29+
try {
30+
const { crmLocales } = await import('@object-ui/example-crm');
31+
const translations = (crmLocales as Record<string, any>)[lang];
32+
if (!translations) return {};
33+
return { crm: translations };
34+
} catch {
35+
return {};
36+
}
37+
}
38+
2439
export async function startMockServer() {
2540
if (kernel) {
2641
console.log('[MSW] ObjectStack Runtime already initialized');
@@ -35,6 +50,13 @@ export async function startMockServer() {
3550
enableBrowser: false,
3651
baseUrl: '/api/v1',
3752
logRequests: false,
53+
customHandlers: [
54+
http.get('/api/v1/i18n/:lang', async ({ params }) => {
55+
const lang = params.lang as string;
56+
const resources = await loadCrmLocale(lang);
57+
return HttpResponse.json(resources);
58+
}),
59+
],
3860
},
3961
});
4062
kernel = result.kernel;

apps/console/src/utils.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,19 @@
55
/**
66
* Resolves an I18nLabel to a plain string.
77
* I18nLabel can be either a string or an object { key, defaultValue?, params? }.
8-
* When it's an object, we return the defaultValue or the key as fallback.
8+
* When it's an object and a `t` function is provided, it resolves the key
9+
* through the i18n translation system. Otherwise returns defaultValue or key.
910
*/
10-
export function resolveI18nLabel(label: string | { key: string; defaultValue?: string; params?: Record<string, any> } | undefined): string | undefined {
11+
export function resolveI18nLabel(
12+
label: string | { key: string; defaultValue?: string; params?: Record<string, any> } | undefined,
13+
t?: (key: string, options?: any) => string,
14+
): string | undefined {
1115
if (label === undefined || label === null) return undefined;
1216
if (typeof label === 'string') return label;
17+
if (t) {
18+
const result = t(label.key, { defaultValue: label.defaultValue, ...label.params });
19+
if (result && result !== label.key) return result;
20+
}
1321
return label.defaultValue || label.key;
1422
}
1523

examples/crm/src/__tests__/crm-metadata.test.ts

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
11
import { describe, it, expect } from 'vitest';
22

3+
/** Resolve an I18nLabel (string or { key, defaultValue }) to a plain string for test assertions */
4+
function resolveTitle(title: string | { key: string; defaultValue?: string } | undefined): string {
5+
if (!title) return '';
6+
if (typeof title === 'string') return title;
7+
return title.defaultValue || title.key;
8+
}
9+
310
// --- Metadata imports ---
411
import { AccountObject } from '../objects/account.object';
512
import { ContactObject } from '../objects/contact.object';
@@ -235,7 +242,7 @@ describe('CRM Metadata Spec Compliance', () => {
235242
});
236243

237244
it('all widgets have unique title', () => {
238-
const titles = CrmDashboard.widgets.map((w) => w.title);
245+
const titles = CrmDashboard.widgets.map((w) => resolveTitle(w.title));
239246
for (const title of titles) {
240247
expect(typeof title).toBe('string');
241248
expect(title!.length).toBeGreaterThan(0);
@@ -245,16 +252,17 @@ describe('CRM Metadata Spec Compliance', () => {
245252

246253
it('all widgets have title', () => {
247254
for (const widget of CrmDashboard.widgets) {
248-
expect(typeof widget.title).toBe('string');
249-
expect(widget.title!.length).toBeGreaterThan(0);
255+
const title = resolveTitle(widget.title);
256+
expect(typeof title).toBe('string');
257+
expect(title!.length).toBeGreaterThan(0);
250258
}
251259
});
252260

253261
it('metric widgets have title matching options.label', () => {
254262
const metrics = CrmDashboard.widgets.filter((w) => w.type === 'metric');
255263
for (const widget of metrics) {
256264
const opts = widget.options as { label?: string };
257-
expect(widget.title).toBe(opts.label);
265+
expect(resolveTitle(widget.title)).toBe(opts.label);
258266
}
259267
});
260268

0 commit comments

Comments
 (0)