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 (
- Save the datasource first to browse its remote tables and validate - federated objects. + {tr('engine.externalDatasource.saveFirst', locale)}
); } @@ -94,7 +100,7 @@ export function ExternalDatasourcePanel({
- External Datasource + {tr('engine.externalDatasource.title', locale)} {schemaMode ?? 'external'} @@ -106,14 +112,16 @@ export function ExternalDatasourcePanel({ : 'bg-muted text-muted-foreground' }`} > - {allowWrites ? 'writes allowed' : 'read-only'} + {tr(allowWrites ? 'engine.externalDatasource.writesAllowed' : 'engine.externalDatasource.readOnly', locale)}
{catalog?.snapshotAt && ( - snapshot {formatSnapshot(catalog.snapshotAt, displayLocale)} + {tFormat('engine.externalDatasource.snapshot', locale, { + time: formatSnapshot(catalog.snapshotAt, displayLocale), + })} )}
@@ -135,8 +143,8 @@ export function ExternalDatasourcePanel({ - Tables - Validation + {tr('engine.externalDatasource.tables', locale)} + {tr('engine.externalDatasource.validation', locale)} diff --git a/packages/app-shell/src/views/metadata-admin/i18n.ts b/packages/app-shell/src/views/metadata-admin/i18n.ts index 3a10d0e42f..d634d05eb6 100644 --- a/packages/app-shell/src/views/metadata-admin/i18n.ts +++ b/packages/app-shell/src/views/metadata-admin/i18n.ts @@ -1579,6 +1579,173 @@ const ENGINE_STRINGS_EN: Record = { '`{identifier}` is not the row on this surface: a row predicate binds the record as `{canonical}` and nothing else. The CEL scope vocabulary still accepts `{identifier}`, so nothing here blocks the save, but at runtime the expression faults with `Unknown variable: {identifier}` and the rule never fires. Re-root the reference on `{canonical}`.', 'engine.celLint.notPushdownable': "This read filter isn't a simple field comparison ({detail}), so the server may be unable to push it down to the query — depending on the evaluation path it can be dropped and WIDEN access. Prefer a pushdown-able predicate (field vs. value or scope variable).", + // objectui#10862 (slice 3) — the object / data previews (`ObjectPreview`, + // `DatasetPreview`, `ViewPreview`, `ReportPreview`, `PagePreview`, + // `PageBlockCanvas` and its add-block picker, `SourcePageEditor`, + // `ValidationPreview`, the view column manager) and the federated + // datasource panel, each en row the literal it replaces. Author data (names, + // labels, messages, tags, region and slot names, CEL and regex sources) and + // producer text (the error a renderer threw, an engine or HTTP message) stay + // as written. + 'engine.objectPreview.renderFailed': "The form designer couldn't be rendered. Check the Form tab.", + 'engine.datasetPreview.pickObject': 'Pick a base object', + 'engine.datasetPreview.pickObjectHint': "Set the dataset's `object` to preview it against live data.", + 'engine.datasetPreview.addMeasure': 'Add a measure', + 'engine.datasetPreview.addMeasureHint': + 'A dataset needs at least one measure (e.g. revenue = sum(amount)) to preview.', + 'engine.datasetPreview.run': 'Run preview', + 'engine.datasetPreview.measureOne': '{count} measure', + 'engine.datasetPreview.measureOther': '{count} measures', + 'engine.datasetPreview.dimensionOne': '{count} dimension', + 'engine.datasetPreview.dimensionOther': '{count} dimensions', + 'engine.datasetPreview.notInstalled': 'Analytics capability not installed', + 'engine.datasetPreview.notInstalledHint': + "This deployment has no analytics service, so dataset previews can't run. Install @objectstack/service-analytics and mount AnalyticsServicePlugin — the dataset definition itself is fine.", + 'engine.datasetPreview.noRows': 'No rows', + 'engine.datasetPreview.noRowsHint': 'The dataset returned no rows for the current scope.', + 'engine.datasetPreview.chartFailed': + "Couldn't render the chart for this result — the table below still shows the data.", + 'engine.datasetPreview.ratioAxis': 'Ratio measures ({measures}) use the right axis.', + 'engine.viewPreview.schemaFailed': + "The view's `type` may not be registered, or required fields are missing.", + 'engine.viewPreview.noObject': + 'This view has no object binding yet. Set the bound {object} in the right panel to fetch live data and field options.', + 'engine.viewPreview.formFailed': "The form view references an object or field that doesn't resolve.", + 'engine.viewPreview.listFailed': "The view references an object or field that doesn't resolve.", + 'engine.reportPreview.renderFailed': + "The Report references a dataset/measure that doesn't resolve, or its config is incomplete.", + 'engine.reportPreview.loading': 'Loading report renderer…', + 'engine.reportPreview.joinedEmpty': 'Add a block to preview this joined report', + 'engine.reportPreview.joinedEmptyHint': + 'A joined report stacks dataset-bound blocks. Add a block and bind its dataset + measures in the right panel to start designing.', + 'engine.reportPreview.empty': 'Bind a dataset to preview this report', + 'engine.reportPreview.emptyHint': + "Since the 9.0 single-form cutover a report renders its dataset's measures (values) grouped by dimensions (rows). Choose a Dataset in the right panel to start designing.", + 'engine.pagePreview.interfaceFailed': + "The interface page references a source object/view that isn't available.", + 'engine.pagePreview.schemaFailed': + "The Page schema is incomplete or references a component that hasn't been registered yet.", + 'engine.pagePreview.previewRecord': 'Preview record', + 'engine.pagePreview.sampleOne': '{count} sample', + 'engine.pagePreview.sampleOther': '{count} samples', + 'engine.pagePreview.recordFallback': '(record)', + 'engine.pagePreview.addComponents': 'Add components to the page to see a preview.', + 'engine.pagePreview.blockN': 'block {n}', + 'engine.pageBlockCanvas.renderFailed': + '"{type}" can\'t render with its current configuration — check its Properties.', + 'engine.pageBlockCanvas.itemN': 'Item {n}', + 'engine.pageBlockCanvas.body': 'Body', + 'engine.pageBlockCanvas.content': 'Content', + 'engine.pageBlockCanvas.noRegions': 'No regions yet', + 'engine.pageBlockCanvas.noRegionsHint': + 'A Page is composed of regions (header / main / sidebar / …). Add a region to start dropping blocks into it.', + 'engine.pageBlockCanvas.addRegion': 'Add region', + 'engine.pageBlockCanvas.regionN': 'region {n}', + 'engine.pageBlockCanvas.dropHere': 'drop here', + 'engine.pageBlockCanvas.slotInherited': + 'Inherited from the default page — add a block to override this slot.', + 'engine.pageBlockCanvas.regionEmpty': 'Empty region — drop a block here or use the add button below.', + 'engine.pageBlockCanvas.select': 'Select {label}', + 'engine.pageBlockCanvas.groupEmpty': 'Empty — add a block.', + 'engine.pageBlockCanvas.searchTypes': 'Search block type…', + 'engine.pageBlockCanvas.noMatch': 'No matching types.', + 'engine.pageBlockCanvas.category.data': 'Data', + 'engine.pageBlockCanvas.category.layout': 'Layout', + 'engine.pageBlockCanvas.category.record': 'Record context', + 'engine.pageBlockCanvas.category.navigation': 'Navigation', + 'engine.pageBlockCanvas.category.element': 'Elements', + 'engine.pageBlockCanvas.category.ai': 'AI', + 'engine.pageBlockCanvas.category.misc': 'Other', + 'engine.pageBlockCanvas.type.grid': 'Grid', + 'engine.pageBlockCanvas.type.objectGrid': 'Table', + 'engine.pageBlockCanvas.type.objectForm': 'Form', + 'engine.pageBlockCanvas.type.objectMetric': 'Metric', + 'engine.pageBlockCanvas.type.objectKanban': 'Kanban', + 'engine.pageBlockCanvas.type.pageHeader': 'Header', + 'engine.pageBlockCanvas.type.pageFooter': 'Footer', + 'engine.pageBlockCanvas.type.pageSidebar': 'Sidebar', + 'engine.pageBlockCanvas.type.pageTabs': 'Tabs', + 'engine.pageBlockCanvas.type.pageAccordion': 'Accordion', + 'engine.pageBlockCanvas.type.pageCard': 'Card', + 'engine.pageBlockCanvas.type.pageSection': 'Section', + 'engine.pageBlockCanvas.type.recordDetails': 'Record details', + 'engine.pageBlockCanvas.type.recordHighlights': 'Highlights', + 'engine.pageBlockCanvas.type.recordRelatedList': 'Related list', + 'engine.pageBlockCanvas.type.recordActivity': 'Activity timeline', + 'engine.pageBlockCanvas.type.recordDiscussion': 'Discussion', + 'engine.pageBlockCanvas.type.recordPath': 'Stage path', + 'engine.pageBlockCanvas.type.recordAlert': 'Alert banner', + 'engine.pageBlockCanvas.type.recordQuickActions': 'Quick actions', + 'engine.pageBlockCanvas.type.recordReferenceRail': 'Reference rail', + 'engine.pageBlockCanvas.type.recordHistory': 'History', + 'engine.pageBlockCanvas.type.navMenu': 'Nav menu', + 'engine.pageBlockCanvas.type.navBreadcrumb': 'Breadcrumb', + 'engine.pageBlockCanvas.type.globalSearch': 'Global search', + 'engine.pageBlockCanvas.type.aiSuggestion': 'AI suggestion', + 'engine.pageBlockCanvas.type.elementText': 'Text', + 'engine.pageBlockCanvas.type.elementNumber': 'Number', + 'engine.pageBlockCanvas.type.elementImage': 'Image', + 'engine.pageBlockCanvas.type.elementDivider': 'Divider', + 'engine.pageBlockCanvas.type.elementButton': 'Button', + 'engine.pageBlockCanvas.type.elementDefinitionList': 'Definition list', + 'engine.pageBlockCanvas.type.elementRepeater': 'Repeater', + 'engine.sourcePageEditor.source': 'Page source', + 'engine.sourcePageEditor.renderFailed': + 'The page source threw while rendering — fix the code in the Source tab.', + 'engine.validationPreview.empty': 'Give the validation a name to see the preview.', + 'engine.validationPreview.active': 'Active', + 'engine.validationPreview.disabled': 'Disabled', + 'engine.validationPreview.priority': 'priority {priority}', + 'engine.validationPreview.on': 'on {events}', + 'engine.validationPreview.noMessage': 'no message set', + 'engine.validationPreview.tags': 'Tags', + 'engine.validationPreview.removed.unique': + 'Uniqueness is a unique INDEX, not a validation rule: declare it on the object as `indexes: [{ fields: [...], unique: true }]` (or `unique: true` on the field). A SELECT-then-INSERT rule is inherently racy (TOCTOU); a database constraint is not.', + 'engine.validationPreview.removed.async': + 'Remote/async validation is a form-layer concern — on the server write path it is an SSRF and latency hazard. Keep it in the form, or enforce the underlying invariant with a unique index or a lifecycle hook.', + 'engine.validationPreview.removed.custom': + 'Arbitrary validation code belongs in a `beforeInsert` / `beforeUpdate` lifecycle hook — the typed, supported extension point.', + 'engine.validationPreview.notARule': 'Not a validation rule', + 'engine.validationPreview.removedType': + '{type} was removed from ValidationRuleSchema — this rule cannot be saved.', + 'engine.validationPreview.condition': 'Condition', + 'engine.validationPreview.when': 'When', + 'engine.validationPreview.thenApply': 'Then apply', + 'engine.validationPreview.noNested': 'No nested rule set — this rule enforces nothing.', + 'engine.validationPreview.otherwiseApply': 'Otherwise apply', + 'engine.validationPreview.transitions': 'Transitions', + 'engine.validationPreview.transitionsOn': 'Transitions on {field}', + 'engine.validationPreview.noTransitions': 'No transitions declared.', + 'engine.validationPreview.deadEnd': '∅ (dead-end)', + 'engine.validationPreview.createdIn': 'May be created in', + 'engine.validationPreview.format': 'Format', + 'engine.validationPreview.formatOn': 'Format on {field}', + 'engine.validationPreview.builtIn': 'Built-in:', + 'engine.validationPreview.regex': 'Regex:', + 'engine.validationPreview.noFormat': 'No format or regex set.', + 'engine.validationPreview.fieldsInvolved': 'Fields involved', + 'engine.validationPreview.none': 'none', + 'engine.validationPreview.crossFieldCondition': 'Cross-field condition', + 'engine.validationPreview.jsonSchemaOn': 'JSON Schema on {field}', + 'engine.validationPreview.noJsonSchema': 'No JSON Schema set.', + 'engine.validationPreview.rule': 'Rule', + 'engine.validationPreview.unknownType': 'Unknown rule type "{type}". Showing common fields only.', + 'engine.validationPreview.noType': 'no type', + 'engine.validationPreview.noExpression': 'No expression set.', + 'engine.fieldsListEditor.columns': 'Columns', + 'engine.fieldsListEditor.empty': 'No columns yet. Add a field below.', + 'engine.fieldsListEditor.colN': 'col {n}', + 'engine.viewColumnPanes.remove': 'Remove {label}', + 'engine.externalDatasource.unavailable': 'Federation is not enabled on this server.', + 'engine.externalDatasource.saveFirst': + 'Save the datasource first to browse its remote tables and validate federated objects.', + 'engine.externalDatasource.title': 'External Datasource', + 'engine.externalDatasource.writesAllowed': 'writes allowed', + 'engine.externalDatasource.readOnly': 'read-only', + 'engine.externalDatasource.snapshot': 'snapshot {time}', + 'engine.externalDatasource.refresh': 'Refresh catalog', + 'engine.externalDatasource.tables': 'Tables', + 'engine.externalDatasource.validation': 'Validation', // Structural flow validation (flow-sim-validate) — canvas banner, Problems // panel, and the debug simulator. 'engine.flowValidate.nodeMissingId': 'A node is missing an id.', @@ -4287,6 +4454,150 @@ const ENGINE_STRINGS_ZH: Record = { '`{identifier}` 在此处不是记录行:行条件只把记录绑定为 `{canonical}`,别无其他。CEL 作用域词汇仍接受 `{identifier}`,所以这里不会阻止保存,但运行时表达式会以 `Unknown variable: {identifier}` 报错,规则永远不会触发。请改为以 `{canonical}` 为根引用。', 'engine.celLint.notPushdownable': '此读取过滤器不是简单的字段比较({detail}),服务器可能无法把它下推到查询 —— 视求值路径而定,它可能被丢弃并扩大访问范围。建议使用可下推的条件(字段与值或作用域变量比较)。', + 'engine.objectPreview.renderFailed': '无法渲染表单设计器。请检查“表单”标签页。', + 'engine.datasetPreview.pickObject': '选择一个基础对象', + 'engine.datasetPreview.pickObjectHint': '设置数据集的 `object`,即可用实时数据预览它。', + 'engine.datasetPreview.addMeasure': '添加一个度量', + 'engine.datasetPreview.addMeasureHint': '数据集至少需要一个度量(例如 revenue = sum(amount))才能预览。', + 'engine.datasetPreview.run': '运行预览', + 'engine.datasetPreview.measureOne': '{count} 个度量', + 'engine.datasetPreview.measureOther': '{count} 个度量', + 'engine.datasetPreview.dimensionOne': '{count} 个维度', + 'engine.datasetPreview.dimensionOther': '{count} 个维度', + 'engine.datasetPreview.notInstalled': '未安装分析能力', + 'engine.datasetPreview.notInstalledHint': + '此部署没有分析服务,因此无法运行数据集预览。请安装 @objectstack/service-analytics 并挂载 AnalyticsServicePlugin —— 数据集定义本身没有问题。', + 'engine.datasetPreview.noRows': '没有数据行', + 'engine.datasetPreview.noRowsHint': '数据集在当前范围内没有返回任何数据行。', + 'engine.datasetPreview.chartFailed': '无法为此结果渲染图表 —— 下方表格仍会显示数据。', + 'engine.datasetPreview.ratioAxis': '比率度量({measures})使用右侧坐标轴。', + 'engine.viewPreview.schemaFailed': '该视图的 `type` 可能尚未注册,或缺少必填字段。', + 'engine.viewPreview.noObject': '此视图尚未绑定对象。请在右侧面板中设置绑定的 {object},以获取实时数据和字段选项。', + 'engine.viewPreview.formFailed': '表单视图引用了无法解析的对象或字段。', + 'engine.viewPreview.listFailed': '视图引用了无法解析的对象或字段。', + 'engine.reportPreview.renderFailed': '报表引用了无法解析的数据集/度量,或其配置不完整。', + 'engine.reportPreview.loading': '正在加载报表渲染器…', + 'engine.reportPreview.joinedEmpty': '添加一个区块以预览此联合报表', + 'engine.reportPreview.joinedEmptyHint': '联合报表由绑定数据集的区块堆叠而成。添加一个区块,并在右侧面板中绑定它的数据集 + 度量,即可开始设计。', + 'engine.reportPreview.empty': '绑定一个数据集以预览此报表', + 'engine.reportPreview.emptyHint': '自 9.0 单一形态切换起,报表按维度(rows)分组呈现其数据集的度量(values)。在右侧面板中选择一个数据集即可开始设计。', + 'engine.pagePreview.interfaceFailed': '界面页面引用了一个不可用的来源对象/视图。', + 'engine.pagePreview.schemaFailed': '页面 schema 不完整,或引用了尚未注册的组件。', + 'engine.pagePreview.previewRecord': '预览记录', + 'engine.pagePreview.sampleOne': '{count} 条示例', + 'engine.pagePreview.sampleOther': '{count} 条示例', + 'engine.pagePreview.recordFallback': '(记录)', + 'engine.pagePreview.addComponents': '向页面添加组件即可查看预览。', + 'engine.pagePreview.blockN': '区块 {n}', + 'engine.pageBlockCanvas.renderFailed': '“{type}” 无法以当前配置渲染 —— 请检查它的属性。', + 'engine.pageBlockCanvas.itemN': '第 {n} 项', + 'engine.pageBlockCanvas.body': '主体', + 'engine.pageBlockCanvas.content': '内容', + 'engine.pageBlockCanvas.noRegions': '还没有区域', + 'engine.pageBlockCanvas.noRegionsHint': '页面由区域(header / main / sidebar / …)组成。添加一个区域,即可开始向其中放入区块。', + 'engine.pageBlockCanvas.addRegion': '添加区域', + 'engine.pageBlockCanvas.regionN': '区域 {n}', + 'engine.pageBlockCanvas.dropHere': '放到这里', + 'engine.pageBlockCanvas.slotInherited': '继承自默认页面 —— 添加一个区块即可覆盖此插槽。', + 'engine.pageBlockCanvas.regionEmpty': '空区域 —— 将区块拖放到这里,或使用下方的添加按钮。', + 'engine.pageBlockCanvas.select': '选择 {label}', + 'engine.pageBlockCanvas.groupEmpty': '空 —— 添加一个区块。', + 'engine.pageBlockCanvas.searchTypes': '搜索区块类型…', + 'engine.pageBlockCanvas.noMatch': '没有匹配的类型。', + 'engine.pageBlockCanvas.category.data': '数据', + 'engine.pageBlockCanvas.category.layout': '布局', + 'engine.pageBlockCanvas.category.record': '记录上下文', + 'engine.pageBlockCanvas.category.navigation': '导航', + 'engine.pageBlockCanvas.category.element': '元素', + 'engine.pageBlockCanvas.category.ai': 'AI', + 'engine.pageBlockCanvas.category.misc': '其他', + 'engine.pageBlockCanvas.type.grid': '网格', + 'engine.pageBlockCanvas.type.objectGrid': '表格', + 'engine.pageBlockCanvas.type.objectForm': '表单', + 'engine.pageBlockCanvas.type.objectMetric': '指标', + 'engine.pageBlockCanvas.type.objectKanban': '看板', + 'engine.pageBlockCanvas.type.pageHeader': '页头', + 'engine.pageBlockCanvas.type.pageFooter': '页脚', + 'engine.pageBlockCanvas.type.pageSidebar': '侧边栏', + 'engine.pageBlockCanvas.type.pageTabs': '标签页', + 'engine.pageBlockCanvas.type.pageAccordion': '折叠面板', + 'engine.pageBlockCanvas.type.pageCard': '卡片', + 'engine.pageBlockCanvas.type.pageSection': '分区', + 'engine.pageBlockCanvas.type.recordDetails': '记录详情', + 'engine.pageBlockCanvas.type.recordHighlights': '重点信息', + 'engine.pageBlockCanvas.type.recordRelatedList': '关联列表', + 'engine.pageBlockCanvas.type.recordActivity': '活动时间线', + 'engine.pageBlockCanvas.type.recordDiscussion': '讨论', + 'engine.pageBlockCanvas.type.recordPath': '阶段路径', + 'engine.pageBlockCanvas.type.recordAlert': '提醒横幅', + 'engine.pageBlockCanvas.type.recordQuickActions': '快捷操作', + 'engine.pageBlockCanvas.type.recordReferenceRail': '参考侧栏', + 'engine.pageBlockCanvas.type.recordHistory': '历史记录', + 'engine.pageBlockCanvas.type.navMenu': '导航菜单', + 'engine.pageBlockCanvas.type.navBreadcrumb': '面包屑', + 'engine.pageBlockCanvas.type.globalSearch': '全局搜索', + 'engine.pageBlockCanvas.type.aiSuggestion': 'AI 建议', + 'engine.pageBlockCanvas.type.elementText': '文本', + 'engine.pageBlockCanvas.type.elementNumber': '数字', + 'engine.pageBlockCanvas.type.elementImage': '图片', + 'engine.pageBlockCanvas.type.elementDivider': '分隔线', + 'engine.pageBlockCanvas.type.elementButton': '按钮', + 'engine.pageBlockCanvas.type.elementDefinitionList': '定义列表', + 'engine.pageBlockCanvas.type.elementRepeater': '重复器', + 'engine.sourcePageEditor.source': '页面源码', + 'engine.sourcePageEditor.renderFailed': '页面源码在渲染时抛出错误 —— 请在“源码”标签页中修复代码。', + 'engine.validationPreview.empty': '为校验规则命名即可查看预览。', + 'engine.validationPreview.active': '已启用', + 'engine.validationPreview.disabled': '已停用', + 'engine.validationPreview.priority': '优先级 {priority}', + 'engine.validationPreview.on': '触发于 {events}', + 'engine.validationPreview.noMessage': '未设置消息', + 'engine.validationPreview.tags': '标签', + 'engine.validationPreview.removed.unique': + '唯一性是唯一索引(INDEX),不是校验规则:请在对象上声明为 `indexes: [{ fields: [...], unique: true }]`(或在字段上设置 `unique: true`)。先 SELECT 再 INSERT 的规则天生存在竞态(TOCTOU);数据库约束则不会。', + 'engine.validationPreview.removed.async': + '远程/异步校验属于表单层的职责 —— 放在服务端写入路径上会带来 SSRF 与延迟风险。请把它留在表单中,或用唯一索引或生命周期钩子来强制其背后的约束。', + 'engine.validationPreview.removed.custom': + '任意校验代码应放在 `beforeInsert` / `beforeUpdate` 生命周期钩子中 —— 那是有类型、受支持的扩展点。', + 'engine.validationPreview.notARule': '不是校验规则', + 'engine.validationPreview.removedType': '{type} 已从 ValidationRuleSchema 中移除 —— 此规则无法保存。', + 'engine.validationPreview.condition': '条件', + 'engine.validationPreview.when': '条件成立时', + 'engine.validationPreview.thenApply': '则应用', + 'engine.validationPreview.noNested': '未设置嵌套规则 —— 此规则不强制任何约束。', + 'engine.validationPreview.otherwiseApply': '否则应用', + 'engine.validationPreview.transitions': '状态流转', + 'engine.validationPreview.transitionsOn': '{field} 的状态流转', + 'engine.validationPreview.noTransitions': '未声明任何状态流转。', + 'engine.validationPreview.deadEnd': '∅(无后续状态)', + 'engine.validationPreview.createdIn': '可在以下状态创建', + 'engine.validationPreview.format': '格式', + 'engine.validationPreview.formatOn': '{field} 的格式', + 'engine.validationPreview.builtIn': '内置:', + 'engine.validationPreview.regex': '正则:', + 'engine.validationPreview.noFormat': '未设置格式或正则表达式。', + 'engine.validationPreview.fieldsInvolved': '涉及字段', + 'engine.validationPreview.none': '无', + 'engine.validationPreview.crossFieldCondition': '跨字段条件', + 'engine.validationPreview.jsonSchemaOn': '{field} 的 JSON Schema', + 'engine.validationPreview.noJsonSchema': '未设置 JSON Schema。', + 'engine.validationPreview.rule': '规则', + 'engine.validationPreview.unknownType': '未知的规则类型“{type}”。仅显示通用字段。', + 'engine.validationPreview.noType': '无类型', + 'engine.validationPreview.noExpression': '未设置表达式。', + 'engine.fieldsListEditor.columns': '列', + 'engine.fieldsListEditor.empty': '还没有列。请在下方添加字段。', + 'engine.fieldsListEditor.colN': '列 {n}', + 'engine.viewColumnPanes.remove': '移除 {label}', + 'engine.externalDatasource.unavailable': '此服务器未启用数据联邦。', + 'engine.externalDatasource.saveFirst': '请先保存数据源,才能浏览其远程表并校验联邦对象。', + 'engine.externalDatasource.title': '外部数据源', + 'engine.externalDatasource.writesAllowed': '允许写入', + 'engine.externalDatasource.readOnly': '只读', + 'engine.externalDatasource.snapshot': '快照 {time}', + 'engine.externalDatasource.refresh': '刷新目录', + 'engine.externalDatasource.tables': '表', + 'engine.externalDatasource.validation': '校验', // 连线(edge)Type 枚举值 —— 属性表单里的 SchemaForm 下拉(与画布连线检查器一致)。 'engine.enum.type.default': '普通', 'engine.enum.type.conditional': '条件', diff --git a/packages/app-shell/src/views/metadata-admin/previews/DatasetPreview.tsx b/packages/app-shell/src/views/metadata-admin/previews/DatasetPreview.tsx index 8d7934dbe0..ace4740a7a 100644 --- a/packages/app-shell/src/views/metadata-admin/previews/DatasetPreview.tsx +++ b/packages/app-shell/src/views/metadata-admin/previews/DatasetPreview.tsx @@ -30,6 +30,7 @@ import { } from '@object-ui/core'; import { useDatasetDimensionLabels } from '@object-ui/react'; import { builtinAggregateLabels, useSafeFieldLabel, useSafeTranslate, useDisplayLocale } from '@object-ui/i18n'; +import { t as tr, tFormat } from '../i18n.js'; // Lazy-loaded so the (recharts-backed) chart bundle only loads when a dataset // preview actually renders a chart — keeps the metadata-admin bundle small. @@ -47,7 +48,7 @@ type PreviewState = // rather than a red banner blaming the dataset the author just wrote. | { status: 'not-installed'; rows: Row[]; error?: undefined }; -export function DatasetPreview({ draft }: MetadataPreviewProps) { +export function DatasetPreview({ draft, locale }: MetadataPreviewProps) { const adapter = useAdapter(); const { fieldLabel } = useSafeFieldLabel(); // objectui#7534 — the locale bundle's built-in aggregate labels; `@object-ui/ @@ -57,8 +58,9 @@ export function DatasetPreview({ draft }: MetadataPreviewProps) { // (objectui#4575, completing objectui#4566's channel). Deliberately NOT the // `locale` PROP in scope: that one is the metadata designer's own chrome // language (`useMetadataLocale()`, which resolves to exactly 'en-US' or - // 'zh-CN'), while these numbers must match what the report and dashboard - // render for the same dataset — which is `useDisplayLocale()`. + // 'zh-CN') and picks this preview's own words (objectui#10862), while these + // numbers must match what the report and dashboard render for the same + // dataset — which is `useDisplayLocale()`. const displayLocale = useDisplayLocale(); const objectName = (draft as Record).object as string | undefined; @@ -150,8 +152,8 @@ export function DatasetPreview({ draft }: MetadataPreviewProps) { } - title="Pick a base object" - description="Set the dataset's `object` to preview it against live data." + title={tr('engine.datasetPreview.pickObject', locale)} + description={tr('engine.datasetPreview.pickObjectHint', locale)} /> ); @@ -162,8 +164,8 @@ export function DatasetPreview({ draft }: MetadataPreviewProps) { } - title="Add a measure" - description="A dataset needs at least one measure (e.g. revenue = sum(amount)) to preview." + title={tr('engine.datasetPreview.addMeasure', locale)} + description={tr('engine.datasetPreview.addMeasureHint', locale)} /> ); @@ -224,10 +226,20 @@ export function DatasetPreview({ draft }: MetadataPreviewProps) { {state.status === 'loading' ? : } - Run preview + {tr('engine.datasetPreview.run', locale)} - {measureNames.length} measure{measureNames.length === 1 ? '' : 's'} · {dimensionNames.length} dimension{dimensionNames.length === 1 ? '' : 's'} + {tFormat( + measureNames.length === 1 ? 'engine.datasetPreview.measureOne' : 'engine.datasetPreview.measureOther', + locale, + { count: measureNames.length }, + )} + {' · '} + {tFormat( + dimensionNames.length === 1 ? 'engine.datasetPreview.dimensionOne' : 'engine.datasetPreview.dimensionOther', + locale, + { count: dimensionNames.length }, + )} @@ -241,21 +253,21 @@ export function DatasetPreview({ draft }: MetadataPreviewProps) { {state.status === 'not-installed' && ( } - title="Analytics capability not installed" - description="This deployment has no analytics service, so dataset previews can't run. Install @objectstack/service-analytics and mount AnalyticsServicePlugin — the dataset definition itself is fine." + title={tr('engine.datasetPreview.notInstalled', locale)} + description={tr('engine.datasetPreview.notInstalledHint', locale)} /> )} {state.status === 'ok' && state.rows.length === 0 && ( } - title="No rows" - description="The dataset returned no rows for the current scope." + title={tr('engine.datasetPreview.noRows', locale)} + description={tr('engine.datasetPreview.noRowsHint', locale)} /> )} {state.rows.length > 0 && dimensionNames.length >= 1 && ( - + }>
{mixedScale && (

- Ratio measures ({ratioMeasures.map(headerLabel).join(', ')}) use the right axis. + {tFormat('engine.datasetPreview.ratioAxis', locale, { measures: ratioMeasures.map(headerLabel).join(', ') })}

)}
diff --git a/packages/app-shell/src/views/metadata-admin/previews/DatasourcePreview.tsx b/packages/app-shell/src/views/metadata-admin/previews/DatasourcePreview.tsx index f517e6ad7f..3c926c5f46 100644 --- a/packages/app-shell/src/views/metadata-admin/previews/DatasourcePreview.tsx +++ b/packages/app-shell/src/views/metadata-admin/previews/DatasourcePreview.tsx @@ -157,6 +157,7 @@ export function DatasourcePreview({ name, draft, locale }: MetadataPreviewProps) datasource={String(name ?? '')} schemaMode={schemaMode} allowWrites={allowWrites} + locale={locale} /> )} diff --git a/packages/app-shell/src/views/metadata-admin/previews/FieldsListEditor.tsx b/packages/app-shell/src/views/metadata-admin/previews/FieldsListEditor.tsx index 4243681887..8ea92449a1 100644 --- a/packages/app-shell/src/views/metadata-admin/previews/FieldsListEditor.tsx +++ b/packages/app-shell/src/views/metadata-admin/previews/FieldsListEditor.tsx @@ -32,6 +32,12 @@ import { remapIndexAfterRemove, usedFieldNames, } from './view-column-io.js'; +import { useMetadataLocale, t, tFormat } from '../i18n.js'; + +/** A column's label, its positional fallback (`col N`) read in the designer locale (objectui#10862). */ +function labelOf(c: unknown, i: number, locale: string): string { + return colLabel(c, i, (n) => tFormat('engine.fieldsListEditor.colN', locale, { n })); +} export interface FieldsListEditorProps { /** Top-level variant key the columns belong to (e.g. 'list'). */ @@ -74,6 +80,10 @@ export function FieldsListEditor({ onSelectionChange, }: FieldsListEditorProps) { const canEdit = !readOnly; + // The designer locale, read the way this editor's add-field popover + // (`AddFieldPopover`) and rows (`FieldListRow`) read it (objectui#10862): + // its hosts, the view and report inspectors, hold the same hook's value. + const locale = useMetadataLocale(); const { fields, loading, error } = useObjectFields( objectName || undefined, objectFieldsOverride, @@ -120,11 +130,11 @@ export function FieldsListEditor({ onSelectionChange?.({ kind: 'column', id: `${variantKey}.columns[${remapped}]`, - label: colLabel(next[remapped], remapped), + label: labelOf(next[remapped], remapped, locale), }); } }, - [columns, writeColumns, selectedIndex, variantKey, onSelectionChange], + [columns, writeColumns, selectedIndex, variantKey, onSelectionChange, locale], ); const moveColumn = React.useCallback( @@ -137,11 +147,11 @@ export function FieldsListEditor({ onSelectionChange?.({ kind: 'column', id: `${variantKey}.columns[${remapped}]`, - label: colLabel(next[remapped], remapped), + label: labelOf(next[remapped], remapped, locale), }); } }, - [columns, writeColumns, selectedIndex, variantKey, onSelectionChange], + [columns, writeColumns, selectedIndex, variantKey, onSelectionChange, locale], ); const selectColumn = React.useCallback( @@ -149,10 +159,10 @@ export function FieldsListEditor({ onSelectionChange?.({ kind: 'column', id: `${variantKey}.columns[${index}]`, - label: colLabel(columns[index], index), + label: labelOf(columns[index], index, locale), }); }, - [columns, variantKey, onSelectionChange], + [columns, variantKey, onSelectionChange, locale], ); const usedNames = usedFieldNames(columns); @@ -160,7 +170,7 @@ export function FieldsListEditor({ return (
- + {columns.length} @@ -168,7 +178,7 @@ export function FieldsListEditor({ {columns.length === 0 ? (

- No columns yet. Add a field below. + {t('engine.fieldsListEditor.empty', locale)}

) : (
@@ -176,7 +186,7 @@ export function FieldsListEditor({ - + (rootMargin = '300px') { * block scrolls near the viewport: data-bound blocks (grids, related lists, * repeaters) each fetch on mount, so a tall page would otherwise fire every * query at once. Capped height keeps a tall widget from dominating the canvas. */ -function BlockLivePreview({ block, maxHeightClass = 'max-h-72' }: { block: Block; maxHeightClass?: string }) { +function BlockLivePreview({ + block, + maxHeightClass = 'max-h-72', + locale, +}: { + block: Block; + maxHeightClass?: string; + locale?: string; +}) { const typeStr = String(block?.type ?? ''); const { ref, inView } = useLazyInView(); return (
{inView ? ( - + @@ -126,15 +135,17 @@ const GRID_COLS_CLASS: Record = { /** Container blocks expose nested child arrays (issue #1499). Returns each * group's display label, the path suffix to its children array (relative to - * the block), and the current children. */ -function childGroups(block: Block): Array<{ label: string; pathSuffix: string; children: Block[] }> { + * the block), and the current children. A tab / accordion item's own `label` + * or `key` is author data; the fallbacks are the designer's words, read in + * `locale` (objectui#10862). */ +function childGroups(block: Block, locale?: string): Array<{ label: string; pathSuffix: string; children: Block[] }> { const props = (block?.properties as any) || {}; switch (block?.type) { case 'page:tabs': case 'page:accordion': { const items = Array.isArray(props.items) ? props.items : []; return items.map((it: any, i: number) => ({ - label: it?.label || it?.key || `Item ${i + 1}`, + label: it?.label || it?.key || tFormat('engine.pageBlockCanvas.itemN', locale, { n: i + 1 }), pathSuffix: `properties.items[${i}].children`, children: Array.isArray(it?.children) ? it.children : [], })); @@ -165,18 +176,18 @@ function childGroups(block: Block): Array<{ label: string; pathSuffix: string; c // under that block. Recorded on objectui#9916 (comment 5733844778), ⛔ not // repaired here — widening the canvas is that card's call, not this one's. if (Array.isArray(props.body) && !Array.isArray(props.children)) { - return [{ label: 'Body', pathSuffix: 'properties.body', children: props.body }]; + return [{ label: tr('engine.pageBlockCanvas.body', locale), pathSuffix: 'properties.body', children: props.body }]; } - return [{ label: 'Body', pathSuffix: 'properties.children', children: Array.isArray(props.children) ? props.children : [] }]; + return [{ label: tr('engine.pageBlockCanvas.body', locale), pathSuffix: 'properties.children', children: Array.isArray(props.children) ? props.children : [] }]; } case 'page:section': case 'grid': - return [{ label: 'Content', pathSuffix: 'properties.children', children: Array.isArray(props.children) ? props.children : [] }]; + return [{ label: tr('engine.pageBlockCanvas.content', locale), pathSuffix: 'properties.children', children: Array.isArray(props.children) ? props.children : [] }]; default: // Future-proof: any block carrying a `properties.children` array (e.g. // `container`) exposes those children for selection/editing. if (Array.isArray(props.children)) { - return [{ label: 'Content', pathSuffix: 'properties.children', children: props.children }]; + return [{ label: tr('engine.pageBlockCanvas.content', locale), pathSuffix: 'properties.children', children: props.children }]; } return []; } @@ -202,6 +213,8 @@ export interface PageBlockCanvasProps { onPatch?: (patch: Record) => void; selection?: MetadataSelection | null; onSelectionChange?: (next: MetadataSelection | null) => void; + /** The designer locale the canvas's own words read in — `PagePreview` passes the one it receives (objectui#10862). */ + locale?: string; } /** Canonical record-page slots, in render order. A `kind:'slotted'` page @@ -280,6 +293,7 @@ export function PageBlockCanvas({ onPatch, selection, onSelectionChange, + locale, }: PageBlockCanvasProps) { const readOnly = !onPatch; const { regions, shape } = React.useMemo(() => readRegions(draft), [draft]); @@ -435,15 +449,15 @@ export function PageBlockCanvas({
-
No regions yet
+
{tr('engine.pageBlockCanvas.noRegions', locale)}
- A Page is composed of regions (header / main / sidebar / …). Add a region to start dropping blocks into it. + {tr('engine.pageBlockCanvas.noRegionsHint', locale)}
{!readOnly && (
)} @@ -477,13 +491,14 @@ export function PageBlockCanvas({ baseIdOf={(compIdx) => selectionId(shape, regionIdx, compIdx, region.name)} onSelectId={(sid, lbl) => onSelectionChange?.({ kind: 'block', id: sid, label: lbl })} onAddNested={addNestedBlock} + locale={locale} /> ))} {!readOnly && shape === 'regions' && (
)} @@ -506,6 +521,7 @@ function RegionSection({ onRenameLabel, onSelectId, onAddNested, + locale, }: { region: Region; regionIdx: number; @@ -524,6 +540,7 @@ function RegionSection({ baseIdOf: (compIdx: number) => string; onSelectId: (id: string, label: string) => void; onAddNested: (baseId: string, pathSuffix: string, type: BlockTypeId) => void; + locale?: string; }) { const comps = Array.isArray(region.components) ? region.components : []; const [active, setActive] = React.useState(false); @@ -564,18 +581,16 @@ function RegionSection({ onDrop={handleDrop} >
- {region.name || `region ${regionIdx + 1}`} + {region.name || tFormat('engine.pageBlockCanvas.regionN', locale, { n: regionIdx + 1 })} {region.width && ( · {region.width} )} - {active && drop here} + {active && {tr('engine.pageBlockCanvas.dropHere', locale)}}
{comps.length === 0 ? (
- {shape === 'slots' - ? 'Inherited from the default page — add a block to override this slot.' - : 'Empty region — drop a block here or use the add button below.'} + {tr(shape === 'slots' ? 'engine.pageBlockCanvas.slotInherited' : 'engine.pageBlockCanvas.regionEmpty', locale)}
) : ( comps.map((blk, compIdx) => { @@ -591,6 +606,7 @@ function RegionSection({ onClick={() => onSelectBlock(compIdx, blk)} onMoveBlock={onMoveBlock} onRenameLabel={(v) => onRenameLabel(regionIdx, compIdx, v)} + locale={locale} /> ); @@ -606,7 +623,7 @@ function RegionSection({ )} {!readOnly && (
- onAddBlock(regionIdx, type)} /> + onAddBlock(regionIdx, type)} locale={locale} />
)}
@@ -625,6 +642,7 @@ function BlockRow({ onClick, onMoveBlock, onRenameLabel, + locale, }: { block: Block; regionIdx: number; @@ -639,6 +657,7 @@ function BlockRow({ | { region: number; appendEnd: true }, ) => void; onRenameLabel: (nextLabel: string) => void; + locale?: string; }) { const typeStr = String(block.type ?? ''); // DISPLAY meta, not the palette catalogue. `BLOCK_TYPE_META` answers "what may @@ -764,7 +783,7 @@ function BlockRow({ {label} @@ -779,13 +798,13 @@ function BlockRow({ ) : ( <> - +
)} - {!readOnly && onAddNested(baseId, g.pathSuffix, type)} />} + {!readOnly && onAddNested(baseId, g.pathSuffix, type)} locale={locale} />}
))}
@@ -895,7 +916,12 @@ function NestedChildren({ /* ─────────────── Add block picker ─────────────── */ -function AddBlockButton({ onPick }: { onPick: (type: BlockTypeId) => void }) { +/** + * The add-block picker. A type's DISPLAYED name is its `labelKey` row, read in + * `locale`, and the search matches that name or the type id (objectui#10862, + * the add-widget picker's shape from objectui#10804). + */ +function AddBlockButton({ onPick, locale }: { onPick: (type: BlockTypeId) => void; locale?: string }) { const [open, setOpen] = React.useState(false); const [filter, setFilter] = React.useState(''); const q = filter.trim().toLowerCase(); @@ -907,18 +933,18 @@ function AddBlockButton({ onPick }: { onPick: (type: BlockTypeId) => void }) { category: g.category, types: g.types.filter((id) => { const m = BLOCK_TYPE_META[id]; - return id.includes(q) || m.label.toLowerCase().includes(q); + return id.includes(q) || tr(m.labelKey, locale).toLowerCase().includes(q); }), })) .filter((g) => g.types.length > 0); - }, [q]); + }, [q, locale]); return ( { setOpen(o); if (!o) setFilter(''); }}> @@ -927,18 +953,18 @@ function AddBlockButton({ onPick }: { onPick: (type: BlockTypeId) => void }) { autoFocus value={filter} onChange={(e) => setFilter(e.target.value)} - placeholder="Search block type…" + placeholder={tr('engine.pageBlockCanvas.searchTypes', locale)} className="h-7 w-full px-2 text-sm border rounded bg-background outline-none focus:ring-1 focus:ring-primary" />
{groups.length === 0 ? ( -
No matching types.
+
{tr('engine.pageBlockCanvas.noMatch', locale)}
) : ( groups.map((g) => (
- {CATEGORY_LABEL_EN[g.category]} + {tr(CATEGORY_LABEL_KEY[g.category], locale)}
{g.types.map((id) => { const m = BLOCK_TYPE_META[id]; @@ -952,7 +978,7 @@ function AddBlockButton({ onPick }: { onPick: (type: BlockTypeId) => void }) { className="w-full flex items-center gap-2 px-2 py-1.5 rounded text-sm hover:bg-accent text-left" > - {m.label} + {tr(m.labelKey, 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 && (
- Preview record + {tr('engine.pagePreview.previewRecord', locale)} - {recordSamples.length} sample{recordSamples.length === 1 ? '' : 's'} + + {tFormat( + recordSamples.length === 1 ? 'engine.pagePreview.sampleOne' : 'engine.pagePreview.sampleOther', + locale, + { count: recordSamples.length }, + )} +
)} {node} @@ -288,7 +297,7 @@ export function PagePreview({ draft, editing, selection, onSelectionChange, onPa if (isInterfacePage) { return ( - + } onConfigChange={canEdit ? (patch) => onPatch!({ interfaceConfig: { ...(((draft as any).interfaceConfig) || {}), ...patch } }) : undefined} @@ -309,6 +318,7 @@ export function PagePreview({ draft, editing, selection, onSelectionChange, onPa draft={draft as Record} onPatch={canEdit ? onPatch : undefined} readOnly={!canEdit} + locale={locale} /> ); } @@ -324,6 +334,7 @@ export function PagePreview({ draft, editing, selection, onSelectionChange, onPa onPatch={canEdit ? onPatch : undefined} selection={selection ?? null} onSelectionChange={onSelectionChange} + locale={locale} /> ) : designMode ? ( ) : null} - {!designMode && Add components to the page to see a preview.} + {!designMode && {tr('engine.pagePreview.addComponents', locale)}} ); } @@ -353,6 +364,7 @@ export function PagePreview({ draft, editing, selection, onSelectionChange, onPa onPatch={canEdit ? onPatch : undefined} selection={selection ?? null} onSelectionChange={onSelectionChange} + locale={locale} /> )} @@ -361,7 +373,7 @@ export function PagePreview({ draft, editing, selection, onSelectionChange, onPa return ( - + {designMode && ( import('@object-ui/plugin-report').then((m) => ({ default: m.ReportRenderer })), ); -export function ReportPreview({ draft }: MetadataPreviewProps) { +export function ReportPreview({ draft, locale }: MetadataPreviewProps) { const adapter = useAdapter(); const d = draft as any; @@ -51,11 +52,11 @@ export function ReportPreview({ draft }: MetadataPreviewProps) { : `report · dataset "${d.dataset}"${rows.length ? ' · by ' + rows.join(', ') : ''}`; return ( - + - Loading report renderer… + {tr('engine.reportPreview.loading', locale)}
} > @@ -76,12 +77,8 @@ export function ReportPreview({ draft }: MetadataPreviewProps) { } - title={joined ? 'Add a block to preview this joined report' : 'Bind a dataset to preview this report'} - description={ - joined - ? 'A joined report stacks dataset-bound blocks. Add a block and bind its dataset + measures in the right panel to start designing.' - : "Since the 9.0 single-form cutover a report renders its dataset's measures (values) grouped by dimensions (rows). Choose a Dataset in the right panel to start designing." - } + title={tr(joined ? 'engine.reportPreview.joinedEmpty' : 'engine.reportPreview.empty', locale)} + description={tr(joined ? 'engine.reportPreview.joinedEmptyHint' : 'engine.reportPreview.emptyHint', locale)} /> ); diff --git a/packages/app-shell/src/views/metadata-admin/previews/SourcePageEditor.tsx b/packages/app-shell/src/views/metadata-admin/previews/SourcePageEditor.tsx index 1734443483..17a1abf41a 100644 --- a/packages/app-shell/src/views/metadata-admin/previews/SourcePageEditor.tsx +++ b/packages/app-shell/src/views/metadata-admin/previews/SourcePageEditor.tsx @@ -26,6 +26,7 @@ import { Skeleton } from '@object-ui/components'; import { SchemaRenderer } from '@object-ui/react'; import { PreviewShell, PreviewErrorBoundary } from './PreviewShell.js'; import { useMonacoFallback } from '../useMonacoFallback.js'; +import { t as tr } from '../i18n.js'; // Lazy for the same reason as JsonSourceEditor — Monaco's core is ~3MB and // stays out of the initial app-shell chunk. @@ -53,6 +54,12 @@ export interface SourcePageEditorProps { * - `preview`: live preview only — the Studio canvas for source pages. */ mode?: 'split' | 'editor' | 'preview'; + /** + * The designer locale its own words read in (objectui#10862): the host's + * `useMetadataLocale()` — `PagePreview` passes the one it receives, the + * Studio surface its own. + */ + locale?: string; } /** @@ -102,6 +109,7 @@ export function SourcePageEditor({ readOnly, fallbackDelayMs = 4000, mode = 'split', + locale, }: SourcePageEditorProps) { const kind = (draft as { kind?: string }).kind === 'react' ? 'react' : 'html'; const source = typeof draft.source === 'string' ? (draft.source as string) : ''; @@ -154,7 +162,7 @@ export function SourcePageEditor({ onChange={(e) => handleChange(e.target.value)} readOnly={readOnly} spellCheck={false} - aria-label="Page source" + aria-label={tr('engine.sourcePageEditor.source', locale)} className="h-full w-full resize-none bg-background p-3 font-mono text-xs leading-relaxed outline-none" /> ) : ( @@ -186,7 +194,7 @@ export function SourcePageEditor({ const previewEl = (
- +
diff --git a/packages/app-shell/src/views/metadata-admin/previews/ValidationPreview.tsx b/packages/app-shell/src/views/metadata-admin/previews/ValidationPreview.tsx index 4578f57dc3..20737e8881 100644 --- a/packages/app-shell/src/views/metadata-admin/previews/ValidationPreview.tsx +++ b/packages/app-shell/src/views/metadata-admin/previews/ValidationPreview.tsx @@ -74,6 +74,8 @@ import { import { EmptyDescription } from '@object-ui/components'; import type { MetadataPreviewProps } from '../preview-registry.js'; import { PreviewShell, PreviewMessage, PreviewErrorBoundary } from './PreviewShell.js'; +import { t as tr, tFormat } from '../i18n.js'; +import { withNodes } from './row-nodes.js'; type Severity = 'error' | 'warning' | 'info'; @@ -108,7 +110,7 @@ function severityTone(s: Severity) { } } -export function ValidationPreview({ name, draft }: MetadataPreviewProps) { +export function ValidationPreview({ name, draft, locale }: MetadataPreviewProps) { const d = draft as Record; const ruleName = String(d.name ?? name ?? ''); const label = String(d.label ?? ruleName); @@ -125,7 +127,7 @@ export function ValidationPreview({ name, draft }: MetadataPreviewProps) { if (!ruleName) { return ( - Give the validation a name to see the preview. + {tr('engine.validationPreview.empty', locale)} ); } @@ -148,10 +150,14 @@ export function ValidationPreview({ name, draft }: MetadataPreviewProps) {
{description}
)}
- - {priority != null && } + + {priority != null && } {events.length > 0 && ( - + )}
@@ -165,20 +171,20 @@ export function ValidationPreview({ name, draft }: MetadataPreviewProps) {
{severity}
- {message || no message set} + {message || {tr('engine.validationPreview.noMessage', locale)}}
{/* Type-specific body */} - + {/* Tags */} {tags.length > 0 && (
- Tags + {tr('engine.validationPreview.tags', locale)}
{tags.map((t) => ( @@ -202,33 +208,27 @@ export function ValidationPreview({ name, draft }: MetadataPreviewProps) { * The three rule types the spec removed, each with the layer that owns * the job instead. Rendered as a redirect, not as an editor: an author * looking at a `unique` draft needs to know where uniqueness actually - * lives, not a prettier view of a rule that cannot be saved. + * lives, not a prettier view of a rule that cannot be saved. Each value is + * the designer catalogue row of the redirect sentence (objectui#10862). */ const REMOVED_TYPES: Record = { - unique: - 'Uniqueness is a unique INDEX, not a validation rule: declare it on the object as ' - + '`indexes: [{ fields: [...], unique: true }]` (or `unique: true` on the field). A ' - + 'SELECT-then-INSERT rule is inherently racy (TOCTOU); a database constraint is not.', - async: - 'Remote/async validation is a form-layer concern — on the server write path it is an ' - + 'SSRF and latency hazard. Keep it in the form, or enforce the underlying invariant ' - + 'with a unique index or a lifecycle hook.', - custom: - 'Arbitrary validation code belongs in a `beforeInsert` / `beforeUpdate` lifecycle ' - + 'hook — the typed, supported extension point.', + unique: 'engine.validationPreview.removed.unique', + async: 'engine.validationPreview.removed.async', + custom: 'engine.validationPreview.removed.custom', }; -function TypeBody({ type, d }: { type: string; d: Record }) { +function TypeBody({ type, d, locale }: { type: string; d: Record; locale?: string }) { const removed = REMOVED_TYPES[type]; if (removed) { return ( -
+
- {type} was removed from ValidationRuleSchema — this - rule cannot be saved. + {withNodes(tr('engine.validationPreview.removedType', locale), { + type: {type}, + })}
-
{removed}
+
{tr(removed, locale)}
); @@ -237,10 +237,10 @@ function TypeBody({ type, d }: { type: string; d: Record }) { switch (type) { case 'script': return ( -
+
{/* `condition` only. A CEL predicate that FAILS the record when TRUE — never `expression`, which no branch has ever declared. */} - +
); @@ -252,15 +252,15 @@ function TypeBody({ type, d }: { type: string; d: Record }) { const otherwise = d.otherwise as Record | undefined; return ( <> -
- +
+
-
- +
+
{otherwise && ( -
- +
+
)} @@ -278,9 +278,16 @@ function TypeBody({ type, d }: { type: string; d: Record }) { const initialStates = Array.isArray(d.initialStates) ? (d.initialStates as string[]) : []; return ( <> -
+
{entries.length === 0 ? ( -
No transitions declared.
+
{tr('engine.validationPreview.noTransitions', locale)}
) : (
    {entries.map(([from, tos]) => ( @@ -288,7 +295,7 @@ function TypeBody({ type, d }: { type: string; d: Record }) { {from} - {Array.isArray(tos) && tos.length > 0 ? tos.join(' | ') : '∅ (dead-end)'} + {Array.isArray(tos) && tos.length > 0 ? tos.join(' | ') : tr('engine.validationPreview.deadEnd', locale)} ))} @@ -299,7 +306,7 @@ function TypeBody({ type, d }: { type: string; d: Record }) { `transitions` only governs UPDATE, so without this a record can be born mid-flow (objectstack#3165). */} {initialStates.length > 0 && ( -
    +
    {initialStates.map((s) => ( @@ -319,21 +326,30 @@ function TypeBody({ type, d }: { type: string; d: Record }) { const format = d.format as string | undefined; const field = d.field as string | undefined; return ( -
    +
    {format && (
    - Built-in:{' '} + {tr('engine.validationPreview.builtIn', locale)}{' '} {format}
    )} {regex && (
    - Regex:{' '} + {tr('engine.validationPreview.regex', locale)}{' '} {regex}
    )} - {!format && !regex && No format or regex set.} + {!format && !regex && ( + {tr('engine.validationPreview.noFormat', locale)} + )}
    ); @@ -344,7 +360,7 @@ function TypeBody({ type, d }: { type: string; d: Record }) { const condition = celText(d.condition); return ( <> -
    +
    {fields.map((f) => ( @@ -361,11 +377,13 @@ function TypeBody({ type, d }: { type: string; d: Record }) { * (the same swap in a NON-flex block container does move them, * which is what proves the comparison was live). */} - {fields.length === 0 && none} + {fields.length === 0 && ( + {tr('engine.validationPreview.none', locale)} + )}
    -
    - +
    +
    ); @@ -379,14 +397,18 @@ function TypeBody({ type, d }: { type: string; d: Record }) { const schema = d.schema; const json = schema && typeof schema === 'object' ? JSON.stringify(schema, null, 2) : undefined; + // A bare `JSON Schema` names the standard and reads the same in every locale. return ( -
    +
    {json ? (
                   {json}
                 
    ) : ( -
    No JSON Schema set.
    +
    {tr('engine.validationPreview.noJsonSchema', locale)}
    )}
    ); @@ -394,9 +416,9 @@ function TypeBody({ type, d }: { type: string; d: Record }) { default: return ( -
    +
    - Unknown rule type "{type}". Showing common fields only. + {tFormat('engine.validationPreview.unknownType', locale, { type })}
    ); @@ -411,9 +433,11 @@ function TypeBody({ type, d }: { type: string; d: Record }) { function NestedRule({ rule, emptyHint, + locale, }: { rule: Record | undefined; emptyHint: string; + locale?: string; }) { if (!rule || typeof rule !== 'object') { return
    {emptyHint}
    ; @@ -424,7 +448,7 @@ function NestedRule({
    - {nestedType || 'no type'} + {nestedType || tr('engine.validationPreview.noType', locale)} {typeof rule.name === 'string' && ( {rule.name} @@ -442,9 +466,9 @@ function NestedRule({ ); } -function CelBlock({ value }: { value: string | undefined }) { +function CelBlock({ value, locale }: { value: string | undefined; locale?: string }) { if (!value) { - return
    No expression set.
    ; + return
    {tr('engine.validationPreview.noExpression', locale)}
    ; } return (
    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 ? ( - +
    ) : ( - +