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
51 changes: 51 additions & 0 deletions .changeset/zh-branch-migration-part2.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
---
"@object-ui/i18n": patch
"@object-ui/app-shell": patch
"@object-ui/plugin-chatbot": patch
---

fix(i18n): delete the four `pick({en,zh})` clones (objectui#2871, part 2)

Four files each carried an identical private resolver:

```ts
function pick(label: I18n): string {
const lang = document.documentElement.getAttribute('lang') || 'en';
return lang.toLowerCase().startsWith('zh') ? label.zh : label.en;
}
```

Only Chinese was ever handled, so ja/ko/de/fr/es/pt/ru/ar silently rendered
English — and because the copy was baked into the components as inline
`{en, zh}` pairs, no translator could reach it. All four copies are deleted
along with their `I18n` type alias.

Migrated to the locale packs, **all ten languages**:

- `excelImport.*` (8 keys) — `ExcelImportBar`. The completion toast becomes a
proper `{{count}}` / `{{object}}` interpolation instead of a template literal
baked into both language variants.
- `cloudOnboarding.*` (5 keys) — `CloudOnboardingNext`, the Cloud welcome page.
- `aiModelStatus.*` (11 keys) — `CloudAiModelStatus`, including the
`sourceLabel()` enum→prose helper (now `t`-driven with a `{{source}}`
placeholder) and the three `ModelRow` labels. The conditional
`(HTTP nnn)` fragment becomes two whole sentences rather than a string
spliced mid-clause, which is not translatable into every word order.
- `chatbotQuota.*` (4 keys) — the AI quota banner in `ChatbotEnhanced`.

The chatbot banner keeps choosing between the server's `quota.message` (zh) and
`quota.messageEn` — that pair is server-owned — but now decides using the
console's active language instead of `navigator.language`, which had ignored
the in-app locale switcher entirely.

`CloudOnboardingNext`'s tests now render inside a real `I18nProvider`; without
one `t()` returns the raw key, so the previous assertions on literal English
were asserting nothing.

This completes the `pick()` cluster from #2871. The remaining
`startsWith('zh')` sites are the ones that classification marked KEEP —
`LoadingScreen` (bootstrap, selects real locale packs before i18next is up),
`conversationLanguage` (detects the chat's language for the agent, not UI
copy), `containers.tsx` (normalises author-supplied schema data; its `'与'`
separator is a CJK typography rule), and the Studio catalog / `field-types.ts`
data catalog.
27 changes: 9 additions & 18 deletions packages/app-shell/src/console/ai/ExcelImportBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,9 @@ import { useEffect, useMemo, useState } from 'react';
import { Button, Badge } from '@object-ui/components';
import { createAuthenticatedFetch } from '@object-ui/auth';
import { toast } from 'sonner';
import { useObjectTranslation } from '@object-ui/i18n';
import { ImportWizard } from '@object-ui/plugin-grid';

type I18n = { en: string; zh: string };

interface WizardField {
name: string;
label: string;
Expand All @@ -41,12 +40,6 @@ interface ExcelImportBarProps {
onDone: () => void;
}

function pick(label: I18n): string {
const lang =
(typeof document !== 'undefined' && document.documentElement.getAttribute('lang')) || 'en';
return lang.toLowerCase().startsWith('zh') ? label.zh : label.en;
}

const SKIP_OBJECT_PREFIXES = ['sys_', 'ai_', 'cloud_'];
const NON_WRITABLE_TYPES = ['formula', 'summary', 'autonumber'];

Expand All @@ -68,6 +61,7 @@ function normalizeFields(schema: any): WizardField[] {
}

export function ExcelImportBar({ file, dataSource, defaultObjectName, onDone }: ExcelImportBarProps) {
const { t } = useObjectTranslation();
const authFetch = useMemo(() => createAuthenticatedFetch(), []);
const [objects, setObjects] = useState<Array<{ name: string; label: string }> | null>(null);
const [selected, setSelected] = useState<string>(defaultObjectName ?? '');
Expand Down Expand Up @@ -109,13 +103,13 @@ export function ExcelImportBar({ file, dataSource, defaultObjectName, onDone }:
const schema = await dataSource.getObjectSchema(selected);
const f = normalizeFields(schema);
if (!f.length) {
toast.error(pick({ en: 'That object has no importable fields.', zh: '该对象没有可导入的字段。' }));
toast.error(t('excelImport.noImportableFields'));
return;
}
setFields(f);
setOpen(true);
} catch {
toast.error(pick({ en: 'Could not read the object schema.', zh: '无法读取对象结构。' }));
toast.error(t('excelImport.schemaReadFailed'));
} finally {
setLoadingFields(false);
}
Expand All @@ -135,10 +129,7 @@ export function ExcelImportBar({ file, dataSource, defaultObjectName, onDone }:
initialFile={file}
onComplete={(result) => {
toast.success(
pick({
en: `Imported ${result.importedRows} row(s) into ${selectedLabel}.`,
zh: `已把 ${result.importedRows} 行真实数据导入「${selectedLabel}」。`,
}),
t('excelImport.imported', { count: result.importedRows, object: selectedLabel }),
);
}}
/>
Expand All @@ -149,10 +140,10 @@ export function ExcelImportBar({ file, dataSource, defaultObjectName, onDone }:
<div className="flex flex-wrap items-center gap-2 rounded-md border border-border bg-muted/40 px-3 py-2 text-sm" data-excel-import-bar>
<Badge variant="secondary">CSV / Excel</Badge>
<span className="text-muted-foreground">
{pick({ en: 'Import the real rows from', zh: '把真实数据导入自' })}
{t('excelImport.importFrom')}
</span>
<code className="font-medium">{file.name}</code>
<span className="text-muted-foreground">{pick({ en: 'into', zh: '到' })}</span>
<span className="text-muted-foreground">{t('excelImport.into')}</span>
<select
className="h-8 rounded-md border border-input bg-background px-2 text-sm"
value={selected}
Expand All @@ -164,10 +155,10 @@ export function ExcelImportBar({ file, dataSource, defaultObjectName, onDone }:
))}
</select>
<Button size="sm" onClick={openWizard} disabled={!selected || loadingFields}>
{loadingFields ? pick({ en: 'Opening…', zh: '打开中…' }) : pick({ en: 'Import', zh: '导入' })}
{loadingFields ? t('excelImport.opening') : t('excelImport.importAction')}
</Button>
<Button size="sm" variant="ghost" onClick={onDone}>
{pick({ en: 'Dismiss', zh: '忽略' })}
{t('excelImport.dismiss')}
</Button>
</div>
);
Expand Down
51 changes: 23 additions & 28 deletions packages/app-shell/src/console/diagnostics/CloudAiModelStatus.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,9 @@ import { useEffect, useMemo, useState } from 'react';
import { Badge, Skeleton } from '@object-ui/components';
import { createAuthenticatedFetch } from '@object-ui/auth';
import { ComponentRegistry } from '@object-ui/core';
import { useObjectTranslation } from '@object-ui/i18n';

type I18n = { en: string; zh: string };
type TFn = (key: string, vars?: Record<string, unknown>) => string;

interface EffectiveModelReport {
conversational: { model?: string; source: string };
Expand All @@ -47,21 +48,12 @@ type Phase =
| { phase: 'ready'; report: EffectiveModelReport }
| { phase: 'error'; status?: number };

function pick(label: I18n): string {
const lang =
(typeof document !== 'undefined' && document.documentElement.getAttribute('lang')) || 'en';
return lang.toLowerCase().startsWith('zh') ? label.zh : label.en;
}

/** Env-override source → a friendly label; `code-default` reads as such. */
function sourceLabel(source: string): I18n {
if (source === 'code-default')
return { en: 'code default (no env override)', zh: '代码默认(无 env 覆盖)' };
if (source === 'inherits-conversational')
return { en: 'same as build/ask', zh: '与 build/ask 相同' };
if (source.startsWith('env:'))
return { en: `pinned by ${source.slice(4)}`, zh: `被 ${source.slice(4)} 钉住` };
return { en: source, zh: source };
function sourceLabel(source: string, t: TFn): string {
if (source === 'code-default') return t('aiModelStatus.sourceCodeDefault');
if (source === 'inherits-conversational') return t('aiModelStatus.sourceInherits');
if (source.startsWith('env:')) return t('aiModelStatus.sourcePinned', { source: source.slice(4) });
return source;
}

/** `code-default` is the calm state; any env pin is worth a highlight. */
Expand Down Expand Up @@ -104,19 +96,20 @@ function useEffectiveModel(url: string): Phase {
}

/** One labelled row: dimension name, the resolved model, and a source badge. */
function ModelRow({ label, model, source }: { label: I18n; model?: string; source: string }) {
function ModelRow({ label, model, source, t }: { label: string; model?: string; source: string; t: TFn }) {
return (
<div className="flex flex-wrap items-center justify-between gap-2 py-2 border-b border-border last:border-b-0">
<span className="text-sm text-muted-foreground">{pick(label)}</span>
<span className="text-sm text-muted-foreground">{label}</span>
<span className="flex items-center gap-2">
<code className="text-sm font-medium">{model ?? '—'}</code>
<Badge variant={sourceTone(source)}>{pick(sourceLabel(source))}</Badge>
<Badge variant={sourceTone(source)}>{sourceLabel(source, t)}</Badge>
</span>
</div>
);
}

export function CloudAiModelStatus({ properties }: CloudAiModelStatusProps) {
const { t } = useObjectTranslation();
const url = properties?.effectiveModelUrl || DEFAULT_URL;
const state = useEffectiveModel(url);

Expand All @@ -133,10 +126,9 @@ export function CloudAiModelStatus({ properties }: CloudAiModelStatusProps) {
if (state.phase === 'error') {
return (
<div className="text-sm text-muted-foreground" data-ai-model-status="error">
{pick({
en: `Couldn't read the effective AI model${state.status ? ` (HTTP ${state.status})` : ''}. This environment may not run an AI service, or you may lack the ai:read permission.`,
zh: `无法读取有效 AI 模型${state.status ? `(HTTP ${state.status})` : ''}。该环境可能未运行 AI 服务,或你没有 ai:read 权限。`,
})}
{state.status
? t('aiModelStatus.readFailedWithStatus', { status: state.status })
: t('aiModelStatus.readFailed')}
</div>
);
}
Expand All @@ -150,33 +142,36 @@ export function CloudAiModelStatus({ properties }: CloudAiModelStatusProps) {

<div className="rounded-md border border-border px-3">
<ModelRow
label={{ en: 'Build / Ask model', zh: 'Build / Ask 模型' }}
label={t('aiModelStatus.rowConversational')}
t={t}
model={report.conversational.model}
source={report.conversational.source}
/>
<ModelRow
label={{ en: 'Structured (blueprint / seed)', zh: '结构化(蓝图 / 种子)' }}
label={t('aiModelStatus.rowStructured')}
t={t}
model={report.structured.model}
source={report.structured.source}
/>
<ModelRow
label={{ en: 'Reasoning effort', zh: '推理强度' }}
label={t('aiModelStatus.rowReasoning')}
t={t}
model={report.reasoningEffort.effective}
source={report.reasoningEffort.source}
/>
</div>

<div className="text-xs text-muted-foreground">
<span className="font-medium">{pick({ en: 'Overrides in effect: ', zh: '生效的 env 覆盖:' })}</span>
<span className="font-medium">{t('aiModelStatus.overridesInEffect')}</span>
{setOverrides.length === 0 ? (
<span>{pick({ en: 'none — running the deployed code defaults.', zh: '无 —— 跑的是部署代码的默认值。' })}</span>
<span>{t('aiModelStatus.noOverrides')}</span>
) : (
<code>{setOverrides.map(([k, v]) => `${k}=${v}`).join(' · ')}</code>
)}
</div>

<p className="text-xs text-muted-foreground">
{pick({ en: 'Adapter: ', zh: '适配器:' })}
{t('aiModelStatus.adapter')}
<code>{report.adapter}{report.provider ? ` / ${report.provider}` : ''}</code>
</p>
</div>
Expand Down
31 changes: 9 additions & 22 deletions packages/app-shell/src/console/home/CloudOnboardingNext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,7 @@ import { Rocket, Plus, Settings2 } from 'lucide-react';
import { useAuth } from '@object-ui/auth';
import { createAuthenticatedFetch } from '@object-ui/auth';
import { ComponentRegistry } from '@object-ui/core';

/** Inline {en,zh} copy resolved against the active locale. */
type I18n = { en: string; zh: string };
import { useObjectTranslation } from '@object-ui/i18n';

interface CloudOnboardingNextProps {
properties?: {
Expand Down Expand Up @@ -63,12 +61,6 @@ const DEFAULT_OPEN_PRODUCTION_URL = '/api/v1/cloud/environments/production/sso-o
const DEFAULT_ENVIRONMENTS_ROUTE = '/apps/cloud_control/sys_environment';

/** Resolve the active locale's string (cheap; the page uses {en,zh} pairs). */
function pick(label: I18n): string {
const lang =
(typeof document !== 'undefined' && document.documentElement.getAttribute('lang')) || 'en';
return lang.toLowerCase().startsWith('zh') ? label.zh : label.en;
}

/**
* Resolve `hasProductionEnv` from the org-scoped entitlements summary. Returns
* `unknown` on any failure so the caller degrades gracefully rather than
Expand Down Expand Up @@ -127,6 +119,7 @@ function openProduction(url: string) {
}

export function CloudOnboardingNext({ properties }: CloudOnboardingNextProps) {
const { t } = useObjectTranslation();
const navigate = useNavigate();
const state = useProductionEnvState(properties?.warmUrl);
const openUrl = properties?.openProductionUrl || DEFAULT_OPEN_PRODUCTION_URL;
Expand All @@ -143,16 +136,10 @@ export function CloudOnboardingNext({ properties }: CloudOnboardingNextProps) {
);
}

const hint: I18n =
const hint =
state.phase === 'ready' && !state.hasProductionEnv
? {
en: 'Spin up your first environment — a private workspace with its own URL, database, and plan. Building happens inside it.',
zh: '创建你的第一个环境——一个独立的工作区,有自己的网址、数据库和套餐。应用的搭建在里面进行。',
}
: {
en: 'Your production environment is ready. Open it to build and run your apps — that all happens inside the environment.',
zh: '你的生产环境已就绪。打开它来搭建和运行应用——这些都在环境内部进行。',
};
? t('cloudOnboarding.hintCreate')
: t('cloudOnboarding.hintReady');

// No production env yet → the real first step is "create", not "open".
const showCreatePrimary = state.phase === 'ready' && !state.hasProductionEnv;
Expand All @@ -168,20 +155,20 @@ export function CloudOnboardingNext({ properties }: CloudOnboardingNextProps) {
// second create button on the list page.
<Button size="lg" onClick={() => navigate(`${envsRoute}?runAction=create_environment`)}>
<Plus className="mr-2 h-4 w-4" />
{pick({ en: 'Create your environment', zh: '创建你的环境' })}
{t('cloudOnboarding.createEnvironment')}
</Button>
) : (
<Button size="lg" onClick={() => openProduction(openUrl)}>
<Rocket className="mr-2 h-4 w-4" />
{pick({ en: 'Open Production', zh: '打开生产环境' })}
{t('cloudOnboarding.openProduction')}
</Button>
)}
<Button size="lg" variant="secondary" onClick={() => navigate(envsRoute)}>
<Settings2 className="mr-2 h-4 w-4" />
{pick({ en: 'Manage environments', zh: '管理环境' })}
{t('cloudOnboarding.manageEnvironments')}
</Button>
</div>
<p className="max-w-xl text-center text-sm text-muted-foreground">{pick(hint)}</p>
<p className="max-w-xl text-center text-sm text-muted-foreground">{hint}</p>
</div>
);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,22 @@ vi.mock('@object-ui/auth', () => ({
createAuthenticatedFetch: () => (url: string, init?: any) => fetchImpl(url, init),
}));

import { I18nProvider } from '@object-ui/i18n';
import { CloudOnboardingNext } from '../CloudOnboardingNext';

/**
* The CTA labels resolve from the locale packs (objectui#2871), so these
* renders need a real i18n context — without one `t()` returns the raw key and
* these assertions would pass against nothing. Pinned to `en` with browser
* detection off so the expectations stay deterministic.
*/
const renderOnboarding = (ui: React.ReactElement) =>
render(
<I18nProvider config={{ defaultLanguage: 'en', detectBrowserLanguage: false }}>
{ui}
</I18nProvider>,
);

function summary(hasProductionEnv: boolean) {
return {
ok: true,
Expand All @@ -46,7 +60,7 @@ describe('CloudOnboardingNext', () => {

it('shows "Create your environment" when the org has no production env', async () => {
fetchImpl = async () => summary(false);
render(<CloudOnboardingNext {...PROPS} />);
renderOnboarding(<CloudOnboardingNext {...PROPS} />);

const create = await screen.findByText('Create your environment');
expect(create).toBeTruthy();
Expand All @@ -62,15 +76,15 @@ describe('CloudOnboardingNext', () => {

it('shows "Open Production" once the org has a production env', async () => {
fetchImpl = async () => summary(true);
render(<CloudOnboardingNext {...PROPS} />);
renderOnboarding(<CloudOnboardingNext {...PROPS} />);

expect(await screen.findByText('Open Production')).toBeTruthy();
expect(screen.queryByText('Create your environment')).toBeNull();
});

it('degrades to the open-production actions when the signal cannot be resolved', async () => {
fetchImpl = async () => ({ ok: false, status: 500, json: async () => null });
render(<CloudOnboardingNext {...PROPS} />);
renderOnboarding(<CloudOnboardingNext {...PROPS} />);

// Unknown state is fail-safe: it must NEVER strand a real user behind a
// wrong "create" CTA, so it shows Open Production + Manage environments.
Expand All @@ -82,7 +96,7 @@ describe('CloudOnboardingNext', () => {
it('renders a non-CTA skeleton while the signal is still loading', async () => {
let resolveFetch: (v: any) => void = () => {};
fetchImpl = () => new Promise((r) => { resolveFetch = r; });
const { container } = render(<CloudOnboardingNext {...PROPS} />);
const { container } = renderOnboarding(<CloudOnboardingNext {...PROPS} />);

// Before the fetch resolves: no CTA text, just the skeleton placeholder.
expect(screen.queryByText('Open Production')).toBeNull();
Expand Down
Loading
Loading