diff --git a/.changeset/11439-bound-action-copy.md b/.changeset/11439-bound-action-copy.md new file mode 100644 index 0000000000..9a088fe05f --- /dev/null +++ b/.changeset/11439-bound-action-copy.md @@ -0,0 +1,12 @@ +--- +'@object-ui/i18n': patch +'@object-ui/react': patch +--- + +An action's translated copy is now read from ONE bundle node, chosen by the object the action belongs to, the same way `@objectstack/spec` 17.7.0 reads it on the server (objectui#11439). That object is the action's declared `objectName`, else the object whose `actions` embed it. + +- An action that belongs to an object reads `objects.OBJECT._actions.ACTION.*` only. Copy for it filed under `globalActions.ACTION` no longer applies: its label, confirm text, success message, outcome messages, description, parameters and result dialog show the authored text instead, as they already did everywhere the server translates. Move that copy to `objects.OBJECT._actions.ACTION`, which is where `os validate` asks for it. +- An action with no object (no `objectName`, not embedded in an object) still reads `globalActions.ACTION.*`. +- `useActionTextLocalizer` keys on the action's declared `objectName` before the object its caller passes, so an action declared on one object reads that object's copy wherever it is drawn. + +`useObjectLabel()` and `useActionTextLocalizer()` keep their signatures; no input, export or translation key is added. diff --git a/apps/console/src/__tests__/preview-samples-translation-data-11439.test.ts b/apps/console/src/__tests__/preview-samples-translation-data-11439.test.ts new file mode 100644 index 0000000000..7a095cc1aa --- /dev/null +++ b/apps/console/src/__tests__/preview-samples-translation-data-11439.test.ts @@ -0,0 +1,50 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * The `translation` preview sample's `data` is a valid `TranslationData` + * bundle, and it files the bound `action` sample's copy where the spec reads + * it (objectui#11439). + * + * `preview-samples-spec-valid.test.ts` keeps the `translation` sample in + * `KNOWN_STALE` for a different question (the sample is the metadata-RECORD + * form, not the `translations` collection's shape), and a quarantined sample + * gets no generic signal there — so the bundle INSIDE the record is pinned + * here. Before objectui#11439 it carried two shapes the spec refuses: a bare + * string as a field's entry (`fields.amount`; the field node is `{ label }`) + * and the bound `close_order`'s copy as a bare string under `globalActions`, + * which is only for actions bound to no object. + */ + +import { describe, it, expect } from 'vitest'; +import { TranslationDataSchema } from '@objectstack/spec/system'; +import { SAMPLES } from '../preview-samples'; + +type Bundle = { + globalActions?: Record; + objects?: Record }>; +}; + +describe('translation preview sample (objectui#11439)', () => { + it('its `data` parses as TranslationData', () => { + const parsed = TranslationDataSchema.safeParse(SAMPLES.translation.data); + const issues = parsed.success + ? [] + : parsed.error.issues.map((issue) => `${issue.path.join('.')}: ${issue.code}`); + expect(issues).toEqual([]); + }); + + it('files the bound `action` sample\'s copy under its object, never under globalActions', () => { + const action = SAMPLES.action as { name: string; objectName?: string }; + const data = SAMPLES.translation.data as Bundle; + // Control: the action sample IS bound, so this question applies to it. + expect(typeof action.objectName).toBe('string'); + expect(data.globalActions?.[action.name]).toBeUndefined(); + expect(typeof data.objects?.[action.objectName as string]?._actions?.[action.name]?.label).toBe( + 'string', + ); + }); +}); diff --git a/apps/console/src/preview-samples.ts b/apps/console/src/preview-samples.ts index 6b4d4b0abe..ee93adfcf9 100644 --- a/apps/console/src/preview-samples.ts +++ b/apps/console/src/preview-samples.ts @@ -484,12 +484,21 @@ export const SAMPLES: Record> = { description: 'Simplified Chinese bundle for the CRM app.', data: { objects: { - sales_order: { label: '销售订单', fields: { amount: '金额' } }, + sales_order: { + label: '销售订单', + // A field's entry is a node (`{ label }`), never a bare string. + fields: { amount: { label: '金额' } }, + // `close_order` is bound to `sales_order` (the `action` sample's + // `objectName`), so its copy lives under the object's `_actions`. + // `globalActions` is only for actions bound to no object: a bound + // action's copy filed there is never read for it, by the server or + // the console, and `os validate` refuses it (objectui#11439). + _actions: { close_order: { label: '关闭订单' } }, + }, account: { label: '客户' }, }, apps: { crm: { label: 'CRM' } }, messages: { welcome: '欢迎', saved: '已保存' }, - globalActions: { close_order: '关闭订单' }, }, }, }; diff --git a/packages/i18n/src/__tests__/useObjectLabel-globalAction.test.tsx b/packages/i18n/src/__tests__/useObjectLabel-globalAction.test.tsx index b87790c8d6..cf99748f1e 100644 --- a/packages/i18n/src/__tests__/useObjectLabel-globalAction.test.tsx +++ b/packages/i18n/src/__tests__/useObjectLabel-globalAction.test.tsx @@ -11,13 +11,24 @@ const wrapper = ({ children }: { children: React.ReactNode }) => ); /** - * Regression for objectui#3372 — a globalAction surfaced on a record-detail - * action bar (where the caller passes `objectDef.name` for every action) must - * still pick up its `globalActions..*` overlay when no object-scoped - * translation exists. Mirrors the canonical `@objectstack/spec` resolver - * (`lookupActionField`): object-scoped wins, global is the fallback. + * Which bundle node an action's copy is read from — objectui#3372, as amended + * by objectui#11439. + * + * Mirrors the canonical `@objectstack/spec` resolver (`actionTranslationNode` + * behind `lookupActionField`): an action with a KEY object (its declared + * `objectName`, else the host that embeds it) reads that object's + * `_actions..*` node and nothing else; an action with no key object + * reads `globalActions..*`. There is no object-then-global chain. + * + * objectui#3372 originally pinned such a chain here: `globalActions` copy was + * read for a bound action whenever its object-scoped key missed. Triage's + * amended ruling on objectui#11439 (comment 6030552631, amending 5942994297) + * retired it, because `@objectstack/spec` 17.7.0 reads `globalActions` only for + * an action not bound to an object, so the two resolvers disagreed on the same + * bundle. The object-less case objectui#3372 also covered is kept unchanged + * ("still resolves a globalAction when objectName is omitted"). */ -describe('useObjectLabel() globalAction fallback (objectui#3372)', () => { +describe('useObjectLabel() action copy is keyed on the action\'s own object (objectui#3372, objectui#11439)', () => { const setup = () => { const { result } = renderHook( () => ({ labels: useObjectLabel(), i18n: useObjectTranslation().i18n }), @@ -36,13 +47,27 @@ describe('useObjectLabel() globalAction fallback (objectui#3372)', () => { escalate_case: { label: '升级工单' }, // A name that exists in BOTH scopes — object must win. shared_action: { label: '对象级' }, + // objectui#3372's action, bound to `crm_case` — its copy filed + // where the spec reads it (objectui#11439). + log_call: { label: '记录工单通话', successMessage: '工单通话已记录。' }, }, }, }, - // Global action — translated only under the global scope. + // Global copy — what an object-less action reads. globalActions: { log_call: { label: '记录通话', successMessage: '通话记录成功!' }, shared_action: { label: '全局级' }, + // Filed ONLY under the global scope, so a bound action of this + // name has no object-scoped copy at all (objectui#11439). + merge_cases: { + label: '合并工单', + confirmText: '确认合并?', + successMessage: '已合并。', + description: '全局描述', + outcomeMessages: { merged: '全局结果' }, + params: { target: { label: '全局参数', options: { a: '全局选项' } } }, + resultDialog: { title: '全局标题' }, + }, }, }, }, @@ -59,12 +84,14 @@ describe('useObjectLabel() globalAction fallback (objectui#3372)', () => { ); }); - it('resolves a globalAction overlay even when an objectName is passed', () => { + it('resolves a bound action from its own object scope, not from globalActions', () => { const result = setup(); - // The bug: with objectName set, `objects.crm_case._actions.log_call.label` - // misses, so before the fix this leaked the English literal "Log a Call". + // Re-pointed per triage's amended ruling on objectui#11439 (comment + // 6030552631): this case used to expect the `globalActions.log_call` copy + // for a call keyed on `crm_case`. A bound action reads the object-scoped + // copy, as `@objectstack/spec` 17.7.0 does. expect(result.current.labels.actionLabel('crm_case', 'log_call', 'Log a Call')).toBe( - '记录通话', + '记录工单通话', ); }); @@ -89,10 +116,51 @@ describe('useObjectLabel() globalAction fallback (objectui#3372)', () => { ); }); - it('applies the global fallback to sibling resolvers (successMessage)', () => { + it('keys sibling resolvers on the same object scope (successMessage)', () => { const result = setup(); + // Re-pointed per triage's amended ruling on objectui#11439 (comment + // 6030552631): this case used to expect the `globalActions.log_call` + // successMessage for a call keyed on `crm_case`. expect( result.current.labels.actionSuccess('crm_case', 'log_call', 'Call logged.'), - ).toBe('通话记录成功!'); + ).toBe('工单通话已记录。'); + }); + + it('never reads globalActions copy for a bound action (objectui#11439)', () => { + const result = setup(); + const l = result.current.labels; + // `merge_cases` is translated ONLY under `globalActions`. Keyed on an + // object, every action resolver shows the authored text instead — the + // answer `@objectstack/spec` 17.7.0 gives for the same bundle. + expect(l.actionLabel('crm_case', 'merge_cases', 'Merge Cases')).toBe('Merge Cases'); + expect(l.actionConfirm('crm_case', 'merge_cases', 'Merge?')).toBe('Merge?'); + expect(l.actionSuccess('crm_case', 'merge_cases', 'Merged.')).toBe('Merged.'); + expect(l.actionDescription('crm_case', 'merge_cases', 'Authored description')).toBe( + 'Authored description', + ); + expect(l.actionOutcome('crm_case', 'merge_cases', 'merged', 'Authored outcome')).toBe( + 'Authored outcome', + ); + expect(l.actionParamText('crm_case', 'merge_cases', 'target', 'label', 'Target')).toBe('Target'); + expect(l.actionParamOptionLabel('crm_case', 'merge_cases', 'target', 'a', 'Option A')).toBe( + 'Option A', + ); + expect(l.actionResultDialog('crm_case', 'merge_cases', { title: 'Authored title' })?.title).toBe( + 'Authored title', + ); + // With no key object the same entry is read in full (control): the + // bundle is loaded and every resolver above reaches the global node. + expect(l.actionLabel(undefined, 'merge_cases', 'Merge Cases')).toBe('合并工单'); + expect(l.actionConfirm(undefined, 'merge_cases', 'Merge?')).toBe('确认合并?'); + expect(l.actionSuccess(undefined, 'merge_cases', 'Merged.')).toBe('已合并。'); + expect(l.actionDescription(undefined, 'merge_cases', 'Authored description')).toBe('全局描述'); + expect(l.actionOutcome(undefined, 'merge_cases', 'merged', 'Authored outcome')).toBe('全局结果'); + expect(l.actionParamText(undefined, 'merge_cases', 'target', 'label', 'Target')).toBe('全局参数'); + expect(l.actionParamOptionLabel(undefined, 'merge_cases', 'target', 'a', 'Option A')).toBe( + '全局选项', + ); + expect(l.actionResultDialog(undefined, 'merge_cases', { title: 'Authored title' })?.title).toBe( + '全局标题', + ); }); }); diff --git a/packages/i18n/src/useObjectLabel.ts b/packages/i18n/src/useObjectLabel.ts index 2fa5f85e1c..159091acc0 100644 --- a/packages/i18n/src/useObjectLabel.ts +++ b/packages/i18n/src/useObjectLabel.ts @@ -123,9 +123,10 @@ export function useObjectLabel() { * include crm.objects.* or crm.apps.*. * * `globalActions` is included so an app bundle whose only translated scope - * is global actions (no object/field entries) is still discovered — its - * `globalActions..*` overlays would otherwise be unreachable - * (objectui#3372). + * is global actions (no object/field entries) is still discovered — the + * `globalActions..*` copy of its object-less actions would otherwise + * be unreachable (objectui#3372; object-less only since objectui#11439, see + * `actionSuffixes`). */ const getAppNamespaces = (): string[] => { if (!hasUsableI18nInstance(i18n)) return []; @@ -185,32 +186,40 @@ export function useObjectLabel() { /** * Build suffix candidates for an action-scoped key, mirroring the canonical - * `@objectstack/spec` resolver (`lookupActionField` in `system/i18n-resolver`). + * `@objectstack/spec` resolver (`actionTranslationNode` behind + * `lookupActionField`, in `system/i18n-resolver`): ONE node per action, chosen + * by its key object. * - * When the action is object-scoped, the object key - * (`objects.._actions..`) wins, but the global key - * (`globalActions..`) is appended as a fallback so a - * **globalAction surfaced on an object's action bar** still picks up its - * `globalActions..*` overlay when no object-scoped translation - * exists (objectui#3372). Without this fallback, a globalAction rendered on - * a record-detail action bar — where the caller passes `objectDef.name` for - * every action — misses `objects.._actions..label` and leaks the - * English metadata literal. + * `objectName` is the action's KEY object — the object the action belongs + * to: its declared `objectName`, else the host object that embeds it, which + * is what the spec's `translateObject` stamps on an embedded action. Callers + * pass that object (`useActionTextLocalizer` derives it from the action). * - * Object-less actions (`objectName` omitted) resolve straight to the global - * namespace, as before. Object precedence is preserved: the global key is - * only consulted after every object-scoped candidate misses. + * - A key object reads `objects.._actions..` and nothing + * else. + * - No key object reads `globalActions..` and nothing else. + * + * There is deliberately NO object-then-global chain. objectui#3372 added one + * so `globalActions` copy reached actions drawn on an object's bar; the + * spec's `TranslationDataSchema.globalActions` is for actions "not bound to a + * specific object via `objectName`", `@objectstack/spec` 17.7.0 never reads it + * for a bound action, and objectui#11439 (triage's amended ruling) retired the + * chain so this client and the server resolve one bundle entry the same way. + * A bound action's copy filed under `globalActions` now misses on every + * surface alike and the authored text shows — the misfiling is what + * `os validate` already refuses, and it is fixed in the bundle, not here. + * + * The two object-name candidates (`objects.` then `objects.`) + * are a separate axis and stay — see `stripNamespace`. */ const actionSuffixes = ( objectName: string | undefined, actionName: string, tail: string, - ): string[] => { - const globalSuffix = `globalActions.${actionName}.${tail}`; - return objectName - ? [...objectSuffixes(objectName, `_actions.${actionName}.${tail}`), globalSuffix] - : [globalSuffix]; - }; + ): string[] => + objectName + ? objectSuffixes(objectName, `_actions.${actionName}.${tail}`) + : [`globalActions.${actionName}.${tail}`]; const fieldSuffixes = (objectName: string, fieldName: string): string[] => { const base = stripNamespace(objectName); @@ -565,19 +574,20 @@ export function useObjectLabel() { /** * Resolve translated action label. - * Convention: `{ns}.objects.{objectName}._actions.{actionName}.label`, - * falling back to `{ns}.globalActions.{actionName}.label` — both when - * objectName is omitted AND when the object-scoped key misses (so a - * globalAction surfaced on a record-detail action bar still resolves; - * objectui#3372). + * `objectName` is the action's KEY object (its declared `objectName`, else + * the host that embeds it). Convention: + * `{ns}.objects.{objectName}._actions.{actionName}.label`; with no key + * object, `{ns}.globalActions.{actionName}.label`. Never both: a bound + * action does not read `globalActions` (objectui#11439, see + * `actionSuffixes`). Every action resolver below follows the same rule. */ actionLabel: (objectName: string | undefined, actionName: string, fallback: string) => resolve(actionSuffixes(objectName, actionName, 'label'), fallback), /** * Resolve translated action confirmation prompt. - * Convention: `{ns}.objects.{objectName}._actions.{actionName}.confirmText`, - * falling back to `{ns}.globalActions.{actionName}.confirmText`. + * Convention: `{ns}.objects.{objectName}._actions.{actionName}.confirmText`; + * with no key object, `{ns}.globalActions.{actionName}.confirmText`. * Returns undefined when no translation and no fallback exist. */ actionConfirm: (objectName: string | undefined, actionName: string, fallback?: string) => { @@ -588,8 +598,8 @@ export function useObjectLabel() { /** * Resolve translated action success message. - * Convention: `{ns}.objects.{objectName}._actions.{actionName}.successMessage`, - * falling back to `{ns}.globalActions.{actionName}.successMessage`. + * Convention: `{ns}.objects.{objectName}._actions.{actionName}.successMessage`; + * with no key object, `{ns}.globalActions.{actionName}.successMessage`. */ actionSuccess: (objectName: string | undefined, actionName: string, fallback?: string) => { const fb = fallback ?? ''; @@ -600,9 +610,9 @@ export function useObjectLabel() { /** * Resolve the translated success copy for ONE handler outcome — an entry of * the action's `outcomeMessages` (`@objectstack/spec` 17.6.0, objectui#11344). - * Convention: `{ns}.objects.{objectName}._actions.{actionName}.outcomeMessages.{outcome}`, - * falling back to `{ns}.globalActions.{actionName}.outcomeMessages.{outcome}` - * — the address the spec's translation bundle declares for the key. + * Convention: `{ns}.objects.{objectName}._actions.{actionName}.outcomeMessages.{outcome}`; + * with no key object, `{ns}.globalActions.{actionName}.outcomeMessages.{outcome}` + * — the addresses the spec's translation bundle declares for the key. * Returns undefined when no translation and no fallback exist. * * Like `actionSuccess`, the answer is a TEMPLATE: a `${result.*}` token in a @@ -624,9 +634,9 @@ export function useObjectLabel() { /** * Resolve translated action description (the explanatory line shown in the * action's param dialog / sheet / drawer header). - * Convention: `{ns}.objects.{objectName}._actions.{actionName}.description`. - * Falls back to `{ns}.globalActions.{actionName}.description`, then the - * metadata's literal string; undefined when nothing resolves. + * Convention: `{ns}.objects.{objectName}._actions.{actionName}.description`; + * with no key object, `{ns}.globalActions.{actionName}.description`. Falls + * back to the metadata's literal string; undefined when nothing resolves. */ actionDescription: (objectName: string | undefined, actionName: string | undefined, fallback?: string) => { const fb = fallback ?? ''; @@ -638,10 +648,9 @@ export function useObjectLabel() { /** * Resolve a translated copy of an action's post-success RESULT DIALOG * (`title` / `description` / `acknowledge` + per-field labels). - * Convention: `{ns}.objects.{objectName}._actions.{actionName}.resultDialog.*`, - * falling back to `{ns}.globalActions.{actionName}.resultDialog.*` (when - * objectName is omitted OR the object-scoped key misses), then to the - * metadata's literal strings. + * Convention: `{ns}.objects.{objectName}._actions.{actionName}.resultDialog.*`; + * with no key object, `{ns}.globalActions.{actionName}.resultDialog.*`. + * Falls back to the metadata's literal strings. * * The `fields` translation node is keyed by the LITERAL result-field path * from the action metadata (may contain dots, e.g. `"user.email"`), so it @@ -702,7 +711,8 @@ export function useObjectLabel() { /** * Resolve translated action-PARAMETER text (label / placeholder / helpText). - * Convention: `{ns}.objects.{objectName}._actions.{actionName}.params.{paramName}.{attr}`. + * Convention: `{ns}.objects.{objectName}._actions.{actionName}.params.{paramName}.{attr}`; + * with no key object, `{ns}.globalActions.{actionName}.params.{paramName}.{attr}`. * Falls back to the provided value (the metadata's literal string) when no * translation exists, so untranslated params keep rendering as authored. */ @@ -723,7 +733,8 @@ export function useObjectLabel() { }, /** * Resolve a translated action-parameter SELECT OPTION label. - * Convention: `{ns}.objects.{objectName}._actions.{actionName}.params.{paramName}.options.{optionValue}`. + * Convention: `{ns}.objects.{objectName}._actions.{actionName}.params.{paramName}.options.{optionValue}`; + * with no key object, the same path under `{ns}.globalActions.{actionName}`. * Falls back to the provided (English metadata) label when untranslated. */ actionParamOptionLabel: ( diff --git a/packages/react/src/hooks/__tests__/useActionTextLocalizer.keyObject-11439.test.tsx b/packages/react/src/hooks/__tests__/useActionTextLocalizer.keyObject-11439.test.tsx new file mode 100644 index 0000000000..dd80c484a8 --- /dev/null +++ b/packages/react/src/hooks/__tests__/useActionTextLocalizer.keyObject-11439.test.tsx @@ -0,0 +1,130 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * `useActionTextLocalizer` keys an action's copy on the action's own object + * (objectui#11439, triage's amended ruling, comment 6030552631). + * + * The key object is the action's declared `objectName`, else the host object + * the caller draws it on — the object `translateObject` in `@objectstack/spec` + * 17.7.0 stamps on an embedded action. A key object reads only that object's + * `_actions.` node; only an action with no key object reads + * `globalActions.`. So a bound action whose copy was filed under + * `globalActions` shows its authored text here, as it does on every surface + * the server translates. + * + * The bundle mirrors the console preview sample's case: `close_order` is bound + * to `sales_order`. + */ + +import { describe, it, expect } from 'vitest'; +import { renderHook } from '@testing-library/react'; +import React from 'react'; +import { I18nProvider, useObjectTranslation } from '@object-ui/i18n'; +import { useActionTextLocalizer } from '../useActionTextLocalizer'; + +const wrapper = ({ children }: { children: React.ReactNode }) => + React.createElement(I18nProvider, { + config: { defaultLanguage: 'en', detectBrowserLanguage: false }, + children, + }); + +/** A bound action, exactly as the console's `action` preview sample declares it. */ +const CLOSE_ORDER = { + name: 'close_order', + label: 'Close Order', + type: 'script', + target: 'closeOrder', + objectName: 'sales_order', + confirmText: 'Close this order? This cannot be undone.', + successMessage: 'Order closed.', + locations: ['record_header'], +}; + +function setup(bundle: Record) { + const { result } = renderHook( + () => ({ localize: useActionTextLocalizer(), i18n: useObjectTranslation().i18n }), + { wrapper }, + ); + result.current.i18n.addResourceBundle('en', 'translation', { crm: bundle }, true, true); + return result; +} + +/** Copy filed ONLY under `globalActions` — the misfiling `os validate` refuses for a bound action. */ +const GLOBAL_ONLY = { + // Present so the namespace is discovered through `objects` as well. + objects: { account: { label: '客户' } }, + globalActions: { + close_order: { label: '关闭订单(全局)', confirmText: '全局确认', successMessage: '全局成功' }, + archive_order: { label: '归档订单(全局)' }, + }, +}; + +/** The same copy filed where the spec reads it for a bound action. */ +const OBJECT_SCOPED = { + objects: { + sales_order: { + _actions: { + close_order: { label: '关闭订单', confirmText: '确认关闭?', successMessage: '订单已关闭。' }, + }, + }, + }, + globalActions: { + close_order: { label: '关闭订单(全局)', confirmText: '全局确认', successMessage: '全局成功' }, + }, +}; + +describe('useActionTextLocalizer — the key object (objectui#11439)', () => { + it('a bound action with only globalActions copy shows its authored text', () => { + const result = setup(GLOBAL_ONLY); + const localized = result.current.localize('sales_order', CLOSE_ORDER); + expect(localized.label).toBe('Close Order'); + expect(localized.confirmText).toBe('Close this order? This cannot be undone.'); + expect(localized.successMessage).toBe('Order closed.'); + }); + + it('a bound action reads its object-scoped copy', () => { + const result = setup(OBJECT_SCOPED); + const localized = result.current.localize('sales_order', CLOSE_ORDER); + expect(localized.label).toBe('关闭订单'); + expect(localized.confirmText).toBe('确认关闭?'); + expect(localized.successMessage).toBe('订单已关闭。'); + }); + + it('the declared objectName is the key even where the caller knows no object', () => { + const result = setup(OBJECT_SCOPED); + // `record:quick_actions` and `record:related_list` pass `undefined` when + // their context carries no object; the action's own declaration still + // names the node it is read from. + expect(result.current.localize(undefined, CLOSE_ORDER).label).toBe('关闭订单'); + const globalOnly = setup(GLOBAL_ONLY); + expect(globalOnly.current.localize(undefined, CLOSE_ORDER).label).toBe('Close Order'); + }); + + it('the declared objectName wins over the host the caller passes', () => { + const result = setup(OBJECT_SCOPED); + expect(result.current.localize('account', CLOSE_ORDER).label).toBe('关闭订单'); + }); + + it('an embedded action (no objectName) is keyed on its host, not on globalActions', () => { + const result = setup(GLOBAL_ONLY); + const { objectName: _bound, ...embedded } = CLOSE_ORDER; + expect(result.current.localize('sales_order', embedded).label).toBe('Close Order'); + const scoped = setup(OBJECT_SCOPED); + expect(scoped.current.localize('sales_order', embedded).label).toBe('关闭订单'); + }); + + it('an action with no key object still reads globalActions', () => { + const result = setup(GLOBAL_ONLY); + const localized = result.current.localize(undefined, { + name: 'archive_order', + label: 'Archive Order', + type: 'script', + target: 'archiveOrder', + }); + expect(localized.label).toBe('归档订单(全局)'); + }); +}); diff --git a/packages/react/src/hooks/useActionTextLocalizer.ts b/packages/react/src/hooks/useActionTextLocalizer.ts index 7417c64c64..bb3c71cb54 100644 --- a/packages/react/src/hooks/useActionTextLocalizer.ts +++ b/packages/react/src/hooks/useActionTextLocalizer.ts @@ -41,6 +41,14 @@ * here overrides it. * - **A nameless action is not translatable.** Without `action.name` there is * no `_actions.` key to look up, so the literals pass through. + * - **The entry is keyed on the action's own object** (objectui#11439): its + * declared `objectName`, else the host object the caller draws it on — the + * object `translateObject` in `@objectstack/spec` stamps on an embedded + * action. Only an action with neither reads `globalActions.`; a bound + * action never does (see `actionSuffixes` in `@object-ui/i18n`). The + * runtime-side resolvers (`useConsoleActionRuntime`, `RecordDetailView`) + * already key the param / description / result-dialog copy the same way, so + * one action reads one bundle node on every surface. * - **`confirmText` / `successMessage` are resolved only when the action * DECLARES them.** A bundle must not be able to bolt a confirmation gate * onto an action whose metadata never asked for one (nor a success toast). @@ -82,7 +90,8 @@ export interface ActionTextLocalizerOptions { * Returns a copy of `action` with `label` — plus `confirmText`, * `successMessage` and `outcomeMessages` when the action declares them — * resolved from the active locale's bundle, falling back to the authored - * literals. + * literals. `objectName` is the host object the action is drawn on; the + * action's own declared `objectName` wins over it when present. */ export type ActionTextLocalizer = >( objectName: string | undefined, @@ -117,16 +126,23 @@ export function useActionTextLocalizer(): ActionTextLocalizer { return displayLabel === (action as any).label ? action : { ...action, label: displayLabel }; } + // The key object: the action's declared `objectName`, else the host the + // caller passes (objectui#11439). An action declared on `sales_order` + // drawn where the caller knows no object still reads `sales_order`'s + // `_actions` node, never `globalActions`. + const declared: unknown = action.objectName; + const keyObject = (typeof declared === 'string' && declared) || objectName; + const out: Record = { ...action, - label: actionLabel(objectName, name, displayLabel), + label: actionLabel(keyObject, name, displayLabel), }; if ((action as any).confirmText !== undefined) { - out.confirmText = actionConfirm(objectName, name, (action as any).confirmText); + out.confirmText = actionConfirm(keyObject, name, (action as any).confirmText); } if ((action as any).successMessage !== undefined) { out.successMessage = actionSuccess( - objectName, + keyObject, name, pickLocalized((action as any).successMessage, language), ); @@ -137,7 +153,7 @@ export function useActionTextLocalizer(): ActionTextLocalizer { if (isConfigBag(authoredOutcomes)) { const outcomeMessages: Record = {}; for (const [outcome, copy] of Object.entries(authoredOutcomes)) { - const text = actionOutcome(objectName, name, outcome, pickLocalized(copy, language)); + const text = actionOutcome(keyObject, name, outcome, pickLocalized(copy, language)); if (text !== undefined) outcomeMessages[outcome] = text; } out.outcomeMessages = outcomeMessages;