diff --git a/.changeset/10586-inspector-strings-i18n-2.md b/.changeset/10586-inspector-strings-i18n-2.md new file mode 100644 index 0000000000..06671f2571 --- /dev/null +++ b/.changeset/10586-inspector-strings-i18n-2.md @@ -0,0 +1,23 @@ +--- +'@object-ui/app-shell': patch +--- + +fix(app-shell): the hook and action inspectors, and the shared inspector defaults, read their words in the designer's locale (objectui#10586) + +`HookDefaultInspector` and `ActionDefaultInspector` rendered their section +titles, field labels, placeholders, hints, option labels and accessible names +as English literals. `InspectorComboField` and `_shared.tsx` fell back to +English defaults wherever a caller passed none: the combo's placeholder, +search box, empty state, loading text and "Use" row, and the shell's close +label, the reorder pair's names and the roster-failure notice. So a zh-CN +author read English inside an otherwise Chinese designer. + +The two inspectors now read the designer's own string catalogue under +`engine.inspector.hook.*` and `engine.inspector.action.*`, each row in en and +zh, from the `locale` their host already passes. The combo's words are new +`engine.inspector.combo.*` rows. The other defaults reuse existing rows +(`engine.form.selectEllipsis`, `engine.close`, `engine.inspector.reorder.*`, +`engine.form.optionsLoadFailedTitle`), and they follow the active language +through `useMetadataLocale()`. A label the caller passes still wins. The en-US +text is unchanged. Stored values, lifecycle event names, HTTP methods, code +samples and sample values 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 0c1dcbe61c..05ab136bd6 100644 --- a/packages/app-shell/src/views/metadata-admin/i18n.ts +++ b/packages/app-shell/src/views/metadata-admin/i18n.ts @@ -792,6 +792,164 @@ const ENGINE_STRINGS_EN: Record = { 'engine.inspector.action.kind': 'Action', 'engine.inspector.action.close': 'Close action', 'engine.inspector.hook.kind': 'Hook', + // Hook default (home) inspector (objectui#10586). Event names, code samples + // and the priority digits stay literal: they are identifiers, not words. + 'engine.inspector.hook.bodyLang.expression': 'Expression (L1)', + 'engine.inspector.hook.bodyLang.js': 'Sandboxed JS (L2)', + 'engine.inspector.hook.basics': 'Basics', + 'engine.inspector.hook.label': 'Label', + 'engine.inspector.hook.labelPlaceholder': 'Human-readable name', + 'engine.inspector.hook.name': 'Name', + 'engine.inspector.hook.namePlaceholder': 'snake_case identifier', + 'engine.inspector.hook.objects': 'Object(s) this hook fires on', + 'engine.inspector.hook.allObjects': 'All objects (*)', + 'engine.inspector.hook.noObjects': 'No objects found — publish an object, then pick it here.', + 'engine.inspector.hook.pickObject': 'Pick at least one object (or All objects).', + 'engine.inspector.hook.events': 'Events', + 'engine.inspector.hook.eventsHint': 'Which lifecycle events invoke this hook.', + 'engine.inspector.hook.eventsWrite': 'Write', + 'engine.inspector.hook.eventsQuery': 'Query', + 'engine.inspector.hook.pickEvent': 'Select at least one event.', + 'engine.inspector.hook.function': 'Function', + 'engine.inspector.hook.functionHint': 'The handler that runs when the hook fires.', + 'engine.inspector.hook.language': 'Language', + 'engine.inspector.hook.handlerBody': 'Handler body', + 'engine.inspector.hook.bodyHint.expression': 'A single L1 expression evaluated against the record / ctx.', + 'engine.inspector.hook.bodyHint.js': 'Runs in the sandbox as (ctx) => Promise.', + 'engine.inspector.hook.options': 'Options', + 'engine.inspector.hook.priority': 'Priority', + 'engine.inspector.hook.async': 'Run asynchronously (after commit)', + 'engine.inspector.hook.condition': 'Run only when (optional CEL)', + 'engine.inspector.hook.moreFieldsHint': 'Advanced / rarely-used properties.', + // Action default (home) inspector (objectui#10586). Option labels are keyed + // by the stored value; `{…}` tokens are slots the inspector fills with a + // node (a code span, a capability list). HTTP methods, `↑` / `↓` and the + // path / dotted-key samples stay literal. + 'engine.inspector.action.type.script': 'Script — run an expression / sandboxed JS', + 'engine.inspector.action.type.api': 'API — call an endpoint', + 'engine.inspector.action.type.flow': 'Flow — invoke a flow', + 'engine.inspector.action.type.modal': 'Modal — open a modal/page', + 'engine.inspector.action.type.form': 'Form — open a FormView', + 'engine.inspector.action.type.url': 'URL — navigate to a link', + 'engine.inspector.action.variant.primary': 'Primary', + 'engine.inspector.action.variant.secondary': 'Secondary', + 'engine.inspector.action.variant.danger': 'Danger', + 'engine.inspector.action.variant.ghost': 'Ghost', + 'engine.inspector.action.variant.link': 'Link', + 'engine.inspector.action.component.button': 'Button', + 'engine.inspector.action.component.icon': 'Icon only', + 'engine.inspector.action.component.menu': 'Menu item', + 'engine.inspector.action.component.group': 'Button group', + 'engine.inspector.action.mode.create': 'Create', + 'engine.inspector.action.mode.edit': 'Edit', + 'engine.inspector.action.mode.delete': 'Delete', + 'engine.inspector.action.mode.custom': 'Custom', + 'engine.inspector.action.bodyLang.expression': 'Expression (L1)', + 'engine.inspector.action.bodyLang.js': 'Sandboxed JS (L2)', + 'engine.inspector.action.paramType.text': 'Text', + 'engine.inspector.action.paramType.textarea': 'Long text', + 'engine.inspector.action.paramType.number': 'Number', + 'engine.inspector.action.paramType.boolean': 'Checkbox', + 'engine.inspector.action.paramType.select': 'Select', + 'engine.inspector.action.paramType.date': 'Date', + 'engine.inspector.action.paramType.datetime': 'Date/time', + 'engine.inspector.action.paramType.lookup': 'Lookup', + 'engine.inspector.action.location.record_header': 'Record header', + 'engine.inspector.action.location.record_more': 'Record · more menu', + 'engine.inspector.action.location.record_section': 'Record · section', + 'engine.inspector.action.location.record_related': 'Record · related list', + 'engine.inspector.action.location.list_toolbar': 'List toolbar', + 'engine.inspector.action.location.list_item': 'List · row', + 'engine.inspector.action.target.url.label': 'URL', + 'engine.inspector.action.target.url.placeholder': 'https://… or /path?x=${param.x}', + 'engine.inspector.action.target.url.hint': 'Supports ${param.x} and ${ctx.x} interpolation.', + 'engine.inspector.action.target.flow.label': 'Flow name', + 'engine.inspector.action.target.flow.placeholder': 'snake_case flow', + 'engine.inspector.action.target.flow.hint': 'The flow to invoke when clicked.', + 'engine.inspector.action.target.modal.label': 'Modal / page name', + 'engine.inspector.action.target.modal.placeholder': 'snake_case page', + 'engine.inspector.action.target.modal.hint': 'The modal or page to open.', + 'engine.inspector.action.target.form.label': 'Form view name', + 'engine.inspector.action.target.form.hint': 'Opens /console/forms/.', + 'engine.inspector.action.target.api.label': 'API endpoint', + 'engine.inspector.action.target.api.hint': 'Endpoint called with the request body below.', + 'engine.inspector.action.scriptOp.run': 'Run a script — sandboxed JS / expression body', + 'engine.inspector.action.scriptOp.update': 'Update fields on this record — no code', + 'engine.inspector.action.objectPlaceholder': 'snake_case object', + 'engine.inspector.action.objectNone': '— None (global) —', + 'engine.inspector.action.options': 'Options', + 'engine.inspector.action.optionsEmpty': 'No choices yet — a Select with no options opens an empty picker in the dialog.', + 'engine.inspector.action.optionLabel': 'Label', + 'engine.inspector.action.optionValue': 'Value', + 'engine.inspector.action.removeOption': 'Remove option {index}', + 'engine.inspector.action.addOption': 'Add option', + 'engine.inspector.action.basics': 'Basics', + 'engine.inspector.action.label': 'Label', + 'engine.inspector.action.labelPlaceholder': 'Button text shown to users', + 'engine.inspector.action.name': 'Name', + 'engine.inspector.action.namePlaceholder': 'snake_case identifier', + 'engine.inspector.action.object': 'Object', + 'engine.inspector.action.objectBoundHint': 'Bound action — surfaces in this object’s views per the placement below.', + 'engine.inspector.action.objectGlobalHint': 'Empty = global action — must be referenced by a page’s quick actions, global nav, a flow, or AI to appear.', + 'engine.inspector.action.icon': 'Icon', + 'engine.inspector.action.variant': 'Variant', + 'engine.inspector.action.behavior': 'Behavior', + 'engine.inspector.action.behaviorHint': 'What happens when the action is triggered.', + 'engine.inspector.action.type': 'Type', + 'engine.inspector.action.pinnedScript': 'Pinned to {script}: the field write is performed on the platform action route, which is this type’s own route. Any other type is refused beside it.', + 'engine.inspector.action.whatItDoes': 'What it does', + 'engine.inspector.action.fieldValues': 'Field values', + 'engine.inspector.action.fieldValuesEmpty': 'No field values yet — add one, or collect the value as an input below.', + 'engine.inspector.action.fieldValueField': 'Field', + 'engine.inspector.action.fieldValueValue': 'Value', + 'engine.inspector.action.removeFieldValue': 'Remove field value', + 'engine.inspector.action.addFieldValue': 'Add field value', + 'engine.inspector.action.updateHint': 'Written to the current record as the caller — object permissions, hooks and validations fire as for a user edit. An input collected below with the same name overrides the fixed value here.', + 'engine.inspector.action.scriptLanguage': 'Script language', + 'engine.inspector.action.scriptBody': 'Script body', + 'engine.inspector.action.scriptBodyHint': 'Runs in the sandbox as {signature}.', + 'engine.inspector.action.method': 'Method', + 'engine.inspector.action.inputs': 'Inputs', + 'engine.inspector.action.inputsHint': 'Collected from the user in a dialog before the action runs.', + 'engine.inspector.action.inputsEmpty': 'No inputs — the action runs immediately on click.', + 'engine.inspector.action.removeInput': 'Remove input', + 'engine.inspector.action.bindToField': 'Bind to field', + 'engine.inspector.action.paramName': 'Name', + 'engine.inspector.action.paramNamePlaceholder': 'request-body key', + 'engine.inspector.action.paramLabel': 'Label', + 'engine.inspector.action.paramTypeLabel': 'Type', + 'engine.inspector.action.paramPlaceholder': 'Placeholder', + 'engine.inspector.action.paramRequired': 'Required', + 'engine.inspector.action.paramPrefill': 'Pre-fill from row', + 'engine.inspector.action.addInput': 'Add input', + 'engine.inspector.action.placement': 'Placement', + 'engine.inspector.action.placementHint': 'Where this action surfaces in the UI.', + 'engine.inspector.action.noPlacement': 'No placement selected — this action will not appear on any record or list surface. Tick a placement above, or place it from a view’s bulk actions.', + 'engine.inspector.action.capabilityGate': 'Capability-gated on {capabilities} — every placement ticked above hides this action from anyone who does not hold {whom}. Not greyed out and not an error: the button is simply absent, and the server refuses the invocation with a 403 either way.', + 'engine.inspector.action.capabilityGateAll': 'all of them', + 'engine.inspector.action.capabilityGateOne': 'it', + 'engine.inspector.action.capabilityGateSelf': 'This session does not hold {capabilities}, so the button is hidden from you too — grant it through a permission set to see it in the app.', + 'engine.inspector.action.component': 'Component', + 'engine.inspector.action.feedback': 'Feedback', + 'engine.inspector.action.feedbackHint': 'Confirmation and post-run messaging.', + 'engine.inspector.action.confirmText': 'Confirm prompt', + 'engine.inspector.action.confirmTextPlaceholder': 'Ask before running (leave blank to skip)', + 'engine.inspector.action.successMessage': 'Success message', + 'engine.inspector.action.errorMessage': 'Error message', + 'engine.inspector.action.mode': 'Mode', + 'engine.inspector.action.refreshAfter': 'Refresh view after', + 'engine.inspector.action.undoable': 'Offer undo', + 'engine.inspector.action.conditions': 'Conditions', + 'engine.inspector.action.conditionsHint': 'No-code predicates over the record / user / ctx (compiled to CEL).', + 'engine.inspector.action.visibleWhen': 'Visible when', + 'engine.inspector.action.disabledWhen': 'Disabled when', + 'engine.inspector.action.ai': 'AI exposure', + 'engine.inspector.action.aiHint': 'Opt-in: expose this action to AI agents as a callable tool.', + 'engine.inspector.action.aiExposed': 'Expose to AI agents', + 'engine.inspector.action.aiDescription': 'Tool description (required, ≥40 chars)', + 'engine.inspector.action.aiDescriptionPlaceholder': 'When and why an agent should call this action…', + 'engine.inspector.action.aiDescriptionTooShort': 'A ≥40-character description is required while exposed.', + 'engine.inspector.action.moreFieldsHint': 'Advanced / rarely-used properties.', // Dashboard default (home) inspector 'engine.inspector.dashboard.kind': 'Dashboard', 'engine.inspector.dashboard.close': 'Close dashboard', @@ -1059,6 +1217,12 @@ const ENGINE_STRINGS_EN: Record = { // Reorder buttons (used in InspectorShell header) 'engine.inspector.reorder.up': 'Move up', 'engine.inspector.reorder.down': 'Move down', + // InspectorComboField defaults (objectui#10586). `{value}` is the typed + // text, drawn in a code span by the combo. + 'engine.inspector.combo.search': 'Search or type…', + 'engine.inspector.combo.noMatch': 'No match — keep typing to use a custom value.', + 'engine.inspector.combo.loading': 'Loading…', + 'engine.inspector.combo.useCustom': 'Use {value}', 'engine.edit.overlay': 'overlay', 'engine.edit.readOnlyBanner': 'Viewing in read-only mode. Click {edit} to make changes.', 'engine.edit.readOnlyTypeBanner': @@ -2872,6 +3036,162 @@ const ENGINE_STRINGS_ZH: Record = { 'engine.inspector.action.kind': '动作', 'engine.inspector.action.close': '关闭动作', 'engine.inspector.hook.kind': '钩子', + // Hook default (home) inspector (objectui#10586). + 'engine.inspector.hook.bodyLang.expression': '表达式(L1)', + 'engine.inspector.hook.bodyLang.js': '沙箱 JS(L2)', + 'engine.inspector.hook.basics': '基本信息', + 'engine.inspector.hook.label': '显示名', + 'engine.inspector.hook.labelPlaceholder': '便于阅读的名称', + 'engine.inspector.hook.name': '名称', + 'engine.inspector.hook.namePlaceholder': 'snake_case 标识符', + 'engine.inspector.hook.objects': '此钩子触发的对象', + 'engine.inspector.hook.allObjects': '所有对象(*)', + 'engine.inspector.hook.noObjects': '未找到对象 —— 请先发布一个对象,再在此处选择。', + 'engine.inspector.hook.pickObject': '请至少选择一个对象(或「所有对象」)。', + 'engine.inspector.hook.events': '事件', + 'engine.inspector.hook.eventsHint': '哪些生命周期事件会调用此钩子。', + 'engine.inspector.hook.eventsWrite': '写入', + 'engine.inspector.hook.eventsQuery': '查询', + 'engine.inspector.hook.pickEvent': '请至少选择一个事件。', + 'engine.inspector.hook.function': '函数', + 'engine.inspector.hook.functionHint': '钩子触发时运行的处理函数。', + 'engine.inspector.hook.language': '语言', + 'engine.inspector.hook.handlerBody': '处理函数主体', + 'engine.inspector.hook.bodyHint.expression': '针对 record / ctx 求值的单个 L1 表达式。', + 'engine.inspector.hook.bodyHint.js': '在沙箱中以 (ctx) => Promise 的形式运行。', + 'engine.inspector.hook.options': '选项', + 'engine.inspector.hook.priority': '优先级', + 'engine.inspector.hook.async': '异步运行(提交之后)', + 'engine.inspector.hook.condition': '仅在以下条件成立时运行(可选 CEL)', + 'engine.inspector.hook.moreFieldsHint': '高级 / 不常用的属性。', + // Action default (home) inspector (objectui#10586). Wording follows the + // catalogue's own rows: pageBlock's variant and location labels, the + // `engine.fieldType.*` names, `designer.field.*` (显示名 / 占位符 / 必填). + 'engine.inspector.action.type.script': '脚本 —— 运行表达式 / 沙箱 JS', + 'engine.inspector.action.type.api': 'API —— 调用端点', + 'engine.inspector.action.type.flow': '流程 —— 调用流程', + 'engine.inspector.action.type.modal': '弹窗 —— 打开弹窗 / 页面', + 'engine.inspector.action.type.form': '表单 —— 打开表单视图', + 'engine.inspector.action.type.url': 'URL —— 跳转到链接', + 'engine.inspector.action.variant.primary': '主要', + 'engine.inspector.action.variant.secondary': '次要', + 'engine.inspector.action.variant.danger': '危险', + 'engine.inspector.action.variant.ghost': '幽灵', + 'engine.inspector.action.variant.link': '链接', + 'engine.inspector.action.component.button': '按钮', + 'engine.inspector.action.component.icon': '仅图标', + 'engine.inspector.action.component.menu': '菜单项', + 'engine.inspector.action.component.group': '按钮组', + 'engine.inspector.action.mode.create': '新建', + 'engine.inspector.action.mode.edit': '编辑', + 'engine.inspector.action.mode.delete': '删除', + 'engine.inspector.action.mode.custom': '自定义', + 'engine.inspector.action.bodyLang.expression': '表达式(L1)', + 'engine.inspector.action.bodyLang.js': '沙箱 JS(L2)', + 'engine.inspector.action.paramType.text': '单行文本', + 'engine.inspector.action.paramType.textarea': '多行文本', + 'engine.inspector.action.paramType.number': '数字', + 'engine.inspector.action.paramType.boolean': '复选框', + 'engine.inspector.action.paramType.select': '下拉选择', + 'engine.inspector.action.paramType.date': '日期', + 'engine.inspector.action.paramType.datetime': '日期时间', + 'engine.inspector.action.paramType.lookup': '查找关系', + 'engine.inspector.action.location.record_header': '记录头部', + 'engine.inspector.action.location.record_more': '记录 · 更多菜单', + 'engine.inspector.action.location.record_section': '记录 · 分区', + 'engine.inspector.action.location.record_related': '记录 · 关联列表', + 'engine.inspector.action.location.list_toolbar': '列表工具栏', + 'engine.inspector.action.location.list_item': '列表 · 行', + 'engine.inspector.action.target.url.label': 'URL 地址', + 'engine.inspector.action.target.url.placeholder': 'https://… 或 /path?x=${param.x}', + 'engine.inspector.action.target.url.hint': '支持 ${param.x} 与 ${ctx.x} 插值。', + 'engine.inspector.action.target.flow.label': '流程名称', + 'engine.inspector.action.target.flow.placeholder': 'snake_case 流程名', + 'engine.inspector.action.target.flow.hint': '点击时调用的流程。', + 'engine.inspector.action.target.modal.label': '弹窗 / 页面名称', + 'engine.inspector.action.target.modal.placeholder': 'snake_case 页面名', + 'engine.inspector.action.target.modal.hint': '要打开的弹窗或页面。', + 'engine.inspector.action.target.form.label': '表单视图名称', + 'engine.inspector.action.target.form.hint': '打开 /console/forms/。', + 'engine.inspector.action.target.api.label': 'API 端点', + 'engine.inspector.action.target.api.hint': '使用下方请求体调用的端点。', + 'engine.inspector.action.scriptOp.run': '运行脚本 —— 沙箱 JS / 表达式主体', + 'engine.inspector.action.scriptOp.update': '更新此记录的字段 —— 无需代码', + 'engine.inspector.action.objectPlaceholder': 'snake_case 对象名', + 'engine.inspector.action.objectNone': '— 无(全局)—', + 'engine.inspector.action.options': '选项', + 'engine.inspector.action.optionsEmpty': '还没有选项 —— 没有选项的下拉选择会在对话框中打开一个空的选择器。', + 'engine.inspector.action.optionLabel': '显示名', + 'engine.inspector.action.optionValue': '值', + 'engine.inspector.action.removeOption': '移除选项 {index}', + 'engine.inspector.action.addOption': '添加选项', + 'engine.inspector.action.basics': '基本信息', + 'engine.inspector.action.label': '显示名', + 'engine.inspector.action.labelPlaceholder': '向用户显示的按钮文字', + 'engine.inspector.action.name': '名称', + 'engine.inspector.action.namePlaceholder': 'snake_case 标识符', + 'engine.inspector.action.object': '对象', + 'engine.inspector.action.objectBoundHint': '绑定动作 —— 按下方的放置位置出现在此对象的视图中。', + 'engine.inspector.action.objectGlobalHint': '留空 = 全局动作 —— 须被页面的快捷动作、全局导航、流程或 AI 引用后才会出现。', + 'engine.inspector.action.icon': '图标', + 'engine.inspector.action.variant': '样式', + 'engine.inspector.action.behavior': '行为', + 'engine.inspector.action.behaviorHint': '动作被触发时会发生什么。', + 'engine.inspector.action.type': '类型', + 'engine.inspector.action.pinnedScript': '已固定为 {script}:字段写入在平台动作路由上执行,而这正是该类型自己的路由。与它并存时,其他任何类型都会被拒绝。', + 'engine.inspector.action.whatItDoes': '作用', + 'engine.inspector.action.fieldValues': '字段值', + 'engine.inspector.action.fieldValuesEmpty': '还没有字段值 —— 添加一个,或在下方把该值作为输入项收集。', + 'engine.inspector.action.fieldValueField': '字段', + 'engine.inspector.action.fieldValueValue': '值', + 'engine.inspector.action.removeFieldValue': '移除字段值', + 'engine.inspector.action.addFieldValue': '添加字段值', + 'engine.inspector.action.updateHint': '以调用者身份写入当前记录 —— 对象权限、钩子与校验会像用户编辑时一样触发。下方收集的同名输入项会覆盖此处的固定值。', + 'engine.inspector.action.scriptLanguage': '脚本语言', + 'engine.inspector.action.scriptBody': '脚本主体', + 'engine.inspector.action.scriptBodyHint': '在沙箱中以 {signature} 的形式运行。', + 'engine.inspector.action.method': '请求方法', + 'engine.inspector.action.inputs': '输入项', + 'engine.inspector.action.inputsHint': '在动作运行前通过对话框向用户收集。', + 'engine.inspector.action.inputsEmpty': '没有输入项 —— 点击后立即运行动作。', + 'engine.inspector.action.removeInput': '移除输入项', + 'engine.inspector.action.bindToField': '绑定到字段', + 'engine.inspector.action.paramName': '名称', + 'engine.inspector.action.paramNamePlaceholder': '请求体键名', + 'engine.inspector.action.paramLabel': '显示名', + 'engine.inspector.action.paramTypeLabel': '类型', + 'engine.inspector.action.paramPlaceholder': '占位符', + 'engine.inspector.action.paramRequired': '必填', + 'engine.inspector.action.paramPrefill': '从行预填', + 'engine.inspector.action.addInput': '添加输入项', + 'engine.inspector.action.placement': '放置位置', + 'engine.inspector.action.placementHint': '此动作在界面中出现的位置。', + 'engine.inspector.action.noPlacement': '未选择放置位置 —— 此动作不会出现在任何记录或列表界面上。请在上方勾选一个放置位置,或通过视图的批量动作放置它。', + 'engine.inspector.action.capabilityGate': '受权限 {capabilities} 控制 —— 对于不具备{whom}的任何人,上方勾选的每个放置位置都会隐藏此动作。这不是置灰,也不是错误:按钮只是不出现,而且无论如何服务器都会以 403 拒绝调用。', + 'engine.inspector.action.capabilityGateAll': '全部这些权限', + 'engine.inspector.action.capabilityGateOne': '该权限', + 'engine.inspector.action.capabilityGateSelf': '当前会话不具备 {capabilities},因此该按钮对你同样隐藏 —— 通过权限集授予后,即可在应用中看到它。', + 'engine.inspector.action.component': '组件', + 'engine.inspector.action.feedback': '反馈', + 'engine.inspector.action.feedbackHint': '确认提示与运行后的消息。', + 'engine.inspector.action.confirmText': '确认提示', + 'engine.inspector.action.confirmTextPlaceholder': '运行前询问(留空则跳过)', + 'engine.inspector.action.successMessage': '成功消息', + 'engine.inspector.action.errorMessage': '错误消息', + 'engine.inspector.action.mode': '模式', + 'engine.inspector.action.refreshAfter': '完成后刷新视图', + 'engine.inspector.action.undoable': '提供撤销', + 'engine.inspector.action.conditions': '条件', + 'engine.inspector.action.conditionsHint': '基于 record / user / ctx 的无代码判断条件(编译为 CEL)。', + 'engine.inspector.action.visibleWhen': '可见条件', + 'engine.inspector.action.disabledWhen': '禁用条件', + 'engine.inspector.action.ai': 'AI 开放', + 'engine.inspector.action.aiHint': '可选:将此动作作为可调用工具开放给 AI 智能体。', + 'engine.inspector.action.aiExposed': '开放给 AI 智能体', + 'engine.inspector.action.aiDescription': '工具描述(必填,≥40 个字符)', + 'engine.inspector.action.aiDescriptionPlaceholder': '智能体应在何时、出于何种目的调用此动作…', + 'engine.inspector.action.aiDescriptionTooShort': '开放期间必须提供至少 40 个字符的描述。', + 'engine.inspector.action.moreFieldsHint': '高级 / 不常用的属性。', // Dashboard default (home) inspector 'engine.inspector.dashboard.kind': '仪表盘', 'engine.inspector.dashboard.close': '关闭仪表盘', @@ -3190,6 +3510,11 @@ const ENGINE_STRINGS_ZH: Record = { // Reorder buttons 'engine.inspector.reorder.up': '上移', 'engine.inspector.reorder.down': '下移', + // InspectorComboField defaults (objectui#10586). + 'engine.inspector.combo.search': '搜索或输入…', + 'engine.inspector.combo.noMatch': '无匹配项 —— 继续输入即可使用自定义值。', + 'engine.inspector.combo.loading': '加载中…', + 'engine.inspector.combo.useCustom': '使用 {value}', 'engine.edit.overlay': '覆盖', 'engine.edit.readOnlyBanner': '当前为只读模式。点击 {edit} 进行修改。', 'engine.edit.readOnlyTypeBanner': diff --git a/packages/app-shell/src/views/metadata-admin/inspectors/ActionDefaultInspector.tsx b/packages/app-shell/src/views/metadata-admin/inspectors/ActionDefaultInspector.tsx index 8cfd645d69..2d7e261e45 100644 --- a/packages/app-shell/src/views/metadata-admin/inspectors/ActionDefaultInspector.tsx +++ b/packages/app-shell/src/views/metadata-admin/inspectors/ActionDefaultInspector.tsx @@ -43,7 +43,7 @@ import { import { usePermissions } from '@object-ui/permissions'; import type { MetadataDefaultInspectorProps } from '../default-inspector-registry.js'; import { SchemaForm } from '../SchemaForm.js'; -import { t } from '../i18n.js'; +import { t, tFormat, type SupportedLocale } from '../i18n.js'; import { InspectorShell, InspectorTextField, @@ -62,37 +62,60 @@ import { IconPickerWidget } from '../widgets.js'; /* ─────────────── constants ─────────────── */ -const ACTION_TYPES = [ - { value: 'script', label: 'Script — run an expression / sandboxed JS' }, - { value: 'api', label: 'API — call an endpoint' }, - { value: 'flow', label: 'Flow — invoke a flow' }, - { value: 'modal', label: 'Modal — open a modal/page' }, - { value: 'form', label: 'Form — open a FormView' }, - { value: 'url', label: 'URL — navigate to a link' }, +/** + * 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, a capability list) — 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, + ); +} + +const ACTION_TYPES: KeyedOption[] = [ + { value: 'script', labelKey: 'engine.inspector.action.type.script' }, + { value: 'api', labelKey: 'engine.inspector.action.type.api' }, + { value: 'flow', labelKey: 'engine.inspector.action.type.flow' }, + { value: 'modal', labelKey: 'engine.inspector.action.type.modal' }, + { value: 'form', labelKey: 'engine.inspector.action.type.form' }, + { value: 'url', labelKey: 'engine.inspector.action.type.url' }, ]; -const VARIANT_OPTS = [ - { value: 'primary', label: 'Primary' }, - { value: 'secondary', label: 'Secondary' }, - { value: 'danger', label: 'Danger' }, - { value: 'ghost', label: 'Ghost' }, - { value: 'link', label: 'Link' }, +const VARIANT_OPTS: KeyedOption[] = [ + { value: 'primary', labelKey: 'engine.inspector.action.variant.primary' }, + { value: 'secondary', labelKey: 'engine.inspector.action.variant.secondary' }, + { value: 'danger', labelKey: 'engine.inspector.action.variant.danger' }, + { value: 'ghost', labelKey: 'engine.inspector.action.variant.ghost' }, + { value: 'link', labelKey: 'engine.inspector.action.variant.link' }, ]; -const COMPONENT_OPTS = [ - { value: 'action:button', label: 'Button' }, - { value: 'action:icon', label: 'Icon only' }, - { value: 'action:menu', label: 'Menu item' }, - { value: 'action:group', label: 'Button group' }, +const COMPONENT_OPTS: KeyedOption[] = [ + { value: 'action:button', labelKey: 'engine.inspector.action.component.button' }, + { value: 'action:icon', labelKey: 'engine.inspector.action.component.icon' }, + { value: 'action:menu', labelKey: 'engine.inspector.action.component.menu' }, + { value: 'action:group', labelKey: 'engine.inspector.action.component.group' }, ]; -const MODE_OPTS = [ - { value: 'create', label: 'Create' }, - { value: 'edit', label: 'Edit' }, - { value: 'delete', label: 'Delete' }, - { value: 'custom', label: 'Custom' }, +const MODE_OPTS: KeyedOption[] = [ + { value: 'create', labelKey: 'engine.inspector.action.mode.create' }, + { value: 'edit', labelKey: 'engine.inspector.action.mode.edit' }, + { value: 'delete', labelKey: 'engine.inspector.action.mode.delete' }, + { value: 'custom', labelKey: 'engine.inspector.action.mode.custom' }, ]; +// HTTP methods are protocol tokens, the same in every locale: plain labels. const METHOD_OPTS = [ { value: 'POST', label: 'POST' }, { value: 'PATCH', label: 'PATCH' }, @@ -100,9 +123,9 @@ const METHOD_OPTS = [ { value: 'DELETE', label: 'DELETE' }, ]; -const BODY_LANG_OPTS = [ - { value: 'expression', label: 'Expression (L1)' }, - { value: 'js', label: 'Sandboxed JS (L2)' }, +const BODY_LANG_OPTS: KeyedOption[] = [ + { value: 'expression', labelKey: 'engine.inspector.action.bodyLang.expression' }, + { value: 'js', labelKey: 'engine.inspector.action.bodyLang.js' }, ]; /* @@ -122,15 +145,15 @@ const BODY_LANG_OPTS = [ */ // eslint-disable-next-line react-refresh/only-export-components -- see above export const PARAM_TYPE_OPTS = [ - { value: 'text', label: 'Text' }, - { value: 'textarea', label: 'Long text' }, - { value: 'number', label: 'Number' }, - { value: 'boolean', label: 'Checkbox' }, - { value: 'select', label: 'Select' }, - { value: 'date', label: 'Date' }, - { value: 'datetime', label: 'Date/time' }, - { value: 'lookup', label: 'Lookup' }, -] satisfies { value: ResolvableParamFieldType; label: string }[]; + { value: 'text', labelKey: 'engine.inspector.action.paramType.text' }, + { value: 'textarea', labelKey: 'engine.inspector.action.paramType.textarea' }, + { value: 'number', labelKey: 'engine.inspector.action.paramType.number' }, + { value: 'boolean', labelKey: 'engine.inspector.action.paramType.boolean' }, + { value: 'select', labelKey: 'engine.inspector.action.paramType.select' }, + { value: 'date', labelKey: 'engine.inspector.action.paramType.date' }, + { value: 'datetime', labelKey: 'engine.inspector.action.paramType.datetime' }, + { value: 'lookup', labelKey: 'engine.inspector.action.paramType.lookup' }, +] satisfies { value: ResolvableParamFieldType; labelKey: string }[]; /** * Every `type` spelling an authored param may carry, as a runtime set — the @@ -156,7 +179,8 @@ function asParamFieldType(value: string): ResolvableParamFieldType | undefined { } /** - * Friendly labels for the spec's action locations. + * Friendly labels for the spec's action locations, as catalogue keys read in + * the designer locale (objectui#10586). * * The vocabulary belongs to `ActionLocation` (spec `ACTION_LOCATIONS`). This * used to restate all seven values under a "mirrors spec ACTION_LOCATIONS" @@ -176,25 +200,30 @@ function asParamFieldType(value: string): ResolvableParamFieldType | undefined { * (record → list), deliberately not the spec's declaration order. */ const LOCATION_LABELS: Record = { - record_header: 'Record header', - record_more: 'Record · more menu', - record_section: 'Record · section', - record_related: 'Record · related list', - list_toolbar: 'List toolbar', - list_item: 'List · row', + record_header: 'engine.inspector.action.location.record_header', + record_more: 'engine.inspector.action.location.record_more', + record_section: 'engine.inspector.action.location.record_section', + record_related: 'engine.inspector.action.location.record_related', + list_toolbar: 'engine.inspector.action.location.list_toolbar', + list_item: 'engine.inspector.action.location.list_item', }; -const LOCATIONS: Array<{ value: ActionLocation; label: string }> = ( +const LOCATIONS: Array<{ value: ActionLocation; labelKey: string }> = ( Object.keys(LOCATION_LABELS) as ActionLocation[] -).map((value) => ({ value, label: LOCATION_LABELS[value] })); - -/** Per-type binding hints for the single `target` field. */ -const TARGET_FIELD: Record = { - url: { label: 'URL', placeholder: 'https://… or /path?x=${param.x}', hint: 'Supports ${param.x} and ${ctx.x} interpolation.' }, - flow: { label: 'Flow name', placeholder: 'snake_case flow', hint: 'The flow to invoke when clicked.' }, - modal: { label: 'Modal / page name', placeholder: 'snake_case page', hint: 'The modal or page to open.' }, - form: { label: 'Form view name', placeholder: 'object.viewKey', hint: 'Opens /console/forms/.' }, - api: { label: 'API endpoint', placeholder: '/api/v1/…', hint: 'Endpoint called with the request body below.' }, +).map((value) => ({ value, labelKey: LOCATION_LABELS[value] })); + +/** + * Per-type binding hints for the single `target` field, as catalogue keys. A + * placeholder is a key when it carries words, and a literal when it is only a + * sample value (a dotted view key, an API path), which reads the same in every + * locale (objectui#10586). + */ +const TARGET_FIELD: Record = { + url: { labelKey: 'engine.inspector.action.target.url.label', placeholderKey: 'engine.inspector.action.target.url.placeholder', hintKey: 'engine.inspector.action.target.url.hint' }, + flow: { labelKey: 'engine.inspector.action.target.flow.label', placeholderKey: 'engine.inspector.action.target.flow.placeholder', hintKey: 'engine.inspector.action.target.flow.hint' }, + modal: { labelKey: 'engine.inspector.action.target.modal.label', placeholderKey: 'engine.inspector.action.target.modal.placeholder', hintKey: 'engine.inspector.action.target.modal.hint' }, + form: { labelKey: 'engine.inspector.action.target.form.label', placeholder: 'object.viewKey', hintKey: 'engine.inspector.action.target.form.hint' }, + api: { labelKey: 'engine.inspector.action.target.api.label', placeholder: '/api/v1/…', hintKey: 'engine.inspector.action.target.api.hint' }, }; /** Action types whose `target` names another metadata record — render a picker @@ -257,9 +286,9 @@ const UPDATE_OPERATION_REFUSES = [ const UPDATE_OPERATION_REFUSED_LOCATION = 'list_toolbar'; /** What a script action DOES — `operation` is authored beside `type`, not in it. */ -const SCRIPT_OPERATIONS = [ - { value: '', label: 'Run a script — sandboxed JS / expression body' }, - { value: 'update', label: 'Update fields on this record — no code' }, +const SCRIPT_OPERATIONS: KeyedOption[] = [ + { value: '', labelKey: 'engine.inspector.action.scriptOp.run' }, + { value: 'update', labelKey: 'engine.inspector.action.scriptOp.update' }, ]; /** Keys this inspector edits with its own controls — hidden from the fallback. */ @@ -318,16 +347,16 @@ function localize(v: unknown): string { } /** Object dropdown (falls back to free text when no objects are resolvable). */ -function ObjectPicker({ label, value, onCommit, disabled, hint }: { - label: string; value: string | undefined; onCommit: (v: string) => void; disabled?: boolean; hint?: string; +function ObjectPicker({ label, value, onCommit, disabled, hint, locale }: { + label: string; value: string | undefined; onCommit: (v: string) => void; disabled?: boolean; hint?: string; locale: SupportedLocale; }) { const { options } = useObjectOptions(); return (
{options.length === 0 ? ( - + ) : ( - + )} {hint &&
{hint}
}
@@ -383,31 +412,33 @@ type ActionParamOption = NonNullable[number]; * param's own "Label" control above has always made. Locale maps stay * authorable through the JSON source tab. */ -function ParamOptionsEditor({ options, onCommit, disabled }: { +function ParamOptionsEditor({ options, onCommit, disabled, locale }: { options: ActionParamOption[] | undefined; onCommit: (next: ActionParamOption[]) => void; disabled?: boolean; + /** The designer locale this editor's own words read in (objectui#10586). */ + locale: SupportedLocale; }) { const opts = Array.isArray(options) ? options : []; return ( -
-
Options
+
+
{t('engine.inspector.action.options', locale)}
{opts.length === 0 ? (

- No choices yet — a Select with no options opens an empty picker in the dialog. + {t('engine.inspector.action.optionsEmpty', locale)}

) : ( opts.map((o, j) => (
onCommit(spliceArray(opts, j, { ...o, label: v }))} disabled={disabled} /> onCommit(spliceArray(opts, j, { ...o, value: v }))} disabled={disabled} @@ -420,7 +451,7 @@ function ParamOptionsEditor({ options, onCommit, disabled }: { size="icon" className="mt-[22px] h-6 w-6" disabled={disabled} - aria-label={`Remove option ${j + 1}`} + aria-label={tFormat('engine.inspector.action.removeOption', locale, { index: j + 1 })} onClick={() => onCommit(spliceArray(opts, j, null))} > @@ -430,7 +461,7 @@ function ParamOptionsEditor({ options, onCommit, disabled }: { )} {!disabled && ( )}
@@ -601,7 +632,14 @@ export function ActionDefaultInspector({ onPatch({ locations: next }); }; - const targetCfg = TARGET_FIELD[type]; + // The target binding's words, resolved in the designer locale. A sample + // value placeholder (no words) passes through as it is. + const targetKeys = TARGET_FIELD[type]; + const targetCfg = targetKeys && { + label: tr(targetKeys.labelKey), + placeholder: targetKeys.placeholderKey ? tr(targetKeys.placeholderKey) : (targetKeys.placeholder ?? ''), + hint: tr(targetKeys.hintKey), + }; const fallbackSchema = serverSchema as Record | undefined; @@ -614,57 +652,57 @@ export function ActionDefaultInspector({ hideClose > {/* 1 ─ Basics */} - - onPatch({ label: v })} placeholder="Button text shown to users" disabled={readOnly} /> - onPatch({ name: v })} placeholder="snake_case identifier" disabled={readOnly} mono /> + + onPatch({ label: v })} placeholder={tr('engine.inspector.action.labelPlaceholder')} disabled={readOnly} /> + onPatch({ name: v })} placeholder={tr('engine.inspector.action.namePlaceholder')} disabled={readOnly} mono /> onPatch({ objectName: v || undefined })} disabled={readOnly} - hint={objectName ? 'Bound action — surfaces in this object’s views per the placement below.' : 'Empty = global action — must be referenced by a page’s quick actions, global nav, a flow, or AI to appear.'} + hint={objectName ? tr('engine.inspector.action.objectBoundHint') : tr('engine.inspector.action.objectGlobalHint')} + locale={locale} />
- + onPatch({ icon: (v as string) || undefined })} readOnly={readOnly} />
- onPatch({ variant: v })} disabled={readOnly} /> + onPatch({ variant: v })} disabled={readOnly} />
{/* 2 ─ Behavior (type-first) */}
- - onPatch({ type: v })} disabled={readOnly || isUpdateOperation} /> + + onPatch({ type: v })} disabled={readOnly || isUpdateOperation} /> {isUpdateOperation && (
- Pinned to script: the field write is performed on the platform action - route, which is this type’s own route. Any other type is refused beside it. + {withSlots(tr('engine.inspector.action.pinnedScript'), { script: script })}
)} {type === 'script' ? ( <> {isUpdateOperation ? (
- + {patchRows.length === 0 ? (

- No field values yet — add one, or collect the value as an input below. + {tr('engine.inspector.action.fieldValuesEmpty')}

) : ( patchRows.map(([key, value], i) => (
commitPatch(patchRows.map((row, j) => (j === i ? [v, row[1]] : row)))} placeholder="status" @@ -674,7 +712,7 @@ export function ActionDefaultInspector({
commitPatch(patchRows.map((row, j) => (j === i ? [row[0], v] : row)))} placeholder="done" @@ -688,7 +726,7 @@ export function ActionDefaultInspector({ size="icon" className="h-8 w-8 shrink-0" disabled={readOnly} - aria-label="Remove field value" + aria-label={tr('engine.inspector.action.removeFieldValue')} onClick={() => commitPatch(patchRows.filter((_, j) => j !== i))} > @@ -704,25 +742,23 @@ export function ActionDefaultInspector({ disabled={readOnly} onClick={() => commitPatch([...patchRows, ['', '']])} > - Add field value + {tr('engine.inspector.action.addFieldValue')}
- Written to the current record as the caller — object permissions, hooks and - validations fire as for a user edit. An input collected below with the same name - overrides the fixed value here. + {tr('engine.inspector.action.updateHint')}
) : ( <> patchBody({ language: v })} disabled={readOnly} />
- +