From 86d7ffec7bac73c986fc9d35dc06acbe0f7b3833 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 13:36:58 +0000 Subject: [PATCH 1/2] fix(app-shell): three designer inspectors read their words in the designer's locale (objectui#10586) DatasetDefaultInspector, FlowReferenceField (ReferenceCombobox) and ConditionBuilder route their labels, placeholders, hints, option labels and accessible names through the designer catalogue under engine.inspector.dataset.*, engine.inspector.reference.* and engine.inspector.condition.*, with en and zh rows. en renders unchanged. Claude-Session: https://claude.ai/code/session_01KUxVUa7e39aNjhkKi1gsoy Co-authored-by: Claude --- .changeset/10586-inspector-strings-i18n.md | 19 + .../src/views/metadata-admin/i18n.ts | 236 +++++++++ .../inspectors/ConditionBuilder.tsx | 40 +- .../inspectors/DatasetDefaultInspector.tsx | 245 +++++---- .../inspectors/FlowReferenceField.tsx | 46 +- .../inspectorStrings.i18n-10586.test.tsx | 481 ++++++++++++++++++ 6 files changed, 929 insertions(+), 138 deletions(-) create mode 100644 .changeset/10586-inspector-strings-i18n.md create mode 100644 packages/app-shell/src/views/metadata-admin/inspectors/inspectorStrings.i18n-10586.test.tsx diff --git a/.changeset/10586-inspector-strings-i18n.md b/.changeset/10586-inspector-strings-i18n.md new file mode 100644 index 0000000000..34f03984d5 --- /dev/null +++ b/.changeset/10586-inspector-strings-i18n.md @@ -0,0 +1,19 @@ +--- +'@object-ui/app-shell': patch +--- + +fix(app-shell): three designer inspectors read their words in the designer's locale (objectui#10586) + +`DatasetDefaultInspector`, `FlowReferenceField` (with the `ReferenceCombobox` it +wraps) and `ConditionBuilder` rendered their section titles, field labels, +placeholders, hints, option labels and accessible names as English literals, so a +zh-CN author read an English form inside an otherwise Chinese designer. + +Those strings now come from the designer's own string catalogue, under +`engine.inspector.dataset.*`, `engine.inspector.reference.*` and +`engine.inspector.condition.*`, plus the existing `engine.form.add` and +`engine.form.searchFields`, each with an en and a zh row. The dataset inspector +reads the `locale` its host already passes; the other two follow the active +language through `useMetadataLocale()`. The en-US text is unchanged. Stored +values, the `≥` / `≤` operator symbols, currency codes and decimal digits are not +words and stay as they were. diff --git a/packages/app-shell/src/views/metadata-admin/i18n.ts b/packages/app-shell/src/views/metadata-admin/i18n.ts index c778277dfb..0c1dcbe61c 100644 --- a/packages/app-shell/src/views/metadata-admin/i18n.ts +++ b/packages/app-shell/src/views/metadata-admin/i18n.ts @@ -528,6 +528,22 @@ const ENGINE_STRINGS_EN: Record = { // ConditionBuilder raw-expression mode (CEL editor, #1582) 'engine.condition.celLabel': 'CEL expression', 'engine.condition.advancedHint': 'Advanced expression — Builder only supports simple AND/OR conditions.', + // ConditionBuilder row mode (objectui#10586). `≥` / `≤` stay symbols. + 'engine.inspector.condition.builder': 'Builder', + 'engine.inspector.condition.expression': 'Expression', + 'engine.inspector.condition.always': 'Always — no condition.', + 'engine.inspector.condition.and': 'AND', + 'engine.inspector.condition.or': 'OR', + 'engine.inspector.condition.subjectPlaceholder': 'field / context', + 'engine.inspector.condition.remove': 'Remove condition', + 'engine.inspector.condition.valuePlaceholder': 'value', + 'engine.inspector.condition.add': 'Add condition', + 'engine.inspector.condition.op.equals': 'equals', + 'engine.inspector.condition.op.notEquals': 'not equals', + 'engine.inspector.condition.op.greaterThan': 'greater than', + 'engine.inspector.condition.op.lessThan': 'less than', + 'engine.inspector.condition.op.truthy': 'is set / true', + 'engine.inspector.condition.op.falsy': 'is empty / false', 'engine.inspector.view.type.grid': 'Table / List', 'engine.inspector.view.type.kanban': 'Kanban', 'engine.inspector.view.type.calendar': 'Calendar', @@ -787,6 +803,108 @@ const ENGINE_STRINGS_EN: Record = { 'engine.inspector.dashboard.widgetsEmpty': 'No widgets yet. Add one below.', 'engine.inspector.dashboard.removeWidget': 'Remove widget', 'engine.inspector.dashboard.noSchema': 'Spec schema unavailable — basic properties only.', + // Dataset default (home) inspector (objectui#10586). Option labels are keyed + // by the stored value; `{…}` tokens in a sentence are slots the inspector + // fills with a node (a code span, a count). + 'engine.inspector.dataset.kind': 'Dataset', + 'engine.inspector.dataset.aggregate.count': 'count', + 'engine.inspector.dataset.aggregate.sum': 'sum', + 'engine.inspector.dataset.aggregate.avg': 'avg', + 'engine.inspector.dataset.aggregate.min': 'min', + 'engine.inspector.dataset.aggregate.max': 'max', + 'engine.inspector.dataset.aggregate.count_distinct': 'count distinct', + 'engine.inspector.dataset.dimType.string': 'string', + 'engine.inspector.dataset.dimType.number': 'number', + 'engine.inspector.dataset.dimType.date': 'date', + 'engine.inspector.dataset.dimType.boolean': 'boolean', + 'engine.inspector.dataset.dimType.lookup': 'lookup', + 'engine.inspector.dataset.granularity.none': '— none —', + 'engine.inspector.dataset.granularity.day': 'day', + 'engine.inspector.dataset.granularity.week': 'week', + 'engine.inspector.dataset.granularity.month': 'month', + 'engine.inspector.dataset.granularity.quarter': 'quarter', + 'engine.inspector.dataset.granularity.year': 'year', + 'engine.inspector.dataset.derivedOp.ratio': 'ratio (a ÷ b)', + 'engine.inspector.dataset.derivedOp.sum': 'sum (a + b)', + 'engine.inspector.dataset.derivedOp.difference': 'difference (a − b)', + 'engine.inspector.dataset.derivedOp.product': 'product (a × b)', + 'engine.inspector.dataset.formatKind.raw': 'Raw number', + 'engine.inspector.dataset.formatKind.number': 'Number — 1,234.5', + 'engine.inspector.dataset.formatKind.currency': 'Currency — $1,234.50', + 'engine.inspector.dataset.formatKind.percent': 'Percent — 12.3%', + 'engine.inspector.dataset.usage.bound': 'Bound by {reports} · {dashboards} — changes affect them.', + 'engine.inspector.dataset.usage.reportsOne': '{count} report', + 'engine.inspector.dataset.usage.reportsOther': '{count} reports', + 'engine.inspector.dataset.usage.dashboardsOne': '{count} dashboard', + 'engine.inspector.dataset.usage.dashboardsOther': '{count} dashboards', + 'engine.inspector.dataset.usage.unbound': 'Not yet bound by any report or dashboard.', + 'engine.inspector.dataset.name': 'Name', + 'engine.inspector.dataset.namePlaceholder': 'snake_case identifier', + 'engine.inspector.dataset.label': 'Label', + 'engine.inspector.dataset.description': 'Description', + 'engine.inspector.dataset.baseObject': 'Base object', + 'engine.inspector.dataset.baseObjectPlaceholder': 'Select an object…', + 'engine.inspector.dataset.searchObjects': 'Search objects…', + 'engine.inspector.dataset.baseObjectChangeHint': 'Changing the base object clears its dimensions, measures, joins & filters.', + 'engine.inspector.dataset.includedRelationships': 'Included relationships', + 'engine.inspector.dataset.noJoins': 'No joins. Add a relationship (a lookup field on {object}) to use {path} dimensions/measures.', + 'engine.inspector.dataset.baseObjectFallback': 'the base object', + 'engine.inspector.dataset.includedRelationship': 'Included relationship', + 'engine.inspector.dataset.relationshipPlaceholder': 'Select a relationship…', + 'engine.inspector.dataset.searchRelationships': 'Search relationships…', + 'engine.inspector.dataset.removeRelationship': 'Remove relationship', + 'engine.inspector.dataset.scopeFilter': 'Scope filter', + 'engine.inspector.dataset.scopeFilterHelp': 'Intrinsic scope, ANDed into every query (e.g. exclude soft-deleted records).', + 'engine.inspector.dataset.dimensions': 'Dimensions', + 'engine.inspector.dataset.addDimension': 'Add dimension', + 'engine.inspector.dataset.dimensionN': 'Dimension {index}', + 'engine.inspector.dataset.removeDimension': 'Remove dimension', + 'engine.inspector.dataset.dimensionNamePlaceholder': 'e.g. region', + 'engine.inspector.dataset.field': 'Field', + 'engine.inspector.dataset.dimensionFieldPlaceholder': 'field or relationship.field', + 'engine.inspector.dataset.type': 'Type', + 'engine.inspector.dataset.labelOptional': 'Label (optional)', + 'engine.inspector.dataset.displayLabel': 'Display label', + 'engine.inspector.dataset.dateBucket': 'Date bucket', + 'engine.inspector.dataset.measures': 'Measures', + 'engine.inspector.dataset.addMeasure': 'Add measure', + 'engine.inspector.dataset.measureN': 'Measure {index}', + 'engine.inspector.dataset.removeMeasure': 'Remove measure', + 'engine.inspector.dataset.measureNamePlaceholder': 'e.g. revenue', + 'engine.inspector.dataset.aggregate': 'Aggregate', + 'engine.inspector.dataset.measureFieldPlaceholder': 'field (optional for count)', + 'engine.inspector.dataset.measureFilter': 'Filter (measure-scoped)', + 'engine.inspector.dataset.measureFilterHelp': 'Only rows matching this filter feed this measure (e.g. won_amount = sum(amount) where stage = won).', + 'engine.inspector.dataset.derived': 'Derived — computed from other measures', + 'engine.inspector.dataset.operation': 'Operation', + 'engine.inspector.dataset.operands': 'Operands (other measures)', + 'engine.inspector.dataset.derivedNeedTwo': 'Select exactly 2 measures for {op}.', + 'engine.inspector.dataset.derivedNeedOne': 'Select at least 1 measure for {op}.', + 'engine.inspector.dataset.addOtherMeasuresFirst': 'Add other measures first.', + 'engine.inspector.dataset.advanced': 'Advanced', + 'engine.inspector.dataset.displayFormat': 'Display format', + 'engine.inspector.dataset.decimals': 'Decimals', + 'engine.inspector.dataset.currency': 'Currency', + 'engine.inspector.dataset.sample': 'Sample: {sample}', + 'engine.inspector.dataset.relWarning': "Relationship {rel} isn't in Included relationships.", + 'engine.inspector.dataset.relWarningAdd': 'Add it', + 'engine.inspector.dataset.filterAdvanced': 'Advanced filter (nested / OR) — edit it in the {source} tab.', + 'engine.inspector.dataset.sourceTab': 'Source', + 'engine.inspector.dataset.filterConditionsOne': '{count} condition', + 'engine.inspector.dataset.filterConditionsOther': '{count} conditions', + 'engine.inspector.dataset.addFilter': '+ Add filter…', + 'engine.inspector.dataset.filterNeedsObject': 'Pick a base object to add filter conditions.', + // Flow reference picker — ReferenceCombobox / FlowReferenceField (objectui#10586) + 'engine.inspector.reference.pickFromRecords': 'Pick from records', + 'engine.inspector.reference.enterManually': 'Enter value manually', + 'engine.inspector.reference.managerPlaceholder': 'Resolved automatically', + 'engine.inspector.reference.managerHint': "Resolved at runtime from the submitter's manager — no value needed.", + 'engine.inspector.reference.queueUnsupported': 'Queue approvers are not supported by the runtime yet — this slot resolves to nobody.', + 'engine.inspector.reference.fieldsOf': 'Fields of {object}.', + 'engine.inspector.reference.setTriggerObject': 'Set the flow’s trigger object (on the Start node) to list fields.', + 'engine.inspector.reference.actionsOf': 'Actions of {connector}.', + 'engine.inspector.reference.chooseConnector': 'Choose a Connector above to list its actions.', + 'engine.inspector.reference.declarative': 'declarative', // Add affordances (used by OutlineStrip + custom previews) 'engine.inspector.add.widget': 'Add widget', 'engine.inspector.add.block': 'Add block', @@ -2513,6 +2631,23 @@ const ENGINE_STRINGS_ZH: Record = { // ConditionBuilder 裸表达式模式(CEL 编辑器,#1582) 'engine.condition.celLabel': 'CEL 表达式', 'engine.condition.advancedHint': '高级表达式 —— 搭建器仅支持简单的 AND/OR 条件。', + // ConditionBuilder row mode (objectui#10586). The join words follow the + // packs' own `filterBuilder.and` / `filterBuilder.or`. + 'engine.inspector.condition.builder': '搭建器', + 'engine.inspector.condition.expression': '表达式', + 'engine.inspector.condition.always': '始终生效 —— 无条件。', + 'engine.inspector.condition.and': '且', + 'engine.inspector.condition.or': '或', + 'engine.inspector.condition.subjectPlaceholder': '字段 / 上下文', + 'engine.inspector.condition.remove': '移除条件', + 'engine.inspector.condition.valuePlaceholder': '值', + 'engine.inspector.condition.add': '添加条件', + 'engine.inspector.condition.op.equals': '等于', + 'engine.inspector.condition.op.notEquals': '不等于', + 'engine.inspector.condition.op.greaterThan': '大于', + 'engine.inspector.condition.op.lessThan': '小于', + 'engine.inspector.condition.op.truthy': '已设置 / 为真', + 'engine.inspector.condition.op.falsy': '为空 / 为假', 'engine.inspector.view.type.grid': '表格 / 列表', 'engine.inspector.view.type.kanban': '看板', 'engine.inspector.view.type.calendar': '日历', @@ -2748,6 +2883,107 @@ const ENGINE_STRINGS_ZH: Record = { 'engine.inspector.dashboard.widgetsEmpty': '还没有组件。在下方添加。', 'engine.inspector.dashboard.removeWidget': '删除组件', 'engine.inspector.dashboard.noSchema': '规格 schema 不可用 —— 仅显示基础属性。', + // Dataset default (home) inspector (objectui#10586). zh has one plural form, + // so each `…One` / `…Other` pair carries the same row. + 'engine.inspector.dataset.kind': '数据集', + 'engine.inspector.dataset.aggregate.count': '计数', + 'engine.inspector.dataset.aggregate.sum': '求和', + 'engine.inspector.dataset.aggregate.avg': '平均值', + 'engine.inspector.dataset.aggregate.min': '最小值', + 'engine.inspector.dataset.aggregate.max': '最大值', + 'engine.inspector.dataset.aggregate.count_distinct': '去重计数', + 'engine.inspector.dataset.dimType.string': '字符串', + 'engine.inspector.dataset.dimType.number': '数字', + 'engine.inspector.dataset.dimType.date': '日期', + 'engine.inspector.dataset.dimType.boolean': '布尔值', + 'engine.inspector.dataset.dimType.lookup': '查找关系', + 'engine.inspector.dataset.granularity.none': '— 无 —', + 'engine.inspector.dataset.granularity.day': '日', + 'engine.inspector.dataset.granularity.week': '周', + 'engine.inspector.dataset.granularity.month': '月', + 'engine.inspector.dataset.granularity.quarter': '季度', + 'engine.inspector.dataset.granularity.year': '年', + 'engine.inspector.dataset.derivedOp.ratio': '比率(a ÷ b)', + 'engine.inspector.dataset.derivedOp.sum': '求和(a + b)', + 'engine.inspector.dataset.derivedOp.difference': '差值(a − b)', + 'engine.inspector.dataset.derivedOp.product': '乘积(a × b)', + 'engine.inspector.dataset.formatKind.raw': '原始数值', + 'engine.inspector.dataset.formatKind.number': '数字 —— 1,234.5', + 'engine.inspector.dataset.formatKind.currency': '货币 —— $1,234.50', + 'engine.inspector.dataset.formatKind.percent': '百分比 —— 12.3%', + 'engine.inspector.dataset.usage.bound': '已被 {reports} · {dashboards} 绑定 —— 修改会影响它们。', + 'engine.inspector.dataset.usage.reportsOne': '{count} 个报表', + 'engine.inspector.dataset.usage.reportsOther': '{count} 个报表', + 'engine.inspector.dataset.usage.dashboardsOne': '{count} 个仪表盘', + 'engine.inspector.dataset.usage.dashboardsOther': '{count} 个仪表盘', + 'engine.inspector.dataset.usage.unbound': '尚未被任何报表或仪表盘绑定。', + 'engine.inspector.dataset.name': '名称', + 'engine.inspector.dataset.namePlaceholder': 'snake_case 标识符', + 'engine.inspector.dataset.label': '显示名', + 'engine.inspector.dataset.description': '描述', + 'engine.inspector.dataset.baseObject': '基础对象', + 'engine.inspector.dataset.baseObjectPlaceholder': '选择对象…', + 'engine.inspector.dataset.searchObjects': '搜索对象…', + 'engine.inspector.dataset.baseObjectChangeHint': '更改基础对象会清空其维度、度量、关联与筛选。', + 'engine.inspector.dataset.includedRelationships': '包含的关联', + 'engine.inspector.dataset.noJoins': '暂无关联。添加一个关联({object} 上的查找字段),即可使用 {path} 形式的维度 / 度量。', + 'engine.inspector.dataset.baseObjectFallback': '基础对象', + 'engine.inspector.dataset.includedRelationship': '包含的关联', + 'engine.inspector.dataset.relationshipPlaceholder': '选择关联…', + 'engine.inspector.dataset.searchRelationships': '搜索关联…', + 'engine.inspector.dataset.removeRelationship': '移除关联', + 'engine.inspector.dataset.scopeFilter': '范围筛选', + 'engine.inspector.dataset.scopeFilterHelp': '固有范围,以 AND 并入每一次查询(例如排除已软删除的记录)。', + 'engine.inspector.dataset.dimensions': '维度', + 'engine.inspector.dataset.addDimension': '添加维度', + 'engine.inspector.dataset.dimensionN': '维度 {index}', + 'engine.inspector.dataset.removeDimension': '移除维度', + 'engine.inspector.dataset.dimensionNamePlaceholder': '例如:region', + 'engine.inspector.dataset.field': '字段', + 'engine.inspector.dataset.dimensionFieldPlaceholder': '字段 或 关联.字段', + 'engine.inspector.dataset.type': '类型', + 'engine.inspector.dataset.labelOptional': '显示名(可选)', + 'engine.inspector.dataset.displayLabel': '显示名', + 'engine.inspector.dataset.dateBucket': '日期分桶', + 'engine.inspector.dataset.measures': '度量', + 'engine.inspector.dataset.addMeasure': '添加度量', + 'engine.inspector.dataset.measureN': '度量 {index}', + 'engine.inspector.dataset.removeMeasure': '移除度量', + 'engine.inspector.dataset.measureNamePlaceholder': '例如:revenue', + 'engine.inspector.dataset.aggregate': '聚合方式', + 'engine.inspector.dataset.measureFieldPlaceholder': '字段(count 时可选)', + 'engine.inspector.dataset.measureFilter': '筛选(度量级)', + 'engine.inspector.dataset.measureFilterHelp': '只有满足此筛选的行才计入该度量(例如 won_amount = sum(amount) where stage = won)。', + 'engine.inspector.dataset.derived': '派生 —— 由其他度量计算', + 'engine.inspector.dataset.operation': '运算', + 'engine.inspector.dataset.operands': '操作数(其他度量)', + 'engine.inspector.dataset.derivedNeedTwo': '{op} 需要恰好选择 2 个度量。', + 'engine.inspector.dataset.derivedNeedOne': '{op} 至少需要选择 1 个度量。', + 'engine.inspector.dataset.addOtherMeasuresFirst': '请先添加其他度量。', + 'engine.inspector.dataset.advanced': '高级', + 'engine.inspector.dataset.displayFormat': '显示格式', + 'engine.inspector.dataset.decimals': '小数位数', + 'engine.inspector.dataset.currency': '货币', + 'engine.inspector.dataset.sample': '示例:{sample}', + 'engine.inspector.dataset.relWarning': '关联 {rel} 不在「包含的关联」中。', + 'engine.inspector.dataset.relWarningAdd': '添加它', + 'engine.inspector.dataset.filterAdvanced': '高级筛选(嵌套 / OR)—— 请在「{source}」标签页中编辑。', + 'engine.inspector.dataset.sourceTab': '源码', + 'engine.inspector.dataset.filterConditionsOne': '{count} 个条件', + 'engine.inspector.dataset.filterConditionsOther': '{count} 个条件', + 'engine.inspector.dataset.addFilter': '+ 添加筛选…', + 'engine.inspector.dataset.filterNeedsObject': '先选择基础对象,才能添加筛选条件。', + // Flow reference picker — ReferenceCombobox / FlowReferenceField (objectui#10586) + 'engine.inspector.reference.pickFromRecords': '从记录中选择', + 'engine.inspector.reference.enterManually': '手动输入值', + 'engine.inspector.reference.managerPlaceholder': '自动解析', + 'engine.inspector.reference.managerHint': '运行时根据提交人的上级自动解析 —— 无需填写。', + 'engine.inspector.reference.queueUnsupported': '运行时暂不支持队列审批人 —— 该位置不会解析到任何人。', + 'engine.inspector.reference.fieldsOf': '{object} 的字段。', + 'engine.inspector.reference.setTriggerObject': '请先在开始节点上设置流程的触发对象,才能列出字段。', + 'engine.inspector.reference.actionsOf': '{connector} 的动作。', + 'engine.inspector.reference.chooseConnector': '请先在上方选择连接器,才能列出其动作。', + 'engine.inspector.reference.declarative': '声明式', // Add affordances 'engine.inspector.add.widget': '添加组件', 'engine.inspector.add.block': '添加区块', diff --git a/packages/app-shell/src/views/metadata-admin/inspectors/ConditionBuilder.tsx b/packages/app-shell/src/views/metadata-admin/inspectors/ConditionBuilder.tsx index 42e2035a40..ffe8bf3000 100644 --- a/packages/app-shell/src/views/metadata-admin/inspectors/ConditionBuilder.tsx +++ b/packages/app-shell/src/views/metadata-admin/inspectors/ConditionBuilder.tsx @@ -35,15 +35,17 @@ type Quote = '"' | "'"; interface Row { subject: string; op: Op; value: string; quote?: Quote } -const COMPARE_OPS: Array<{ value: Op; label: string }> = [ - { value: '==', label: 'equals' }, - { value: '!=', label: 'not equals' }, - { value: '>', label: 'greater than' }, - { value: '<', label: 'less than' }, - { value: '>=', label: '≥' }, - { value: '<=', label: '≤' }, - { value: 'truthy', label: 'is set / true' }, - { value: 'falsy', label: 'is empty / false' }, +/** Operator choices: a catalogue key for the worded ones, a bare symbol for + * `≥` / `≤`, which read the same in every locale (objectui#10586). */ +const COMPARE_OPS: ReadonlyArray<{ value: Op; labelKey: string } | { value: Op; symbol: string }> = [ + { value: '==', labelKey: 'engine.inspector.condition.op.equals' }, + { value: '!=', labelKey: 'engine.inspector.condition.op.notEquals' }, + { value: '>', labelKey: 'engine.inspector.condition.op.greaterThan' }, + { value: '<', labelKey: 'engine.inspector.condition.op.lessThan' }, + { value: '>=', symbol: '≥' }, + { value: '<=', symbol: '≤' }, + { value: 'truthy', labelKey: 'engine.inspector.condition.op.truthy' }, + { value: 'falsy', labelKey: 'engine.inspector.condition.op.falsy' }, ]; /** @@ -708,7 +710,7 @@ export function ConditionBuilder({ label, value, onCommit, objectName, fields: f {/* CEL editor with inline lint + field autocomplete (#1582) — the same @@ -753,12 +755,12 @@ export function ConditionBuilder({ label, value, onCommit, objectName, fields: f {label ? : } {rows.length === 0 ? ( -

Always — no condition.

+

{tLocal('engine.inspector.condition.always')}

) : (
{rows.map((r, i) => ( @@ -768,8 +770,8 @@ export function ConditionBuilder({ label, value, onCommit, objectName, fields: f
@@ -777,7 +779,7 @@ export function ConditionBuilder({ label, value, onCommit, objectName, fields: f
- @@ -796,12 +798,12 @@ export function ConditionBuilder({ label, value, onCommit, objectName, fields: f
{r.op !== 'truthy' && r.op !== 'falsy' && ( - update(rows.map((x, j) => j === i ? { ...x, value: e.target.value } : x))} /> )} @@ -812,7 +814,7 @@ export function ConditionBuilder({ label, value, onCommit, objectName, fields: f {!disabled && ( )} diff --git a/packages/app-shell/src/views/metadata-admin/inspectors/DatasetDefaultInspector.tsx b/packages/app-shell/src/views/metadata-admin/inspectors/DatasetDefaultInspector.tsx index 0ae1c25ff0..dcc8e5c420 100644 --- a/packages/app-shell/src/views/metadata-admin/inspectors/DatasetDefaultInspector.tsx +++ b/packages/app-shell/src/views/metadata-admin/inspectors/DatasetDefaultInspector.tsx @@ -42,50 +42,74 @@ import { fieldTypeToDimensionType, } from './useDatasetFields.js'; import type { MetadataDefaultInspectorProps } from '../default-inspector-registry.js'; +import { t, tFormat, type SupportedLocale } from '../i18n.js'; + +/** + * An option whose label is a catalogue key, resolved in the designer locale at + * render (objectui#10586). The stored `value` never moves. + */ +type KeyedOption = { value: string; labelKey: string }; + +function localizeOptions(options: ReadonlyArray, locale: SupportedLocale): Array<{ value: string; label: string }> { + return options.map((o) => ({ value: o.value, label: t(o.labelKey, locale) })); +} + +/** + * Render a catalogue sentence whose `{token}` slots are React nodes (a code + * span, an emphasised word) — the split-and-interleave `ResourceEditPage` + * already uses for `engine.edit.readOnlyBanner`. Where a slot sits, and the + * words around it, belong to the locale rather than to this JSX. + */ +function withSlots(template: string, slots: Record): React.ReactNode { + return template.split(/\{(\w+)\}/).map((part, i) => + i % 2 === 1 ? {part in slots ? slots[part] : `{${part}}`} : part, + ); +} // Closed to what the dataset compiler supports (no array_agg/string_agg in v1). -const AGGREGATE_OPTIONS = [ - { value: 'count', label: 'count' }, - { value: 'sum', label: 'sum' }, - { value: 'avg', label: 'avg' }, - { value: 'min', label: 'min' }, - { value: 'max', label: 'max' }, - { value: 'count_distinct', label: 'count distinct' }, +const AGGREGATE_OPTIONS: KeyedOption[] = [ + { value: 'count', labelKey: 'engine.inspector.dataset.aggregate.count' }, + { value: 'sum', labelKey: 'engine.inspector.dataset.aggregate.sum' }, + { value: 'avg', labelKey: 'engine.inspector.dataset.aggregate.avg' }, + { value: 'min', labelKey: 'engine.inspector.dataset.aggregate.min' }, + { value: 'max', labelKey: 'engine.inspector.dataset.aggregate.max' }, + { value: 'count_distinct', labelKey: 'engine.inspector.dataset.aggregate.count_distinct' }, ]; -const DIMENSION_TYPE_OPTIONS = [ - { value: 'string', label: 'string' }, - { value: 'number', label: 'number' }, - { value: 'date', label: 'date' }, - { value: 'boolean', label: 'boolean' }, - { value: 'lookup', label: 'lookup' }, +const DIMENSION_TYPE_OPTIONS: KeyedOption[] = [ + { value: 'string', labelKey: 'engine.inspector.dataset.dimType.string' }, + { value: 'number', labelKey: 'engine.inspector.dataset.dimType.number' }, + { value: 'date', labelKey: 'engine.inspector.dataset.dimType.date' }, + { value: 'boolean', labelKey: 'engine.inspector.dataset.dimType.boolean' }, + { value: 'lookup', labelKey: 'engine.inspector.dataset.dimType.lookup' }, ]; -const DATE_GRANULARITY_OPTIONS = [ - { value: '', label: '— none —' }, - { value: 'day', label: 'day' }, - { value: 'week', label: 'week' }, - { value: 'month', label: 'month' }, - { value: 'quarter', label: 'quarter' }, - { value: 'year', label: 'year' }, +const DATE_GRANULARITY_OPTIONS: KeyedOption[] = [ + { value: '', labelKey: 'engine.inspector.dataset.granularity.none' }, + { value: 'day', labelKey: 'engine.inspector.dataset.granularity.day' }, + { value: 'week', labelKey: 'engine.inspector.dataset.granularity.week' }, + { value: 'month', labelKey: 'engine.inspector.dataset.granularity.month' }, + { value: 'quarter', labelKey: 'engine.inspector.dataset.granularity.quarter' }, + { value: 'year', labelKey: 'engine.inspector.dataset.granularity.year' }, ]; -const DERIVED_OP_OPTIONS = [ - { value: 'ratio', label: 'ratio (a ÷ b)' }, - { value: 'sum', label: 'sum (a + b)' }, - { value: 'difference', label: 'difference (a − b)' }, - { value: 'product', label: 'product (a × b)' }, +const DERIVED_OP_OPTIONS: KeyedOption[] = [ + { value: 'ratio', labelKey: 'engine.inspector.dataset.derivedOp.ratio' }, + { value: 'sum', labelKey: 'engine.inspector.dataset.derivedOp.sum' }, + { value: 'difference', labelKey: 'engine.inspector.dataset.derivedOp.difference' }, + { value: 'product', labelKey: 'engine.inspector.dataset.derivedOp.product' }, ]; // Display-format picker options — a business user shouldn't have to know numeral // syntax (`$0,0.00`), so the inspector offers kind + decimals + currency and // generates the `format`/`currency` strings. -const FORMAT_KIND_OPTIONS = [ - { value: 'raw', label: 'Raw number' }, - { value: 'number', label: 'Number — 1,234.5' }, - { value: 'currency', label: 'Currency — $1,234.50' }, - { value: 'percent', label: 'Percent — 12.3%' }, +const FORMAT_KIND_OPTIONS: KeyedOption[] = [ + { value: 'raw', labelKey: 'engine.inspector.dataset.formatKind.raw' }, + { value: 'number', labelKey: 'engine.inspector.dataset.formatKind.number' }, + { value: 'currency', labelKey: 'engine.inspector.dataset.formatKind.currency' }, + { value: 'percent', labelKey: 'engine.inspector.dataset.formatKind.percent' }, ]; +// Digits and ISO codes: the same in every locale, so plain labels. const DECIMALS_OPTIONS = [ { value: '0', label: '0' }, { value: '1', label: '1' }, @@ -132,13 +156,13 @@ function SectionHeader({ title, count, onAdd, addLabel }: { title: string; count } /** Native disclosure for a row's optional / advanced fields. */ -function Advanced({ children }: { children: React.ReactNode }) { +function Advanced({ children, locale }: { children: React.ReactNode; locale: SupportedLocale }) { return (
- Advanced + {t('engine.inspector.dataset.advanced', locale)}
{children}
@@ -173,7 +197,7 @@ function buildMeasureFormat(kind: string, decimals: number, currency: string): { * ⇄ the spec's `format`/`currency` strings and shows a live sample so a business * user never has to hand-write a numeral pattern. */ -function MeasureFormatField({ measure, onPatch, disabled }: { measure: Measure; onPatch: (p: Partial) => void; disabled?: boolean }) { +function MeasureFormatField({ measure, onPatch, disabled, locale }: { measure: Measure; onPatch: (p: Partial) => void; disabled?: boolean; locale: SupportedLocale }) { const { kind, decimals } = parseMeasureFormat(measure.format, measure.currency); const currency = measure.currency || 'USD'; const apply = (k: string, d: number, c: string) => onPatch(buildMeasureFormat(k, d, c)); @@ -190,16 +214,18 @@ function MeasureFormatField({ measure, onPatch, disabled }: { measure: Measure; return (
- apply(v, decimals, currency)} disabled={disabled} /> + apply(v, decimals, currency)} disabled={disabled} /> {kind !== 'raw' && ( - apply(kind, parseInt(v, 10) || 0, currency)} disabled={disabled} /> + apply(kind, parseInt(v, 10) || 0, currency)} disabled={disabled} /> )}
{kind === 'currency' && ( - apply(kind, decimals, v)} disabled={disabled} /> + apply(kind, decimals, v)} disabled={disabled} /> )} {kind !== 'raw' && ( -

Sample: {sample}

+

+ {withSlots(t('engine.inspector.dataset.sample', locale), { sample: {sample} })} +

)}
); @@ -215,13 +241,13 @@ function missingRelationship(field: string | undefined, include: string[]): stri } /** Inline author-time warning: a `relationship.field` whose join isn't declared in `include`. */ -function RelWarning({ rel, onAdd, disabled }: { rel: string; onAdd?: () => void; disabled?: boolean }) { +function RelWarning({ rel, onAdd, disabled, locale }: { rel: string; onAdd?: () => void; disabled?: boolean; locale: SupportedLocale }) { return (

- Relationship {rel} isn't in Included relationships. + {withSlots(t('engine.inspector.dataset.relWarning', locale), { rel: {rel} })} {!disabled && onAdd && ( - + )}

); @@ -234,13 +260,14 @@ function RelWarning({ rel, onAdd, disabled }: { rel: string; onAdd?: () => void; * (nested / `$or` / multi-op) degrade to a "edit in Source" note rather than being * silently rewritten. See {@link conditionToGroup} / {@link groupToCondition}. */ -function DatasetFilterField({ label, help, value, onCommit, fields, disabled }: { +function DatasetFilterField({ label, help, value, onCommit, fields, disabled, locale }: { label: string; help?: string; value: FilterCondition | undefined; onCommit: (fc: FilterCondition | undefined) => void; fields: Array<{ value: string; label?: string; type?: string }>; disabled?: boolean; + locale: SupportedLocale; }) { // `fields` is handed to the READ half as well as to the builder: it is what // lets a stored `$gt` on a date column read back as `after` — the operator @@ -278,19 +305,25 @@ function DatasetFilterField({ label, help, value, onCommit, fields, disabled }: {!representable ? (

- Advanced filter (nested / OR) — edit it in the Source tab. + {withSlots(t('engine.inspector.dataset.filterAdvanced', locale), { + source: {t('engine.inspector.dataset.sourceTab', locale)}, + })}

) : ( {fields.length === 0 ? ( -

Pick a base object to add filter conditions.

+

{t('engine.inspector.dataset.filterNeedsObject', locale)}

) : ( )} @@ -314,7 +347,11 @@ export function objectChangePatch(next: string, current: string): Record t(key, locale); const label = typeof draft.label === 'string' ? draft.label : ''; const description = typeof draft.description === 'string' ? draft.description : ''; const object = typeof draft.object === 'string' ? draft.object : ''; @@ -380,7 +417,7 @@ export function DatasetDefaultInspector({ draft, onPatch, readOnly, name }: Meta }; return ( - {}} hideClose> + {}} hideClose> {datasetName && !usage.loading && (

{usage.reports + usage.dashboards > 0 - ? `Bound by ${usage.reports} report${usage.reports === 1 ? '' : 's'} · ${usage.dashboards} dashboard${usage.dashboards === 1 ? '' : 's'} — changes affect them.` - : 'Not yet bound by any report or dashboard.'} + ? tFormat('engine.inspector.dataset.usage.bound', locale, { + reports: tFormat(usage.reports === 1 ? 'engine.inspector.dataset.usage.reportsOne' : 'engine.inspector.dataset.usage.reportsOther', locale, { count: usage.reports }), + dashboards: tFormat(usage.dashboards === 1 ? 'engine.inspector.dataset.usage.dashboardsOne' : 'engine.inspector.dataset.usage.dashboardsOther', locale, { count: usage.dashboards }), + }) + : tr('engine.inspector.dataset.usage.unbound')}

)} {createMode && ( { nameTouched.current = true; onPatch({ name: toFieldName(v) }); }} - placeholder="snake_case identifier" + placeholder={tr('engine.inspector.dataset.namePlaceholder')} disabled={readOnly} mono /> )} { // Live-derive the snake_case name from the label until the author edits @@ -417,33 +457,36 @@ export function DatasetDefaultInspector({ draft, onPatch, readOnly, name }: Meta }} disabled={readOnly} /> - onPatch({ description: v })} disabled={readOnly} /> + onPatch({ description: v })} disabled={readOnly} /> onPatch(objectChangePatch(v, object))} options={objectComboOptions} loading={objectsLoading} - placeholder="Select an object…" - searchPlaceholder="Search objects…" + placeholder={tr('engine.inspector.dataset.baseObjectPlaceholder')} + searchPlaceholder={tr('engine.inspector.dataset.searchObjects')} disabled={readOnly} mono /> {object && (dimensions.length > 0 || measures.length > 0 || include.length > 0 || !!datasetFilter) && ( -

Changing the base object clears its dimensions, measures, joins & filters.

+

{tr('engine.inspector.dataset.baseObjectChangeHint')}

)} {/* Included relationships (the join allowlist) */}
onPatch({ include: appendArray(include, '') })} /> {include.length === 0 ? (

- No joins. Add a relationship (a lookup field on {baseLabel || 'the base object'}) to use relationship.field dimensions/measures. + {withSlots(tr('engine.inspector.dataset.noJoins'), { + object: {baseLabel || tr('engine.inspector.dataset.baseObjectFallback')}, + path: relationship.field, + })}

) : ( include.map((rel, i) => ( @@ -452,18 +495,18 @@ export function DatasetDefaultInspector({ draft, onPatch, readOnly, name }: Meta // One row per join under the "Included relationships" heading; // no per-row visible label, so the trigger is named directly // rather than left anonymous (objectui#3997). - ariaLabel="Included relationship" + ariaLabel={tr('engine.inspector.dataset.includedRelationship')} value={rel} onCommit={(v) => onPatch({ include: include.map((r, idx) => (idx === i ? v : r)) })} options={relationshipComboOptions} loading={catalogLoading} - placeholder="Select a relationship…" - searchPlaceholder="Search relationships…" + placeholder={tr('engine.inspector.dataset.relationshipPlaceholder')} + searchPlaceholder={tr('engine.inspector.dataset.searchRelationships')} disabled={readOnly} mono /> {!readOnly && ( - )} @@ -489,34 +532,35 @@ export function DatasetDefaultInspector({ draft, onPatch, readOnly, name }: Meta {/* Scope filter — the dataset's intrinsic FilterCondition */}
onPatch({ filter: fc })} fields={filterFields} disabled={readOnly} + locale={locale} />
{/* Dimensions */}
onPatch({ dimensions: appendArray(dimensions, { name: '', field: '', type: 'string' }) })} /> {dimensions.map((d, i) => (
- Dimension {i + 1} + {tFormat('engine.inspector.dataset.dimensionN', locale, { index: i + 1 })} {!readOnly && ( )}
- patchDimension(i, { name: v })} placeholder="e.g. region" disabled={readOnly} mono /> + patchDimension(i, { name: v })} placeholder={tr('engine.inspector.dataset.dimensionNamePlaceholder')} disabled={readOnly} mono /> pickDimensionField(i, v)} options={fieldComboOptions} loading={catalogLoading} - placeholder="field or relationship.field" - searchPlaceholder="Search fields…" + placeholder={tr('engine.inspector.dataset.dimensionFieldPlaceholder')} + searchPlaceholder={tr('engine.form.searchFields')} disabled={readOnly} mono /> - {(() => { const rel = missingRelationship(d.field, include); return rel ? onPatch({ include: appendArray(include, rel) })} /> : null; })()} - patchDimension(i, { type: v })} disabled={readOnly} /> - - patchDimension(i, { label: v || undefined })} placeholder={d.name || 'Display label'} disabled={readOnly} /> + {(() => { const rel = missingRelationship(d.field, include); return rel ? onPatch({ include: appendArray(include, rel) })} /> : null; })()} + patchDimension(i, { type: v })} disabled={readOnly} /> + + patchDimension(i, { label: v || undefined })} placeholder={d.name || tr('engine.inspector.dataset.displayLabel')} disabled={readOnly} /> {d.type === 'date' && ( - patchDimension(i, { dateGranularity: v || undefined })} disabled={readOnly} /> + patchDimension(i, { dateGranularity: v || undefined })} disabled={readOnly} /> )}
@@ -551,9 +595,9 @@ export function DatasetDefaultInspector({ draft, onPatch, readOnly, name }: Meta {/* Measures */}
onPatch({ measures: appendArray(measures, { name: '', aggregate: 'sum', field: '' }) })} /> {measures.map((m, i) => { @@ -562,14 +606,14 @@ export function DatasetDefaultInspector({ draft, onPatch, readOnly, name }: Meta return (
- Measure {i + 1} + {tFormat('engine.inspector.dataset.measureN', locale, { index: i + 1 })} {!readOnly && ( )}
- patchMeasure(i, { name: v })} placeholder="e.g. revenue" disabled={readOnly} mono /> - patchMeasure(i, { aggregate: v })} disabled={readOnly} /> + patchMeasure(i, { name: v })} placeholder={tr('engine.inspector.dataset.measureNamePlaceholder')} disabled={readOnly} mono /> + patchMeasure(i, { aggregate: v })} disabled={readOnly} /> pickMeasureField(i, v)} options={fieldComboOptions} loading={catalogLoading} - placeholder="field (optional for count)" - searchPlaceholder="Search fields…" + placeholder={tr('engine.inspector.dataset.measureFieldPlaceholder')} + searchPlaceholder={tr('engine.form.searchFields')} disabled={readOnly} mono /> - {(() => { const rel = missingRelationship(m.field, include); return rel ? onPatch({ include: appendArray(include, rel) })} /> : null; })()} - - patchMeasure(i, { label: v || undefined })} placeholder={m.name || 'Display label'} disabled={readOnly} /> - patchMeasure(i, pp)} disabled={readOnly} /> + {(() => { const rel = missingRelationship(m.field, include); return rel ? onPatch({ include: appendArray(include, rel) })} /> : null; })()} + + patchMeasure(i, { label: v || undefined })} placeholder={m.name || tr('engine.inspector.dataset.displayLabel')} disabled={readOnly} /> + patchMeasure(i, pp)} disabled={readOnly} locale={locale} /> patchMeasure(i, { filter: fc })} fields={filterFields} disabled={readOnly} + locale={locale} /> patchMeasure(i, { derived: v ? { op: 'ratio', of: [] } : undefined })} disabled={readOnly} /> {derived && (
- patchMeasure(i, { derived: { ...derived, op: v } })} disabled={readOnly} /> - - {(() => { const need = derived.op === 'ratio' || derived.op === 'difference' ? 2 : 1; const have = Array.isArray(derived.of) ? derived.of.length : 0; return have < need ?

Select {need === 2 ? 'exactly 2 measures' : 'at least 1 measure'} for {derived.op}.

: null; })()} + patchMeasure(i, { derived: { ...derived, op: v } })} disabled={readOnly} /> + + {(() => { const need = derived.op === 'ratio' || derived.op === 'difference' ? 2 : 1; const have = Array.isArray(derived.of) ? derived.of.length : 0; return have < need ?

{tFormat(need === 2 ? 'engine.inspector.dataset.derivedNeedTwo' : 'engine.inspector.dataset.derivedNeedOne', locale, { op: derived.op ?? '' })}

: null; })()} {otherMeasures.length === 0 ? ( -

Add other measures first.

+

{tr('engine.inspector.dataset.addOtherMeasuresFirst')}

) : (
{otherMeasures.map((nm) => { diff --git a/packages/app-shell/src/views/metadata-admin/inspectors/FlowReferenceField.tsx b/packages/app-shell/src/views/metadata-admin/inspectors/FlowReferenceField.tsx index 0eeaa2af59..e0822d6507 100644 --- a/packages/app-shell/src/views/metadata-admin/inspectors/FlowReferenceField.tsx +++ b/packages/app-shell/src/views/metadata-admin/inspectors/FlowReferenceField.tsx @@ -46,7 +46,7 @@ import { LookupField } from '@object-ui/fields'; import type { FlowReferenceSpec, ReferenceKind, RefValueSource } from './flow-node-config.js'; import { useMetadataClient } from '../useMetadata.js'; import { useObjectFields } from '../previews/useObjectFields.js'; -import { t, useMetadataLocale } from '../i18n.js'; +import { t, tFormat, useMetadataLocale, type SupportedLocale } from '../i18n.js'; import { flagUnknownValue } from './_shared.js'; /** Context the reference picker needs to resolve dynamic option sources. */ @@ -357,15 +357,19 @@ export function connectorActionsToOptions(actions: unknown): Option[] { * annotated `· declarative` so an author can tell it apart from a plugin-registered * connector — both are equally dispatchable, but the provenance differs. The * option `value` stays the bare connector name, so selecting it commits the name. + * The annotation word is read in `locale` (objectui#10586); omitted, it is en. */ -export function connectorsToOptions(connectors: unknown): Option[] { +export function connectorsToOptions(connectors: unknown, locale?: SupportedLocale): Option[] { if (!Array.isArray(connectors)) return []; return connectors .filter((c): c is { name: string; label?: string; origin?: string } => !!c && typeof (c as { name?: unknown }).name === 'string' && !!(c as { name: string }).name) .map((c) => { const base = typeof c.label === 'string' && c.label && c.label !== c.name ? `${c.label} (${c.name})` : c.name; - return { value: c.name, label: c.origin === 'declarative' ? `${base} · declarative` : base }; + return { + value: c.name, + label: c.origin === 'declarative' ? `${base} · ${t('engine.inspector.reference.declarative', locale)}` : base, + }; }); } @@ -455,8 +459,9 @@ function useConnectorActionOptions(connectorName: string | undefined): { options * connectors a `connector_action` node can call: plugin connectors AND materialized * declarative instances (ADR-0096), the latter annotated by origin. `enabled === false` * disables the fetch (hook stays unconditional). Degrades to empty on any failure. + * `locale` is the annotation's language; switching it re-reads the registry. */ -function useConnectorListOptions(enabled: boolean): { options: Option[] } { +function useConnectorListOptions(enabled: boolean, locale: SupportedLocale): { options: Option[] } { const [state, setState] = React.useState<{ options: Option[] }>({ options: [] }); React.useEffect(() => { if (!enabled) { @@ -469,7 +474,7 @@ function useConnectorListOptions(enabled: boolean): { options: Option[] } { .then((payload) => { if (cancelled) return; const connectors = payload?.data?.connectors ?? payload?.connectors ?? []; - setState({ options: connectorsToOptions(connectors) }); + setState({ options: connectorsToOptions(connectors, locale) }); }) .catch(() => { if (!cancelled) setState({ options: [] }); @@ -477,7 +482,7 @@ function useConnectorListOptions(enabled: boolean): { options: Option[] } { return () => { cancelled = true; }; - }, [enabled]); + }, [enabled, locale]); return state; } @@ -493,7 +498,7 @@ function useConnectorListOptions(enabled: boolean): { options: Option[] } { * adapter a pencil toggle switches to manual entry — a value the directory * can't resolve yet (fresh env, dangling id) stays typeable. */ -function RecordLookupCell({ binding, value, onPick, onCommit, onBlur, disabled, placeholder }: { +function RecordLookupCell({ binding, value, onPick, onCommit, onBlur, disabled, placeholder, locale }: { binding: RecordLookupBinding; value: unknown; /** Immediate commit for picker selections (there is no blur to flush on). */ @@ -503,6 +508,8 @@ function RecordLookupCell({ binding, value, onPick, onCommit, onBlur, disabled, onBlur?: () => void; disabled?: boolean; placeholder?: string; + /** The designer locale the two toggle buttons are named in. */ + locale: SupportedLocale; }) { const adapter = useAdapter(); const [manual, setManual] = React.useState(false); @@ -530,8 +537,8 @@ function RecordLookupCell({ binding, value, onPick, onCommit, onBlur, disabled, className="h-8 w-8 shrink-0 p-0 text-muted-foreground" onClick={() => setManual(false)} disabled={disabled} - aria-label="Pick from records" - title="Pick from records" + aria-label={t('engine.inspector.reference.pickFromRecords', locale)} + title={t('engine.inspector.reference.pickFromRecords', locale)} > @@ -574,8 +581,8 @@ function RecordLookupCell({ binding, value, onPick, onCommit, onBlur, disabled, className="h-8 w-8 shrink-0 p-0 text-muted-foreground" onClick={() => setManual(true)} disabled={disabled} - aria-label="Enter value manually" - title="Enter value manually" + aria-label={t('engine.inspector.reference.enterManually', locale)} + title={t('engine.inspector.reference.enterManually', locale)} > @@ -633,7 +640,7 @@ export function ReferenceCombobox({ resolved, value, onCommit, onBlur, onSelect, // connector: the dispatchable runtime registry (plugin + materialized declarative // instances, ADR-0096), NOT the generic declared-metadata list — see the hook. - const { options: connectorListOptions } = useConnectorListOptions(kind === 'connector'); + const { options: connectorListOptions } = useConnectorListOptions(kind === 'connector', locale); // Flat metadata-list kinds (object / flow / role / user / team / …). const listType = @@ -690,6 +697,7 @@ export function ReferenceCombobox({ resolved, value, onCommit, onBlur, onSelect, onBlur={onBlur} disabled={disabled} placeholder={placeholder} + locale={locale} /> ); } @@ -733,11 +741,11 @@ export function ReferenceCombobox({ resolved, value, onCommit, onBlur, onSelect,

- Resolved at runtime from the submitter's manager — no value needed. + {t('engine.inspector.reference.managerHint', locale)}

); @@ -759,7 +767,7 @@ export function ReferenceCombobox({ resolved, value, onCommit, onBlur, onSelect, className="h-8 text-sm" />

- Queue approvers are not supported by the runtime yet — this slot resolves to nobody. + {t('engine.inspector.reference.queueUnsupported', locale)}

); @@ -786,18 +794,18 @@ export function ReferenceCombobox({ resolved, value, onCommit, onBlur, onSelect, )} {showHint && kind === 'object-field' && objectName && ( -

Fields of {objectName}.

+

{tFormat('engine.inspector.reference.fieldsOf', locale, { object: objectName })}

)} {showHint && unresolvedObject && (

- Set the flow’s trigger object (on the Start node) to list fields. + {t('engine.inspector.reference.setTriggerObject', locale)}

)} {showHint && kind === 'connector-action' && connectorName && ( -

Actions of {connectorName}.

+

{tFormat('engine.inspector.reference.actionsOf', locale, { connector: connectorName })}

)} {showHint && unresolvedConnector && ( -

Choose a Connector above to list its actions.

+

{t('engine.inspector.reference.chooseConnector', locale)}

)}
); diff --git a/packages/app-shell/src/views/metadata-admin/inspectors/inspectorStrings.i18n-10586.test.tsx b/packages/app-shell/src/views/metadata-admin/inspectors/inspectorStrings.i18n-10586.test.tsx new file mode 100644 index 0000000000..6ebe557fec --- /dev/null +++ b/packages/app-shell/src/views/metadata-admin/inspectors/inspectorStrings.i18n-10586.test.tsx @@ -0,0 +1,481 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectui#10586 (PR 1 of 2) — three designer inspectors render their own + * words in the designer's locale: `DatasetDefaultInspector`, + * `FlowReferenceField` (its `ReferenceCombobox`) and `ConditionBuilder`. + * + * Their section titles, field labels, placeholders, hints, option labels and + * accessible names were English literals, so a zh-CN author read an English + * form inside an otherwise Chinese designer. They now resolve through the + * designer's own catalogue (`../i18n`) under `engine.inspector.*` and + * `engine.form.*`, the way the translated sibling inspectors do. + * + * ── The probe ──────────────────────────────────────────────────────────────── + * `surface()` collects every string an author can read off the rendered tree: + * each element's whole text, each text node, and every `aria-label`, `title` + * and `placeholder`. Membership is EXACT, so a short word (`sum`, `Name`) + * cannot pass by occurring inside some unrelated sentence. + * + * ── Why each fixture is read twice ─────────────────────────────────────────── + * Every list below is the pre-fix English the fixture renders. Under `en` each + * entry must be present, which proves the fixture really reaches that branch + * and that the English wording did not move; under `zh` none may be, which is + * the defect. The zh half alone could pass on a fixture that renders nothing, + * so it is only meaningful beside the en half, plus a named zh sample per + * inspector read back from the catalogue. + * + * ── Lit controls ───────────────────────────────────────────────────────────── + * The same probe sees a translation that was already there before this change, + * once per locale channel: `ObjectDefaultInspector` through the host's + * `locale` prop (the channel `DatasetDefaultInspector` now reads), and the + * `I18nProvider` language through `useMetadataLocale()` (the channel the other + * two read) — the membership-tier flag and the raw CEL editor's label. + */ + +import * as React from 'react'; +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, cleanup, fireEvent, act } from '@testing-library/react'; +import { I18nProvider } from '@object-ui/i18n'; + +const state = vi.hoisted(() => ({ + usage: { reports: 0, dashboards: 0, loading: false }, + adapter: null as unknown, + // STABLE identity, like the real memoized client — a fresh object per render + // would setState → re-render → setState forever. + metadataClient: { get: async () => undefined, list: async () => [] as unknown[] }, +})); + +vi.mock('./useDatasetFields', () => ({ + useObjectOptions: () => ({ options: [], loading: false }), + useDatasetFieldCatalog: () => ({ relationships: [], fieldOptions: [], loading: false }), + useDatasetUsage: () => state.usage, + fieldTypeToDimensionType: (t: string) => (t === 'date' ? 'date' : 'string'), +})); +vi.mock('@object-ui/react', async (importOriginal) => ({ + ...(await importOriginal>()), + useAdapter: () => state.adapter, + // @object-ui/components wires this at module scope (related-count-store). + subscribeDataChanges: () => () => {}, +})); +vi.mock('@object-ui/fields', async (importOriginal) => ({ + ...(await importOriginal()), + LookupField: () =>
, +})); +vi.mock('../useMetadata', () => ({ + useMetadataClient: () => state.metadataClient, +})); +vi.mock('../previews/useObjectFields', () => ({ + useObjectFields: () => ({ fields: [] }), +})); + +import { t } from '../i18n'; +import { DatasetDefaultInspector } from './DatasetDefaultInspector'; +import { ObjectDefaultInspector } from './ObjectDefaultInspector'; +import { FlowReferenceField } from './FlowReferenceField'; +import { ConditionBuilder } from './ConditionBuilder'; + +afterEach(() => { + cleanup(); + state.usage = { reports: 0, dashboards: 0, loading: false }; + state.adapter = null; +}); + +// The connector pickers read the runtime registry with a relative `fetch`. +// ONE double for the whole file, never torn down, so no read can reach the +// network after a test body returns (the network-escape guard's own advice). +const CONNECTORS = [ + { name: 'billing', label: 'Billing API', origin: 'declarative', actions: [{ key: 'charge', label: 'Charge' }] }, + { name: 'slack', actions: [{ key: 'post', label: 'Post' }] }, +]; +vi.stubGlobal( + 'fetch', + (async (url: string) => + String(url).includes('/api/v1/automation/connectors') + ? { ok: true, json: async () => ({ data: { connectors: CONNECTORS } }) } + : { ok: false, json: async () => null }) as unknown as typeof globalThis.fetch, +); + +type Lang = 'en' | 'zh'; +const LOCALE = { en: 'en-US', zh: 'zh-CN' } as const; + +/** Every string an author can read off the rendered document. */ +function surface(): Set { + const out = new Set(); + const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT); + for (let n = walker.nextNode(); n; n = walker.nextNode()) { + if (n.nodeType === Node.TEXT_NODE) { + const v = n.nodeValue?.trim(); + if (v) out.add(v); + continue; + } + const el = n as Element; + const text = el.textContent?.trim(); + if (text) out.add(text); + for (const attr of ['aria-label', 'title', 'placeholder']) { + const v = el.getAttribute(attr); + if (v) out.add(v); + } + } + return out; +} + +function inLang(lang: Lang, ui: React.ReactElement) { + return render( + {ui}, + ); +} + +/** Render each fixture in `lang` and union what the author can read. */ +async function readAll(lang: Lang, fixtures: Array<(lang: Lang) => Promise | void>): Promise> { + const all = new Set(); + for (const fixture of fixtures) { + await fixture(lang); + for (const s of surface()) all.add(s); + cleanup(); + } + return all; +} + +/** 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 en one`).not.toBe(t(key, 'en-US')); + return zh; +} + +function expectAll(seen: Set, literals: readonly string[]) { + const missing = literals.filter((l) => !seen.has(l)); + expect(missing, 'en: pre-fix wording the fixture no longer renders').toEqual([]); +} + +function expectNone(seen: Set, literals: readonly string[]) { + const leaked = literals.filter((l) => seen.has(l)); + expect(leaked, 'zh: English still rendered').toEqual([]); +} + +// ─── DatasetDefaultInspector ──────────────────────────────────────────────── + +const datasetProps = (lang: Lang) => ({ type: 'dataset', locale: LOCALE[lang], onPatch: () => {}, readOnly: false }); + +const DATASET_FIXTURES: Array<(lang: Lang) => void> = [ + (lang) => { + state.usage = { reports: 2, dashboards: 1, loading: false }; + inLang(lang, ( + + )); + }, + (lang) => { + inLang(lang, ( + + )); + }, + (lang) => { + state.usage = { reports: 1, dashboards: 0, loading: false }; + inLang(lang, ( + + )); + }, + (lang) => { + state.usage = { reports: 0, dashboards: 0, loading: false }; + inLang(lang, ); + // The scope-filter trigger is the one button reading as an empty filter. + const trigger = Array.from(document.body.querySelectorAll('button')).find( + (b) => b.textContent === '+ Add filter…' || b.textContent === t('engine.inspector.dataset.addFilter', LOCALE[lang]), + ); + expect(trigger, 'the scope-filter trigger').toBeTruthy(); + fireEvent.click(trigger!); + }, +]; + +/** The pre-fix English the four dataset fixtures render. */ +const DATASET_EN = [ + 'Dataset', + 'Bound by 2 reports · 1 dashboard — changes affect them.', + 'Bound by 1 report · 0 dashboards — changes affect them.', + 'Not yet bound by any report or dashboard.', + 'Name', + 'snake_case identifier', + 'Label', + 'Description', + 'Base object', + 'Select an object…', + 'Changing the base object clears its dimensions, measures, joins & filters.', + 'Included relationships', + 'Add', + 'No joins. Add a relationship (a lookup field on the base object) to use relationship.field dimensions/measures.', + 'No joins. Add a relationship (a lookup field on opportunity) to use relationship.field dimensions/measures.', + 'Included relationship', + 'Remove relationship', + 'Scope filter', + 'Intrinsic scope, ANDed into every query (e.g. exclude soft-deleted records).', + 'Advanced filter (nested / OR) — edit it in the Source tab.', + '1 condition', + '2 conditions', + '+ Add filter…', + 'Pick a base object to add filter conditions.', + 'Dimensions', + 'Add dimension', + 'Dimension 1', + 'Remove dimension', + 'e.g. region', + 'Field', + 'field or relationship.field', + "Relationship owner isn't in Included relationships.", + 'Add it', + 'Type', + 'Advanced', + 'Label (optional)', + 'Display label', + 'Date bucket', + 'Measures', + 'Add measure', + 'Measure 1', + 'Remove measure', + 'e.g. revenue', + 'Aggregate', + 'field (optional for count)', + 'Display format', + 'Decimals', + 'Currency', + 'Filter (measure-scoped)', + 'Only rows matching this filter feed this measure (e.g. won_amount = sum(amount) where stage = won).', + 'Derived — computed from other measures', + 'Operation', + 'Operands (other measures)', + 'Select exactly 2 measures for difference.', + 'Select at least 1 measure for sum.', + 'Add other measures first.', + // option labels — one fixture or another selects each of them + 'count', 'sum', 'avg', 'min', 'max', 'count distinct', + 'string', 'number', 'date', 'boolean', 'lookup', + '— none —', 'month', + 'ratio (a ÷ b)', 'sum (a + b)', 'difference (a − b)', 'product (a × b)', + 'Raw number', 'Number — 1,234.5', 'Currency — $1,234.50', 'Percent — 12.3%', +] as const; + +describe('DatasetDefaultInspector reads its words in the designer locale (objectui#10586)', () => { + it('zh: a named sample is the catalogue row, and none of the pre-fix English renders', async () => { + const seen = await readAll('zh', DATASET_FIXTURES); + expect(seen.has(zhRow('engine.inspector.dataset.baseObject'))).toBe(true); + expect(seen.has(zhRow('engine.inspector.dataset.usage.unbound'))).toBe(true); + expect(seen.has(zhRow('engine.inspector.dataset.aggregate.sum'))).toBe(true); + expectNone(seen, DATASET_EN); + }); + + it('en: every pre-fix string still renders, word for word', async () => { + expectAll(await readAll('en', DATASET_FIXTURES), DATASET_EN); + }); + + it('the format sample keeps its value in a slot the locale words around', () => { + for (const lang of ['en', 'zh'] as const) { + DATASET_FIXTURES[0](lang); + const value = document.body.querySelector('span.font-mono.tabular-nums'); + expect(value, 'the sample value span').toBeTruthy(); + const lead = value!.parentElement!.firstChild?.nodeValue; + expect(lead).toBe(lang === 'en' ? 'Sample: ' : t('engine.inspector.dataset.sample', 'zh-CN').split('{sample}')[0]); + cleanup(); + } + expect(zhRow('engine.inspector.dataset.sample')).toContain('{sample}'); + }); + + it('lit control: the same probe reads ObjectDefaultInspector’s existing zh (the host `locale` channel)', () => { + inLang('zh', {}} readOnly={false} />); + const seen = surface(); + expect(seen.has(zhRow('designer.object.section.basic'))).toBe(true); + expect(seen.has(t('designer.object.section.basic', 'en-US'))).toBe(false); + }); +}); + +// ─── FlowReferenceField / ReferenceCombobox ───────────────────────────────── + +const flowDraft = { + nodes: [ + { id: 'start', type: 'start', config: { objectName: 'account' } }, + { id: 'n1', type: 'connector_action', label: 'Call' }, + ], +}; + +function mountRef(lang: Lang, ref: Record, context?: Record, value = '') { + inLang(lang, ( + {}} context={context as never} /> + )); +} + +const REFERENCE_FIXTURES: Array<(lang: Lang) => Promise | void> = [ + (lang) => mountRef(lang, { kind: 'manager' }), + (lang) => mountRef(lang, { kind: 'queue' }), + (lang) => mountRef(lang, { kind: 'object-field' }, { draft: flowDraft, node: null }), + (lang) => mountRef(lang, { kind: 'object-field' }, { draft: { nodes: [] }, node: null }), + async (lang) => { + mountRef(lang, { kind: 'connector-action' }, { draft: flowDraft, node: { id: 'n1', connectorConfig: { connectorId: 'slack' } } }); + await act(async () => { await new Promise((r) => setTimeout(r, 0)); }); + }, + (lang) => mountRef(lang, { kind: 'connector-action' }, { draft: flowDraft, node: { id: 'n1' } }), + async (lang) => { + mountRef(lang, { kind: 'connector' }, { draft: flowDraft, node: null }); + await act(async () => { await new Promise((r) => setTimeout(r, 0)); }); + expect(document.body.querySelector('datalist option'), 'the connector registry answered').toBeTruthy(); + }, + (lang) => { + state.adapter = { find: async () => ({ data: [] }) }; + mountRef(lang, { kind: 'user' }, undefined, 'u1'); + // Collect the lookup half's toggle, then flip to manual entry for the other. + for (const s of surface()) extra.add(s); + fireEvent.click(document.body.querySelector('button[aria-label]')!); + }, +]; +// The record-lookup fixture reads two states of one mount; `readAll` only sees the last. +const extra = new Set(); + +const REFERENCE_EN = [ + 'Resolved automatically', + "Resolved at runtime from the submitter's manager — no value needed.", + 'Queue approvers are not supported by the runtime yet — this slot resolves to nobody.', + 'Fields of account.', + 'Set the flow’s trigger object (on the Start node) to list fields.', + 'Actions of slack.', + 'Choose a Connector above to list its actions.', + 'Billing API (billing) · declarative', + 'Enter value manually', + 'Pick from records', +] as const; + +async function readReference(lang: Lang): Promise> { + extra.clear(); + const seen = await readAll(lang, REFERENCE_FIXTURES); + for (const s of extra) seen.add(s); + return seen; +} + +describe('FlowReferenceField reads its words in the designer locale (objectui#10586)', () => { + it('zh: a named sample is the catalogue row, and none of the pre-fix English renders', async () => { + const seen = await readReference('zh'); + expect(seen.has(zhRow('engine.inspector.reference.managerPlaceholder'))).toBe(true); + expect(seen.has(`Billing API (billing) · ${zhRow('engine.inspector.reference.declarative')}`)).toBe(true); + expectNone(seen, REFERENCE_EN); + }); + + it('en: every pre-fix string still renders, word for word', async () => { + expectAll(await readReference('en'), REFERENCE_EN); + }); + + it('lit control: the same probe reads the membership-tier flag’s existing zh (the `I18nProvider` channel)', () => { + mountRef('zh', { kind: 'org-membership-level' }, undefined, 'sales_manager'); + expect(surface().has(`sales_manager${zhRow('engine.form.invalid')}`)).toBe(true); + }); +}); + +// ─── ConditionBuilder ─────────────────────────────────────────────────────── + +const CONDITION_FIELDS = [{ name: 'a' }, { name: 'b' }]; + +function mountCondition(lang: Lang, value: string) { + inLang(lang, {}} fields={CONDITION_FIELDS} />); +} + +const CONDITION_FIXTURES: Array<(lang: Lang) => void> = [ + (lang) => { + mountCondition(lang, ''); + for (const s of surface()) extra.add(s); + // Adding a row: the empty state gives way to a row with no subject yet. + const add = Array.from(document.body.querySelectorAll('button')).at(-1)!; + fireEvent.click(add); + }, + (lang) => mountCondition(lang, "record.a == 'x' && record.b"), + (lang) => mountCondition(lang, "record.a != 'x' || record.b > 1 || record.a < 2 || !record.b"), + (lang) => mountCondition(lang, 'record.a >= 1 && record.b <= 2'), + // Mixed joins do not round-trip, so this one opens the raw CEL editor. + (lang) => mountCondition(lang, "record.a == 'x' && record.b || record.a"), +]; + +const CONDITION_EN = [ + 'Always — no condition.', + 'Expression', + 'Add condition', + 'field / context', + 'Remove condition', + 'value', + 'equals', + 'not equals', + 'greater than', + 'less than', + 'is set / true', + 'is empty / false', + 'AND', + 'OR', + 'Builder', +] as const; + +async function readCondition(lang: Lang): Promise> { + extra.clear(); + const seen = await readAll(lang, CONDITION_FIXTURES); + for (const s of extra) seen.add(s); + return seen; +} + +describe('ConditionBuilder reads its words in the designer locale (objectui#10586)', () => { + it('zh: a named sample is the catalogue row, and none of the pre-fix English renders', async () => { + const seen = await readCondition('zh'); + expect(seen.has(zhRow('engine.inspector.condition.always'))).toBe(true); + expect(seen.has(zhRow('engine.inspector.condition.op.notEquals'))).toBe(true); + expectNone(seen, CONDITION_EN); + // `≥` / `≤` are symbols, deliberately the same in every locale. + expect(seen.has('≥') && seen.has('≤')).toBe(true); + }); + + it('en: every pre-fix string still renders, word for word', async () => { + expectAll(await readCondition('en'), CONDITION_EN); + }); + + it('lit control: the same probe reads the raw editor’s existing zh label (the `I18nProvider` channel)', () => { + mountCondition('zh', "record.a == 'x' && record.b || record.a"); + expect(surface().has(zhRow('engine.condition.celLabel'))).toBe(true); + }); +}); From 30171d274f323186737acd2d541d7788b3e5792f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 13:38:37 +0000 Subject: [PATCH 2/2] test(app-shell): the zh sweep runs before the named samples, so a base run lists the leaked English Claude-Session: https://claude.ai/code/session_01KUxVUa7e39aNjhkKi1gsoy Co-authored-by: Claude --- .../inspectors/inspectorStrings.i18n-10586.test.tsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/app-shell/src/views/metadata-admin/inspectors/inspectorStrings.i18n-10586.test.tsx b/packages/app-shell/src/views/metadata-admin/inspectors/inspectorStrings.i18n-10586.test.tsx index 6ebe557fec..ffb4b999db 100644 --- a/packages/app-shell/src/views/metadata-admin/inspectors/inspectorStrings.i18n-10586.test.tsx +++ b/packages/app-shell/src/views/metadata-admin/inspectors/inspectorStrings.i18n-10586.test.tsx @@ -301,12 +301,12 @@ const DATASET_EN = [ ] as const; describe('DatasetDefaultInspector reads its words in the designer locale (objectui#10586)', () => { - it('zh: a named sample is the catalogue row, and none of the pre-fix English renders', async () => { + it('zh: none of the pre-fix English renders, and a named sample is the catalogue row', async () => { const seen = await readAll('zh', DATASET_FIXTURES); + expectNone(seen, DATASET_EN); expect(seen.has(zhRow('engine.inspector.dataset.baseObject'))).toBe(true); expect(seen.has(zhRow('engine.inspector.dataset.usage.unbound'))).toBe(true); expect(seen.has(zhRow('engine.inspector.dataset.aggregate.sum'))).toBe(true); - expectNone(seen, DATASET_EN); }); it('en: every pre-fix string still renders, word for word', async () => { @@ -395,11 +395,11 @@ async function readReference(lang: Lang): Promise> { } describe('FlowReferenceField reads its words in the designer locale (objectui#10586)', () => { - it('zh: a named sample is the catalogue row, and none of the pre-fix English renders', async () => { + it('zh: none of the pre-fix English renders, and a named sample is the catalogue row', async () => { const seen = await readReference('zh'); + expectNone(seen, REFERENCE_EN); expect(seen.has(zhRow('engine.inspector.reference.managerPlaceholder'))).toBe(true); expect(seen.has(`Billing API (billing) · ${zhRow('engine.inspector.reference.declarative')}`)).toBe(true); - expectNone(seen, REFERENCE_EN); }); it('en: every pre-fix string still renders, word for word', async () => { @@ -461,11 +461,11 @@ async function readCondition(lang: Lang): Promise> { } describe('ConditionBuilder reads its words in the designer locale (objectui#10586)', () => { - it('zh: a named sample is the catalogue row, and none of the pre-fix English renders', async () => { + it('zh: none of the pre-fix English renders, and a named sample is the catalogue row', async () => { const seen = await readCondition('zh'); + expectNone(seen, CONDITION_EN); expect(seen.has(zhRow('engine.inspector.condition.always'))).toBe(true); expect(seen.has(zhRow('engine.inspector.condition.op.notEquals'))).toBe(true); - expectNone(seen, CONDITION_EN); // `≥` / `≤` are symbols, deliberately the same in every locale. expect(seen.has('≥') && seen.has('≤')).toBe(true); });