From 8a62e099dcb268f9de21fe12a638553a38762f93 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 07:01:16 +0000 Subject: [PATCH 1/6] wip(ui): localise the console's leftover literals (#25916 round 3) Claude-Session: https://claude.ai/code/session_01C6T942afhqAP5w9XBmfTDC Co-authored-by: Claude --- .../src/pages/developer/FlowRunsPage.tsx | 40 ++-- .../app-shell/src/views/ObjectView.tsx | 4 +- .../views/metadata-admin/ResourceEditPage.tsx | 6 +- .../src/views/metadata-admin/i18n.ts | 7 + .../renderer-copy-locale-25916.test.tsx | 179 ++++++++++++++++++ .../src/renderers/complex/data-table.tsx | 10 +- .../src/renderers/layout/react-page.tsx | 69 ++++++- ui/packages/i18n/src/locales/ar.ts | 14 ++ ui/packages/i18n/src/locales/de.ts | 14 ++ ui/packages/i18n/src/locales/en.ts | 16 ++ ui/packages/i18n/src/locales/es.ts | 14 ++ ui/packages/i18n/src/locales/fr.ts | 14 ++ ui/packages/i18n/src/locales/ja.ts | 14 ++ ui/packages/i18n/src/locales/ko.ts | 14 ++ ui/packages/i18n/src/locales/pt.ts | 14 ++ ui/packages/i18n/src/locales/ru.ts | 14 ++ ui/packages/i18n/src/locales/zh.ts | 14 ++ 17 files changed, 428 insertions(+), 29 deletions(-) create mode 100644 ui/packages/components/src/__tests__/renderer-copy-locale-25916.test.tsx diff --git a/ui/apps/console/src/pages/developer/FlowRunsPage.tsx b/ui/apps/console/src/pages/developer/FlowRunsPage.tsx index 4599c67b096..89de80d6f08 100644 --- a/ui/apps/console/src/pages/developer/FlowRunsPage.tsx +++ b/ui/apps/console/src/pages/developer/FlowRunsPage.tsx @@ -56,6 +56,22 @@ import { Play, Loader2, CheckCircle2, XCircle, Clock, AlertCircle, RefreshCw, Workflow, } from 'lucide-react'; +/** + * A panel's error: the message a call threw, or the client method the panel + * needs and this client lacks. The method is kept by name and worded at render, + * so the diagnostic reads in the viewer's locale (#25916). + */ +type PanelError = { message: string } | { unavailable: string }; + +function panelErrorText(t: ReturnType['t'], error: PanelError): string { + return 'unavailable' in error + ? t('console.developer.flowRuns.clientMethodUnavailable', { + defaultValue: '{{method}} is not available on this client', + method: error.unavailable, + }) + : error.message; +} + interface FlowVariable { name: string; type: string; @@ -197,13 +213,13 @@ export function FlowRunsPage() { const [flows, setFlows] = useState([]); const [flowsLoading, setFlowsLoading] = useState(true); - const [flowsError, setFlowsError] = useState(null); + const [flowsError, setFlowsError] = useState(null); const [selectedFlowName, setSelectedFlowName] = useState(''); const [refreshRuns, setRefreshRuns] = useState(0); const loadFlows = useCallback(async () => { if (!client?.meta?.getItems) { - setFlowsError('meta.getItems is not available on this client'); + setFlowsError({ unavailable: 'meta.getItems' }); setFlowsLoading(false); return; } @@ -233,7 +249,7 @@ export function FlowRunsPage() { setSelectedFlowName(normalized[0].name); } } catch (e: any) { - setFlowsError(e?.message ?? String(e)); + setFlowsError({ message: e?.message ?? String(e) }); } finally { setFlowsLoading(false); } @@ -276,7 +292,7 @@ export function FlowRunsPage() { {flowsLoading && } {flowsError && ( -

{flowsError}

+

{panelErrorText(t, flowsError)}

)} {!flowsLoading && !flowsError && flows.length === 0 && (

{t('console.developer.flowRuns.noFlows', { defaultValue: 'No flow definitions found.' })}

@@ -335,7 +351,7 @@ function FlowTestRunner({ const [values, setValues] = useState>({}); const [running, setRunning] = useState(false); const [result, setResult] = useState(null); - const [error, setError] = useState(null); + const [error, setError] = useState(null); // The paused screen a test run stopped at — driven to completion by // , exactly as a `type: 'flow'` action would. const [screenFlow, setScreenFlow] = useState(null); @@ -351,7 +367,7 @@ function FlowTestRunner({ const handleRun = async () => { if (!client?.automation?.execute) { - setError('automation.execute is not available on this client'); + setError({ unavailable: 'automation.execute' }); return; } setRunning(true); @@ -376,7 +392,7 @@ function FlowTestRunner({ } onExecuted?.(); } catch (e: any) { - setError(e?.message ?? String(e)); + setError({ message: e?.message ?? String(e) }); } finally { setRunning(false); } @@ -455,7 +471,7 @@ function FlowTestRunner({ )} - {error &&

{error}

} + {error &&

{panelErrorText(t, error)}

} {/* A run left paused (the runner was dismissed) can be reopened — the suspension is durable, so the screen is still waiting. */} {result?.status === 'paused' && result?.screen && result?.runId && !screenFlow && ( @@ -518,7 +534,7 @@ function FlowRunsPanel({ const displayLocale = useDisplayLocale(); const [runs, setRuns] = useState([]); const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); + const [error, setError] = useState(null); const [openRunId, setOpenRunId] = useState(null); const [runDetail, setRunDetail] = useState(null); @@ -527,7 +543,7 @@ function FlowRunsPanel({ const loadRuns = useCallback(async () => { if (!client?.automation?.listRuns) { - setError('automation.listRuns is not available on this client'); + setError({ unavailable: 'automation.listRuns' }); setLoading(false); return; } @@ -538,7 +554,7 @@ function FlowRunsPanel({ const items: RunSummary[] = Array.isArray(res) ? res : (res?.items ?? res?.runs ?? []); setRuns(items); } catch (e: any) { - setError(e?.message ?? String(e)); + setError({ message: e?.message ?? String(e) }); } finally { setLoading(false); } @@ -585,7 +601,7 @@ function FlowRunsPanel({ )} {!loading && error && ( -

{error}

+

{panelErrorText(t, error)}

)} {!loading && !error && runs.length === 0 && (

diff --git a/ui/packages/app-shell/src/views/ObjectView.tsx b/ui/packages/app-shell/src/views/ObjectView.tsx index 5b82dabb51d..780a9c5b976 100644 --- a/ui/packages/app-shell/src/views/ObjectView.tsx +++ b/ui/packages/app-shell/src/views/ObjectView.tsx @@ -3176,7 +3176,7 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: Co const dims: string[] = Array.isArray(chartConfig.dimensions) ? chartConfig.dimensions : []; const vals: string[] = Array.isArray(chartConfig.values) ? chartConfig.values : []; return withViewTabs( - Loading chart…}> + {t('chart.loading')}}> Loading chart…}> + {t('chart.loading')}}> - From type - From name - Path + {t('engine.edit.refsColFromType', locale)} + {t('engine.edit.refsColFromName', locale)} + {t('engine.edit.refsColPath', locale)} diff --git a/ui/packages/app-shell/src/views/metadata-admin/i18n.ts b/ui/packages/app-shell/src/views/metadata-admin/i18n.ts index 08c1eca5a0f..a9be7f7d3c8 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/i18n.ts +++ b/ui/packages/app-shell/src/views/metadata-admin/i18n.ts @@ -2172,6 +2172,10 @@ const ENGINE_STRINGS_EN: Record = { 'engine.edit.refsScanning': 'Scanning references…', 'engine.edit.refsEmptyTitle': 'No references found', 'engine.edit.refsEmptyDesc': 'Nothing in the metadata graph points at this item. Safe to delete.', + // #25916 — the references table's column headers. + 'engine.edit.refsColFromType': 'From type', + 'engine.edit.refsColFromName': 'From name', + 'engine.edit.refsColPath': 'Path', // …and its FAILED state (objectui#5110), which must never be confusable with // the empty one above: the copy states that the check did not complete and // deliberately makes no claim — in either direction — about whether deleting @@ -5798,6 +5802,9 @@ const ENGINE_STRINGS_ZH: Record = { 'engine.edit.refsScanning': '正在扫描引用…', 'engine.edit.refsEmptyTitle': '未找到引用', 'engine.edit.refsEmptyDesc': '元数据图中没有任何项指向它,可以安全删除。', + 'engine.edit.refsColFromType': '来源类型', + 'engine.edit.refsColFromName': '来源名称', + 'engine.edit.refsColPath': '路径', // …以及它的失败态(objectui#5110):必须与上面的空态不可混淆 —— 文案只说明检查 // 没有完成,对「删除是否安全」不做任何方向的断言,因为这个问题根本没有被回答。 // 后续改写务必保留这一性质;「未找到引用」正是此状态要避免的那句话。 diff --git a/ui/packages/components/src/__tests__/renderer-copy-locale-25916.test.tsx b/ui/packages/components/src/__tests__/renderer-copy-locale-25916.test.tsx new file mode 100644 index 00000000000..e42febbf05e --- /dev/null +++ b/ui/packages/components/src/__tests__/renderer-copy-locale-25916.test.tsx @@ -0,0 +1,179 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * #25916, third pass — two renderers in this package drew English literals + * whatever the session's language. + * + * - `data-table`: the hover "open record" affordance's title and the inline + * edit row's cancel / save buttons (`table.openRecord`, + * `table.cancelChanges`, `table.saveRow`); + * - the `kind:'react'` page renderer: its capability notice, its empty-source + * refusal, its loading line and its error titles (`reactPage.*`). + * + * What this file pins, through the real renderers and a real `I18nProvider`: + * - under zh each surface draws the zh pack's row (expectations read from the + * pack, guarded as real zh rows); + * - a sentence with an inline `` is ONE translated string: the code + * sits where the zh string put it, and no `{{slot}}` or private-use mark + * is left in the text; + * - control: with no provider each surface still reads its English. + */ +import { describe, it, expect, afterEach } from 'vitest'; +import { cleanup, fireEvent, render, screen, within } from '@testing-library/react'; +import React from 'react'; +import { CAP_REACT_PAGES, ComponentRegistry, disableCapability, enableCapability } from '@object-ui/core'; +import { AdapterCtx, SchemaRenderer } from '@object-ui/react'; +import { I18nProvider } from '@object-ui/i18n'; +import { builtInLocales } from '@object-ui/i18n/locales'; +import type { PageDocumentNode } from '@object-ui/types'; +// Registers the renderers at module scope, NOT inside a `beforeAll`; see +// object-ui/no-dynamic-import-in-test-hook (objectui#3010/#3021). +import '../renderers'; + +const { zh, en } = builtInLocales; + +/** No `{{slot}}` and no private-use slot mark survives into the rendered text. */ +const LEAK = /\{\{|[\u{E000}-\u{F8FF}]/u; + +function withLanguage(element: React.ReactElement, language: 'zh' | null) { + return render( + language ? ( + + {element} + + ) : ( + element + ), + ); +} + +const ALPHA = { id: 'a', name: 'Alpha', qty: 10 }; + +function DataTable({ onRowClick }: { onRowClick?: () => void }) { + const Table = ComponentRegistry.get('data-table')!; + const schema = { + type: 'data-table', + editable: true, + singleClickEdit: true, + rowActions: true, + showRowNumbers: true, + columns: [ + { header: 'Task', accessorKey: 'name', editable: false }, + { header: 'Estimate', accessorKey: 'qty', type: 'number' }, + ], + data: [ALPHA], + onRowSave: async () => undefined, + ...(onRowClick ? { onRowClick } : {}), + } as any; + return ; +} + +/** Stage an edit on Alpha's estimate, so the row draws its cancel / save buttons. */ +function stageEdit(container: HTMLElement) { + const cell = container.querySelectorAll('tbody tr td')[2] as HTMLElement; + fireEvent.click(cell); + const input = cell.querySelector('input') as HTMLInputElement; + fireEvent.change(input, { target: { value: '11' } }); + fireEvent.blur(input); +} + +const PAGE = (source: string): PageDocumentNode => ({ + type: 'home', + kind: 'react', + name: 'locale_page', + label: 'Locale page', + source, +}); + +function ReactPage({ source }: { source: string }) { + return ( + [] } as any}> + + + ); +} + +afterEach(() => { + cleanup(); + disableCapability(CAP_REACT_PAGES); +}); + +describe('renderer copy under zh (#25916)', () => { + it('guard: the zh values read below are real zh rows, not English copies', () => { + for (const [zhValue, enValue] of [ + [zh.table.openRecord, en.table.openRecord], + [zh.table.cancelChanges, en.table.cancelChanges], + [zh.table.saveRow, en.table.saveRow], + [zh.reactPage.disabledTitle, en.reactPage.disabledTitle], + [zh.reactPage.disabledBody, en.reactPage.disabledBody], + [zh.reactPage.sourceRequired, en.reactPage.sourceRequired], + ]) { + expect(zhValue).not.toBe(enValue); + expect(zhValue).toMatch(/\p{Script=Han}/u); + } + }); + + it('data-table: the open affordance and the edited row buttons are titled in zh', () => { + const { container } = withLanguage( undefined} />, 'zh'); + expect(screen.getByTestId('row-expand-button').getAttribute('title')).toBe(zh.table.openRecord); + stageEdit(container); + const row = container.querySelector('tbody tr') as HTMLElement; + expect(within(row).getByTitle(zh.table.cancelChanges)).toBeTruthy(); + expect(within(row).getByTitle(zh.table.saveRow)).toBeTruthy(); + }); + + it('react page, capability off: the notice is zh, its three chips where the zh string put them', async () => { + const { container } = withLanguage(, 'zh'); + expect(await screen.findByText(zh.reactPage.disabledTitle)).toBeTruthy(); + const flag = screen.getByText('OS_PAGE_REACT_ENABLED=true', { selector: 'code' }); + expect(flag.parentElement!.textContent).toBe( + zh.reactPage.disabledBody + .replace('{{kind}}', "kind:'react'") + .replace('{{flag}}', 'OS_PAGE_REACT_ENABLED=true') + .replace('{{call}}', "enableCapability('react-pages')"), + ); + expect(container.textContent).not.toMatch(LEAK); + }); + + it('react page, capability on: an empty source is refused in zh, its chips in place', async () => { + enableCapability(CAP_REACT_PAGES); + const { container } = withLanguage(, 'zh'); + const source = await screen.findByText('source', { selector: 'code' }); + expect(source.parentElement!.textContent).toBe( + zh.reactPage.sourceRequired.replace('{{kind}}', "kind:'react'").replace('{{source}}', 'source'), + ); + expect(container.textContent).not.toMatch(LEAK); + }); +}); + +describe('control: with no provider each surface still reads its English (#25916)', () => { + it('data-table', () => { + const { container } = withLanguage( undefined} />, null); + expect(screen.getByTestId('row-expand-button').getAttribute('title')).toBe('Open record'); + stageEdit(container); + expect(screen.getByTitle('Cancel changes')).toBeTruthy(); + expect(screen.getByTitle('Save row')).toBeTruthy(); + }); + + it('react page notice', async () => { + withLanguage(, null); + const flag = await screen.findByText('OS_PAGE_REACT_ENABLED=true', { selector: 'code' }); + expect(screen.getByText('React pages are disabled on this deployment')).toBeTruthy(); + expect(flag.parentElement!.textContent).toBe( + "kind:'react' pages execute author JavaScript directly in the application, so they are off unless the deployment turns them on. An operator whose page authors are trusted sets OS_PAGE_REACT_ENABLED=true on the server (a host embedding the renderer calls enableCapability('react-pages')).", + ); + }); + + it('react page empty source', async () => { + enableCapability(CAP_REACT_PAGES); + withLanguage(, null); + const source = await screen.findByText('source', { selector: 'code' }); + expect(source.parentElement!.textContent).toBe("A kind:'react' page requires a non-empty source."); + }); +}); diff --git a/ui/packages/components/src/renderers/complex/data-table.tsx b/ui/packages/components/src/renderers/complex/data-table.tsx index df568e9c4b3..8e14f45da0f 100644 --- a/ui/packages/components/src/renderers/complex/data-table.tsx +++ b/ui/packages/components/src/renderers/complex/data-table.tsx @@ -328,6 +328,10 @@ const TABLE_DEFAULT_TRANSLATIONS: Record = { 'table.rowActions': 'Row actions', 'table.edit': 'Edit', 'table.delete': 'Delete', + // #25916 — the hover open affordance and the inline-edit row buttons. + 'table.openRecord': 'Open record', + 'table.cancelChanges': 'Cancel changes', + 'table.saveRow': 'Save row', 'common.actions': 'Actions', // objectui#10625 — the inline date editors' notice for a stored value // written on a day that does not exist. The SAME keys `DateField` / @@ -3575,7 +3579,7 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => { // plain one. schema.onRowClick?.(row, e); }} - title="Open record" + title={t('table.openRecord')} > {t('table.open')} @@ -3947,7 +3951,7 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => { size="icon" onClick={() => cancelRowChanges(rowKey)} disabled={isSaving} - title="Cancel changes" + title={t('table.cancelChanges')} > @@ -3956,7 +3960,7 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => { size="icon" onClick={() => saveRow(rowKey)} disabled={isSaving} - title="Save row" + title={t('table.saveRow')} > diff --git a/ui/packages/components/src/renderers/layout/react-page.tsx b/ui/packages/components/src/renderers/layout/react-page.tsx index f82e28f7431..ca3a21287ea 100644 --- a/ui/packages/components/src/renderers/layout/react-page.tsx +++ b/ui/packages/components/src/renderers/layout/react-page.tsx @@ -44,6 +44,7 @@ import * as React from 'react'; import { ComponentRegistry, isCapabilityEnabled, CAP_REACT_PAGES } from '@object-ui/core'; import { SchemaRenderer, SchemaRendererProvider, useAdapter, useDataInvalidation } from '@object-ui/react'; +import { useObjectTranslation } from '@object-ui/i18n'; type RuntimeModule = typeof import('@object-ui/react-runtime'); @@ -198,15 +199,51 @@ function sameContent(a: unknown, b: unknown, seen: Map = new Map return true; } +/** + * #25916 — this renderer's notices read `reactPage.*`, so a non-English page + * draws its own language. Each inline `defaultValue` is the `en` pack's value. + * + * Two sentences carry a `` chip (the page kind, an env flag, a call). The + * chip cannot be interpolated as text, and splitting the sentence around it + * would fix the word order to English's. So each chip's slot is interpolated + * with a private-use mark ({@link CHIP}), and {@link withChips} splits the + * translated sentence on those marks: the language decides where the chip + * sits. The same technique as `element:metadata_viewer`'s chips. + */ +const CHIP = ['\uE000', '\uE001', '\uE002'] as const; + +/** The translated `text` with `chips[i]` drawn where `CHIP[i]` was interpolated. */ +function withChips(text: string, chips: React.ReactNode[]): React.ReactNode { + return text.split(/([\uE000-\uE002])/u).map((part, i) => { + const at = (CHIP as readonly string[]).indexOf(part); + return at === -1 ? part : {chips[at]}; + }); +} + +const KIND_CHIP = kind:'react'; + function CapabilityDisabledNotice(): React.ReactElement { + const { t } = useObjectTranslation(); return (
-
React pages are disabled on this deployment
+
+ {t('reactPage.disabledTitle', { defaultValue: 'React pages are disabled on this deployment' })} +

- kind:'react' pages execute author JavaScript directly in the - application, so they are off unless the deployment turns them on. An operator whose - page authors are trusted sets OS_PAGE_REACT_ENABLED=true on the server - (a host embedding the renderer calls enableCapability('react-pages')). + {withChips( + t('reactPage.disabledBody', { + defaultValue: + '{{kind}} pages execute author JavaScript directly in the application, so they are off unless the deployment turns them on. An operator whose page authors are trusted sets {{flag}} on the server (a host embedding the renderer calls {{call}}).', + kind: CHIP[0], + flag: CHIP[1], + call: CHIP[2], + }), + [ + KIND_CHIP, + OS_PAGE_REACT_ENABLED=true, + enableCapability('react-pages'), + ], + )}

); @@ -217,6 +254,7 @@ export const ReactKindPage: React.FC<{ schema: any }> = ({ schema }) => { // The live data source for the injected data blocks (and the page's own // `useAdapter()` calls). Same object the rest of the app renders against. const adapter = useAdapter(); + const { t } = useObjectTranslation(); // Gate: default-closed, on only where the host enabled it. Read here so the hooks // below stay unconditional; the disabled notice is returned after them, and // the effect never loads the gated runtime when disabled. @@ -279,7 +317,9 @@ export const ReactKindPage: React.FC<{ schema: any }> = ({ schema }) => { if (loadError) { return (
-
Failed to load the react runtime
+
+ {t('reactPage.loadFailed', { defaultValue: 'Failed to load the react runtime' })} +
{String(loadError)}
); @@ -287,12 +327,23 @@ export const ReactKindPage: React.FC<{ schema: any }> = ({ schema }) => { if (!source.trim()) { return (
- A kind:'react' page requires a non-empty source. + {withChips( + t('reactPage.sourceRequired', { + defaultValue: 'A {{kind}} page requires a non-empty {{source}}.', + kind: CHIP[0], + source: CHIP[1], + }), + [KIND_CHIP, source], + )}
); } if (!runtime) { - return
Loading react runtime…
; + return ( +
+ {t('reactPage.loading', { defaultValue: 'Loading react runtime…' })} +
+ ); } const { ReactRunner } = runtime; @@ -303,7 +354,7 @@ export const ReactKindPage: React.FC<{ schema: any }> = ({ schema }) => { scope={scope} fallback={(error) => (
-
React page error
+
{t('reactPage.error', { defaultValue: 'React page error' })}
{String(error)}
)} diff --git a/ui/packages/i18n/src/locales/ar.ts b/ui/packages/i18n/src/locales/ar.ts index ac057c1c960..891cfe2b3cf 100644 --- a/ui/packages/i18n/src/locales/ar.ts +++ b/ui/packages/i18n/src/locales/ar.ts @@ -474,6 +474,10 @@ const ar = { selected_other: "تم تحديد {{count}}", edit: "تعديل", delete: "حذف", + // The inline-edit row buttons and the hover open affordance (#25916). + openRecord: "فتح السجل", + cancelChanges: "إلغاء التغييرات", + saveRow: "حفظ الصف", }, grid: { bulkSelected: "تم تحديد {{count}}", @@ -2611,6 +2615,8 @@ const ar = { stepInput: 'الإدخال', stepOutput: 'الإخراج', stepError: 'خطأ', + // A client that lacks the method a panel calls (#25916). + clientMethodUnavailable: "{{method}} غير متاح في هذا العميل", }, // objectui#13460 — the Integrations page's "Connect an AI agent" card (`AgentConnectSection`). agentConnect: { @@ -5177,6 +5183,14 @@ const ar = { unknownTypeNamed: "نوع عرض بيانات وصفية غير معروف «{{type}}». المتوقع {{a}} أو {{b}} أو {{c}}.", }, }, + reactPage: { + disabledTitle: "صفحات React معطلة في هذا النشر", + disabledBody: "تنفذ صفحات {{kind}} شيفرة JavaScript الخاصة بالمؤلف مباشرة داخل التطبيق، لذا تكون معطلة ما لم يفعّلها النشر. يضبط المشغّل الذي يثق بمؤلفي صفحاته {{flag}} على الخادم (ويستدعي المضيف الذي يضمّن العارض {{call}}).", + loadFailed: "تعذّر تحميل بيئة تشغيل React", + sourceRequired: "تتطلب صفحة {{kind}} قيمة {{source}} غير فارغة.", + loading: "جارٍ تحميل بيئة تشغيل React…", + error: "خطأ في صفحة React", + }, }; export default ar; diff --git a/ui/packages/i18n/src/locales/de.ts b/ui/packages/i18n/src/locales/de.ts index 2dca8512ae5..45df300eb6b 100644 --- a/ui/packages/i18n/src/locales/de.ts +++ b/ui/packages/i18n/src/locales/de.ts @@ -444,6 +444,10 @@ const de = { selected_other: "{{count}} ausgewählt", edit: "Bearbeiten", delete: "Löschen", + // The inline-edit row buttons and the hover open affordance (#25916). + openRecord: "Datensatz öffnen", + cancelChanges: "Änderungen verwerfen", + saveRow: "Zeile speichern", }, grid: { bulkSelected: "{{count}} ausgewählt", @@ -2409,6 +2413,8 @@ const de = { stepInput: 'Eingabe', stepOutput: 'Ausgabe', stepError: 'Fehler', + // A client that lacks the method a panel calls (#25916). + clientMethodUnavailable: "{{method}} ist auf diesem Client nicht verfügbar", }, // objectui#13460 — the Integrations page's "Connect an AI agent" card (`AgentConnectSection`). agentConnect: { @@ -4723,6 +4729,14 @@ const de = { unknownTypeNamed: "Unbekannter Metadaten-Ansichtstyp „{{type}}“. Erwartet: {{a}}, {{b}} oder {{c}}.", }, }, + reactPage: { + disabledTitle: "React-Seiten sind in dieser Bereitstellung deaktiviert", + disabledBody: "Seiten vom Typ {{kind}} führen das JavaScript ihrer Autoren direkt in der Anwendung aus und sind daher deaktiviert, sofern die Bereitstellung sie nicht einschaltet. Ein Betreiber, der seinen Seitenautoren vertraut, setzt {{flag}} auf dem Server (ein Host, der den Renderer einbettet, ruft {{call}} auf).", + loadFailed: "Die React-Laufzeit konnte nicht geladen werden", + sourceRequired: "Eine Seite vom Typ {{kind}} benötigt einen nicht leeren Wert für {{source}}.", + loading: "React-Laufzeit wird geladen…", + error: "Fehler auf der React-Seite", + }, }; export default de; diff --git a/ui/packages/i18n/src/locales/en.ts b/ui/packages/i18n/src/locales/en.ts index ef2ecfa2f7c..912c9ea46ae 100644 --- a/ui/packages/i18n/src/locales/en.ts +++ b/ui/packages/i18n/src/locales/en.ts @@ -673,6 +673,10 @@ const en = { selected_other: '{{count}} selected', edit: 'Edit', delete: 'Delete', + // The inline-edit row buttons and the hover open affordance (#25916). + openRecord: 'Open record', + cancelChanges: 'Cancel changes', + saveRow: 'Save row', }, grid: { actions: 'Actions', @@ -2932,6 +2936,8 @@ const en = { stepInput: 'Input', stepOutput: 'Output', stepError: 'Error', + // A client that lacks the method a panel calls (#25916). + clientMethodUnavailable: '{{method}} is not available on this client', }, // objectui#13460 — the Integrations page's "Connect an AI agent" card (`AgentConnectSection`). agentConnect: { @@ -5522,6 +5528,16 @@ const en = { unknownTypeNamed: 'Unknown metadata view type “{{type}}”. Expected {{a}}, {{b}} or {{c}}.', }, }, + // `kind:'react'` page renderer's notices (`react-page.tsx`, #25916). A + // `{{slot}}` is a `` chip the renderer draws where the language put it. + reactPage: { + disabledTitle: 'React pages are disabled on this deployment', + disabledBody: '{{kind}} pages execute author JavaScript directly in the application, so they are off unless the deployment turns them on. An operator whose page authors are trusted sets {{flag}} on the server (a host embedding the renderer calls {{call}}).', + loadFailed: 'Failed to load the react runtime', + sourceRequired: 'A {{kind}} page requires a non-empty {{source}}.', + loading: 'Loading react runtime…', + error: 'React page error', + }, } as const; export default en; diff --git a/ui/packages/i18n/src/locales/es.ts b/ui/packages/i18n/src/locales/es.ts index 464ece759f5..49755a9440b 100644 --- a/ui/packages/i18n/src/locales/es.ts +++ b/ui/packages/i18n/src/locales/es.ts @@ -455,6 +455,10 @@ const es = { selected_other: "{{count}} seleccionados", edit: "Editar", delete: "Eliminar", + // The inline-edit row buttons and the hover open affordance (#25916). + openRecord: "Abrir registro", + cancelChanges: "Descartar cambios", + saveRow: "Guardar fila", }, grid: { bulkSelected: "{{count}} seleccionados", @@ -2461,6 +2465,8 @@ const es = { stepInput: 'Entrada', stepOutput: 'Salida', stepError: 'Error', + // A client that lacks the method a panel calls (#25916). + clientMethodUnavailable: "{{method}} no está disponible en este cliente", }, // objectui#13460 — the Integrations page's "Connect an AI agent" card (`AgentConnectSection`). agentConnect: { @@ -4836,6 +4842,14 @@ const es = { unknownTypeNamed: "Tipo de vista de metadatos desconocido «{{type}}». Se esperaba {{a}}, {{b}} o {{c}}.", }, }, + reactPage: { + disabledTitle: "Las páginas React están desactivadas en esta implementación", + disabledBody: "Las páginas {{kind}} ejecutan el JavaScript del autor directamente en la aplicación, por lo que están desactivadas salvo que la implementación las active. Un operador que confía en los autores de sus páginas establece {{flag}} en el servidor (un host que integra el renderizador llama a {{call}}).", + loadFailed: "No se pudo cargar el entorno de ejecución de React", + sourceRequired: "Una página {{kind}} requiere un {{source}} no vacío.", + loading: "Cargando el entorno de ejecución de React…", + error: "Error en la página React", + }, }; export default es; diff --git a/ui/packages/i18n/src/locales/fr.ts b/ui/packages/i18n/src/locales/fr.ts index 16c2d6bef70..977e180ee73 100644 --- a/ui/packages/i18n/src/locales/fr.ts +++ b/ui/packages/i18n/src/locales/fr.ts @@ -450,6 +450,10 @@ const fr = { selected_other: "{{count}} sélectionnés", edit: "Modifier", delete: "Supprimer", + // The inline-edit row buttons and the hover open affordance (#25916). + openRecord: "Ouvrir l'enregistrement", + cancelChanges: "Annuler les modifications", + saveRow: "Enregistrer la ligne", }, grid: { bulkSelected: "{{count}} sélectionné(s)", @@ -2457,6 +2461,8 @@ const fr = { stepInput: 'Entrée', stepOutput: 'Sortie', stepError: 'Erreur', + // A client that lacks the method a panel calls (#25916). + clientMethodUnavailable: "{{method}} n'est pas disponible sur ce client", }, // objectui#13460 — the Integrations page's "Connect an AI agent" card (`AgentConnectSection`). agentConnect: { @@ -4832,6 +4838,14 @@ const fr = { unknownTypeNamed: "Type de vue de métadonnées inconnu « {{type}} ». Attendu : {{a}}, {{b}} ou {{c}}.", }, }, + reactPage: { + disabledTitle: "Les pages React sont désactivées sur ce déploiement", + disabledBody: "Les pages {{kind}} exécutent le JavaScript de leur auteur directement dans l'application, elles sont donc désactivées sauf si le déploiement les active. Un opérateur qui fait confiance aux auteurs de ses pages définit {{flag}} sur le serveur (un hôte qui intègre le moteur de rendu appelle {{call}}).", + loadFailed: "Impossible de charger l'environnement d'exécution React", + sourceRequired: "Une page {{kind}} nécessite un {{source}} non vide.", + loading: "Chargement de l'environnement d'exécution React…", + error: "Erreur de la page React", + }, }; export default fr; diff --git a/ui/packages/i18n/src/locales/ja.ts b/ui/packages/i18n/src/locales/ja.ts index ae922186d1a..567ea51bf82 100644 --- a/ui/packages/i18n/src/locales/ja.ts +++ b/ui/packages/i18n/src/locales/ja.ts @@ -444,6 +444,10 @@ const ja = { selected_other: "{{count}} 件選択中", edit: "編集", delete: "削除", + // The inline-edit row buttons and the hover open affordance (#25916). + openRecord: "レコードを開く", + cancelChanges: "変更を取り消す", + saveRow: "行を保存", }, grid: { bulkSelected: "{{count}} 件を選択中", @@ -2411,6 +2415,8 @@ const ja = { stepInput: '入力', stepOutput: '出力', stepError: 'エラー', + // A client that lacks the method a panel calls (#25916). + clientMethodUnavailable: "このクライアントでは {{method}} を利用できません", }, // objectui#13460 — the Integrations page's "Connect an AI agent" card (`AgentConnectSection`). agentConnect: { @@ -4724,6 +4730,14 @@ const ja = { unknownTypeNamed: "不明なメタデータビューの種類「{{type}}」です。{{a}}、{{b}}、{{c}} のいずれかを指定してください。", }, }, + reactPage: { + disabledTitle: "このデプロイでは React ページが無効です", + disabledBody: "{{kind}} ページは作成者の JavaScript をアプリケーション内で直接実行するため、デプロイで有効にしない限りオフになっています。ページ作成者を信頼できるオペレーターは、サーバーで {{flag}} を設定します(レンダラーを埋め込むホストは {{call}} を呼び出します)。", + loadFailed: "React ランタイムを読み込めませんでした", + sourceRequired: "{{kind}} ページには空でない {{source}} が必要です。", + loading: "React ランタイムを読み込んでいます…", + error: "React ページのエラー", + }, }; export default ja; diff --git a/ui/packages/i18n/src/locales/ko.ts b/ui/packages/i18n/src/locales/ko.ts index 4d2e691ec58..72a5459b2e7 100644 --- a/ui/packages/i18n/src/locales/ko.ts +++ b/ui/packages/i18n/src/locales/ko.ts @@ -444,6 +444,10 @@ const ko = { selected_other: "{{count}}개 선택됨", edit: "편집", delete: "삭제", + // The inline-edit row buttons and the hover open affordance (#25916). + openRecord: "레코드 열기", + cancelChanges: "변경 취소", + saveRow: "행 저장", }, grid: { bulkSelected: "{{count}}건 선택됨", @@ -2409,6 +2413,8 @@ const ko = { stepInput: '입력', stepOutput: '출력', stepError: '오류', + // A client that lacks the method a panel calls (#25916). + clientMethodUnavailable: "이 클라이언트에서는 {{method}} 기능을 사용할 수 없습니다", }, // objectui#13460 — the Integrations page's "Connect an AI agent" card (`AgentConnectSection`). agentConnect: { @@ -4721,6 +4727,14 @@ const ko = { unknownTypeNamed: "알 수 없는 메타데이터 보기 유형 “{{type}}”입니다. {{a}}, {{b}} 또는 {{c}}이어야 합니다.", }, }, + reactPage: { + disabledTitle: "이 배포에서는 React 페이지가 비활성화되어 있습니다", + disabledBody: "{{kind}} 페이지는 작성자의 JavaScript를 애플리케이션에서 직접 실행하므로 배포에서 켜지 않는 한 꺼져 있습니다. 페이지 작성자를 신뢰하는 운영자는 서버에 {{flag}} 설정을 적용합니다(렌더러를 임베드하는 호스트는 {{call}} 호출).", + loadFailed: "React 런타임을 불러오지 못했습니다", + sourceRequired: "{{kind}} 페이지에는 비어 있지 않은 {{source}} 값이 필요합니다.", + loading: "React 런타임을 불러오는 중…", + error: "React 페이지 오류", + }, }; export default ko; diff --git a/ui/packages/i18n/src/locales/pt.ts b/ui/packages/i18n/src/locales/pt.ts index 6947902e67b..02b58db8da4 100644 --- a/ui/packages/i18n/src/locales/pt.ts +++ b/ui/packages/i18n/src/locales/pt.ts @@ -450,6 +450,10 @@ const pt = { selected_other: "{{count}} selecionados", edit: "Editar", delete: "Excluir", + // The inline-edit row buttons and the hover open affordance (#25916). + openRecord: "Abrir registro", + cancelChanges: "Descartar alterações", + saveRow: "Salvar linha", }, grid: { bulkSelected: "{{count}} selecionados", @@ -2456,6 +2460,8 @@ const pt = { stepInput: 'Entrada', stepOutput: 'Saída', stepError: 'Erro', + // A client that lacks the method a panel calls (#25916). + clientMethodUnavailable: "{{method}} não está disponível neste cliente", }, // objectui#13460 — the Integrations page's "Connect an AI agent" card (`AgentConnectSection`). agentConnect: { @@ -4831,6 +4837,14 @@ const pt = { unknownTypeNamed: "Tipo de visualização de metadados desconhecido “{{type}}”. Esperado {{a}}, {{b}} ou {{c}}.", }, }, + reactPage: { + disabledTitle: "As páginas React estão desativadas nesta implantação", + disabledBody: "As páginas {{kind}} executam o JavaScript do autor diretamente no aplicativo, por isso ficam desativadas, a menos que a implantação as ative. Um operador que confia nos autores das suas páginas define {{flag}} no servidor (um host que incorpora o renderizador chama {{call}}).", + loadFailed: "Não foi possível carregar o runtime do React", + sourceRequired: "Uma página {{kind}} requer um {{source}} não vazio.", + loading: "Carregando o runtime do React…", + error: "Erro na página React", + }, }; export default pt; diff --git a/ui/packages/i18n/src/locales/ru.ts b/ui/packages/i18n/src/locales/ru.ts index bd5316c6919..747023fad25 100644 --- a/ui/packages/i18n/src/locales/ru.ts +++ b/ui/packages/i18n/src/locales/ru.ts @@ -459,6 +459,10 @@ const ru = { selected_other: "Выбрано: {{count}}", edit: "Редактировать", delete: "Удалить", + // The inline-edit row buttons and the hover open affordance (#25916). + openRecord: "Открыть запись", + cancelChanges: "Отменить изменения", + saveRow: "Сохранить строку", }, grid: { bulkSelected: "Выбрано: {{count}}", @@ -2518,6 +2522,8 @@ const ru = { stepInput: 'Вход', stepOutput: 'Выход', stepError: 'Ошибка', + // A client that lacks the method a panel calls (#25916). + clientMethodUnavailable: "Метод {{method}} недоступен в этом клиенте", }, // objectui#13460 — the Integrations page's "Connect an AI agent" card (`AgentConnectSection`). agentConnect: { @@ -4958,6 +4964,14 @@ const ru = { unknownTypeNamed: "Неизвестный тип представления метаданных «{{type}}». Ожидается {{a}}, {{b}} или {{c}}.", }, }, + reactPage: { + disabledTitle: "Страницы React отключены в этом развёртывании", + disabledBody: "Страницы {{kind}} выполняют JavaScript автора прямо в приложении, поэтому они отключены, пока развёртывание их не включит. Оператор, доверяющий авторам своих страниц, задаёт {{flag}} на сервере (хост, встраивающий рендерер, вызывает {{call}}).", + loadFailed: "Не удалось загрузить среду выполнения React", + sourceRequired: "Странице {{kind}} требуется непустое значение {{source}}.", + loading: "Загрузка среды выполнения React…", + error: "Ошибка страницы React", + }, }; export default ru; diff --git a/ui/packages/i18n/src/locales/zh.ts b/ui/packages/i18n/src/locales/zh.ts index e7253201c8d..aee0eb1ff9d 100644 --- a/ui/packages/i18n/src/locales/zh.ts +++ b/ui/packages/i18n/src/locales/zh.ts @@ -453,6 +453,10 @@ const zh = { selected_other: '已选择 {{count}} 项', edit: '编辑', delete: '删除', + // The inline-edit row buttons and the hover open affordance (#25916). + openRecord: '打开记录', + cancelChanges: '取消更改', + saveRow: '保存行', }, grid: { actions: '操作', @@ -2502,6 +2506,8 @@ const zh = { stepInput: '输入', stepOutput: '输出', stepError: '错误', + // A client that lacks the method a panel calls (#25916). + clientMethodUnavailable: '此客户端不支持 {{method}}', }, // objectui#13460 — the Integrations page's "Connect an AI agent" card (`AgentConnectSection`). agentConnect: { @@ -4779,6 +4785,14 @@ const zh = { unknownTypeNamed: '未知的元数据视图类型“{{type}}”。应为 {{a}}、{{b}} 或 {{c}}。', }, }, + reactPage: { + disabledTitle: 'React 页面在此部署中已禁用', + disabledBody: '{{kind}} 页面会在应用中直接执行作者编写的 JavaScript,因此除非部署将其开启,否则处于关闭状态。信任其页面作者的运维人员可在服务器上设置 {{flag}}(嵌入渲染器的宿主则调用 {{call}})。', + loadFailed: '无法加载 React 运行时', + sourceRequired: '{{kind}} 页面需要非空的 {{source}}。', + loading: '正在加载 React 运行时…', + error: 'React 页面错误', + }, } as const; export default zh; From 962206c59cbe2c0ac3b61823f74f25f52a51bc2c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 07:10:05 +0000 Subject: [PATCH 2/6] test(ui): pin the round-3 locale reads under zh and their English controls (#25916) Claim-free WIP on the card branch. Claude-Session: https://claude.ai/code/session_01C6T942afhqAP5w9XBmfTDC Co-authored-by: Claude --- .../flowRunsClientDiagnostic-25916.test.tsx | 104 ++++++++++++++ ...eEditPage.referencesColumns-25916.test.tsx | 129 ++++++++++++++++++ 2 files changed, 233 insertions(+) create mode 100644 ui/apps/console/src/pages/developer/flowRunsClientDiagnostic-25916.test.tsx create mode 100644 ui/packages/app-shell/src/views/metadata-admin/ResourceEditPage.referencesColumns-25916.test.tsx diff --git a/ui/apps/console/src/pages/developer/flowRunsClientDiagnostic-25916.test.tsx b/ui/apps/console/src/pages/developer/flowRunsClientDiagnostic-25916.test.tsx new file mode 100644 index 00000000000..01aec33d5b3 --- /dev/null +++ b/ui/apps/console/src/pages/developer/flowRunsClientDiagnostic-25916.test.tsx @@ -0,0 +1,104 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #25916, third pass — Flow Runs' client-capability diagnostics speak the + * session's language. + * + * Each of the page's three panels calls one client method (`meta.getItems`, + * `automation.execute`, `automation.listRuns`). A client without it used to + * leave an English sentence in the panel's error state. The state now keeps + * the method's NAME and the panel words it at render through + * `console.developer.flowRuns.clientMethodUnavailable`, so under zh the + * sentence is the zh row with the method name in it. A thrown message is + * still drawn verbatim. Control: with no provider the English sentence is + * unchanged. + */ + +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { I18nProvider } from '@object-ui/i18n'; +import { builtInLocales } from '@object-ui/i18n/locales'; + +const CLIENT = vi.hoisted(() => ({ current: {} as Record })); +const ADAPTER = vi.hoisted(() => ({ getClient: () => CLIENT.current })); +vi.mock('@object-ui/app-shell', async (importOriginal) => ({ + ...(await importOriginal>()), + useAdapter: () => ADAPTER, + useMetadata: () => ({ objects: [], getTypeStatus: () => 'loaded' }), +})); + +// Imported after the mock so the page picks it up. +import { FlowRunsPage } from './FlowRunsPage'; + +afterEach(() => cleanup()); + +const { zh } = builtInLocales; +const zhFlow = zh.console.developer.flowRuns; + +/** A client that serves one flow but has no automation surface at all. */ +const NO_AUTOMATION = { meta: { getItems: async () => ({ type: 'flow', items: [{ name: 'welcome_flow' }] }) } }; +/** A client that throws from the one method it has. */ +const THROWS = { meta: { getItems: async () => { throw new Error('boom: 503'); } } }; + +function page(language: 'zh' | null) { + const routed = ( + + + + ); + return render( + language ? ( + + {routed} + + ) : ( + routed + ), + ); +} + +const unavailable = (method: string) => zhFlow.clientMethodUnavailable.replace('{{method}}', method); + +describe('Flow Runs client diagnostics under zh (#25916)', () => { + it('guard: the zh row is a real zh row', () => { + expect(zhFlow.clientMethodUnavailable).toMatch(/\p{Script=Han}/u); + expect(zhFlow.clientMethodUnavailable).toContain('{{method}}'); + }); + + it('a client without meta.getItems: the flow panel says so in zh', async () => { + CLIENT.current = {}; + page('zh'); + expect(await screen.findByText(unavailable('meta.getItems'))).toBeTruthy(); + }); + + it('a client without automation: the runs panel and a test run say so in zh', async () => { + CLIENT.current = NO_AUTOMATION; + page('zh'); + expect(await screen.findByText(unavailable('automation.listRuns'))).toBeTruthy(); + fireEvent.click(screen.getByRole('button', { name: zhFlow.runFlow })); + expect(await screen.findByText(unavailable('automation.execute'))).toBeTruthy(); + }); + + it('a thrown message is still drawn verbatim', async () => { + CLIENT.current = THROWS; + page('zh'); + expect(await screen.findByText('boom: 503')).toBeTruthy(); + }); +}); + +describe('control: with no provider the diagnostics read their English (#25916)', () => { + it('meta.getItems', async () => { + CLIENT.current = {}; + page(null); + expect(await screen.findByText('meta.getItems is not available on this client')).toBeTruthy(); + }); + + it('automation.listRuns and automation.execute', async () => { + CLIENT.current = NO_AUTOMATION; + page(null); + expect(await screen.findByText('automation.listRuns is not available on this client')).toBeTruthy(); + fireEvent.click(screen.getByRole('button', { name: 'Run Flow' })); + expect(await screen.findByText('automation.execute is not available on this client')).toBeTruthy(); + }); +}); diff --git a/ui/packages/app-shell/src/views/metadata-admin/ResourceEditPage.referencesColumns-25916.test.tsx b/ui/packages/app-shell/src/views/metadata-admin/ResourceEditPage.referencesColumns-25916.test.tsx new file mode 100644 index 00000000000..9029254bb86 --- /dev/null +++ b/ui/packages/app-shell/src/views/metadata-admin/ResourceEditPage.referencesColumns-25916.test.tsx @@ -0,0 +1,129 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #25916, third pass — the References sheet's table headers read + * `engine.edit.refsCol*` from this module's own `engine.*` table (the Studio + * carve-out, `packages/i18n/README.md`), so a zh session reads its column + * names in zh where they were English literals. Control: an English session + * still reads From type / From name / Path. + */ + +import '@testing-library/jest-dom/vitest'; +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { fireEvent, render, screen, cleanup, waitFor, within } from '@testing-library/react'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import { I18nProvider } from '@object-ui/i18n'; +import { t } from './i18n'; + +const objectDef = { + name: 'showcase_account', + label: 'Account', + fields: [{ name: 'title', label: 'Title', type: 'text' }], +}; + +/** A plain packaged entry — this suite is about the References sheet only. */ +const LAYERED = { + code: { ...objectDef, _packageId: 'com.example.showcase', _provenance: 'package' }, + overlay: null, + effective: { ...objectDef }, + provenance: 'package', + packageId: 'com.example.showcase', + editable: true, + deletable: true, + resettable: true, +}; + +const referencesImpl = { + current: vi.fn(async () => [{ fromType: 'view', fromName: 'all_accounts', path: 'data.object' }]), +}; + +const mockClient = { + list: vi.fn(async () => []), + listDrafts: vi.fn(async () => []), + get: vi.fn(async () => null), + getDraft: vi.fn(async () => null), + layered: vi.fn(async () => LAYERED), + reset: vi.fn(async () => ({ success: true })), + references: vi.fn(async (...args: unknown[]) => referencesImpl.current(...args)), +}; + +vi.mock('./useMetadata', async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + useMetadataClient: () => mockClient, + useMetadataTypes: () => ({ + loading: false, + error: null, + entries: [ + { + type: 'object', + name: 'object', + label: 'Object', + allowEnvironmentOverlay: true, + allowRuntimeCreate: true, + schema: { + type: 'object', + properties: { name: { type: 'string' }, label: { type: 'string' } }, + }, + }, + ], + }), + }; +}); + +import { MetadataResourceEditPage } from './ResourceEditPage'; + +const START_PATH = '/metadata/object/showcase_account'; + +function editor(language: 'zh' | 'en') { + return render( + + + + } + /> + + + , + ); +} + +/** Open the References sheet (the lazy load) and return its table's header cells. */ +async function headersIn(locale: 'zh-CN' | 'en-US') { + await waitFor(() => expect(mockClient.layered).toHaveBeenCalled()); + fireEvent.click(await screen.findByTitle(t('engine.edit.references', locale))); + const name = await screen.findByText('all_accounts'); + const table = name.closest('table') as HTMLElement; + return within(table).getAllByRole('columnheader').map((th) => th.textContent); +} + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); + +describe('References sheet column headers (#25916)', () => { + it('guard: the zh rows are real zh, not English copies', () => { + for (const key of ['engine.edit.refsColFromType', 'engine.edit.refsColFromName', 'engine.edit.refsColPath']) { + expect(t(key, 'zh-CN')).not.toBe(t(key, 'en-US')); + expect(t(key, 'zh-CN')).toMatch(/\p{Script=Han}/u); + } + }); + + it('a zh session reads the column names in zh', async () => { + editor('zh'); + expect(await headersIn('zh-CN')).toEqual([ + t('engine.edit.refsColFromType', 'zh-CN'), + t('engine.edit.refsColFromName', 'zh-CN'), + t('engine.edit.refsColPath', 'zh-CN'), + ]); + }); + + it('control: an English session still reads From type / From name / Path', async () => { + editor('en'); + expect(await headersIn('en-US')).toEqual(['From type', 'From name', 'Path']); + }); +}); From 7f325cf53dd2c4cee02e0c72168cdc8234204599 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 07:18:46 +0000 Subject: [PATCH 3/6] chore(changeset): declare the round-3 locale reads (#25916) Claude-Session: https://claude.ai/code/session_01C6T942afhqAP5w9XBmfTDC Co-authored-by: Claude --- .../25916-console-leftover-strings-round-3.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 .changeset/25916-console-leftover-strings-round-3.md diff --git a/.changeset/25916-console-leftover-strings-round-3.md b/.changeset/25916-console-leftover-strings-round-3.md new file mode 100644 index 00000000000..3e64135b680 --- /dev/null +++ b/.changeset/25916-console-leftover-strings-round-3.md @@ -0,0 +1,19 @@ +--- +'@object-ui/i18n': patch +'@object-ui/components': patch +'@object-ui/app-shell': patch +'@object-ui/console': patch +--- + +fix(i18n): the last English literals on the data table, the chart view, react pages, the References sheet and Flow Runs read the user's language + +Clause-②: no +Metadata-protocol: none + +- **Data table.** The hover "open record" affordance and the inline-edit row's cancel and save buttons are titled from `table.openRecord`, `table.cancelChanges` and `table.saveRow`. +- **Chart view.** A chart view's loading fallback reads the existing `chart.loading` row. +- **`kind:'react'` pages.** The capability notice, the empty-source refusal, the loading line and the two error titles read `reactPage.*`. Sentences with an inline code chip are one translated string, so each language decides where the chip sits. +- **References sheet (Studio).** The table's column headers read `engine.edit.refsColFromType`, `refsColFromName` and `refsColPath` from the Studio's own table, in English and Chinese. +- **Flow Runs.** When the client lacks the method a panel calls (`meta.getItems`, `automation.execute`, `automation.listRuns`), the panel keeps the method's name and words the diagnostic at render from `console.developer.flowRuns.clientMethodUnavailable`. A message the call threw is still shown as it came. + +All ten locale packs carry the new rows. With no i18n provider every surface still renders its English. From 522c33d653493c3673d3dcc62fb85027077ab07b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 07:39:44 +0000 Subject: [PATCH 4/6] test(ui): type the References-columns fixture without a rest spread (#25916) Claude-Session: https://claude.ai/code/session_01C6T942afhqAP5w9XBmfTDC Co-authored-by: Claude --- .../ResourceEditPage.referencesColumns-25916.test.tsx | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/ui/packages/app-shell/src/views/metadata-admin/ResourceEditPage.referencesColumns-25916.test.tsx b/ui/packages/app-shell/src/views/metadata-admin/ResourceEditPage.referencesColumns-25916.test.tsx index 9029254bb86..e52275778a2 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/ResourceEditPage.referencesColumns-25916.test.tsx +++ b/ui/packages/app-shell/src/views/metadata-admin/ResourceEditPage.referencesColumns-25916.test.tsx @@ -33,9 +33,8 @@ const LAYERED = { resettable: true, }; -const referencesImpl = { - current: vi.fn(async () => [{ fromType: 'view', fromName: 'all_accounts', path: 'data.object' }]), -}; +/** One reference, so the sheet draws its table. */ +const REFERENCE = { fromType: 'view', fromName: 'all_accounts', path: 'data.object' }; const mockClient = { list: vi.fn(async () => []), @@ -44,7 +43,7 @@ const mockClient = { getDraft: vi.fn(async () => null), layered: vi.fn(async () => LAYERED), reset: vi.fn(async () => ({ success: true })), - references: vi.fn(async (...args: unknown[]) => referencesImpl.current(...args)), + references: vi.fn(async () => [REFERENCE]), }; vi.mock('./useMetadata', async (importOriginal) => { From d0fdc5fbb3ae1c11bfba49b64b269e527cb47397 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 07:40:07 +0000 Subject: [PATCH 5/6] chore(sdui): re-record the manifest's registry-source hash after the round-3 copy edits (#25916) The manifest's content is unchanged (106 components); only the hash of the registry sources it was generated from moved, because data-table.tsx and react-page.tsx are registry sources. Claude-Session: https://claude.ai/code/session_01C6T942afhqAP5w9XBmfTDC Co-authored-by: Claude --- scripts/sdui-manifest.record.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/sdui-manifest.record.json b/scripts/sdui-manifest.record.json index 1555759c7e3..76e9096d400 100644 --- a/scripts/sdui-manifest.record.json +++ b/scripts/sdui-manifest.record.json @@ -49,6 +49,6 @@ "ui/packages/types" ], "registrySources": 922, - "registrySourcesSha256": "ad9e0875b83f339e03c528fc3be774592615dfdc5ee5fe9becc4376ddb5f0d52" + "registrySourcesSha256": "6a79f03a5fabc2ecde1a419202adbac7137a3e3a679beb2881c2fac39e88bb0d" } } From 3d2fd1da9042dfa91f6733cd23affde531dae091 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 07:46:19 +0000 Subject: [PATCH 6/6] test(ui): resolve the data-table renderer at module scope in the round-3 locale pin (#25916) react-hooks/static-components refused the in-render registry lookup. Claude-Session: https://claude.ai/code/session_01C6T942afhqAP5w9XBmfTDC Co-authored-by: Claude --- .../src/__tests__/renderer-copy-locale-25916.test.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/ui/packages/components/src/__tests__/renderer-copy-locale-25916.test.tsx b/ui/packages/components/src/__tests__/renderer-copy-locale-25916.test.tsx index e42febbf05e..665f6af2115 100644 --- a/ui/packages/components/src/__tests__/renderer-copy-locale-25916.test.tsx +++ b/ui/packages/components/src/__tests__/renderer-copy-locale-25916.test.tsx @@ -55,8 +55,10 @@ function withLanguage(element: React.ReactElement, language: 'zh' | null) { const ALPHA = { id: 'a', name: 'Alpha', qty: 10 }; +/** Resolved once, at module scope: the renderers registered above. */ +const Table = ComponentRegistry.get('data-table')!; + function DataTable({ onRowClick }: { onRowClick?: () => void }) { - const Table = ComponentRegistry.get('data-table')!; const schema = { type: 'data-table', editable: true,