From 3a2fdfe73399a614525da14714a445630cd47a3b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 18:50:08 +0000 Subject: [PATCH 1/2] fix(spec): translateAction overlays action description and params copy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `TranslationItemSchema` declares `_actions.ACTION.description` and `_actions.ACTION.params.PARAM.{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. `translateAction` applied neither, so a translated deployment rendered a translated action button that opened an untranslated parameter dialog. The resolver now overlays `description` through the existing object-scoped-then-`globalActions` lookup, and each `params[]` entry matched by `name` with a `field` fallback — the same collection rule the linter validates against — for `label` / `helpText` / `placeholder` / `options`. `options` is matched on the stored `value` because the translation side is a `value -> label` map while the authored side is an array. The params array keeps its identity when nothing matched, so an action with no param translations comes back with the array it was authored with. No schema and no validator change: every key applied here was already declared and already validated. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_017RbbUMnxkUnWhE4j94v8FE --- .../spec/src/system/i18n-resolver.test.ts | 167 ++++++++++++++++ packages/spec/src/system/i18n-resolver.ts | 187 +++++++++++++++++- 2 files changed, 349 insertions(+), 5 deletions(-) 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 } : {}), }; } From 84b89af72fc225c0f1925413adce388b816afd30 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 19:04:27 +0000 Subject: [PATCH 2/2] chore(spec): changeset + regenerated public-surface baselines `ActionParamLike` is a new exported interface, so `api-surface/system.json` and `export-origins/system.json` gain one name each. `translateAction`'s own exported signature is unchanged. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_017RbbUMnxkUnWhE4j94v8FE --- ...tion-description-and-params-translation.md | 33 +++++++++++++++++++ packages/spec/api-surface/system.json | 1 + packages/spec/export-origins/system.json | 1 + 3 files changed, 35 insertions(+) create mode 100644 .changeset/action-description-and-params-translation.md 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)",