Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .changeset/11439-bound-action-copy.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
objects?: Record<string, { _actions?: Record<string, { label?: unknown }> }>;
};

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',
);
});
});
13 changes: 11 additions & 2 deletions apps/console/src/preview-samples.ts
Original file line number Diff line number Diff line change
Expand Up @@ -484,12 +484,21 @@ export const SAMPLES: Record<string, Record<string, unknown>> = {
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: '关闭订单' },
},
},
};
94 changes: 81 additions & 13 deletions packages/i18n/src/__tests__/useObjectLabel-globalAction.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.<action>.*` 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.<action>.*` node and nothing else; an action with no key object
* reads `globalActions.<action>.*`. 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 }),
Expand All @@ -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: '全局标题' },
},
},
},
},
Expand All @@ -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(
'记录通话',
'记录工单通话',
);
});

Expand All @@ -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(
'全局标题',
);
});
});
Loading
Loading