diff --git a/CHANGELOG.md b/CHANGELOG.md index d011fffd..07656b49 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **i18n Kernel Service `getTranslations` Returns Wrong Format** (`apps/console`): Fixed the `createKernel` i18n service registration where `getTranslations` returned a wrapped `{ locale, translations }` object instead of the flat `Record` dictionary expected by the spec's `II18nService` interface. The HttpDispatcher wraps the service return value in `{ data: { locale, translations } }`, so the extra wrapping caused translations to be empty or double-nested in the API response (`/api/v1/i18n/translations/:lang`). The service now returns `resolveI18nTranslations(bundles, lang)` directly, aligning with the spec and making CRM business translations work correctly in all environments (MSW, server, dev). + +- **i18n Translations Empty in Server Mode (`pnpm start`)** (`apps/console`): Fixed translations returning `{}` when running the real ObjectStack server on port 3000. The root cause was that the CLI's `isHostConfig()` function detects plugins with `init` methods in the config's `plugins` array and treats it as a "host config" — **skipping auto-registration of `AppPlugin`**. Without `AppPlugin`, the config's translations, objects, and seed data were never loaded. Additionally, the kernel's memory i18n fallback is only auto-registered in `validateSystemRequirements()` (after all plugin starts), too late for `AppPlugin.start()` → `loadTranslations()`. Fixed by: (1) explicitly adding `AppPlugin(sharedConfig)` to `objectstack.config.ts` plugins, and (2) adding `MemoryI18nPlugin` before it to register the i18n service during init phase. Also added a spec-format `translations` array to `objectstack.shared.ts` so `AppPlugin.loadTranslations()` can iterate and load the CRM locale bundles. + +- **i18n Translations Empty in Root Dev Mode (`pnpm dev`)** (root `objectstack.config.ts`): Fixed translations returning `{}` when running `pnpm dev` from the monorepo root. The root config uses `objectstack dev` → `objectstack serve --dev` which loads the root `objectstack.config.ts` — but this config did not aggregate i18n bundles from the example stacks (CRM, Todo, etc.). The `composeStacks()` function doesn't handle the custom `i18n` field, so translation data was lost during composition. Fixed by: (1) aggregating i18n bundles from all plugin configs (same pattern as `objectstack.shared.ts`), (2) building a spec-format `translations` array passed to `AppPlugin(mergedApp)`, and (3) adding `MemoryI18nPlugin` to register the i18n service during init phase. + - **Duplicate Data in Calendar and Kanban Views** (`@object-ui/plugin-view`, `@object-ui/plugin-kanban`, `@object-ui/plugin-list`): Fixed a bug where Calendar and Kanban views displayed every record twice. The root cause was that `ObjectView` (plugin-view) unconditionally fetched data even when a `renderListView` callback was provided — causing parallel duplicate requests since `ListView` (plugin-list) independently fetches its own data. The duplicate fetch results triggered re-renders that destabilised child component rendering, leading to duplicate events in the calendar and duplicate cards on the kanban board. Additionally, `ObjectKanban` lacked proper external-data handling (`data`/`loading` props with `hasExternalData` guard), unlike `ObjectCalendar` which already had this pattern. Fixes: (1) `ObjectView` now skips its own fetch when `renderListView` is provided, (2) `ObjectKanban` now accepts explicit `data`/`loading` props and skips internal fetch when external data is supplied (matching `ObjectCalendar`'s pattern), (3) `ListView` now handles `{ value: [] }` OData response format consistently with `extractRecords` utility. Includes regression tests. - **CI Build Fix: Replace dynamic `require()` with static imports** (`@object-ui/plugin-view`): Replaced module-level `require('@object-ui/react')` calls in `index.tsx` and `ObjectView.tsx` with static `import` statements. The dynamic `require()` pattern is not supported by Next.js Turbopack, causing the docs site build to fail during SSR prerendering of the `/docs/components/complex/view-switcher` page with `Error: dynamic usage of require is not supported`. Since `@object-ui/react` is already a declared dependency and other files in the package use static imports from it, replacing the `require()` with static imports is safe and eliminates the SSR compatibility issue. diff --git a/apps/console/objectstack.config.ts b/apps/console/objectstack.config.ts index 6dfe7c9e..3ebbe2d4 100644 --- a/apps/console/objectstack.config.ts +++ b/apps/console/objectstack.config.ts @@ -16,16 +16,64 @@ import * as HonoServerPluginPkg from '@objectstack/plugin-hono-server'; import * as DriverMemoryPkg from '@objectstack/driver-memory'; // @ts-ignore import * as RuntimePkg from '@objectstack/runtime'; +// @ts-ignore +import * as CorePkg from '@objectstack/core'; const MSWPlugin = MSWPluginPkg.MSWPlugin || (MSWPluginPkg as any).default?.MSWPlugin || (MSWPluginPkg as any).default; const ObjectQLPlugin = ObjectQLPluginPkg.ObjectQLPlugin || (ObjectQLPluginPkg as any).default?.ObjectQLPlugin || (ObjectQLPluginPkg as any).default; const InMemoryDriver = DriverMemoryPkg.InMemoryDriver || (DriverMemoryPkg as any).default?.InMemoryDriver || (DriverMemoryPkg as any).default; const DriverPlugin = RuntimePkg.DriverPlugin || (RuntimePkg as any).default?.DriverPlugin || (RuntimePkg as any).default; +const AppPlugin = RuntimePkg.AppPlugin || (RuntimePkg as any).default?.AppPlugin || (RuntimePkg as any).default; const HonoServerPlugin = HonoServerPluginPkg.HonoServerPlugin || (HonoServerPluginPkg as any).default?.HonoServerPlugin || (HonoServerPluginPkg as any).default; +const createMemoryI18n = CorePkg.createMemoryI18n || (CorePkg as any).default?.createMemoryI18n; import { ConsolePlugin } from './plugin'; +/** + * Lightweight plugin that registers the in-memory i18n service during the + * init phase. This is critical for server mode (`pnpm start`) because: + * + * 1. AppPlugin.start() → loadTranslations() needs an i18n service. + * 2. The kernel's own memory i18n fallback is auto-registered in + * validateSystemRequirements() — which runs AFTER all plugin starts. + * 3. Without an early-registered i18n service, loadTranslations() finds + * nothing and silently skips — translations never get loaded. + * + * By registering the service during init (Phase 1), AppPlugin.start() + * (Phase 2) finds it and loads the spec-format `translations` array. + * + * Name matches the CLI's dedup check so it won't attempt to also import + * @objectstack/service-i18n. + */ +class MemoryI18nPlugin { + readonly name = 'com.objectstack.service.i18n'; + readonly version = '1.0.0'; + readonly type = 'service' as const; + + init(ctx: any) { + const svc = createMemoryI18n(); + ctx.registerService('i18n', svc); + } +} + +/** + * Plugin ordering matters for server mode (`pnpm start`): + * + * The CLI's isHostConfig() detects that config.plugins contains objects with + * init methods (ObjectQLPlugin, DriverPlugin, etc.) and treats this as a + * "host config" — skipping auto-registration of AppPlugin. + * + * We therefore include AppPlugin explicitly so that: + * - Objects/metadata are registered with the kernel + * - Seed data is loaded into the in-memory driver + * - Translations are loaded into the i18n service (via loadTranslations) + * + * MemoryI18nPlugin MUST come before AppPlugin so that the i18n service + * exists when AppPlugin.start() → loadTranslations() runs. + */ const plugins: any[] = [ + new MemoryI18nPlugin(), + new AppPlugin(sharedConfig), new ObjectQLPlugin(), new DriverPlugin(new InMemoryDriver(), 'memory'), new HonoServerPlugin({ port: 3000 }), diff --git a/apps/console/objectstack.shared.ts b/apps/console/objectstack.shared.ts index eadb5c80..92b1e738 100644 --- a/apps/console/objectstack.shared.ts +++ b/apps/console/objectstack.shared.ts @@ -30,6 +30,19 @@ const i18nBundles = allConfigs .map((c: any) => c.i18n) .filter((i: any) => i?.namespace && i?.translations); +// Build the spec `translations` array for the runtime's AppPlugin. +// AppPlugin.loadTranslations expects `translations: Array<{ [locale]: data }>`. +// Each locale's data is nested under the bundle's namespace so that +// both the server-mode (AppPlugin → memory i18n) and MSW-mode (createKernel) +// produce the same structure: `{ crm: { objects: { ... } } }`. +const specTranslations: Record[] = i18nBundles.map((bundle: any) => { + const result: Record = {}; + for (const [locale, data] of Object.entries(bundle.translations)) { + result[locale] = { [bundle.namespace]: data }; + } + return result; +}); + // Protocol-level composition via @objectstack/spec: handles object dedup, // array concatenation, actions→objects mapping, and manifest selection. const composed = composeStacks(allConfigs as any[], { objectConflict: 'override' }) as any; @@ -97,7 +110,13 @@ export const sharedConfig = { }, i18n: { bundles: i18nBundles, + defaultLocale: 'en', }, + // Spec-format translations array consumed by AppPlugin.loadTranslations() + // in real-server mode (pnpm start). Each entry maps locale → namespace-scoped + // translation data so the runtime's memory i18n fallback serves the same + // structure as the MSW mock handler. + translations: specTranslations, plugins: [], datasources: [ { diff --git a/apps/console/src/__tests__/i18n-translations.test.ts b/apps/console/src/__tests__/i18n-translations.test.ts new file mode 100644 index 00000000..09873378 --- /dev/null +++ b/apps/console/src/__tests__/i18n-translations.test.ts @@ -0,0 +1,189 @@ +/** + * i18n Translations Pipeline Tests + * + * Validates that the i18n translation pipeline delivers CRM translations + * correctly through all code paths: + * - Kernel service: getTranslations returns flat dict (not wrapped) + * - HttpDispatcher: wraps into standard spec envelope + * - MSW handler: returns correct { data: { locale, translations } } + * + * Regression test for empty translations:{} response. + */ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { setupServer } from 'msw/node'; +import { createKernel, type KernelResult } from '../mocks/createKernel'; +import { createAuthHandlers } from '../mocks/authHandlers'; +import appConfig from '../../objectstack.shared'; +import { crmLocales } from '@object-ui/example-crm'; + +// Expected values from the CRM i18n bundles — avoid hard-coding in assertions +const EXPECTED_ZH_ACCOUNT_LABEL = crmLocales.zh.objects.account.label; +const EXPECTED_EN_ACCOUNT_LABEL = crmLocales.en.objects.account.label; + +describe('i18n translations pipeline', () => { + let result: KernelResult; + let server: ReturnType; + + beforeAll(async () => { + result = await createKernel({ + appConfig, + persistence: false, + mswOptions: { + enableBrowser: false, + baseUrl: '/api/v1', + logRequests: false, + customHandlers: [ + ...createAuthHandlers('/api/v1/auth'), + ], + }, + }); + + const handlers = result.mswPlugin?.getHandlers() ?? []; + server = setupServer(...handlers); + server.listen({ onUnhandledRequest: 'bypass' }); + }); + + afterAll(() => { + server?.close(); + }); + + // ── Kernel service layer ─────────────────────────────────────────── + + it('kernel i18n service returns flat translations dict (not wrapped)', () => { + const i18nService = result.kernel.getService('i18n') as any; + const translations = i18nService.getTranslations('zh'); + + // Must NOT have the { locale, translations } wrapper + expect(translations).not.toHaveProperty('locale'); + expect(translations).toHaveProperty('crm'); + expect(translations.crm.objects.account.label).toBe(EXPECTED_ZH_ACCOUNT_LABEL); + }); + + it('kernel i18n service returns English translations', () => { + const i18nService = result.kernel.getService('i18n') as any; + const translations = i18nService.getTranslations('en'); + + expect(translations.crm.objects.account.label).toBe(EXPECTED_EN_ACCOUNT_LABEL); + }); + + it('kernel i18n service returns empty for unknown locale', () => { + const i18nService = result.kernel.getService('i18n') as any; + const translations = i18nService.getTranslations('xx'); + + expect(Object.keys(translations)).toHaveLength(0); + }); + + // ── HttpDispatcher layer ─────────────────────────────────────────── + + it('HttpDispatcher returns populated translations in spec envelope', async () => { + const { HttpDispatcher } = await import('@objectstack/runtime'); + const dispatcher = new HttpDispatcher(result.kernel); + + const dispatchResult = await dispatcher.handleI18n('/translations/zh', 'GET', {}, {} as any); + + expect(dispatchResult.handled).toBe(true); + expect(dispatchResult.response?.status).toBe(200); + const body = dispatchResult.response?.body; + expect(body?.success).toBe(true); + expect(body?.data?.locale).toBe('zh'); + expect(Object.keys(body?.data?.translations ?? {}).length).toBeGreaterThan(0); + expect(body?.data?.translations?.crm?.objects?.account?.label).toBe(EXPECTED_ZH_ACCOUNT_LABEL); + }); + + // ── MSW handler layer (fetch) ────────────────────────────────────── + + it('GET /api/v1/i18n/translations/zh returns CRM zh translations', async () => { + const res = await fetch('http://localhost/api/v1/i18n/translations/zh'); + expect(res.ok).toBe(true); + + const json = await res.json(); + const translations = json?.data?.translations; + + expect(translations).toBeDefined(); + expect(Object.keys(translations).length).toBeGreaterThan(0); + expect(translations.crm).toBeDefined(); + expect(translations.crm.objects.account.label).toBe(EXPECTED_ZH_ACCOUNT_LABEL); + }); + + it('GET /api/v1/i18n/translations/en returns CRM en translations', async () => { + const res = await fetch('http://localhost/api/v1/i18n/translations/en'); + const json = await res.json(); + + expect(json?.data?.translations?.crm?.objects?.account?.label).toBe(EXPECTED_EN_ACCOUNT_LABEL); + }); + + // ── Server-mode compatibility (AppPlugin.loadTranslations) ──────── + + it('kernel i18n service supports loadTranslations (AppPlugin compat)', () => { + const i18nService = result.kernel.getService('i18n') as any; + + // AppPlugin.loadTranslations calls these methods; they must exist + expect(typeof i18nService.loadTranslations).toBe('function'); + expect(typeof i18nService.getLocales).toBe('function'); + expect(typeof i18nService.getDefaultLocale).toBe('function'); + expect(typeof i18nService.setDefaultLocale).toBe('function'); + }); + + it('kernel i18n service getLocales returns all CRM locales', () => { + const i18nService = result.kernel.getService('i18n') as any; + const locales = i18nService.getLocales(); + + // CRM declares 10 locales: en, zh, ja, ko, de, fr, es, pt, ru, ar + expect(locales).toContain('en'); + expect(locales).toContain('zh'); + expect(locales.length).toBeGreaterThanOrEqual(10); + }); + + it('appConfig.translations is spec-format array for AppPlugin', () => { + const translations = (appConfig as any).translations; + + expect(Array.isArray(translations)).toBe(true); + expect(translations.length).toBeGreaterThan(0); + + // Each entry maps locale → namespace-scoped data + const first = translations[0]; + expect(first).toHaveProperty('zh'); + expect(first).toHaveProperty('en'); + // Data must be nested under namespace (e.g. 'crm') + expect(first.zh).toHaveProperty('crm'); + expect(first.zh.crm.objects.account.label).toBe(EXPECTED_ZH_ACCOUNT_LABEL); + expect(first.en.crm.objects.account.label).toBe(EXPECTED_EN_ACCOUNT_LABEL); + }); + + // ── Server-mode flow simulation ─────────────────────────────────── + // Simulates the exact flow that `pnpm start` (CLI serve) uses: + // 1. createMemoryI18n() registers the i18n service + // 2. AppPlugin.loadTranslations() iterates config.translations + // 3. HttpDispatcher.handleI18n() calls getTranslations(locale) + + it('server-mode: memory i18n + AppPlugin loadTranslations produces populated response', async () => { + // Import the same createMemoryI18n used by the MemoryI18nPlugin in objectstack.config.ts + const { createMemoryI18n } = await import('@objectstack/core'); + const svc = createMemoryI18n(); + + // Simulate AppPlugin.loadTranslations() iterating the spec-format translations array + const translations = (appConfig as any).translations; + for (const bundle of translations) { + for (const [locale, data] of Object.entries(bundle)) { + if (data && typeof data === 'object') { + svc.loadTranslations(locale, data as Record); + } + } + } + + // After loading, getTranslations must return populated CRM data + const zh = svc.getTranslations('zh') as any; + expect(zh).toHaveProperty('crm'); + expect(zh.crm.objects.account.label).toBe(EXPECTED_ZH_ACCOUNT_LABEL); + + const en = svc.getTranslations('en') as any; + expect(en).toHaveProperty('crm'); + expect(en.crm.objects.account.label).toBe(EXPECTED_EN_ACCOUNT_LABEL); + + // getLocales must list all loaded languages + const locales = svc.getLocales(); + expect(locales).toContain('en'); + expect(locales).toContain('zh'); + expect(locales.length).toBeGreaterThanOrEqual(10); + }); +}); \ No newline at end of file diff --git a/apps/console/src/mocks/createKernel.ts b/apps/console/src/mocks/createKernel.ts index a6d7898f..0e84f284 100644 --- a/apps/console/src/mocks/createKernel.ts +++ b/apps/console/src/mocks/createKernel.ts @@ -332,11 +332,43 @@ export async function createKernel(options: KernelOptions): Promise 0) { + // Build a complete i18n service that satisfies both: + // - HttpDispatcher.handleI18n (calls getTranslations, getLocales) + // - AppPlugin.loadTranslations (calls loadTranslations, setDefaultLocale) + // In MSW mode the custom handler serves translations directly, but the + // kernel service is still needed for the broker shim and dispatcher paths. + const appPluginTranslations = new Map>(); + let defaultLocale = 'en'; + kernel.registerService('i18n', { - getTranslations: (lang: string) => ({ - locale: lang, - translations: resolveI18nTranslations(i18nBundles, lang), - }), + getTranslations: (lang: string) => { + const resolved = resolveI18nTranslations(i18nBundles, lang); + const extra = appPluginTranslations.get(lang); + if (extra) { + // Shallow merge: AppPlugin-loaded translations override bundle translations + return { ...resolved, ...extra }; + } + return resolved; + }, + loadTranslations: (locale: string, data: Record) => { + const existing = appPluginTranslations.get(locale) ?? {}; + appPluginTranslations.set(locale, { ...existing, ...data }); + }, + getLocales: () => { + // Collect all available locales from bundles + any loaded via loadTranslations + const locales = new Set(); + for (const bundle of i18nBundles) { + for (const lang of Object.keys(bundle.translations)) { + locales.add(lang); + } + } + for (const lang of appPluginTranslations.keys()) { + locales.add(lang); + } + return [...locales]; + }, + getDefaultLocale: () => defaultLocale, + setDefaultLocale: (locale: string) => { defaultLocale = locale; }, }); } diff --git a/objectstack.config.ts b/objectstack.config.ts index b3edb2b7..d7d9478d 100644 --- a/objectstack.config.ts +++ b/objectstack.config.ts @@ -22,6 +22,7 @@ import { AuthPlugin } from '@objectstack/plugin-auth'; import { ConsolePlugin } from '@object-ui/console'; import { composeStacks } from '@objectstack/spec'; import { mergeViewsIntoObjects } from '@object-ui/core'; +import { createMemoryI18n } from '@objectstack/core'; import { CRMPlugin } from './examples/crm/plugin'; import { TodoPlugin } from './examples/todo/plugin'; import { KitchenSinkPlugin } from './examples/kitchen-sink/plugin'; @@ -39,6 +40,24 @@ const allConfigs = plugins.map((p) => { // so we collect data from all stacks before composing). const allData = allConfigs.flatMap((c: any) => c.manifest?.data || c.data || []); +// Aggregate i18n bundles from all stacks that declare an i18n section. +// Each bundle carries a namespace (e.g. 'crm') and per-language translations. +const i18nBundles = allConfigs + .map((c: any) => c.i18n) + .filter((i: any) => i?.namespace && i?.translations); + +// Build the spec `translations` array for AppPlugin.loadTranslations(). +// Format: Array<{ [locale]: { namespace: data } }>. +// Each locale's data is nested under the bundle's namespace so that the +// i18n service serves `{ crm: { objects: { account: { label: '客户' } } } }`. +const specTranslations: Record[] = i18nBundles.map((bundle: any) => { + const result: Record = {}; + for (const [locale, data] of Object.entries(bundle.translations)) { + result[locale] = { [bundle.namespace]: data }; + } + return result; +}); + // Protocol-level composition via @objectstack/spec: handles object dedup, // array concatenation, actions→objects mapping, and manifest selection. const composed = composeStacks(allConfigs as any[], { objectConflict: 'override' }) as any; @@ -58,13 +77,37 @@ const mergedApp = { type: 'app', data: allData, }, + // Spec-format translations consumed by AppPlugin.loadTranslations() in server mode + translations: specTranslations, + i18n: { + bundles: i18nBundles, + defaultLocale: 'en', + }, }; +/** + * Registers the in-memory i18n service during the init phase (Phase 1) + * so that AppPlugin.start() → loadTranslations() (Phase 2) can find + * and load translation bundles. Without this, the kernel's memory i18n + * fallback only registers in validateSystemRequirements() — too late. + */ +class MemoryI18nPlugin { + readonly name = 'com.objectstack.service.i18n'; + readonly version = '1.0.0'; + readonly type = 'service' as const; + + init(ctx: any) { + const svc = createMemoryI18n(); + ctx.registerService('i18n', svc); + } +} + // Export only plugins — no top-level objects/manifest/apps. // The CLI auto-creates an AppPlugin from the config if it detects objects/manifest/apps, // which would conflict with our explicit AppPlugin and skip seed data loading. export default { plugins: [ + new MemoryI18nPlugin(), new ObjectQLPlugin(), new DriverPlugin(new InMemoryDriver()), new AppPlugin(mergedApp),