diff --git a/.changeset/10862-designer-object-data-previews-locale.md b/.changeset/10862-designer-object-data-previews-locale.md new file mode 100644 index 0000000000..e269a902aa --- /dev/null +++ b/.changeset/10862-designer-object-data-previews-locale.md @@ -0,0 +1,28 @@ +--- +'@object-ui/app-shell': patch +--- + +fix(app-shell): the object / data previews and the federated datasource panel read the designer locale (objectui#10862, slice 3) + +Under zh-CN these designer surfaces rendered their own words in English beside +Chinese text: + +- the object, dataset, view, report, page and validation previews: their + empty states and notes, the dataset preview's run button, counts and + ratio-axis note, the page preview's sample-record picker, the validation + rule's pills, section headings and redirect sentences for the removed rule + types, and the hint beneath every preview's error box; +- the page block canvas: its region and container labels, its empty states (no regions, an empty region, an inherited slot, an empty container group), the add-region and add-block buttons, drop target, rename tooltip, select labels, each block's render-failure hint and the add-block picker's search box, category headings and block names; +- the page source editor's label and error hint; +- the view column manager's heading, empty note, remove controls and + positional column label; +- the federated datasource panel inside the datasource preview: the save + prompt, header, write badge, snapshot line, refresh button, tab labels and + the federation-unavailable message. + +Each new en row is the English the literal carried, so en renders unchanged. +Author data (names, labels, messages, tags, rule and block types, region and +slot names, field names, CEL and regex sources), the error a renderer threw and +the name "JSON Schema" show as written in every locale. + +This is slice 3 of objectui#10862 (the object / data previews). diff --git a/packages/app-shell/src/views/metadata-admin/external/ExternalDatasourcePanel.tsx b/packages/app-shell/src/views/metadata-admin/external/ExternalDatasourcePanel.tsx index af98d0c9c5..e7b4e39716 100644 --- a/packages/app-shell/src/views/metadata-admin/external/ExternalDatasourcePanel.tsx +++ b/packages/app-shell/src/views/metadata-admin/external/ExternalDatasourcePanel.tsx @@ -35,6 +35,7 @@ import { ExternalServiceUnavailableError, type ExternalCatalog, } from './api.js'; +import { t as tr, tFormat } from '../i18n.js'; export interface ExternalDatasourcePanelProps { /** The datasource's saved name. Empty while the datasource is unsaved. */ @@ -43,12 +44,18 @@ export interface ExternalDatasourcePanelProps { schemaMode?: string; /** Whether the datasource permits writes (`external.allowWrites`). */ allowWrites?: boolean; + /** + * The designer locale the panel's own words read in — `DatasourcePreview` + * passes the one its host gives it (objectui#10862). + */ + locale?: string; } export function ExternalDatasourcePanel({ datasource, schemaMode, allowWrites, + locale, }: ExternalDatasourcePanelProps) { // Dates and numbers on this surface read the display locale; a bare // `toLocale*()` call used the MACHINE's locale (objectui#9909). @@ -67,7 +74,7 @@ export function ExternalDatasourcePanel({ } catch (err) { setError( err instanceof ExternalServiceUnavailableError - ? 'Federation is not enabled on this server.' + ? tr('engine.externalDatasource.unavailable', locale) : err instanceof Error ? err.message : String(err), @@ -75,15 +82,14 @@ export function ExternalDatasourcePanel({ } finally { setRefreshing(false); } - }, [datasource]); + }, [datasource, locale]); // Unsaved datasource: the REST routes key off a saved `:name`, so nothing // can be introspected yet. Guide the user to save first. if (!datasource) { return (
- Ratio measures ({ratioMeasures.map(headerLabel).join(', ')}) use the right axis. + {tFormat('engine.datasetPreview.ratioAxis', locale, { measures: ratioMeasures.map(headerLabel).join(', ') })}
)}- No columns yet. Add a field below. + {t('engine.fieldsListEditor.empty', locale)}
) : ({id}
);
diff --git a/packages/app-shell/src/views/metadata-admin/previews/PagePreview.tsx b/packages/app-shell/src/views/metadata-admin/previews/PagePreview.tsx
index c077276445..e2f41984d3 100644
--- a/packages/app-shell/src/views/metadata-admin/previews/PagePreview.tsx
+++ b/packages/app-shell/src/views/metadata-admin/previews/PagePreview.tsx
@@ -20,7 +20,7 @@ import { OutlineStrip } from './OutlineStrip.js';
import { SourcePageEditor } from './SourcePageEditor.js';
import { PageBlockCanvas } from './PageBlockCanvas.js';
import { InterfaceListPage } from '../../InterfaceListPage.js';
-import { t as tr } from '../i18n.js';
+import { t as tr, tFormat } from '../i18n.js';
interface Block { type?: string; id?: string; children?: Block[]; [k: string]: unknown }
@@ -80,8 +80,11 @@ export function PagePreview({ draft, editing, selection, onSelectionChange, onPa
return out;
}
const children = Array.isArray((draft as any).children) ? (draft as any).children as Block[] : [];
- return children.map((b, i) => ({ id: `children[${i}]`, label: b.id || b.type || `block ${i + 1}` }));
- }, [draft, shape]);
+ return children.map((b, i) => ({
+ id: `children[${i}]`,
+ label: b.id || b.type || tFormat('engine.pagePreview.blockN', locale, { n: i + 1 }),
+ }));
+ }, [draft, shape, locale]);
const handleAddBlock = React.useCallback(() => {
if (!canEdit) return;
@@ -218,7 +221,7 @@ export function PagePreview({ draft, editing, selection, onSelectionChange, onPa
}, [recordObject, perms]);
const recordIdOf = (r: any) => r?.id ?? r?._id ?? r?.name;
const recordLabelOf = (r: any) =>
- String(r?.name ?? r?.title ?? r?.label ?? r?.subject ?? recordIdOf(r) ?? '(record)');
+ String(r?.name ?? r?.title ?? r?.label ?? r?.subject ?? recordIdOf(r) ?? tr('engine.pagePreview.recordFallback', locale));
const selectedRecord = React.useMemo(() => {
if (!recordSamples.length) return null;
return recordSamples.find((r) => String(recordIdOf(r)) === String(selectedRecordId)) ?? recordSamples[0];
@@ -261,7 +264,7 @@ export function PagePreview({ draft, editing, selection, onSelectionChange, onPa
>
{recordSamples.length > 0 && (
{type} was removed from ValidationRuleSchema — this
- rule cannot be saved.
+ {withNodes(tr('engine.validationPreview.removedType', locale), {
+ type: {type},
+ })}
{format}
{regex}
{json}
) : (
-
diff --git a/packages/app-shell/src/views/metadata-admin/previews/ViewColumnPanes.tsx b/packages/app-shell/src/views/metadata-admin/previews/ViewColumnPanes.tsx
index 128126dd64..db56086798 100644
--- a/packages/app-shell/src/views/metadata-admin/previews/ViewColumnPanes.tsx
+++ b/packages/app-shell/src/views/metadata-admin/previews/ViewColumnPanes.tsx
@@ -183,6 +183,10 @@ export function FieldListRow({
onDragOverRow: () => void;
onDropRow: () => void;
}) {
+ // The designer locale, read as this file's `AddFieldPopover` reads it
+ // (objectui#10862): both hosts (`FieldsListEditor`, `ReportDefaultInspector`)
+ // render the popover beside these rows.
+ const locale = useMetadataLocale();
return (
{dropBefore && (
@@ -241,7 +245,7 @@ export function FieldListRow({
{
e.stopPropagation();
onRemove();
diff --git a/packages/app-shell/src/views/metadata-admin/previews/ViewPreview.tsx b/packages/app-shell/src/views/metadata-admin/previews/ViewPreview.tsx
index 640f50c21b..1ebf852563 100644
--- a/packages/app-shell/src/views/metadata-admin/previews/ViewPreview.tsx
+++ b/packages/app-shell/src/views/metadata-admin/previews/ViewPreview.tsx
@@ -28,6 +28,8 @@ import { toInlineFormType } from './form-preview.js';
import type { MetadataPreviewProps } from '../preview-registry.js';
import { PreviewShell, PreviewErrorBoundary, PreviewMessage } from './PreviewShell.js';
import { primaryVariantBinding } from '../view-variant-model.js';
+import { t as tr } from '../i18n.js';
+import { withNodes } from './row-nodes.js';
function resolveObjectName(
draft: Record,
@@ -136,7 +138,7 @@ function buildFormPreviewSchema(
};
}
-export function ViewPreview({ name, draft, editing }: MetadataPreviewProps) {
+export function ViewPreview({ name, draft, editing, locale }: MetadataPreviewProps) {
// The single ViewItem body (`draft.config`), or undefined for a raw schema.
const body = React.useMemo(
() => primaryVariantBinding(draft)?.schema,
@@ -195,7 +197,7 @@ export function ViewPreview({ name, draft, editing }: MetadataPreviewProps) {
const schema = { ...(draft as Record) };
return (
-
+
@@ -208,8 +210,11 @@ export function ViewPreview({ name, draft, editing }: MetadataPreviewProps) {
return (
- This view has no object binding yet. Set the bound Object in
- the right panel to fetch live data and field options.
+ {/* The code span names the right panel's field by the label that
+ panel shows it under (`ViewVariantInspector`), in the same locale. */}
+ {withNodes(tr('engine.viewPreview.noObject', locale), {
+ object: {tr('engine.inspector.view.object', locale)},
+ })}
);
@@ -225,7 +230,7 @@ export function ViewPreview({ name, draft, editing }: MetadataPreviewProps) {
const formSchema = buildFormPreviewSchema(objectName, body as Record);
return (
-
+
@@ -240,7 +245,7 @@ export function ViewPreview({ name, draft, editing }: MetadataPreviewProps) {
// -------------------------------------------------------------------------
return (
-
+
diff --git a/packages/app-shell/src/views/metadata-admin/previews/block-types.ts b/packages/app-shell/src/views/metadata-admin/previews/block-types.ts
index 4bc87b87fc..71cdbd310e 100644
--- a/packages/app-shell/src/views/metadata-admin/previews/block-types.ts
+++ b/packages/app-shell/src/views/metadata-admin/previews/block-types.ts
@@ -72,44 +72,51 @@ export type BlockCategory = 'data' | 'layout' | 'record' | 'navigation' | 'eleme
export interface BlockTypeMeta {
id: BlockTypeId;
+ /** The type's English name; its `labelKey` row's en value is this, word for word. */
label: string;
+ /**
+ * Designer catalogue key of the name the add-block picker DISPLAYS, read in
+ * the designer locale (objectui#10862; the add-widget picker's `labelKey`
+ * shape from objectui#10804).
+ */
+ labelKey: string;
category: BlockCategory;
Icon: LucideIcon;
}
export const BLOCK_TYPE_META: Record> = {
// Data — object-bound views & layout grid
- 'grid': { label: 'Grid', category: 'data', Icon: LayoutGrid },
- 'object-grid': { label: 'Table', category: 'data', Icon: Table },
- 'object-form': { label: 'Form', category: 'data', Icon: FormInput },
- 'object-metric': { label: 'Metric', category: 'data', Icon: Gauge },
- 'object-kanban': { label: 'Kanban', category: 'data', Icon: Columns3 },
+ 'grid': { label: 'Grid', labelKey: 'engine.pageBlockCanvas.type.grid', category: 'data', Icon: LayoutGrid },
+ 'object-grid': { label: 'Table', labelKey: 'engine.pageBlockCanvas.type.objectGrid', category: 'data', Icon: Table },
+ 'object-form': { label: 'Form', labelKey: 'engine.pageBlockCanvas.type.objectForm', category: 'data', Icon: FormInput },
+ 'object-metric': { label: 'Metric', labelKey: 'engine.pageBlockCanvas.type.objectMetric', category: 'data', Icon: Gauge },
+ 'object-kanban': { label: 'Kanban', labelKey: 'engine.pageBlockCanvas.type.objectKanban', category: 'data', Icon: Columns3 },
// Page layout
- 'page:header': { label: 'Header', category: 'layout', Icon: PanelTop },
- 'page:footer': { label: 'Footer', category: 'layout', Icon: PanelBottom },
- 'page:sidebar': { label: 'Sidebar', category: 'layout', Icon: PanelLeft },
- 'page:tabs': { label: 'Tabs', category: 'layout', Icon: Folders },
- 'page:accordion': { label: 'Accordion', category: 'layout', Icon: ChevronsUpDown },
- 'page:card': { label: 'Card', category: 'layout', Icon: Square },
- 'page:section': { label: 'Section', category: 'layout', Icon: Layers },
+ 'page:header': { label: 'Header', labelKey: 'engine.pageBlockCanvas.type.pageHeader', category: 'layout', Icon: PanelTop },
+ 'page:footer': { label: 'Footer', labelKey: 'engine.pageBlockCanvas.type.pageFooter', category: 'layout', Icon: PanelBottom },
+ 'page:sidebar': { label: 'Sidebar', labelKey: 'engine.pageBlockCanvas.type.pageSidebar', category: 'layout', Icon: PanelLeft },
+ 'page:tabs': { label: 'Tabs', labelKey: 'engine.pageBlockCanvas.type.pageTabs', category: 'layout', Icon: Folders },
+ 'page:accordion': { label: 'Accordion', labelKey: 'engine.pageBlockCanvas.type.pageAccordion', category: 'layout', Icon: ChevronsUpDown },
+ 'page:card': { label: 'Card', labelKey: 'engine.pageBlockCanvas.type.pageCard', category: 'layout', Icon: Square },
+ 'page:section': { label: 'Section', labelKey: 'engine.pageBlockCanvas.type.pageSection', category: 'layout', Icon: Layers },
// Record context
- 'record:details': { label: 'Record details', category: 'record', Icon: FileText },
- 'record:highlights': { label: 'Highlights', category: 'record', Icon: Tag },
- 'record:related_list': { label: 'Related list', category: 'record', Icon: ListChecks },
- 'record:activity': { label: 'Activity timeline', category: 'record', Icon: Activity },
- 'record:discussion': { label: 'Discussion', category: 'record', Icon: MessageSquare },
- 'record:path': { label: 'Stage path', category: 'record', Icon: Compass },
- 'record:alert': { label: 'Alert banner', category: 'record', Icon: AlertTriangle },
- 'record:quick_actions': { label: 'Quick actions', category: 'record', Icon: Zap },
- 'record:reference_rail': { label: 'Reference rail', category: 'record', Icon: BookOpen },
- 'record:history': { label: 'History', category: 'record', Icon: History },
+ 'record:details': { label: 'Record details', labelKey: 'engine.pageBlockCanvas.type.recordDetails', category: 'record', Icon: FileText },
+ 'record:highlights': { label: 'Highlights', labelKey: 'engine.pageBlockCanvas.type.recordHighlights', category: 'record', Icon: Tag },
+ 'record:related_list': { label: 'Related list', labelKey: 'engine.pageBlockCanvas.type.recordRelatedList', category: 'record', Icon: ListChecks },
+ 'record:activity': { label: 'Activity timeline', labelKey: 'engine.pageBlockCanvas.type.recordActivity', category: 'record', Icon: Activity },
+ 'record:discussion': { label: 'Discussion', labelKey: 'engine.pageBlockCanvas.type.recordDiscussion', category: 'record', Icon: MessageSquare },
+ 'record:path': { label: 'Stage path', labelKey: 'engine.pageBlockCanvas.type.recordPath', category: 'record', Icon: Compass },
+ 'record:alert': { label: 'Alert banner', labelKey: 'engine.pageBlockCanvas.type.recordAlert', category: 'record', Icon: AlertTriangle },
+ 'record:quick_actions': { label: 'Quick actions', labelKey: 'engine.pageBlockCanvas.type.recordQuickActions', category: 'record', Icon: Zap },
+ 'record:reference_rail': { label: 'Reference rail', labelKey: 'engine.pageBlockCanvas.type.recordReferenceRail', category: 'record', Icon: BookOpen },
+ 'record:history': { label: 'History', labelKey: 'engine.pageBlockCanvas.type.recordHistory', category: 'record', Icon: History },
// Navigation (page-content only; shell singletons are not page blocks)
- 'nav:menu': { label: 'Nav menu', category: 'navigation', Icon: Menu },
- 'nav:breadcrumb': { label: 'Breadcrumb', category: 'navigation', Icon: Compass },
- 'global:search': { label: 'Global search', category: 'navigation', Icon: Search },
+ 'nav:menu': { label: 'Nav menu', labelKey: 'engine.pageBlockCanvas.type.navMenu', category: 'navigation', Icon: Menu },
+ 'nav:breadcrumb': { label: 'Breadcrumb', labelKey: 'engine.pageBlockCanvas.type.navBreadcrumb', category: 'navigation', Icon: Compass },
+ 'global:search': { label: 'Global search', labelKey: 'engine.pageBlockCanvas.type.globalSearch', category: 'navigation', Icon: Search },
// AI
// `ai:chat_window` is deliberately NOT in the palette: the floating chat
@@ -118,16 +125,16 @@ export const BLOCK_TYPE_META: Record> = {
// documents that exclusion. The palette used to offer it (with a config
// panel), so an author dragged a block Studio advertised and got a red
// "Unknown component type" box (#2943). See PALETTE_EXCLUSIONS.
- 'ai:suggestion': { label: 'AI suggestion', category: 'ai', Icon: Sparkles },
+ 'ai:suggestion': { label: 'AI suggestion', labelKey: 'engine.pageBlockCanvas.type.aiSuggestion', category: 'ai', Icon: Sparkles },
// Elements
- 'element:text': { label: 'Text', category: 'element', Icon: Type },
- 'element:number': { label: 'Number', category: 'element', Icon: Hash },
- 'element:image': { label: 'Image', category: 'element', Icon: ImageIcon },
- 'element:divider': { label: 'Divider', category: 'element', Icon: Minus },
- 'element:button': { label: 'Button', category: 'element', Icon: MousePointerClick },
- 'element:definition-list': { label: 'Definition list', category: 'element', Icon: List },
- 'element:repeater': { label: 'Repeater', category: 'element', Icon: Rows3 },
+ 'element:text': { label: 'Text', labelKey: 'engine.pageBlockCanvas.type.elementText', category: 'element', Icon: Type },
+ 'element:number': { label: 'Number', labelKey: 'engine.pageBlockCanvas.type.elementNumber', category: 'element', Icon: Hash },
+ 'element:image': { label: 'Image', labelKey: 'engine.pageBlockCanvas.type.elementImage', category: 'element', Icon: ImageIcon },
+ 'element:divider': { label: 'Divider', labelKey: 'engine.pageBlockCanvas.type.elementDivider', category: 'element', Icon: Minus },
+ 'element:button': { label: 'Button', labelKey: 'engine.pageBlockCanvas.type.elementButton', category: 'element', Icon: MousePointerClick },
+ 'element:definition-list': { label: 'Definition list', labelKey: 'engine.pageBlockCanvas.type.elementDefinitionList', category: 'element', Icon: List },
+ 'element:repeater': { label: 'Repeater', labelKey: 'engine.pageBlockCanvas.type.elementRepeater', category: 'element', Icon: Rows3 },
};
/**
@@ -254,14 +261,18 @@ export const BLOCK_RENDERER_ALIAS_GROUPS: readonly (readonly string[])[] = [
['record:discussion', 'record:chatter'],
];
-export const CATEGORY_LABEL_EN: Record = {
- data: 'Data',
- layout: 'Layout',
- record: 'Record context',
- navigation: 'Navigation',
- element: 'Elements',
- ai: 'AI',
- misc: 'Other',
+/**
+ * Designer catalogue key of each category heading the add-block picker displays
+ * (objectui#10862). The en rows are the English headings this table carried.
+ */
+export const CATEGORY_LABEL_KEY: Record = {
+ data: 'engine.pageBlockCanvas.category.data',
+ layout: 'engine.pageBlockCanvas.category.layout',
+ record: 'engine.pageBlockCanvas.category.record',
+ navigation: 'engine.pageBlockCanvas.category.navigation',
+ element: 'engine.pageBlockCanvas.category.element',
+ ai: 'engine.pageBlockCanvas.category.ai',
+ misc: 'engine.pageBlockCanvas.category.misc',
};
export const TYPES_BY_CATEGORY: Array<{ category: BlockCategory; types: BlockTypeId[] }> = (() => {
diff --git a/packages/app-shell/src/views/metadata-admin/previews/dataPreviews.i18n-10862-s3.test.tsx b/packages/app-shell/src/views/metadata-admin/previews/dataPreviews.i18n-10862-s3.test.tsx
new file mode 100644
index 0000000000..933539929b
--- /dev/null
+++ b/packages/app-shell/src/views/metadata-admin/previews/dataPreviews.i18n-10862-s3.test.tsx
@@ -0,0 +1,792 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+/**
+ * objectui#10862, slice 3 — the object / data previews under zh-CN.
+ *
+ * `ObjectPreview`, `DatasetPreview`, `ViewPreview`, `ReportPreview`,
+ * `PagePreview`, `PageBlockCanvas` (with its add-block picker),
+ * `SourcePageEditor`, `ValidationPreview` and the view column manager
+ * (`FieldsListEditor`, `FieldListRow`) rendered their own words in English
+ * beside a Chinese designer, the error-boundary hints among them. Each now
+ * reads its `engine.*` rows in the designer locale: the previews in the
+ * `locale` their hosts hand them (`ResourceEditPage`, `EmbeddedItemEditor`,
+ * `StudioDesignSurface` pass `useMetadataLocale()`), `PageBlockCanvas` and
+ * `SourcePageEditor` in the one `PagePreview` threads to them, the column
+ * manager through `useMetadataLocale()` as its add-field popover already did.
+ *
+ * ── How each site is read ────────────────────────────────────────────────────
+ * The slice-1 / slice-2 harness: every case mounts the REAL component under the
+ * i18n provider in its language and reads what it rendered. A site is one
+ * designer word on the mount: an element's whole text, a `title`, an
+ * `aria-label` or an input's `placeholder`. Each zh expectation is read back
+ * from the catalogue and guarded by `zhRow`, so no case passes on a missing row
+ * and none restates a translation. Each en case reads the en row through `t` /
+ * `tFormat` for `en-US` rather than pin the wording, so it is the control that
+ * the en row renders what the literal rendered. A row with a code span in it is
+ * read with the hole filled by the code span's text. `PageBlockCanvas` and
+ * `SourcePageEditor` are mounted through `PagePreview`, their host, so the
+ * cases also read that the locale is threaded to them.
+ *
+ * ── Stand-ins ────────────────────────────────────────────────────────────────
+ * An error-boundary hint shows only when the child throws, so the children
+ * that throw here are stand-ins that draw, suspend or throw on demand:
+ * `SchemaRenderer`, the chart and report renderers, `ObjectFormCanvas` and
+ * `InterfaceListPage`. Each mock spreads the real module. Monaco is a stand-in
+ * whose loader fails, which is how the source editor's textarea shows.
+ *
+ * ── Left as written on purpose ───────────────────────────────────────────────
+ * Author data (names, labels, messages, tags, events, severities, rule and
+ * block types, region and slot names, field names, CEL and regex sources), the
+ * error a renderer threw, and a bare `JSON Schema` (the standard's name) read
+ * the same in every locale; the cases check a sample on the same mount. So does
+ * the add-block picker's `AI` heading, whose zh row is `AI` as the metadata
+ * domain table's is.
+ */
+
+import * as React from 'react';
+import { describe, it, expect, vi, afterEach } from 'vitest';
+import { render, cleanup, act, fireEvent, screen } from '@testing-library/react';
+import { I18nProvider } from '@object-ui/i18n';
+
+const state = vi.hoisted(() => ({
+ schema: 'draw' as 'draw' | 'throw',
+ chart: 'draw' as 'draw' | 'throw',
+ report: 'draw' as 'draw' | 'suspend' | 'throw',
+ formCanvas: 'draw' as 'draw' | 'throw',
+ interfacePage: 'draw' as 'draw' | 'throw',
+ queryDataset: undefined as undefined | ((...args: unknown[]) => Promise),
+ metadataClient: { get: async () => undefined, list: async (): Promise => [] },
+}));
+
+vi.mock('@object-ui/react', async (importOriginal) => ({
+ ...(await importOriginal>()),
+ SchemaRenderer: () => {
+ if (state.schema === 'throw') throw new Error('component "x_widget" is not registered');
+ return ;
+ },
+ RecordContextProvider: ({ children }: { children?: React.ReactNode }) => <>{children}>,
+}));
+
+vi.mock('@object-ui/plugin-charts', async (importOriginal) => ({
+ ...(await importOriginal>()),
+ ChartRenderer: () => {
+ if (state.chart === 'throw') throw new Error('series "rate" has no data key');
+ return ;
+ },
+}));
+
+vi.mock('@object-ui/plugin-report', async (importOriginal) => ({
+ ...(await importOriginal>()),
+ ReportRenderer: () => {
+ if (state.report === 'suspend') throw new Promise(() => {});
+ if (state.report === 'throw') throw new Error('measure "arr" is not on dataset "sales"');
+ return ;
+ },
+}));
+// Module-scope imports of the two lazily loaded renderers, so each preview's
+// `React.lazy` factory resolves at once instead of racing the test's wait
+// window (AGENTS.md, flaky-test discipline).
+import '@object-ui/plugin-charts';
+import '@object-ui/plugin-report';
+
+vi.mock('./ObjectFormCanvas', async (importOriginal) => ({
+ ...(await importOriginal>()),
+ ObjectFormCanvas: () => {
+ if (state.formCanvas === 'throw') throw new Error('field "amount" has no type');
+ return ;
+ },
+}));
+
+vi.mock('../../InterfaceListPage', async (importOriginal) => ({
+ ...(await importOriginal>()),
+ InterfaceListPage: () => {
+ if (state.interfacePage === 'throw') throw new Error('view "all_accounts" not found');
+ return ;
+ },
+}));
+
+vi.mock('../../../providers/AdapterProvider', async (importOriginal) => ({
+ ...(await importOriginal>()),
+ useAdapter: () => ({ queryDataset: (...args: unknown[]) => state.queryDataset!(...args) }),
+}));
+
+// `useObjectFields` short-circuits the fetch when handed a catalog, but still
+// constructs the shared metadata client.
+vi.mock('../useMetadata', async (importOriginal) => ({
+ ...(await importOriginal>()),
+ useMetadataClient: () => state.metadataClient,
+}));
+
+// Monaco's loader fails (offline), so the source editor falls back to its textarea.
+vi.mock('@monaco-editor/react', () => {
+ const Editor = () => null;
+ return { Editor, default: Editor, loader: { init: () => Promise.reject(new Error('offline')) } };
+});
+
+import { t, tFormat } from '../i18n';
+import { BLOCK_TYPE_META, CATEGORY_LABEL_KEY, TYPES_BY_CATEGORY } from './block-types';
+
+/**
+ * The picker rows, derived from the type id and the category name rather than
+ * read off the table, so a case reads the rendered picker and nothing else:
+ * `page:header` → `engine.pageBlockCanvas.type.pageHeader`.
+ */
+const typeKey = (id: string) =>
+ 'engine.pageBlockCanvas.type.' +
+ id.split(/[:_-]/).map((w, i) => (i === 0 ? w : w[0].toUpperCase() + w.slice(1))).join('');
+const categoryKey = (c: string) => `engine.pageBlockCanvas.category.${c}`;
+import { ObjectPreview } from './ObjectPreview';
+import { DatasetPreview } from './DatasetPreview';
+import { ViewPreview } from './ViewPreview';
+import { ReportPreview } from './ReportPreview';
+import { PagePreview } from './PagePreview';
+import { ValidationPreview } from './ValidationPreview';
+import { FieldsListEditor } from './FieldsListEditor';
+import type { ObjectFieldInfo } from './useObjectFields';
+
+const realFetch = globalThis.fetch;
+
+afterEach(() => {
+ cleanup();
+ state.schema = 'draw';
+ state.chart = 'draw';
+ state.report = 'draw';
+ state.formCanvas = 'draw';
+ state.interfacePage = 'draw';
+ state.queryDataset = undefined;
+ globalThis.fetch = realFetch;
+ vi.restoreAllMocks();
+});
+
+type Lang = 'en' | 'zh';
+const LANGS = ['zh', 'en'] as const;
+const LOCALE = { en: 'en-US', zh: 'zh-CN' } as const;
+type Vars = Record;
+
+/** The console mounts every designer surface under the i18n provider in its language. */
+function inLang(lang: Lang, ui: React.ReactElement) {
+ return render(
+ {ui} ,
+ );
+}
+
+/** Let mount-time effects, resolved fetches and lazy chunks settle. */
+async function flush() {
+ await act(async () => {
+ await new Promise((r) => setTimeout(r, 0));
+ });
+}
+
+/** The row `key` in `lang`, formatted with `vars`. */
+function row(lang: Lang, key: string, vars?: Vars): string {
+ return vars ? tFormat(key, LOCALE[lang], vars) : t(key, LOCALE[lang]);
+}
+
+/** A zh catalogue row that is really there: not the echoed key, not the en row. */
+function zhRow(key: string): string {
+ const zh = t(key, 'zh-CN');
+ expect(zh, `${key}: a missing zh row echoes the key back`).not.toBe(key);
+ expect(zh, `${key}: the zh row must not be the English one`).not.toBe(t(key, 'en-US'));
+ return zh;
+}
+
+/** One designer word on the mount, and the row it reads. */
+interface Site {
+ key: string;
+ vars?: Vars;
+ /** Where the row lands: an element's whole text (default), a `title`, an `aria-label` or a `placeholder`. */
+ in?: 'text' | 'title' | 'aria-label' | 'placeholder';
+ /** The element's whole text around the row, when the row is not all of it. */
+ around?: (row: string) => string;
+}
+
+const norm = (s: string | null | undefined): string => (s ?? '').replace(/\s+/g, ' ').trim();
+
+/** Whether some element on the page renders `text` in `where`. */
+function rendered(where: NonNullable, text: string): boolean {
+ const els = Array.from(document.body.querySelectorAll('*'));
+ if (where === 'text') return els.some((el) => norm(el.textContent) === text);
+ return els.some((el) => el.getAttribute(where) === text);
+}
+
+/** The sites the mount does NOT render in `lang`, each with the text it was expected to show. */
+function unrendered(lang: Lang, sites: Site[]): string[] {
+ return sites
+ .map((s) => {
+ const r = row(lang, s.key, s.vars);
+ const text = norm(s.around ? s.around(r) : r);
+ return rendered(s.in ?? 'text', text) ? null : `${s.key} (${s.in ?? 'text'}): ${JSON.stringify(text)}`;
+ })
+ .filter((x): x is string => x !== null);
+}
+
+/**
+ * What a case did not find. Every site on every mount of a case is read before
+ * the case judges, so a failing case lists each missing site rather than the
+ * first one.
+ */
+let misses: string[] = [];
+
+/** A case: reads every site it names, then fails once, listing each miss. */
+function pin(name: string, body: () => void | Promise) {
+ it(name, async () => {
+ misses = [];
+ await body();
+ expect(misses).toEqual([]);
+ });
+}
+
+/** Every site renders its row in `lang`; under zh, every row is a real zh row. */
+function expectSites(lang: Lang, sites: Site[]) {
+ misses.push(...unrendered(lang, sites));
+ if (lang === 'zh') for (const s of sites) zhRow(s.key);
+}
+
+/** Author data, identifiers and notation: the same bytes in every locale. */
+function expectAsWritten(texts: string[]) {
+ misses.push(...texts.filter((x) => !rendered('text', x)).map((x) => `as written: ${JSON.stringify(x)}`));
+}
+
+/** One more reading the case must find. */
+function expectEqual(what: string, got: string | null | undefined, want: string) {
+ if (got !== want) misses.push(`${what}: ${JSON.stringify(want)}, read ${JSON.stringify(got)}`);
+}
+
+/** A thrown render error is logged by React; keep the run's output readable. */
+function quietBoundary() {
+ vi.spyOn(console, 'error').mockImplementation(() => {});
+}
+
+/** The error-boundary box's hint line. */
+function boundaryHint(): string | null {
+ return document.body.querySelector('div.m-4 div.flex-1 div.mt-2')?.textContent ?? null;
+}
+
+/** The error boundary's hint reads the row `key` in `lang`. */
+function expectHint(lang: Lang, key: string, vars?: Vars) {
+ expectEqual(`${key} (boundary hint)`, boundaryHint(), row(lang, key, vars));
+ if (lang === 'zh') zhRow(key);
+}
+
+/** A `fetch` answer carrying only the `json()` the previews read. */
+const answer = (body: unknown) => ({ ok: true, json: async () => body }) as unknown as Response;
+
+// ─── ObjectPreview ───────────────────────────────────────────────────────────
+
+describe('ObjectPreview reads the designer locale (objectui#10862)', () => {
+ for (const lang of LANGS) {
+ pin(`${lang}: the form designer's error-boundary hint`, async () => {
+ quietBoundary();
+ state.formCanvas = 'throw';
+ inLang(lang, );
+ await flush();
+ expectHint(lang, 'engine.objectPreview.renderFailed');
+ expectAsWritten(['field "amount" has no type']);
+ });
+ }
+});
+
+// ─── DatasetPreview ──────────────────────────────────────────────────────────
+
+const DATASET = {
+ name: 'sales',
+ object: 'opportunity',
+ dimensions: [{ name: 'region', field: 'region' }],
+ measures: [
+ { name: 'revenue', aggregate: 'sum', field: 'amount' },
+ { name: 'rate', derived: { op: 'ratio', of: ['revenue', 'revenue'] } },
+ ],
+};
+
+function stubDimensionFetch() {
+ globalThis.fetch = vi.fn(async () => answer({ item: { name: 'opportunity', fields: {} } })) as never;
+}
+
+describe('DatasetPreview reads the designer locale (objectui#10862)', () => {
+ for (const lang of LANGS) {
+ pin(`${lang}: the no-object and no-measure empty states`, () => {
+ inLang(lang, );
+ expectSites(lang, [{ key: 'engine.datasetPreview.pickObject' }, { key: 'engine.datasetPreview.pickObjectHint' }]);
+ cleanup();
+ inLang(lang, );
+ expectSites(lang, [{ key: 'engine.datasetPreview.addMeasure' }, { key: 'engine.datasetPreview.addMeasureHint' }]);
+ });
+
+ pin(`${lang}: the run button, the plural counts, the ratio-axis note and the chart's error-boundary hint`, async () => {
+ stubDimensionFetch();
+ state.queryDataset = async () => ({
+ rows: [{ region: 'NA', revenue: 600000, rate: 0.7 }],
+ object: 'opportunity',
+ fields: [
+ { name: 'region', type: 'string', label: 'Region' },
+ { name: 'revenue', type: 'number', label: 'Revenue', format: '0,0' },
+ { name: 'rate', type: 'number', label: 'Win rate', format: '0.0%' },
+ ],
+ });
+ inLang(lang, );
+ await screen.findByTestId('chart-renderer');
+ const dims = row(lang, 'engine.datasetPreview.dimensionOne', { count: 1 });
+ expectSites(lang, [
+ { key: 'engine.datasetPreview.run' },
+ { key: 'engine.datasetPreview.measureOther', vars: { count: 2 }, around: (r) => `${r} · ${dims}` },
+ { key: 'engine.datasetPreview.dimensionOne', vars: { count: 1 }, around: (r) => `${row(lang, 'engine.datasetPreview.measureOther', { count: 2 })} · ${r}` },
+ { key: 'engine.datasetPreview.ratioAxis', vars: { measures: 'Win rate' } },
+ ]);
+ expectAsWritten(['Region', 'Revenue', 'Win rate', 'NA']);
+
+ cleanup();
+ quietBoundary();
+ state.chart = 'throw';
+ inLang(lang, );
+ await flush();
+ await screen.findByText('NA');
+ expectHint(lang, 'engine.datasetPreview.chartFailed');
+ });
+
+ pin(`${lang}: the singular measure count, the plural dimension count and the no-rows state`, async () => {
+ stubDimensionFetch();
+ state.queryDataset = async () => ({ rows: [], fields: [] });
+ const draft = {
+ name: 'sales',
+ object: 'opportunity',
+ dimensions: [{ name: 'region', field: 'region' }, { name: 'stage', field: 'stage' }],
+ measures: [{ name: 'revenue', aggregate: 'sum', field: 'amount' }],
+ };
+ inLang(lang, );
+ await flush();
+ const dims = row(lang, 'engine.datasetPreview.dimensionOther', { count: 2 });
+ expectSites(lang, [
+ { key: 'engine.datasetPreview.measureOne', vars: { count: 1 }, around: (r) => `${r} · ${dims}` },
+ { key: 'engine.datasetPreview.dimensionOther', vars: { count: 2 }, around: (r) => `${row(lang, 'engine.datasetPreview.measureOne', { count: 1 })} · ${r}` },
+ { key: 'engine.datasetPreview.noRows' },
+ { key: 'engine.datasetPreview.noRowsHint' },
+ ]);
+ });
+
+ pin(`${lang}: the analytics-not-installed state`, async () => {
+ stubDimensionFetch();
+ state.queryDataset = async () => {
+ throw Object.assign(new Error('analytics service not mounted'), { code: 'ANALYTICS_NOT_INSTALLED' });
+ };
+ inLang(lang, );
+ await flush();
+ expectSites(lang, [{ key: 'engine.datasetPreview.notInstalled' }, { key: 'engine.datasetPreview.notInstalledHint' }]);
+ });
+ }
+});
+
+// ─── ViewPreview ─────────────────────────────────────────────────────────────
+
+describe('ViewPreview reads the designer locale (objectui#10862)', () => {
+ for (const lang of LANGS) {
+ pin(`${lang}: the no-object note names the right panel's field in the same locale`, () => {
+ inLang(lang, );
+ const field = row(lang, 'engine.inspector.view.object');
+ expectSites(lang, [{ key: 'engine.viewPreview.noObject', around: (r) => r.replace('{object}', field) }]);
+ expectEqual('the code span', document.body.querySelector('code')?.textContent, field);
+ });
+
+ pin(`${lang}: the three error-boundary hints (raw schema, form view, list view)`, async () => {
+ quietBoundary();
+ state.schema = 'throw';
+ const drafts: Array<[Record, string]> = [
+ [{ name: 'raw', type: 'x_widget' }, 'engine.viewPreview.schemaFailed'],
+ [{ name: 'intake', viewKind: 'form', config: { type: 'simple', object: 'account' } }, 'engine.viewPreview.formFailed'],
+ [{ name: 'all', config: { type: 'grid', object: 'account' } }, 'engine.viewPreview.listFailed'],
+ ];
+ for (const [draft, key] of drafts) {
+ inLang(lang, );
+ await flush();
+ expectHint(lang, key);
+ expectAsWritten(['component "x_widget" is not registered']);
+ cleanup();
+ }
+ });
+ }
+});
+
+// ─── ReportPreview ───────────────────────────────────────────────────────────
+
+describe('ReportPreview reads the designer locale (objectui#10862)', () => {
+ for (const lang of LANGS) {
+ pin(`${lang}: the two empty states`, () => {
+ inLang(lang, );
+ expectSites(lang, [{ key: 'engine.reportPreview.empty' }, { key: 'engine.reportPreview.emptyHint' }]);
+ cleanup();
+ inLang(lang, );
+ expectSites(lang, [{ key: 'engine.reportPreview.joinedEmpty' }, { key: 'engine.reportPreview.joinedEmptyHint' }]);
+ });
+
+ pin(`${lang}: the loading text and the error-boundary hint`, async () => {
+ state.report = 'suspend';
+ inLang(lang, );
+ await flush();
+ expectSites(lang, [{ key: 'engine.reportPreview.loading' }]);
+ cleanup();
+ quietBoundary();
+ state.report = 'throw';
+ inLang(lang, );
+ await flush();
+ expectHint(lang, 'engine.reportPreview.renderFailed');
+ expectAsWritten(['measure "arr" is not on dataset "sales"']);
+ });
+ }
+});
+
+// ─── PagePreview ─────────────────────────────────────────────────────────────
+
+/** Serve the record page's two reads: the object schema, then `records`. */
+function stubRecordFetch(records: unknown[]) {
+ globalThis.fetch = vi.fn(async (url: string) => {
+ if (url.startsWith('/api/v1/meta/object/')) return answer({ item: { name: 'account', fields: { name: { type: 'text' } } } });
+ if (url.startsWith('/api/v1/data/')) return answer({ records });
+ return answer({});
+ }) as never;
+}
+
+const RECORD_PAGE = { name: 'acct', type: 'record', object: 'account', regions: [{ name: 'main', components: [{ type: 'record:details' }] }] };
+
+describe('PagePreview reads the designer locale (objectui#10862)', () => {
+ for (const lang of LANGS) {
+ pin(`${lang}: the empty page and the children-shaped outline's positional block label`, () => {
+ inLang(lang, );
+ expectSites(lang, [{ key: 'engine.pagePreview.addComponents' }]);
+ cleanup();
+ inLang(
+ lang,
+ {}}
+ onPatch={() => {}}
+ locale={LOCALE[lang]}
+ />,
+ );
+ expectSites(lang, [{ key: 'engine.pagePreview.blockN', vars: { n: 1 } }]);
+ expectAsWritten(['hero']);
+ });
+
+ pin(`${lang}: the sample-record picker: its label, the plural count and a record with nothing to name it`, async () => {
+ stubRecordFetch([{ id: 'r1', name: 'Northwind' }, { id: 'r2', name: 'Contoso' }, { industry: 'retail' }]);
+ inLang(lang, );
+ await flush();
+ expectSites(lang, [
+ { key: 'engine.pagePreview.previewRecord' },
+ { key: 'engine.pagePreview.sampleOther', vars: { count: 3 } },
+ { key: 'engine.pagePreview.recordFallback' },
+ ]);
+ expectAsWritten(['Northwind', 'Contoso']);
+ });
+
+ pin(`${lang}: the singular sample count`, async () => {
+ stubRecordFetch([{ id: 'r1', name: 'Northwind' }]);
+ inLang(lang, );
+ await flush();
+ expectSites(lang, [{ key: 'engine.pagePreview.sampleOne', vars: { count: 1 } }]);
+ });
+
+ pin(`${lang}: the interface page's and the page schema's error-boundary hints`, async () => {
+ quietBoundary();
+ state.interfacePage = 'throw';
+ inLang(lang, );
+ await flush();
+ expectHint(lang, 'engine.pagePreview.interfaceFailed');
+ cleanup();
+ state.schema = 'throw';
+ inLang(lang, );
+ await flush();
+ expectHint(lang, 'engine.pagePreview.schemaFailed');
+ });
+ }
+});
+
+// ─── SourcePageEditor (through PagePreview) ──────────────────────────────────
+
+describe('SourcePageEditor reads the locale PagePreview threads to it (objectui#10862)', () => {
+ for (const lang of LANGS) {
+ pin(`${lang}: the source textarea's label and the live preview's error-boundary hint`, async () => {
+ quietBoundary();
+ state.schema = 'throw';
+ inLang(lang, ' }} locale={LOCALE[lang]} />);
+ await screen.findByRole('textbox');
+ expectSites(lang, [{ key: 'engine.sourcePageEditor.source', in: 'aria-label' }]);
+ expectHint(lang, 'engine.sourcePageEditor.renderFailed');
+ });
+ }
+});
+
+// ─── PageBlockCanvas (through PagePreview) ───────────────────────────────────
+
+/** Mount `draft` in `PagePreview`'s design mode, where a region-shaped page is the block canvas. */
+function designPage(lang: Lang, draft: Record) {
+ return inLang(
+ lang,
+ {}} onPatch={() => {}} locale={LOCALE[lang]} />,
+ );
+}
+
+const CANVAS_PAGE = {
+ name: 'p',
+ regions: [
+ { name: '', components: [] },
+ {
+ name: 'main',
+ components: [
+ { type: 'page:card', properties: { children: [] } },
+ { type: 'page:tabs', properties: { items: [{ children: [] }] } },
+ { type: 'grid', properties: { children: [{ type: 'element:text', id: 'inner_note' }] } },
+ { type: 'element:text', id: 'hero' },
+ ],
+ },
+ ],
+};
+
+describe('PageBlockCanvas reads the locale PagePreview threads to it (objectui#10862)', () => {
+ for (const lang of LANGS) {
+ pin(`${lang}: the no-regions state`, () => {
+ designPage(lang, { name: 'p', regions: [] });
+ expectSites(lang, [
+ { key: 'engine.pageBlockCanvas.noRegions' },
+ { key: 'engine.pageBlockCanvas.noRegionsHint' },
+ { key: 'engine.pageBlockCanvas.addRegion' },
+ ]);
+ });
+
+ pin(`${lang}: regions, container groups, block rows and the drop target`, () => {
+ designPage(lang, CANVAS_PAGE);
+ expectSites(lang, [
+ { key: 'engine.pageBlockCanvas.regionN', vars: { n: 1 } },
+ { key: 'engine.pageBlockCanvas.regionEmpty' },
+ { key: 'engine.pageBlockCanvas.addRegion' },
+ { key: 'engine.pageBlockCanvas.body' },
+ { key: 'engine.pageBlockCanvas.itemN', vars: { n: 1 } },
+ { key: 'engine.pageBlockCanvas.content' },
+ { key: 'engine.pageBlockCanvas.groupEmpty' },
+ { key: 'engine.pageBlockCanvas.select', vars: { label: 'hero' }, in: 'aria-label' },
+ { key: 'engine.pageBlockCanvas.select', vars: { label: 'inner_note' }, in: 'aria-label' },
+ // Rows the designer already carried, reused with the same meaning.
+ { key: 'designer.canvas.renameHint', in: 'title' },
+ { key: 'engine.inspector.add.block' },
+ ]);
+ expectAsWritten(['main', 'page:card', 'element:text']);
+
+ const main = Array.from(document.body.querySelectorAll('section'))[1]!;
+ fireEvent.dragOver(main, { dataTransfer: { types: ['text/x-objectui-pageblock'], dropEffect: 'none' } });
+ expectSites(lang, [{ key: 'engine.pageBlockCanvas.dropHere' }]);
+ });
+
+ pin(`${lang}: a slotted page's inherited slots`, () => {
+ designPage(lang, { name: 'p', kind: 'slotted', regions: [], slots: {} });
+ expectSites(lang, [{ key: 'engine.pageBlockCanvas.slotInherited' }]);
+ expectAsWritten(['header', 'details']);
+ });
+
+ pin(`${lang}: a block's error-boundary hint names its type as written`, async () => {
+ quietBoundary();
+ state.schema = 'throw';
+ designPage(lang, { name: 'p', regions: [{ name: 'main', components: [{ type: 'x_widget', id: 'hero' }] }] });
+ await flush();
+ expectHint(lang, 'engine.pageBlockCanvas.renderFailed', { type: 'x_widget' });
+ });
+
+ pin(`${lang}: the add-block picker: its search box, category headings, block names and no-match note`, async () => {
+ designPage(lang, { name: 'p', regions: [{ name: 'main', components: [] }] });
+ // Found by what it is (the popover trigger), not by the words under test.
+ const trigger = screen.getAllByRole('button').find((b) => b.getAttribute('aria-haspopup') === 'dialog');
+ fireEvent.click(trigger!);
+ const search = await screen.findByRole('textbox');
+ const categories = TYPES_BY_CATEGORY.map((g) => g.category);
+ const types = TYPES_BY_CATEGORY.flatMap((g) => g.types);
+ expectSites(lang, [
+ { key: 'engine.pageBlockCanvas.searchTypes', in: 'placeholder' },
+ ...categories.filter((c) => c !== 'ai').map((c) => ({ key: categoryKey(c) })),
+ ...types.map((id) => ({ key: typeKey(id) })),
+ ]);
+ // The `AI` heading reads the same in every locale.
+ expectAsWritten(['AI']);
+ expect(t(categoryKey('ai'), 'zh-CN')).toBe('AI');
+
+ fireEvent.change(search, { target: { value: 'zzz_no_such_block' } });
+ expectSites(lang, [{ key: 'engine.pageBlockCanvas.noMatch' }]);
+ });
+ }
+
+ it('each block name and category heading reads, in en, the English its table carried', () => {
+ for (const [id, meta] of Object.entries(BLOCK_TYPE_META)) {
+ expect(t(typeKey(id), 'en-US'), id).toBe(meta.label);
+ }
+ const categories = ['data', 'layout', 'record', 'navigation', 'element', 'ai', 'misc'];
+ expect(Object.fromEntries(categories.map((c) => [c, t(categoryKey(c), 'en-US')]))).toEqual({
+ data: 'Data',
+ layout: 'Layout',
+ record: 'Record context',
+ navigation: 'Navigation',
+ element: 'Elements',
+ ai: 'AI',
+ misc: 'Other',
+ });
+ });
+
+ it('the table names each type and category by the row the picker reads', () => {
+ for (const [id, meta] of Object.entries(BLOCK_TYPE_META)) expect(meta.labelKey, id).toBe(typeKey(id));
+ expect(Object.entries(CATEGORY_LABEL_KEY).filter(([c, k]) => k !== categoryKey(c))).toEqual([]);
+ expect(Object.keys(CATEGORY_LABEL_KEY).sort()).toEqual(['ai', 'data', 'element', 'layout', 'misc', 'navigation', 'record']);
+ });
+});
+
+// ─── ValidationPreview ───────────────────────────────────────────────────────
+
+function mountRule(lang: Lang, draft: Record) {
+ return inLang(lang, );
+}
+
+describe('ValidationPreview reads the designer locale (objectui#10862)', () => {
+ for (const lang of LANGS) {
+ pin(`${lang}: the no-name state`, () => {
+ mountRule(lang, {});
+ expectSites(lang, [{ key: 'engine.validationPreview.empty' }]);
+ });
+
+ pin(`${lang}: the envelope (pills, message, tags) and a script rule with no condition`, () => {
+ mountRule(lang, {
+ name: 'amount_positive',
+ type: 'script',
+ severity: 'warning',
+ active: false,
+ priority: 5,
+ events: ['insert', 'update'],
+ tags: ['finance'],
+ });
+ expectSites(lang, [
+ { key: 'engine.validationPreview.disabled' },
+ { key: 'engine.validationPreview.priority', vars: { priority: 5 } },
+ { key: 'engine.validationPreview.on', vars: { events: 'insert, update' } },
+ { key: 'engine.validationPreview.noMessage' },
+ { key: 'engine.validationPreview.tags' },
+ { key: 'engine.validationPreview.condition' },
+ { key: 'engine.validationPreview.noExpression' },
+ ]);
+ expectAsWritten(['amount_positive', 'script', 'warning', 'finance']);
+ cleanup();
+ mountRule(lang, { name: 'amount_positive', type: 'script', message: 'Amount must be positive', condition: 'amount <= 0' });
+ expectSites(lang, [{ key: 'engine.validationPreview.active' }]);
+ expectAsWritten(['Amount must be positive', 'amount <= 0']);
+ });
+
+ pin(`${lang}: the three removed rule types redirect in the designer's words`, () => {
+ for (const type of ['unique', 'async', 'custom']) {
+ mountRule(lang, { name: 'r', type });
+ expectSites(lang, [
+ { key: 'engine.validationPreview.notARule' },
+ { key: 'engine.validationPreview.removedType', around: (r) => r.replace('{type}', type) },
+ { key: `engine.validationPreview.removed.${type}` },
+ ]);
+ expectEqual('the code span', document.body.querySelector('code')?.textContent, type);
+ cleanup();
+ }
+ });
+
+ pin(`${lang}: a conditional rule with an empty predicate, no nested rule and an untyped otherwise`, () => {
+ mountRule(lang, { name: 'c', type: 'conditional', when: '', otherwise: { name: 'fallback_rule' } });
+ expectSites(lang, [
+ { key: 'engine.validationPreview.when' },
+ { key: 'engine.validationPreview.noExpression' },
+ { key: 'engine.validationPreview.thenApply' },
+ { key: 'engine.validationPreview.noNested' },
+ { key: 'engine.validationPreview.otherwiseApply' },
+ { key: 'engine.validationPreview.noType' },
+ ]);
+ expectAsWritten(['fallback_rule']);
+ });
+
+ pin(`${lang}: state machine, format, cross-field and JSON Schema bodies`, () => {
+ const cases: Array<[Record, Site[], string[]]> = [
+ [{ type: 'state_machine' }, [{ key: 'engine.validationPreview.transitions' }, { key: 'engine.validationPreview.noTransitions' }], []],
+ [
+ { type: 'state_machine', field: 'status', transitions: { draft: ['open'], closed: [] }, initialStates: ['draft'] },
+ [
+ { key: 'engine.validationPreview.transitionsOn', vars: { field: 'status' } },
+ { key: 'engine.validationPreview.deadEnd' },
+ { key: 'engine.validationPreview.createdIn' },
+ ],
+ ['open', 'closed'],
+ ],
+ [{ type: 'format' }, [{ key: 'engine.validationPreview.format' }, { key: 'engine.validationPreview.noFormat' }], []],
+ [
+ { type: 'format', field: 'email', format: 'email', regex: '^.+@.+$' },
+ [
+ { key: 'engine.validationPreview.formatOn', vars: { field: 'email' } },
+ { key: 'engine.validationPreview.builtIn' },
+ { key: 'engine.validationPreview.regex' },
+ ],
+ ['^.+@.+$'],
+ ],
+ [
+ { type: 'cross_field' },
+ [
+ { key: 'engine.validationPreview.fieldsInvolved' },
+ { key: 'engine.validationPreview.none' },
+ { key: 'engine.validationPreview.crossFieldCondition' },
+ { key: 'engine.validationPreview.noExpression' },
+ ],
+ [],
+ ],
+ [
+ { type: 'json_schema', field: 'payload' },
+ [{ key: 'engine.validationPreview.jsonSchemaOn', vars: { field: 'payload' } }, { key: 'engine.validationPreview.noJsonSchema' }],
+ [],
+ ],
+ [{ type: 'json_schema' }, [{ key: 'engine.validationPreview.noJsonSchema' }], ['JSON Schema']],
+ [{ type: 'weird_rule' }, [{ key: 'engine.validationPreview.rule' }, { key: 'engine.validationPreview.unknownType', vars: { type: 'weird_rule' } }], []],
+ ];
+ for (const [body, sites, asWritten] of cases) {
+ mountRule(lang, { name: 'r', ...body });
+ expectSites(lang, sites);
+ expectAsWritten(asWritten);
+ cleanup();
+ }
+ });
+ }
+});
+
+// ─── The view column manager ─────────────────────────────────────────────────
+
+const CATALOG: ObjectFieldInfo[] = [{ name: 'name', label: 'Name', type: 'text', hidden: false }];
+
+function mountColumns(lang: Lang, columns: unknown[]) {
+ return inLang(
+ lang,
+ {}}
+ />,
+ );
+}
+
+describe('FieldsListEditor and FieldListRow read the designer locale (objectui#10862)', () => {
+ for (const lang of LANGS) {
+ pin(`${lang}: the heading and the no-columns note`, () => {
+ mountColumns(lang, []);
+ expectSites(lang, [{ key: 'engine.fieldsListEditor.columns' }, { key: 'engine.fieldsListEditor.empty' }]);
+ });
+
+ pin(`${lang}: a column's remove control, and the positional label of a column the canonical keys cannot name`, () => {
+ mountColumns(lang, [{ field: 'name', label: 'Name' }, { header: 'Legacy' }]);
+ const positional = row(lang, 'engine.fieldsListEditor.colN', { n: 2 });
+ expectSites(lang, [
+ { key: 'engine.fieldsListEditor.colN', vars: { n: 2 } },
+ { key: 'engine.viewColumnPanes.remove', vars: { label: 'Name' }, in: 'aria-label' },
+ { key: 'engine.viewColumnPanes.remove', vars: { label: positional }, in: 'aria-label' },
+ ]);
+ expectAsWritten(['Name']);
+ });
+ }
+});
diff --git a/packages/app-shell/src/views/metadata-admin/previews/externalDatasourcePanel.i18n-10862-s3.test.tsx b/packages/app-shell/src/views/metadata-admin/previews/externalDatasourcePanel.i18n-10862-s3.test.tsx
new file mode 100644
index 0000000000..b3dc82aa6e
--- /dev/null
+++ b/packages/app-shell/src/views/metadata-admin/previews/externalDatasourcePanel.i18n-10862-s3.test.tsx
@@ -0,0 +1,191 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+/**
+ * objectui#10862, slice 3 — `ExternalDatasourcePanel` under zh-CN.
+ *
+ * The panel `DatasourcePreview` renders for a federated datasource
+ * (`schemaMode` other than `managed`) carried its own English beside the
+ * preview's localized words: the save-first prompt, the header, the write
+ * badge, the snapshot line, the refresh button, the two tab labels and the
+ * federation-unavailable message. It now reads its `engine.externalDatasource.*`
+ * rows in the `locale` `DatasourcePreview` threads to it, the one the preview's
+ * own host hands it (`useMetadataLocale()`). The cases mount the panel through
+ * that host.
+ *
+ * ── How each site is read ────────────────────────────────────────────────────
+ * The harness of `dataPreviews.i18n-10862-s3.test.tsx`: the REAL components
+ * under the i18n provider in the case's language; each zh expectation read back
+ * from the catalogue and guarded by `zhRow`; each en case reading the en row
+ * through `t` / `tFormat` for `en-US`.
+ *
+ * ── Stand-ins and as-written ─────────────────────────────────────────────────
+ * The federation REST client (`../external/api`) is the real module with its
+ * two reads answered here; everything else is real. The schema mode, the
+ * datasource name and the snapshot time (formatted in the display locale,
+ * objectui#9909's channel) read as written. The tables tab's own contents are
+ * `SchemaBrowser`'s and are not read here.
+ */
+
+import * as React from 'react';
+import { describe, it, expect, vi, afterEach } from 'vitest';
+import { render, cleanup, act, fireEvent, screen } from '@testing-library/react';
+import { I18nProvider, useDisplayLocale } from '@object-ui/i18n';
+
+const state = vi.hoisted(() => ({
+ refresh: 'ok' as 'ok' | 'unavailable',
+ snapshotAt: '2020-03-04T15:30:00.000Z',
+}));
+
+vi.mock('../external/api', async (importOriginal) => {
+ const mod = await importOriginal();
+ return {
+ ...mod,
+ listRemoteTables: async () => [],
+ refreshCatalog: async () => {
+ if (state.refresh === 'unavailable') throw new mod.ExternalServiceUnavailableError();
+ return { datasource: 'warehouse', snapshotAt: state.snapshotAt, tables: [] };
+ },
+ };
+});
+
+import { t, tFormat } from '../i18n';
+import { DatasourcePreview } from './DatasourcePreview';
+
+afterEach(() => {
+ cleanup();
+ state.refresh = 'ok';
+});
+
+type Lang = 'en' | 'zh';
+const LANGS = ['zh', 'en'] as const;
+const LOCALE = { en: 'en-US', zh: 'zh-CN' } as const;
+type Vars = Record;
+
+/** The display locale the panel formats its snapshot time in, read off the same provider. */
+function DisplayLocaleProbe() {
+ return ;
+}
+
+/** The console mounts every designer surface under the i18n provider in its language. */
+function inLang(lang: Lang, ui: React.ReactElement) {
+ return render(
+
+ {ui}
+
+ ,
+ );
+}
+
+async function flush() {
+ await act(async () => {
+ await new Promise((r) => setTimeout(r, 0));
+ });
+}
+
+function row(lang: Lang, key: string, vars?: Vars): string {
+ return vars ? tFormat(key, LOCALE[lang], vars) : t(key, LOCALE[lang]);
+}
+
+function zhRow(key: string): string {
+ const zh = t(key, 'zh-CN');
+ expect(zh, `${key}: a missing zh row echoes the key back`).not.toBe(key);
+ expect(zh, `${key}: the zh row must not be the English one`).not.toBe(t(key, 'en-US'));
+ return zh;
+}
+
+interface Site {
+ key: string;
+ vars?: Vars;
+}
+
+const norm = (s: string | null | undefined): string => (s ?? '').replace(/\s+/g, ' ').trim();
+
+function rendered(text: string): boolean {
+ return Array.from(document.body.querySelectorAll('*')).some((el) => norm(el.textContent) === text);
+}
+
+/** What a case did not find; a case reads every site before it judges, so a failure lists each miss. */
+let misses: string[] = [];
+
+function pin(name: string, body: () => void | Promise) {
+ it(name, async () => {
+ misses = [];
+ await body();
+ expect(misses).toEqual([]);
+ });
+}
+
+function expectSites(lang: Lang, sites: Site[]) {
+ misses.push(
+ ...sites
+ .map((s) => `${s.key}: ${JSON.stringify(norm(row(lang, s.key, s.vars)))}`)
+ .filter((_, i) => !rendered(norm(row(lang, sites[i].key, sites[i].vars)))),
+ );
+ if (lang === 'zh') for (const s of sites) zhRow(s.key);
+}
+
+function expectAsWritten(texts: string[]) {
+ misses.push(...texts.filter((x) => !rendered(x)).map((x) => `as written: ${JSON.stringify(x)}`));
+}
+
+/** The catalog refresh button, found by being one, whichever language it reads in. */
+function refreshButton(): HTMLElement {
+ const words = [row('en', 'engine.externalDatasource.refresh'), row('zh', 'engine.externalDatasource.refresh')];
+ const button = screen.getAllByRole('button').find((b) => words.includes(norm(b.textContent)));
+ expect(button, 'the catalog refresh button').toBeTruthy();
+ return button!;
+}
+
+/** A federated datasource draft as `DatasourcePreview` receives it. */
+function federated(allowWrites: boolean) {
+ return { name: 'warehouse', label: 'Warehouse', driver: 'postgres', schemaMode: 'external', external: { allowWrites } };
+}
+
+function mount(lang: Lang, name: string, draft: Record) {
+ return inLang(lang, );
+}
+
+describe('ExternalDatasourcePanel reads the locale DatasourcePreview threads to it (objectui#10862)', () => {
+ for (const lang of LANGS) {
+ pin(`${lang}: an unsaved federated datasource (a draft name, no saved one yet) is asked to save first`, () => {
+ mount(lang, '', { name: 'warehouse', schemaMode: 'external' });
+ expectSites(lang, [{ key: 'engine.externalDatasource.saveFirst' }]);
+ });
+
+ pin(`${lang}: the header, the write badge, the refresh button and the two tabs`, async () => {
+ mount(lang, 'warehouse', federated(true));
+ await flush();
+ expectSites(lang, [
+ { key: 'engine.externalDatasource.title' },
+ { key: 'engine.externalDatasource.writesAllowed' },
+ { key: 'engine.externalDatasource.refresh' },
+ { key: 'engine.externalDatasource.tables' },
+ { key: 'engine.externalDatasource.validation' },
+ ]);
+ expectAsWritten(['external']);
+ cleanup();
+ mount(lang, 'warehouse', federated(false));
+ await flush();
+ expectSites(lang, [{ key: 'engine.externalDatasource.readOnly' }]);
+ });
+
+ pin(`${lang}: a refreshed catalog's snapshot line, its time formatted in the display locale`, async () => {
+ mount(lang, 'warehouse', federated(false));
+ await flush();
+ fireEvent.click(refreshButton());
+ await flush();
+ const displayLocale = screen.getByTestId('display-locale').getAttribute('data-locale')!;
+ const time = new Date(state.snapshotAt).toLocaleString(displayLocale);
+ expectSites(lang, [{ key: 'engine.externalDatasource.snapshot', vars: { time } }]);
+ });
+
+ pin(`${lang}: a server without federation`, async () => {
+ state.refresh = 'unavailable';
+ mount(lang, 'warehouse', federated(false));
+ await flush();
+ fireEvent.click(refreshButton());
+ await flush();
+ expectSites(lang, [{ key: 'engine.externalDatasource.unavailable' }]);
+ });
+ }
+});
diff --git a/packages/app-shell/src/views/metadata-admin/previews/view-column-io.ts b/packages/app-shell/src/views/metadata-admin/previews/view-column-io.ts
index 17e0a91e05..3de02b4f17 100644
--- a/packages/app-shell/src/views/metadata-admin/previews/view-column-io.ts
+++ b/packages/app-shell/src/views/metadata-admin/previews/view-column-io.ts
@@ -42,15 +42,21 @@ export interface VariantInfo {
*
* A column the canonical keys cannot name falls through to the positional
* label `col N`, which is what the row shows and what the author clicks — the
- * list never renders nameless.
+ * list never renders nameless. That label is the designer's own word, so a
+ * caller that renders it passes `positional`, the designer row read in its
+ * locale (objectui#10862); this module stays free of the string tables.
*/
-export function colLabel(c: unknown, i: number): string {
- if (typeof c === 'string') return c || `col ${i + 1}`;
+export function colLabel(
+ c: unknown,
+ i: number,
+ positional: (n: number) => string = (n) => `col ${n}`,
+): string {
+ if (typeof c === 'string') return c || positional(i + 1);
if (c && typeof c === 'object') {
const o = c as Record;
- return String(o.label ?? o.field ?? `col ${i + 1}`);
+ return String(o.label ?? o.field ?? positional(i + 1));
}
- return `col ${i + 1}`;
+ return positional(i + 1);
}
/**
diff --git a/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx b/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx
index 36d4647642..62c0679679 100644
--- a/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx
+++ b/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx
@@ -2171,7 +2171,7 @@ export function InterfacesPillar({
) : isSourcePage ? (
// Source pages have no block tree — the canvas shows only the live
// preview; the code editor lives in the inspector's Source tab.
-
+
) : Preview ? (
-
+
) : (
-
+