diff --git a/.changeset/action-description-and-params-translation.md b/.changeset/action-description-and-params-translation.md new file mode 100644 index 0000000000..8c63ff26ce --- /dev/null +++ b/.changeset/action-description-and-params-translation.md @@ -0,0 +1,33 @@ +--- +"@objectstack/spec": patch +--- + +fix(spec): an action's `description` and its parameter dialog now honour the translation bundle + +`translateAction` overlaid only `label`, `confirmText`, `successMessage` and +`resultDialog`. The keys for the rest were already there — the translation +schema declares `_actions.ACTION.description` and +`_actions.ACTION.params.PARAM.{label, helpText, placeholder, options}`, and the +translation linter validates both, reporting a parameter key the action does not +declare with a did-you-mean naming the ones it does. So the keys parsed, they +linted, and they resolved to nothing: a translated deployment rendered a +translated action button that opened an untranslated form, because an action's +`description` is the explanatory line under the dialog title and its +parameters' `label` / `helpText` / `placeholder` / option labels are the rest of +that dialog. + +They are applied now, wherever the action is served — the REST metadata read, +OpenAPI, MCP — and through `globalActions` for an action that belongs to no +object, the same object-scoped-first order every other action key already used. + +Parameters are matched by `name`, falling back to `field` for a field-backed +parameter that names no key of its own: the same rule the linter collects +parameters by, so a key the linter accepts is a key the resolver finds. Option +labels are matched on the stored option `value`, since the authored side is an +array of options while the translation side is a `value` to label map. + +Nothing changes for a bundle that carries none of these keys: the authored text +is kept, the parameter array keeps its identity, and a bundle key naming a +parameter the action does not declare is ignored rather than invented into the +dialog. No schema, no validator and no accepted shape moves — every key applied +here was already declared and already validated. diff --git a/packages/spec/api-surface/system.json b/packages/spec/api-surface/system.json index 7d0fc61fec..efec0a6ea6 100644 --- a/packages/spec/api-surface/system.json +++ b/packages/spec/api-surface/system.json @@ -7,6 +7,7 @@ "AccessControlConfigParsed (type)", "AccessControlConfigSchema (const)", "ActionLike (interface)", + "ActionParamLike (interface)", "ActionResultDialogTranslation (type)", "ActionResultDialogTranslationSchema (const)", "AddFieldOperation (const)", diff --git a/packages/spec/export-origins/system.json b/packages/spec/export-origins/system.json index f536715935..cfd6c1f57c 100644 --- a/packages/spec/export-origins/system.json +++ b/packages/spec/export-origins/system.json @@ -7,6 +7,7 @@ "AccessControlConfigParsed": "src/system/object-storage.zod.ts#AccessControlConfigParsed (type)", "AccessControlConfigSchema": "src/system/object-storage.zod.ts#AccessControlConfigSchema (const)", "ActionLike": "src/system/i18n-resolver.ts#ActionLike (interface)", + "ActionParamLike": "src/system/i18n-resolver.ts#ActionParamLike (interface)", "ActionResultDialogTranslation": "src/system/translation.zod.ts#ActionResultDialogTranslation (type)", "ActionResultDialogTranslationSchema": "src/system/translation.zod.ts#ActionResultDialogTranslationSchema (const)", "AddFieldOperation": "src/system/migration.zod.ts#AddFieldOperation (const)", diff --git a/packages/spec/src/system/i18n-resolver.test.ts b/packages/spec/src/system/i18n-resolver.test.ts index 5c7c666821..a3094d8d2d 100644 --- a/packages/spec/src/system/i18n-resolver.test.ts +++ b/packages/spec/src/system/i18n-resolver.test.ts @@ -464,6 +464,173 @@ describe('resolveActionResultDialog + translateAction', () => { }); }); +describe('translateAction — description + params (the declared-and-linted keys)', () => { + // The card's own zh-CN fixture: `TranslationItemSchema` declares every key + // below and the translation linter validates them, so this bundle is what an + // author writes today and expects to render. + const bundle: TranslationBundle = { + 'zh-CN': { + objects: { + duly_task: { + _actions: { + duly_task_skip: { + label: '跳过', + description: '动作描述', + confirmText: '确认', + successMessage: '已跳过', + params: { + skip_reason: { + label: '原因', + helpText: '帮助', + placeholder: '占位', + options: { too_late: '太晚了' }, + }, + // A key the action does NOT declare — the linter reports it + // (`checkActionParams`); the resolver must ignore it. + nonexistent_param: { label: '幽灵' }, + }, + }, + }, + }, + }, + globalActions: { + export_secrets: { + description: '导出所有密钥', + params: { format: { label: '格式', helpText: '导出格式' } }, + }, + }, + }, + }; + + const skip = { + name: 'duly_task_skip', + label: 'Skip', + description: 'D', + objectName: 'duly_task', + confirmText: 'Sure?', + successMessage: 'Skipped', + params: [ + { + name: 'skip_reason', + label: 'Why skipped', + helpText: 'H', + placeholder: 'P', + options: [ + { value: 'too_late', label: 'Too late' }, + { value: 'duplicate', label: 'Duplicate' }, + ], + }, + ], + }; + + it('overlays the action description', () => { + expect(translateAction(skip, bundle, { locale: 'zh-CN' }).description).toBe('动作描述'); + }); + + it('keeps the authored description when the bundle carries none', () => { + const out = translateAction(skip, bundle, { locale: 'ja-JP', fallbackChain: [] }); + expect(out.description).toBe('D'); + // Source document is not mutated. + expect(skip.description).toBe('D'); + }); + + it('overlays a param label', () => { + expect(translateAction(skip, bundle, { locale: 'zh-CN' }).params?.[0].label).toBe('原因'); + }); + + it('overlays a param helpText — the ACTION spelling, not the bulk `help`', () => { + const out = translateAction(skip, bundle, { locale: 'zh-CN' }); + expect(out.params?.[0].helpText).toBe('帮助'); + // The bulk-action spelling must not appear on an action param. + expect(out.params?.[0]).not.toHaveProperty('help'); + }); + + it('overlays a param placeholder', () => { + expect(translateAction(skip, bundle, { locale: 'zh-CN' }).params?.[0].placeholder).toBe('占位'); + }); + + it('overlays param options by stored value, leaving untranslated entries alone', () => { + const out = translateAction(skip, bundle, { locale: 'zh-CN' }); + expect(out.params?.[0].options).toEqual([ + { value: 'too_late', label: '太晚了' }, + { value: 'duplicate', label: 'Duplicate' }, + ]); + // Source options are not mutated. + expect(skip.params[0].options[0].label).toBe('Too late'); + }); + + it('keeps the authored param copy when the bundle carries none', () => { + const out = translateAction(skip, bundle, { locale: 'ja-JP', fallbackChain: [] }); + expect(out.params?.[0]).toEqual(skip.params[0]); + // Nothing changed, so the array itself is the authored one (same reference). + expect(out.params).toBe(skip.params); + }); + + it('matches a field-backed param by `field` when it declares no `name`', () => { + const fieldBacked = { + name: 'duly_task_skip', + objectName: 'duly_task', + params: [{ field: 'skip_reason', label: 'Why skipped' }], + }; + const out = translateAction(fieldBacked, bundle, { locale: 'zh-CN' }); + expect(out.params?.[0].label).toBe('原因'); + }); + + it('prefers `name` over `field` when both are present', () => { + const both = { + name: 'duly_task_skip', + objectName: 'duly_task', + // `field` names the translated param; `name` is the key the linter + // collects, so the untranslated `name` must win and nothing overlays. + params: [{ name: 'other_key', field: 'skip_reason', label: 'Why skipped' }], + }; + const out = translateAction(both, bundle, { locale: 'zh-CN' }); + expect(out.params?.[0].label).toBe('Why skipped'); + }); + + it('ignores a bundle param the action does not declare', () => { + const out = translateAction(skip, bundle, { locale: 'zh-CN' }); + expect(out.params).toHaveLength(1); + expect(JSON.stringify(out.params)).not.toContain('幽灵'); + }); + + it('resolves description and params through globalActions for object-less actions', () => { + const out = translateAction( + { + name: 'export_secrets', + label: 'Export secrets', + description: 'Export every secret', + params: [{ name: 'format', label: 'Format', helpText: 'File format' }], + }, + bundle, + { locale: 'zh-CN' }, + ); + expect(out.description).toBe('导出所有密钥'); + expect(out.params?.[0].label).toBe('格式'); + expect(out.params?.[0].helpText).toBe('导出格式'); + }); + + it('leaves an action with no params untouched', () => { + const out = translateAction( + { name: 'duly_task_skip', objectName: 'duly_task', label: 'Skip' }, + bundle, + { locale: 'zh-CN' }, + ); + expect(out).not.toHaveProperty('params'); + }); + + it('reaches an inline action through translateObject', () => { + const out = translateMetadataDocument( + 'object', + { name: 'duly_task', actions: [skip] }, + bundle, + { locale: 'zh-CN' }, + ); + expect(out.actions[0].description).toBe('动作描述'); + expect(out.actions[0].params[0].placeholder).toBe('占位'); + }); +}); + describe('translateMetadataDocument', () => { it('translates a view document', () => { const view = { diff --git a/packages/spec/src/system/i18n-resolver.ts b/packages/spec/src/system/i18n-resolver.ts index 6ec50a5a83..1ad0166dd1 100644 --- a/packages/spec/src/system/i18n-resolver.ts +++ b/packages/spec/src/system/i18n-resolver.ts @@ -12,8 +12,13 @@ * objects.._views..label * objects.._views..description * objects.._actions..label + * objects.._actions..description * objects.._actions..confirmText * objects.._actions..successMessage + * objects.._actions..params..label + * objects.._actions..params..helpText + * objects.._actions..params..placeholder + * objects.._actions..params..options. * objects.._tabs..label * * `` is the BARE authoring key (`listViews.`, or the default @@ -23,7 +28,8 @@ * * For object-less actions (no `objectName`), helpers fall back to: * - * globalActions..label / .confirmText / .successMessage + * globalActions..label / .description / .confirmText / + * .successMessage / .params..* * * Lookup order: requested locale → each entry of `fallbackChain` (defaults to * `['en']`) → literal `label` from the metadata. Helpers never throw — they @@ -127,14 +133,52 @@ export interface BulkActionParamLike { export interface ActionLike { name: string; label?: string; + /** + * `ActionSchema.description` — the explanatory line under the title in the + * action's param dialog. Narrowed to `string` for the same reason `label` is: + * a resolver answers with ONE locale's string. + */ + description?: string; confirmText?: string; successMessage?: string; + /** `ActionSchema.params` — the param dialog's own copy. */ + params?: ActionParamLike[]; /** When omitted, the action is treated as global. */ objectName?: string; /** Post-success reveal dialog (see `Action.resultDialog` in ui/action.zod). */ resultDialog?: ResultDialogLike; } +/** + * Minimal action-param shape consumed by {@link translateAction} — + * `ActionParamSchema` (`ui/action.zod.ts`) narrowed to the copy this resolver + * overlays. + * + * The translation node is keyed by the param's `name`, falling back to `field` + * when a field-backed param names no key of its own. That fallback is not a + * convenience: it is the SAME collection rule the linter validates against + * (`checkActionParams`, `packages/lint/src/validate-translation-references.ts`), + * so every key the linter accepts is a key this resolver finds — the two halves + * cannot disagree about which params are addressable. + */ +export interface ActionParamLike { + /** `ActionParamSchema.name` — the `params` translation key. */ + name?: string; + /** `ActionParamSchema.field` — the translation key when `name` is omitted. */ + field?: string; + label?: string; + /** An ACTION param spells its hint `helpText`; a BULK param spells it `help`. */ + helpText?: string; + placeholder?: string; + /** + * `ActionParamSchema.options[]` is an ARRAY of entries while the translation + * side is a `value -> label` MAP, so the overlay matches on `value` — the + * same shape mismatch `translateObject`'s field options resolve across. + */ + options?: Array<{ value?: string | number | boolean; label?: string; [key: string]: unknown }>; + [key: string]: unknown; +} + /** Minimal result-dialog shape consumed by `resolveActionResultDialog`. */ export interface ResultDialogLike { title?: string; @@ -417,7 +461,7 @@ function lookupTabLabel( function lookupActionField( bundle: TranslationBundle | undefined, action: ActionLike, - field: 'label' | 'confirmText' | 'successMessage', + field: 'label' | 'description' | 'confirmText' | 'successMessage', opts?: ResolveOptions, ): string | undefined { if (!bundle) return undefined; @@ -548,6 +592,118 @@ export function resolveActionSuccess( ); } +/** + * The `params.` translation node for one action, in one locale's data — + * object-scoped first, then `globalActions`, the same split + * {@link lookupActionField} walks. + */ +function lookupActionParamNode( + data: TranslationData | undefined, + action: ActionLike, + paramName: string, +): NonNullable< + NonNullable[string]['_actions']>[string]['params']> +>[string] | undefined { + if (!data) return undefined; + const fromObject = action.objectName + ? data.objects?.[action.objectName]?._actions?.[action.name]?.params?.[paramName] + : undefined; + if (fromObject) return fromObject; + return data.globalActions?.[action.name]?.params?.[paramName]; +} + +/** + * One string off an action param's translation node, across the locale chain. + * `undefined` when no locale carries it — the caller then leaves the authored + * value in place rather than overwriting it with a fallback. + */ +function lookupActionParamText( + bundle: TranslationBundle | undefined, + action: ActionLike, + paramName: string, + pick: (node: NonNullable>) => unknown, + opts?: ResolveOptions, +): string | undefined { + if (!bundle) return undefined; + for (const code of localeChain(opts)) { + const node = lookupActionParamNode(pickData(bundle, code), action, paramName); + if (!node) continue; + const value = pick(node); + if (typeof value === 'string' && value.length > 0) return value; + } + return undefined; +} + +/** + * Overlay the bundle's `params` copy onto an action's authored `params[]`, + * returning the SAME array reference when nothing matched. + * + * Reference identity is load-bearing rather than tidy, exactly as it is for + * {@link translateBulkActionDefs}: {@link translateAction} uses it to decide + * whether to write a `params` key at all, so an action with no param + * translations comes back with the very array it was authored with. + * + * A bundle key naming a param the action does NOT declare is ignored here — + * the walk is over the declared params, never over the bundle's keys. That + * asymmetry is deliberate: the linter already reports the unknown key with a + * did-you-mean (`checkActionParams`), and a resolver that invented a param + * from a translation file would put a control in the dialog that the action + * cannot receive. + */ +function translateActionParams( + action: ActionLike, + bundle: TranslationBundle | undefined, + opts?: ResolveOptions, +): ActionLike['params'] { + const params = action.params; + if (!Array.isArray(params) || !bundle) return params; + let changed = false; + const next = params.map((param) => { + if (!param || typeof param !== 'object') return param; + // `name` with `field` fallback — `checkActionParams`' collection rule. + const paramName = + (typeof param.name === 'string' && param.name.length > 0 ? param.name : undefined) ?? + (typeof param.field === 'string' && param.field.length > 0 ? param.field : undefined); + if (!paramName) return param; + const text = (pick: (node: NonNullable>) => unknown) => + lookupActionParamText(bundle, action, paramName, pick, opts); + + const label = text((n) => n.label); + const helpText = text((n) => n.helpText); + const placeholder = text((n) => n.placeholder); + + let options = param.options; + if (Array.isArray(param.options)) { + let optionsChanged = false; + const nextOptions = param.options.map((opt) => { + if (!opt || typeof opt !== 'object' || opt.value === undefined) return opt; + const key = String(opt.value); + const translated = text((n) => n.options?.[key]); + if (translated === undefined) return opt; + optionsChanged = true; + return { ...opt, label: translated }; + }); + if (optionsChanged) options = nextOptions; + } + + if ( + label === undefined && helpText === undefined && placeholder === undefined + && options === param.options + ) { + return param; + } + changed = true; + return { + ...param, + ...(label !== undefined ? { label } : {}), + ...(helpText !== undefined ? { helpText } : {}), + ...(placeholder !== undefined ? { placeholder } : {}), + ...(options !== param.options ? { options } : {}), + }; + }); + return changed ? next : params; +} + /** * The `_views..bulkActions` node for one def, in one locale's data. */ @@ -712,9 +868,26 @@ export function translateView( /** * Apply the active locale to an action metadata document by overwriting - * `label`, `confirmText`, `successMessage`, and the `resultDialog` copy with - * translated values when available. The original document is not mutated; a - * shallow copy is returned. + * `label`, `description`, `confirmText`, `successMessage`, the `params[]` copy + * and the `resultDialog` copy with translated values when available. The + * original document is not mutated; a shallow copy is returned. + * + * ## Why `description` and `params` are overlaid HERE + * + * They were declared and validated long before they were applied, and the + * asymmetry is what made them expensive: `TranslationItemSchema` declares + * `_actions..description` and `_actions..params..{label, + * helpText, placeholder, options}`, and the translation linter validates BOTH — + * `checkActionParams` even reports a param key the action does not declare, with + * a did-you-mean naming the declared ones. An author had every reason to believe + * the keys worked, and they parsed, and they linted, and they resolved to + * nothing: a Chinese deployment got a Chinese button that opened an English form, + * because an action's `description` is its dialog subtitle and `params[].label` / + * `.placeholder` / `.helpText` are the entire parameter dialog. + * + * `params` is overlaid by the param's `name`, falling back to `field` — see + * {@link ActionParamLike} for why that fallback is the linter's rule and not a + * convenience. */ export function translateAction( action: T, @@ -722,15 +895,19 @@ export function translateAction( opts?: ResolveOptions, ): T { const label = resolveActionLabel(bundle, action, opts); + const description = lookupActionField(bundle, action, 'description', opts) ?? action.description; const confirmText = resolveActionConfirm(bundle, action, opts); const successMessage = resolveActionSuccess(bundle, action, opts); const resultDialog = resolveActionResultDialog(bundle, action, opts); + const params = translateActionParams(action, bundle, opts); return { ...action, label, + ...(description !== undefined ? { description } : {}), ...(confirmText !== undefined ? { confirmText } : {}), ...(successMessage !== undefined ? { successMessage } : {}), ...(resultDialog !== undefined ? { resultDialog } : {}), + ...(params !== action.params ? { params } : {}), }; }