Skip to content

Commit cdb95e4

Browse files
authored
Merge pull request #878 from objectstack-ai/copilot/fix-crm-internationalization-issue
2 parents 3f3c472 + c92afa8 commit cdb95e4

File tree

15 files changed

+187
-65
lines changed

15 files changed

+187
-65
lines changed

ROADMAP.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -986,6 +986,7 @@ The `FlowDesigner` is a canvas-based flow editor that bridges the gap between th
986986
- [x] **P1: Dashboard Widget Data Blank — useDataScope/dataSource Injection Fix** — Fixed root cause of dashboard widgets showing blank data with no server requests: `useDataScope(undefined)` was returning the full context `dataSource` (service adapter) instead of `undefined` when no bind path was given, causing ObjectChart and all data components (ObjectKanban, ObjectGallery, ObjectTimeline, ObjectGrid) to treat the adapter as pre-bound data and skip async fetching. Fixed `useDataScope` to return `undefined` when no path is provided. Also improved ObjectChart fault tolerance: uses `useContext` directly instead of `useSchemaContext` (no throw without provider), validates `dataSource.find` is callable before invoking. 14 new tests (7 useDataScope + 7 ObjectChart data fetch/fault tolerance).
987987
- [x] **P1: URL-Driven Debug/Developer Panel** — Universal debug mode activated via `?__debug` URL parameter (amis devtools-style). `@object-ui/core`: exported `DebugFlags`, `DebugCollector` (perf/expr/event data collection, tree-shakeable), `parseDebugFlags()`, enhanced `isDebugEnabled()` (URL → globalThis → env resolution, SSR-safe). `@object-ui/react`: `useDebugMode` hook with URL detection, Ctrl+Shift+D shortcut, manual toggle; `SchemaRendererContext` extended with `debugFlags`; `SchemaRenderer` injects `data-debug-type`/`data-debug-id` attrs + reports render perf to `DebugCollector` when debug enabled. `@object-ui/components`: floating `DebugPanel` with 7 built-in tabs (Schema, Data, Perf, Expr, Events, Registry, Flags), plugin-extensible via `extraTabs`. Console `MetadataInspector` auto-opens when `?__debug` detected. Fine-grained sub-flags: `?__debug_schema`, `?__debug_perf`, `?__debug_data`, `?__debug_expr`, `?__debug_events`, `?__debug_registry`. 48 new tests.
988988
- [x] **P1: Chart Widget Server-Side Aggregation** — Fixed chart widgets (bar/line/area/pie/donut/scatter) downloading all raw data and aggregating client-side. Added optional `aggregate()` method to `DataSource` interface (`AggregateParams`, `AggregateResult` types) enabling server-side grouping/aggregation via analytics API (e.g. `GET /api/v1/analytics/{resource}?category=…&metric=…&agg=…`). `ObjectChart` now prefers `dataSource.aggregate()` when available, falling back to `dataSource.find()` + client-side aggregation for backward compatibility. Implemented `aggregate()` in `ValueDataSource` (in-memory), `ApiDataSource` (HTTP), and `ObjectStackAdapter` (analytics API with client-side fallback). Only detail widgets (grid/table/list) continue to fetch full data. 9 new tests.
989+
- [x] **P1: Spec-Aligned CRM I18n** — Fixed CRM internationalization not taking effect on the console. Root cause: CRM metadata used plain string labels instead of spec-aligned `I18nLabel` objects. Fix: (1) Updated CRM app/dashboard/navigation metadata to use `I18nLabel` objects (`{ key, defaultValue }`) per spec. (2) Updated `NavigationItem` and `NavigationArea` types to support I18nLabel. (3) Added `resolveLabel()` helper in NavigationRenderer. (4) Updated `resolveI18nLabel()` to accept `t()` function for translation. (5) Added `loadLanguage` callback in I18nProvider for API-based translation loading. (6) Added `/api/v1/i18n/:lang` endpoint to mock server. Console contains zero CRM-specific code.
989990

990991
### Ecosystem & Marketplace
991992
- Plugin marketplace website with search, ratings, and install count

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: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,24 @@ 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) {
38+
console.warn(`[i18n] Failed to load translations for '${lang}': HTTP ${res.status}`);
39+
return {};
40+
}
41+
return await res.json();
42+
} catch (err) {
43+
console.warn(`[i18n] Failed to load translations for '${lang}':`, err);
44+
return {};
45+
}
46+
}
47+
3048
// Start MSW before rendering the app
3149
async function bootstrap() {
3250
// Initialize Mock Service Worker if enabled (lazy-loaded to keep production bundle lean)
@@ -39,7 +57,7 @@ async function bootstrap() {
3957
ReactDOM.createRoot(document.getElementById('root')!).render(
4058
<React.StrictMode>
4159
<MobileProvider pwa={{ enabled: true, name: 'ObjectUI Console', shortName: 'Console' }}>
42-
<I18nProvider>
60+
<I18nProvider loadLanguage={loadLanguage}>
4361
<App />
4462
</I18nProvider>
4563
</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+
* Load application-specific locale bundles for the i18n API endpoint.
25+
* In this mock environment, loads translations from installed example packages.
26+
* Returns a flat translation resource for the given language code.
27+
*/
28+
async function loadAppLocale(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+
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 loadAppLocale(lang);
67+
return HttpResponse.json(resources);
68+
}),
69+
],
4570
},
4671
});
4772
kernel = result.kernel;

apps/console/src/mocks/server.ts

Lines changed: 23 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,21 @@ let driver: InMemoryDriver | null = null;
2122
let mswPlugin: MSWPlugin | null = null;
2223
let server: ReturnType<typeof setupServer> | null = null;
2324

25+
/**
26+
* Load application-specific locale bundles for the i18n API endpoint.
27+
* In this mock environment, loads translations from installed example packages.
28+
*/
29+
async function loadAppLocale(lang: string): Promise<Record<string, unknown>> {
30+
try {
31+
const { crmLocales } = await import('@object-ui/example-crm');
32+
const translations = (crmLocales as Record<string, any>)[lang];
33+
if (!translations) return {};
34+
return { crm: translations };
35+
} catch {
36+
return {};
37+
}
38+
}
39+
2440
export async function startMockServer() {
2541
if (kernel) {
2642
console.log('[MSW] ObjectStack Runtime already initialized');
@@ -35,6 +51,13 @@ export async function startMockServer() {
3551
enableBrowser: false,
3652
baseUrl: '/api/v1',
3753
logRequests: false,
54+
customHandlers: [
55+
http.get('/api/v1/i18n/:lang', async ({ params }) => {
56+
const lang = params.lang as string;
57+
const resources = await loadAppLocale(lang);
58+
return HttpResponse.json(resources);
59+
}),
60+
],
3861
},
3962
});
4063
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)