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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, any>` 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.
Expand Down
48 changes: 48 additions & 0 deletions apps/console/objectstack.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Comment on lines +26 to +55
Copy link

Copilot AI Mar 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

createMemoryI18n is resolved differently from the other plugin constructors: it does not fall back to the module's default export (e.g. when the default export is the function itself) and is called without validating it was found. If the @objectstack/core export shape differs in a given build, this will fail at runtime with createMemoryI18n is not a function. Make the resolution consistent with the others (include a (CorePkg as any).default fallback) and throw a clear error if it still can't be resolved before calling it.

Copilot uses AI. Check for mistakes.
}
}

/**
* 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 }),
Expand Down
19 changes: 19 additions & 0 deletions apps/console/objectstack.shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, any>[] = i18nBundles.map((bundle: any) => {
const result: Record<string, any> = {};
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;
Expand Down Expand Up @@ -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: [
{
Expand Down
189 changes: 189 additions & 0 deletions apps/console/src/__tests__/i18n-translations.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof setupServer>;

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<string, unknown>);
}
}
}

// 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);
});
});
40 changes: 36 additions & 4 deletions apps/console/src/mocks/createKernel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -332,11 +332,43 @@ export async function createKernel(options: KernelOptions): Promise<KernelResult
const i18nBundles: I18nBundle[] = appConfig.i18n?.bundles ?? [];

if (i18nBundles.length > 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<string, Record<string, unknown>>();
let defaultLocale = 'en';
Copy link

Copilot AI Mar 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

defaultLocale is hard-coded to 'en' in the mock i18n service, even though the app config now carries i18n.defaultLocale. This can make MSW/mock behavior diverge from server mode when a stack sets a different default locale. Initialize defaultLocale from appConfig.i18n?.defaultLocale (with a fallback) and consider using that value in setDefaultLocale/getDefaultLocale consistently.

Suggested change
let defaultLocale = 'en';
let defaultLocale = appConfig.i18n?.defaultLocale ?? 'en';

Copilot uses AI. Check for mistakes.

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<string, unknown>) => {
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<string>();
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; },
});
}

Expand Down
Loading
Loading