Skip to content

Commit 01ee874

Browse files
Copilothotlong
andcommitted
fix: load CRM i18n locales into I18nProvider and translate navigation/dashboard labels
Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
1 parent 235dc3b commit 01ee874

6 files changed

Lines changed: 135 additions & 12 deletions

File tree

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

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,17 @@ vi.mock('../hooks/useNavPins', () => ({
8484

8585
vi.mock('../utils', () => ({
8686
resolveI18nLabel: (label: any) => (typeof label === 'string' ? label : label?.en || ''),
87+
translateCrmNavigation: (items: any[]) => items,
88+
}));
89+
90+
vi.mock('@object-ui/i18n', () => ({
91+
useObjectTranslation: () => ({
92+
t: (key: string, opts?: any) => opts?.defaultValue ?? key,
93+
language: 'en',
94+
changeLanguage: vi.fn(),
95+
direction: 'ltr',
96+
i18n: {},
97+
}),
8798
}));
8899

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

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,7 @@ vi.mock('../hooks/useNavPins', () => ({
236236

237237
vi.mock('../utils', () => ({
238238
resolveI18nLabel: (label: any) => (typeof label === 'string' ? label : label?.en || ''),
239+
translateCrmNavigation: (items: any[]) => items,
239240
}));
240241

241242
// Mock i18n

apps/console/src/components/AppSidebar.tsx

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,8 @@ import { usePermissions } from '@object-ui/permissions';
5959
import { useRecentItems } from '../hooks/useRecentItems';
6060
import { useFavorites } from '../hooks/useFavorites';
6161
import { useNavPins } from '../hooks/useNavPins';
62-
import { resolveI18nLabel } from '../utils';
62+
import { resolveI18nLabel, translateCrmNavigation } 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(() => {
@@ -219,8 +221,9 @@ export function AppSidebar({ activeAppName, onAppChange }: { activeAppName: stri
219221
// Apply saved order and pin state to navigation items
220222
const processedNavigation = React.useMemo(() => {
221223
const ordered = applyOrder(resolvedNavigation);
222-
return applyPins(ordered);
223-
}, [resolvedNavigation, applyOrder, applyPins]);
224+
const pinned = applyPins(ordered);
225+
return translateCrmNavigation(pinned, t);
226+
}, [resolvedNavigation, applyOrder, applyPins, t]);
224227

225228
// Search filter state for sidebar navigation
226229
const [navSearchQuery, setNavSearchQuery] = React.useState('');
@@ -267,15 +270,15 @@ export function AppSidebar({ activeAppName, onAppChange }: { activeAppName: stri
267270
style={primaryColor ? { backgroundColor: primaryColor } : undefined}
268271
>
269272
{logo ? (
270-
<img src={logo} alt={resolveI18nLabel(activeApp.label)} className="size-6 object-contain" />
273+
<img src={logo} alt={resolveI18nLabel(activeApp.label, t)} className="size-6 object-contain" />
271274
) : (
272275
React.createElement(getIcon(activeApp.icon), { className: "size-4" })
273276
)}
274277
</div>
275278
<div className="grid flex-1 text-left text-sm leading-tight">
276-
<span className="truncate font-semibold">{resolveI18nLabel(activeApp.label)}</span>
279+
<span className="truncate font-semibold">{t('crm.app.name', { defaultValue: resolveI18nLabel(activeApp.label) })}</span>
277280
<span className="truncate text-xs">
278-
{resolveI18nLabel(activeApp.description) || `${activeApps.length} Apps Available`}
281+
{t('crm.app.description', { defaultValue: resolveI18nLabel(activeApp.description) }) || `${activeApps.length} Apps Available`}
279282
</span>
280283
</div>
281284
<ChevronsUpDown className="ml-auto" />

apps/console/src/components/DashboardView.tsx

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,9 @@ import {
2828
import { MetadataToggle, MetadataPanel, useMetadataInspector } from './MetadataInspector';
2929
import { SkeletonDashboard } from './skeletons';
3030
import { useMetadata } from '../context/MetadataProvider';
31-
import { resolveI18nLabel } from '../utils';
31+
import { resolveI18nLabel, translateCrmDashboardWidgets } 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);
@@ -374,14 +376,18 @@ export function DashboardView({ dataSource }: { dataSource?: any }) {
374376
);
375377
}
376378

377-
const previewSchema = editSchema || dashboard;
379+
const previewSchemaRaw = editSchema || dashboard;
380+
const previewSchema = useMemo(() => {
381+
if (!previewSchemaRaw?.widgets) return previewSchemaRaw;
382+
return { ...previewSchemaRaw, widgets: translateCrmDashboardWidgets(previewSchemaRaw.widgets, t) };
383+
}, [previewSchemaRaw, t]);
378384

379385
return (
380386
<div className="flex flex-col h-full overflow-hidden bg-background">
381387
{/* ── Header ───────────────────────────────────────────────── */}
382388
<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">
383389
<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>
390+
<h1 className="text-lg sm:text-xl md:text-2xl font-bold tracking-tight truncate">{t('crm.dashboard.title', { defaultValue: resolveI18nLabel(dashboard.label) || dashboard.name })}</h1>
385391
{dashboard.description && (
386392
<p className="text-sm text-muted-foreground mt-1 line-clamp-2">{resolveI18nLabel(dashboard.description)}</p>
387393
)}

apps/console/src/main.tsx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,14 @@ import './index.css';
1010
import { App } from './App';
1111
import { I18nProvider } from '@object-ui/i18n';
1212
import { MobileProvider } from '@object-ui/mobile';
13+
import { crmLocales } from '@object-ui/example-crm';
14+
15+
// Build i18n resources from CRM locale bundles so translations are
16+
// available under the `crm.*` namespace (e.g. t('crm.navigation.dashboard')).
17+
const crmI18nResources: Record<string, Record<string, unknown>> = {};
18+
for (const [lang, translations] of Object.entries(crmLocales)) {
19+
crmI18nResources[lang] = { crm: translations };
20+
}
1321

1422
// Register plugins (side-effect imports for ComponentRegistry)
1523
import '@object-ui/plugin-grid';
@@ -39,7 +47,7 @@ async function bootstrap() {
3947
ReactDOM.createRoot(document.getElementById('root')!).render(
4048
<React.StrictMode>
4149
<MobileProvider pwa={{ enabled: true, name: 'ObjectUI Console', shortName: 'Console' }}>
42-
<I18nProvider>
50+
<I18nProvider config={{ resources: crmI18nResources }}>
4351
<App />
4452
</I18nProvider>
4553
</MobileProvider>

apps/console/src/utils.ts

Lines changed: 96 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,111 @@
22
* Utility functions for ObjectStack Console
33
*/
44

5+
import type { NavigationItem } from '@object-ui/types';
6+
57
/**
68
* Resolves an I18nLabel to a plain string.
79
* 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.
10+
* When it's an object and a `t` function is provided, it looks up the key
11+
* in the i18n system. Otherwise it returns the defaultValue or the key as fallback.
912
*/
10-
export function resolveI18nLabel(label: string | { key: string; defaultValue?: string; params?: Record<string, any> } | undefined): string | undefined {
13+
export function resolveI18nLabel(
14+
label: string | { key: string; defaultValue?: string; params?: Record<string, any> } | undefined,
15+
t?: (key: string, options?: any) => string,
16+
): string | undefined {
1117
if (label === undefined || label === null) return undefined;
1218
if (typeof label === 'string') return label;
19+
if (t) return t(label.key, { defaultValue: label.defaultValue, ...label.params });
1320
return label.defaultValue || label.key;
1421
}
1522

23+
// ---------------------------------------------------------------------------
24+
// CRM Navigation i18n helpers
25+
// ---------------------------------------------------------------------------
26+
27+
/**
28+
* Map from CRM navigation item IDs to CRM i18n `navigation.*` keys.
29+
* This allows translating navigation labels when the user switches locale.
30+
*/
31+
const CRM_NAV_I18N_MAP: Record<string, string> = {
32+
nav_dashboard: 'dashboard',
33+
nav_contacts: 'contacts',
34+
nav_accounts: 'accounts',
35+
nav_opportunities: 'opportunities',
36+
nav_pipeline: 'pipeline',
37+
nav_projects: 'projects',
38+
nav_events: 'calendar',
39+
nav_sales: 'sales',
40+
nav_orders: 'orders',
41+
nav_products: 'products',
42+
nav_order_items: 'lineItems',
43+
nav_reports: 'reports',
44+
nav_sales_report: 'salesReport',
45+
nav_pipeline_report: 'pipelineReport',
46+
nav_getting_started: 'gettingStarted',
47+
nav_settings: 'settings',
48+
nav_help: 'help',
49+
};
50+
51+
/**
52+
* Recursively translate navigation item labels using CRM i18n keys.
53+
* Falls back to the original label when no translation is found.
54+
*/
55+
export function translateCrmNavigation(
56+
items: NavigationItem[],
57+
t: (key: string, options?: any) => string,
58+
): NavigationItem[] {
59+
return items.map((item) => {
60+
const i18nKey = CRM_NAV_I18N_MAP[item.id];
61+
const translatedLabel = i18nKey
62+
? t(`crm.navigation.${i18nKey}`, { defaultValue: item.label })
63+
: item.label;
64+
return {
65+
...item,
66+
label: translatedLabel,
67+
children: item.children
68+
? translateCrmNavigation(item.children, t)
69+
: undefined,
70+
};
71+
});
72+
}
73+
74+
/**
75+
* Map from CRM dashboard widget IDs to CRM i18n `dashboard.widgets.*` keys.
76+
*/
77+
const CRM_WIDGET_I18N_MAP: Record<string, string> = {
78+
total_revenue: 'totalRevenue',
79+
active_deals: 'activeDeals',
80+
win_rate: 'winRate',
81+
avg_deal_size: 'avgDealSize',
82+
revenue_trends: 'revenueTrends',
83+
lead_source: 'leadSource',
84+
pipeline_by_stage: 'pipelineByStage',
85+
top_products: 'topProducts',
86+
recent_opportunities: 'recentOpportunities',
87+
revenue_by_account: 'revenueByAccount',
88+
avg_deal_by_stage: 'avgDealSizeByStage',
89+
orders_by_status: 'ordersByStatus',
90+
};
91+
92+
/**
93+
* Translate CRM dashboard widget titles using CRM i18n keys.
94+
* Falls back to the original title when no translation is found.
95+
*/
96+
export function translateCrmDashboardWidgets(
97+
widgets: any[],
98+
t: (key: string, options?: any) => string,
99+
): any[] {
100+
return widgets.map((widget) => {
101+
const i18nKey = CRM_WIDGET_I18N_MAP[widget.id];
102+
if (!i18nKey) return widget;
103+
return {
104+
...widget,
105+
title: t(`crm.dashboard.widgets.${i18nKey}`, { defaultValue: widget.title }),
106+
};
107+
});
108+
}
109+
16110
/**
17111
* Format a record title using the titleFormat pattern
18112
* @param titleFormat Pattern like "{name} - {email}" or "{firstName} {lastName}"

0 commit comments

Comments
 (0)