diff --git a/.changeset/action-result-dialog-i18n.md b/.changeset/action-result-dialog-i18n.md new file mode 100644 index 0000000000..2aef382865 --- /dev/null +++ b/.changeset/action-result-dialog-i18n.md @@ -0,0 +1,33 @@ +--- +"@objectstack/spec": minor +"@objectstack/cli": patch +"@objectstack/platform-objects": patch +--- + +feat(i18n): translation slot for action `resultDialog` copy — the one-shot secret-reveal dialogs are now localizable + +The post-success `resultDialog` (temporary passwords, 2FA backup codes, OAuth +client secrets) had no slot in the translation protocol, so its title / +description / acknowledge button / field labels always rendered the hardcoded +English metadata literals even on fully-translated locales. + +- **spec.** `_actions.` (object + object-first node) and + `globalActions.` gain an optional `resultDialog` translation node + (`ActionResultDialogTranslationSchema`): `title`, `description`, + `acknowledge`, and `fields` keyed by the **literal** result-field path + (e.g. `"user.email"` — keys may contain dots; resolvers index the record + directly, never split on `.`). New `resolveActionResultDialog` overlay + resolver, wired into `translateAction` for API-boundary translation. +- **cli.** `os i18n extract` emits the new `resultDialog.*` keys (title / + description / acknowledge / `fields.` for labelled fields), so + coverage and skeleton generation see them. +- **platform-objects.** en / zh-CN / ja-JP / es-ES bundles ship the + resultDialog copy for all six shipped dialogs: `sys_user.create_user`, + `sys_user.set_user_password`, `sys_two_factor.enable_two_factor`, + `sys_two_factor.regenerate_backup_codes`, + `sys_oauth_application.create_oauth_application`, and + `sys_oauth_application.rotate_client_secret`. + +Client-side rendering lands in objectui (`actionResultDialog` resolver in +`@object-ui/i18n` + result-dialog handlers). Purely additive — untranslated +locales keep falling back to the metadata literals. diff --git a/content/docs/references/system/translation.mdx b/content/docs/references/system/translation.mdx index 8709737b62..ec9ed8255a 100644 --- a/content/docs/references/system/translation.mdx +++ b/content/docs/references/system/translation.mdx @@ -16,13 +16,29 @@ Translation data for a single field. ## TypeScript Usage ```typescript -import { AppTranslationBundle, CoverageBreakdownEntry, FieldTranslation, Locale, MessageFormat, ObjectTranslationData, ObjectTranslationNode, TranslationBundle, TranslationConfig, TranslationCoverageResult, TranslationData, TranslationDiffItem, TranslationDiffStatus, TranslationFileOrganization } from '@objectstack/spec/system'; -import type { AppTranslationBundle, CoverageBreakdownEntry, FieldTranslation, Locale, MessageFormat, ObjectTranslationData, ObjectTranslationNode, TranslationBundle, TranslationConfig, TranslationCoverageResult, TranslationData, TranslationDiffItem, TranslationDiffStatus, TranslationFileOrganization } from '@objectstack/spec/system'; +import { ActionResultDialogTranslation, AppTranslationBundle, CoverageBreakdownEntry, FieldTranslation, Locale, MessageFormat, ObjectTranslationData, ObjectTranslationNode, TranslationBundle, TranslationConfig, TranslationCoverageResult, TranslationData, TranslationDiffItem, TranslationDiffStatus, TranslationFileOrganization } from '@objectstack/spec/system'; +import type { ActionResultDialogTranslation, AppTranslationBundle, CoverageBreakdownEntry, FieldTranslation, Locale, MessageFormat, ObjectTranslationData, ObjectTranslationNode, TranslationBundle, TranslationConfig, TranslationCoverageResult, TranslationData, TranslationDiffItem, TranslationDiffStatus, TranslationFileOrganization } from '@objectstack/spec/system'; // Validate data -const result = AppTranslationBundle.parse(data); +const result = ActionResultDialogTranslation.parse(data); ``` +--- + +## ActionResultDialogTranslation + +Translations for an action result dialog + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **title** | `string` | optional | Translated result dialog title | +| **description** | `string` | optional | Translated result dialog description | +| **acknowledge** | `string` | optional | Translated acknowledge button label | +| **fields** | `Record` | optional | Result field labels keyed by the literal field path declared in the action metadata (keys may contain dots) | + + --- ## AppTranslationBundle @@ -110,7 +126,7 @@ Translation data for a single object | **description** | `string` | optional | Translated object description | | **fields** | `Record }>` | optional | Field-level translations | | **_views** | `Record` | optional | View translations keyed by view name | -| **_actions** | `Record }> }>` | optional | Action translations keyed by action name | +| **_actions** | `Record }>; … }>` | optional | Action translations keyed by action name | | **_sections** | `Record` | optional | Section translations keyed by section name | @@ -132,7 +148,7 @@ Object-first aggregated translation node | **_options** | `Record>` | optional | Object-scoped picklist option translations keyed by field name | | **_views** | `Record` | optional | View translations keyed by view name | | **_sections** | `Record` | optional | Section translations keyed by section name | -| **_actions** | `Record }> }>` | optional | Action translations keyed by action name | +| **_actions** | `Record }>; resultDialog?: object }>` | optional | Action translations keyed by action name | | **_notifications** | `Record` | optional | Notification translations keyed by notification name | | **_errors** | `Record` | optional | Error message translations keyed by error code | @@ -195,7 +211,7 @@ Translation data for objects, apps, and UI messages | **apps** | `Record }>` | optional | App translations keyed by app name | | **messages** | `Record` | optional | UI message translations keyed by message ID | | **validationMessages** | `Record` | optional | Translatable validation error messages keyed by rule name (e.g., `{"discount_limit": "折扣不能超过40%"}`) | -| **globalActions** | `Record }> }>` | optional | Global action translations keyed by action name | +| **globalActions** | `Record }>; … }>` | optional | Global action translations keyed by action name | | **dashboards** | `Record; widgets?: Record }>` | optional | Dashboard translations keyed by dashboard name | | **settings** | `Record; keys?: Record }>; … }>` | optional | Settings manifest translations keyed by namespace | | **metadataForms** | `Record; fields?: Record }>` | optional | Translations for metadata-type configuration forms keyed by metadata type | diff --git a/packages/cli/src/utils/i18n-extract.ts b/packages/cli/src/utils/i18n-extract.ts index 84587f89ea..06e1f8a30f 100644 --- a/packages/cli/src/utils/i18n-extract.ts +++ b/packages/cli/src/utils/i18n-extract.ts @@ -27,8 +27,10 @@ * objects.._actions..successMessage * objects.._actions..params..label / .helpText / .placeholder * objects.._actions..params..options. + * objects.._actions..resultDialog.title / .description / .acknowledge + * objects.._actions..resultDialog.fields. * globalActions..label / .confirmText / .successMessage - * globalActions..params..* (same shape as object actions) + * globalActions..params..* / .resultDialog.* (same shape as object actions) * apps..label / .description * apps..navigation..label * dashboards..label / .description @@ -199,6 +201,43 @@ function pushActionParams( } } +/** + * Emit `resultDialog.{title,description,acknowledge}` and + * `resultDialog.fields.` entries under an action's translation root. + * Mirrors `ActionResultDialogTranslationSchema` in @objectstack/spec and the + * client-side `actionResultDialog` resolver. Field entries are keyed by the + * LITERAL result-field path (`"user.email"`) — the dot stays inside a single + * path segment, matching how resolvers index the record without splitting. + */ +function pushActionResultDialog( + out: ExpectedEntry[], + actionRoot: string[], + action: any, + kind: ExpectedEntry['source'], + objectName?: string, +): void { + const dialog = action?.resultDialog; + if (!dialog || typeof dialog !== 'object') return; + const base = [...actionRoot, 'resultDialog']; + if (typeof dialog.title === 'string' && dialog.title.length > 0) { + pushEntry(out, [...base, 'title'], dialog.title, kind, { objectName }); + } + if (typeof dialog.description === 'string' && dialog.description.length > 0) { + pushEntry(out, [...base, 'description'], dialog.description, kind, { objectName }); + } + if (typeof dialog.acknowledge === 'string' && dialog.acknowledge.length > 0) { + pushEntry(out, [...base, 'acknowledge'], dialog.acknowledge, kind, { objectName }); + } + if (Array.isArray(dialog.fields)) { + for (const field of dialog.fields) { + if (!field || typeof field !== 'object') continue; + if (typeof field.path !== 'string' || field.path.length === 0) continue; + if (typeof field.label !== 'string' || field.label.length === 0) continue; + pushEntry(out, [...base, 'fields', field.path], field.label, kind, { objectName }); + } + } +} + /** Collect every translatable entry from a normalized stack config. */ export function collectExpectedEntries(config: any): ExpectedEntry[] { const out: ExpectedEntry[] = []; @@ -286,6 +325,7 @@ export function collectExpectedEntries(config: any): ExpectedEntry[] { pushEntry(out, ['objects', objectName, '_actions', aname, 'successMessage'], action.successMessage, 'action', { objectName }); } pushActionParams(out, ['objects', objectName, '_actions', aname], action, 'action', objectName); + pushActionResultDialog(out, ['objects', objectName, '_actions', aname], action, 'action', objectName); } } } @@ -320,6 +360,7 @@ export function collectExpectedEntries(config: any): ExpectedEntry[] { pushEntry(out, [...root, 'successMessage'], action.successMessage, kind, { objectName }); } pushActionParams(out, root, action, kind, objectName); + pushActionResultDialog(out, root, action, kind, objectName); } // ── Apps + navigation ──────────────────────────────────────────── diff --git a/packages/cli/test/i18n-extract.test.ts b/packages/cli/test/i18n-extract.test.ts index 4b0c8380c3..4e7f806568 100644 --- a/packages/cli/test/i18n-extract.test.ts +++ b/packages/cli/test/i18n-extract.test.ts @@ -37,6 +37,16 @@ const config: any = { { name: 'set_password', label: 'Set Password', + resultDialog: { + title: 'Password Updated', + description: 'Copy the temporary password now — it is shown only once.', + acknowledge: 'Done', + fields: [ + { path: 'user.email', label: 'Email', format: 'text' }, + { path: 'temporaryPassword', label: 'Temporary Password', format: 'secret' }, + { path: 'unlabeled' }, // no label → no entry + ], + }, params: [ { field: 'label' }, { field: 'active', label: 'Enabled Override' }, @@ -143,6 +153,24 @@ describe('collectExpectedEntries', () => { // Top-level (global) actions get the same treatment. expect(byPath['globalActions.export_csv.params.delimiter.label']).toBe('Delimiter'); }); + + it('emits resultDialog entries keyed by the literal field path (dots preserved)', () => { + const entries = collectExpectedEntries(config); + const base = 'objects.sys_position._actions.set_password.resultDialog'; + const byPath = Object.fromEntries(entries.map((e) => [e.path.join('.'), e.sourceValue])); + + expect(byPath[`${base}.title`]).toBe('Password Updated'); + expect(byPath[`${base}.description`]).toBe('Copy the temporary password now — it is shown only once.'); + expect(byPath[`${base}.acknowledge`]).toBe('Done'); + expect(byPath[`${base}.fields.temporaryPassword`]).toBe('Temporary Password'); + // The dotted path stays ONE segment ('user.email'), never split. + const emailEntry = entries.find( + (e) => e.path.join('\u0000') === ['objects', 'sys_position', '_actions', 'set_password', 'resultDialog', 'fields', 'user.email'].join('\u0000'), + ); + expect(emailEntry?.sourceValue).toBe('Email'); + // Fields without a label emit nothing. + expect(byPath[`${base}.fields.unlabeled`]).toBeUndefined(); + }); }); describe('extractTranslations', () => { diff --git a/packages/platform-objects/src/apps/translations/en.objects.generated.ts b/packages/platform-objects/src/apps/translations/en.objects.generated.ts index 1d3dfa909b..bf10073dfa 100644 --- a/packages/platform-objects/src/apps/translations/en.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/en.objects.generated.ts @@ -168,6 +168,16 @@ export const enObjects: NonNullable = { mustChangePassword: { label: "Require Password Change On First Login" } + }, + resultDialog: { + title: "User Created", + description: "Copy the temporary password now — it is shown only once and never stored.", + acknowledge: "I have saved this password", + fields: { + "user.email": "Email", + "user.phoneNumber": "Phone Number", + temporaryPassword: "Temporary Password" + } } }, set_user_password: { @@ -183,6 +193,14 @@ export const enObjects: NonNullable = { mustChangePassword: { label: "Require Password Change On Next Login" } + }, + resultDialog: { + title: "Password Updated", + description: "If a temporary password was generated, copy it now — it is shown only once and never stored.", + acknowledge: "Done", + fields: { + temporaryPassword: "Temporary Password" + } } }, set_user_role: { @@ -988,6 +1006,15 @@ export const enObjects: NonNullable = { password: { label: "Current Password" } + }, + resultDialog: { + title: "Two-factor authentication enabled", + description: "Scan the QR code with your authenticator app, then save the backup codes somewhere safe. The backup codes are shown only once.", + acknowledge: "I have saved my backup codes", + fields: { + totpURI: "Authenticator URI", + backupCodes: "Backup Codes" + } } }, disable_two_factor: { @@ -1007,6 +1034,14 @@ export const enObjects: NonNullable = { password: { label: "Current Password" } + }, + resultDialog: { + title: "New backup codes generated", + description: "Previous backup codes are now invalid. Save these new codes somewhere safe — they are shown only once.", + acknowledge: "I have saved the new codes", + fields: { + backupCodes: "Backup Codes" + } } } } @@ -1287,11 +1322,28 @@ export const enObjects: NonNullable = { public: "Public" } } + }, + resultDialog: { + title: "OAuth application registered", + description: "Save the client_secret now — it is shown only once and cannot be recovered. You can rotate it later if it leaks.", + acknowledge: "I have saved the client secret", + fields: { + "client.client_id": "Client ID", + "client.client_secret": "Client Secret" + } } }, rotate_client_secret: { label: "Rotate Client Secret", - confirmText: "Rotate this OAuth client's secret? The previous secret will stop working immediately and any integrations using it will break until they are updated with the new secret. The new secret is shown only once." + confirmText: "Rotate this OAuth client's secret? The previous secret will stop working immediately and any integrations using it will break until they are updated with the new secret. The new secret is shown only once.", + resultDialog: { + title: "Client secret rotated", + description: "Save the new secret now — it is shown only once. Update every integration before the previous secret's grace period ends.", + acknowledge: "I have updated my integrations", + fields: { + client_secret: "New Client Secret" + } + } }, delete_oauth_application: { label: "Delete OAuth Application", diff --git a/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts b/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts index 3c57a464cf..492d3c01e2 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts @@ -168,6 +168,16 @@ export const esESObjects: NonNullable = { mustChangePassword: { label: "Exigir cambio de contraseña en el primer inicio de sesión" } + }, + resultDialog: { + title: "Usuario creado", + description: "Copie la contraseña temporal ahora: se muestra una sola vez y no se almacena.", + acknowledge: "He guardado esta contraseña", + fields: { + "user.email": "Correo electrónico", + "user.phoneNumber": "Número de teléfono", + temporaryPassword: "Contraseña temporal" + } } }, set_user_password: { @@ -183,6 +193,14 @@ export const esESObjects: NonNullable = { mustChangePassword: { label: "Exigir cambio de contraseña en el próximo inicio de sesión" } + }, + resultDialog: { + title: "Contraseña actualizada", + description: "Si se generó una contraseña temporal, cópiela ahora: se muestra una sola vez y no se almacena.", + acknowledge: "Hecho", + fields: { + temporaryPassword: "Contraseña temporal" + } } }, set_user_role: { @@ -988,6 +1006,15 @@ export const esESObjects: NonNullable = { password: { label: "Contraseña actual" } + }, + resultDialog: { + title: "Autenticación de doble factor habilitada", + description: "Escanee el código QR con su aplicación de autenticación y guarde los códigos de respaldo en un lugar seguro. Los códigos de respaldo se muestran una sola vez.", + acknowledge: "He guardado mis códigos de respaldo", + fields: { + totpURI: "URI del autenticador", + backupCodes: "Códigos de respaldo" + } } }, disable_two_factor: { @@ -1007,6 +1034,14 @@ export const esESObjects: NonNullable = { password: { label: "Contraseña actual" } + }, + resultDialog: { + title: "Nuevos códigos de respaldo generados", + description: "Los códigos de respaldo anteriores ya no son válidos. Guarde estos nuevos códigos en un lugar seguro: se muestran una sola vez.", + acknowledge: "He guardado los nuevos códigos", + fields: { + backupCodes: "Códigos de respaldo" + } } } } @@ -1287,11 +1322,28 @@ export const esESObjects: NonNullable = { public: "Pública" } } + }, + resultDialog: { + title: "Aplicación OAuth registrada", + description: "Guarde el client_secret ahora: se muestra una sola vez y no se puede recuperar. Puede rotarlo más adelante si se filtra.", + acknowledge: "He guardado el secreto de cliente", + fields: { + "client.client_id": "ID de cliente", + "client.client_secret": "Secreto de cliente" + } } }, rotate_client_secret: { label: "Rotar Client Secret", - confirmText: "¿Rotar el secreto de este cliente OAuth? El secreto anterior dejará de funcionar de inmediato y cualquier integración que lo utilice fallará hasta que se actualice con el nuevo secreto. El nuevo secreto se muestra una sola vez." + confirmText: "¿Rotar el secreto de este cliente OAuth? El secreto anterior dejará de funcionar de inmediato y cualquier integración que lo utilice fallará hasta que se actualice con el nuevo secreto. El nuevo secreto se muestra una sola vez.", + resultDialog: { + title: "Secreto de cliente rotado", + description: "Guarde el nuevo secreto ahora: se muestra una sola vez. Actualice todas las integraciones antes de que termine el periodo de gracia del secreto anterior.", + acknowledge: "He actualizado mis integraciones", + fields: { + client_secret: "Nuevo secreto de cliente" + } + } }, delete_oauth_application: { label: "Eliminar aplicación OAuth", diff --git a/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts b/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts index 7eda8ade18..f530c54907 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts @@ -168,6 +168,16 @@ export const jaJPObjects: NonNullable = { mustChangePassword: { label: "初回サインイン時にパスワード変更を必須にする" } + }, + resultDialog: { + title: "ユーザーを作成しました", + description: "一時パスワードを今すぐコピーしてください。表示は一度きりで、保存されません。", + acknowledge: "パスワードを保存しました", + fields: { + "user.email": "メールアドレス", + "user.phoneNumber": "電話番号", + temporaryPassword: "一時パスワード" + } } }, set_user_password: { @@ -183,6 +193,14 @@ export const jaJPObjects: NonNullable = { mustChangePassword: { label: "次回サインイン時にパスワード変更を必須にする" } + }, + resultDialog: { + title: "パスワードを更新しました", + description: "一時パスワードが生成された場合は今すぐコピーしてください。表示は一度きりで、保存されません。", + acknowledge: "完了", + fields: { + temporaryPassword: "一時パスワード" + } } }, set_user_role: { @@ -988,6 +1006,15 @@ export const jaJPObjects: NonNullable = { password: { label: "現在のパスワード" } + }, + resultDialog: { + title: "二要素認証を有効化しました", + description: "認証アプリで QR コードをスキャンし、バックアップコードを安全な場所に保存してください。バックアップコードの表示は一度きりです。", + acknowledge: "バックアップコードを保存しました", + fields: { + totpURI: "認証アプリ URI", + backupCodes: "バックアップコード" + } } }, disable_two_factor: { @@ -1007,6 +1034,14 @@ export const jaJPObjects: NonNullable = { password: { label: "現在のパスワード" } + }, + resultDialog: { + title: "新しいバックアップコードを生成しました", + description: "以前のバックアップコードは無効になりました。新しいコードを安全な場所に保存してください。表示は一度きりです。", + acknowledge: "新しいコードを保存しました", + fields: { + backupCodes: "バックアップコード" + } } } } @@ -1287,11 +1322,28 @@ export const jaJPObjects: NonNullable = { public: "パブリック" } } + }, + resultDialog: { + title: "OAuthアプリケーションを登録しました", + description: "client_secret を今すぐ保存してください。表示は一度きりで、復元できません。漏えいした場合は後でローテーションできます。", + acknowledge: "クライアントシークレットを保存しました", + fields: { + "client.client_id": "クライアント ID", + "client.client_secret": "クライアントシークレット" + } } }, rotate_client_secret: { label: "クライアントシークレット更新", - confirmText: "このOAuthクライアントのシークレットをローテーションしますか?以前のシークレットは直ちに使用できなくなり、それを使用している連携は新しいシークレットに更新されるまで動作しなくなります。新しいシークレットは一度しか表示されません。" + confirmText: "このOAuthクライアントのシークレットをローテーションしますか?以前のシークレットは直ちに使用できなくなり、それを使用している連携は新しいシークレットに更新されるまで動作しなくなります。新しいシークレットは一度しか表示されません。", + resultDialog: { + title: "クライアントシークレットをローテーションしました", + description: "新しいシークレットを今すぐ保存してください。表示は一度きりです。旧シークレットの猶予期間が終わる前にすべての連携を更新してください。", + acknowledge: "連携を更新しました", + fields: { + client_secret: "新しいクライアントシークレット" + } + } }, delete_oauth_application: { label: "OAuthアプリケーションを削除", diff --git a/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts b/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts index b5851490c0..14bc242281 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts @@ -168,6 +168,16 @@ export const zhCNObjects: NonNullable = { mustChangePassword: { label: "首次登录时必须修改密码" } + }, + resultDialog: { + title: "用户已创建", + description: "请立即复制临时密码——它只显示一次,不会被保存。", + acknowledge: "我已保存该密码", + fields: { + "user.email": "邮箱", + "user.phoneNumber": "手机号", + temporaryPassword: "临时密码" + } } }, set_user_password: { @@ -183,6 +193,14 @@ export const zhCNObjects: NonNullable = { mustChangePassword: { label: "下次登录时必须修改密码" } + }, + resultDialog: { + title: "密码已更新", + description: "如果生成了临时密码,请立即复制——它只显示一次,不会被保存。", + acknowledge: "完成", + fields: { + temporaryPassword: "临时密码" + } } }, set_user_role: { @@ -988,6 +1006,15 @@ export const zhCNObjects: NonNullable = { password: { label: "当前密码" } + }, + resultDialog: { + title: "双因素认证已启用", + description: "请用身份验证器应用扫描二维码,并将备用码妥善保存。备用码只显示一次。", + acknowledge: "我已保存备用码", + fields: { + totpURI: "身份验证器 URI", + backupCodes: "备用码" + } } }, disable_two_factor: { @@ -1007,6 +1034,14 @@ export const zhCNObjects: NonNullable = { password: { label: "当前密码" } + }, + resultDialog: { + title: "已生成新的备用码", + description: "之前的备用码已全部失效。请妥善保存这些新备用码——它们只显示一次。", + acknowledge: "我已保存新备用码", + fields: { + backupCodes: "备用码" + } } } } @@ -1287,11 +1322,28 @@ export const zhCNObjects: NonNullable = { public: "公共客户端" } } + }, + resultDialog: { + title: "OAuth 应用已注册", + description: "请立即保存 client_secret——它只显示一次,无法找回。如发生泄露可稍后轮换。", + acknowledge: "我已保存 Client Secret", + fields: { + "client.client_id": "Client ID", + "client.client_secret": "Client Secret" + } } }, rotate_client_secret: { label: "轮换 Client Secret", - confirmText: "确定要轮换此 OAuth 客户端的密钥吗?旧密钥将立即失效,任何使用它的集成都将中断,直到更新为新密钥为止。新密钥仅显示一次。" + confirmText: "确定要轮换此 OAuth 客户端的密钥吗?旧密钥将立即失效,任何使用它的集成都将中断,直到更新为新密钥为止。新密钥仅显示一次。", + resultDialog: { + title: "Client Secret 已轮换", + description: "请立即保存新密钥——它只显示一次。请在旧密钥宽限期结束前更新所有集成。", + acknowledge: "我已更新所有集成", + fields: { + client_secret: "新的 Client Secret" + } + } }, delete_oauth_application: { label: "删除 OAuth 应用", diff --git a/packages/spec/api-surface.json b/packages/spec/api-surface.json index bc78f78064..51c448b517 100644 --- a/packages/spec/api-surface.json +++ b/packages/spec/api-surface.json @@ -531,6 +531,8 @@ "AccessControlConfig (type)", "AccessControlConfigSchema (const)", "ActionLike (interface)", + "ActionResultDialogTranslation (type)", + "ActionResultDialogTranslationSchema (const)", "AddFieldOperation (const)", "AdvancedAuthConfig (type)", "AdvancedAuthConfigSchema (const)", @@ -1007,6 +1009,7 @@ "ResolvedSettingValue (type)", "ResolvedSettingValueSchema (const)", "ResolverDoc (interface)", + "ResultDialogLike (interface)", "RetryPolicy (type)", "RetryPolicySchema (const)", "RollbackPlan (type)", @@ -1227,6 +1230,7 @@ "minioStorageExample (const)", "resolveActionConfirm (function)", "resolveActionLabel (function)", + "resolveActionResultDialog (function)", "resolveActionSuccess (function)", "resolveBookClaimedDocs (function)", "resolveBookTree (function)", diff --git a/packages/spec/json-schema.manifest.json b/packages/spec/json-schema.manifest.json index 98eb0819b9..d38c439937 100644 --- a/packages/spec/json-schema.manifest.json +++ b/packages/spec/json-schema.manifest.json @@ -1267,6 +1267,7 @@ "studio/StudioPluginManifest", "studio/ViewMode", "system/AccessControlConfig", + "system/ActionResultDialogTranslation", "system/AddFieldOperation", "system/AdvancedAuthConfig", "system/AnalyzerConfig", diff --git a/packages/spec/src/system/i18n-resolver.test.ts b/packages/spec/src/system/i18n-resolver.test.ts index 934385cfca..1c0d113491 100644 --- a/packages/spec/src/system/i18n-resolver.test.ts +++ b/packages/spec/src/system/i18n-resolver.test.ts @@ -8,6 +8,8 @@ import { resolveActionLabel, resolveActionConfirm, resolveActionSuccess, + resolveActionResultDialog, + translateAction, translateMetadataDocument, } from './i18n-resolver'; @@ -40,6 +42,29 @@ describe('ObjectTranslationDataSchema (_views/_actions extensions)', () => { expect(data._actions?.convert_lead.confirmText).toBe('确定要转化此线索吗?'); expect(data._actions?.convert_lead.successMessage).toBe('线索转化成功!'); }); + + it('accepts _actions entries with a resultDialog node (dotted field paths stay literal keys)', () => { + const data = ObjectTranslationDataSchema.parse({ + label: '用户', + _actions: { + create_user: { + label: '创建用户', + resultDialog: { + title: '用户已创建', + description: '请立即复制临时密码——它只显示一次,不会被存储。', + acknowledge: '我已保存该密码', + fields: { + 'user.email': '邮箱', + temporaryPassword: '临时密码', + }, + }, + }, + }, + }); + expect(data._actions?.create_user.resultDialog?.title).toBe('用户已创建'); + expect(data._actions?.create_user.resultDialog?.fields?.['user.email']).toBe('邮箱'); + expect(data._actions?.create_user.resultDialog?.fields?.temporaryPassword).toBe('临时密码'); + }); }); describe('TranslationDataSchema globalActions', () => { @@ -214,6 +239,93 @@ describe('resolveActionLabel + confirm + success', () => { }); }); +describe('resolveActionResultDialog + translateAction', () => { + const dialogBundle: TranslationBundle = { + 'zh-CN': { + objects: { + sys_user: { + label: '用户', + _actions: { + create_user: { + resultDialog: { + title: '用户已创建', + description: '请立即复制临时密码——它只显示一次,不会被存储。', + acknowledge: '我已保存该密码', + fields: { + 'user.email': '邮箱', + temporaryPassword: '临时密码', + }, + }, + }, + }, + }, + }, + globalActions: { + export_secrets: { + resultDialog: { title: '密钥已导出' }, + }, + }, + }, + }; + + const createUser = { + name: 'create_user', + label: 'Create User', + objectName: 'sys_user', + resultDialog: { + title: 'User Created', + description: 'Copy the temporary password now — it is shown only once and never stored.', + acknowledge: 'I have saved this password', + fields: [ + { path: 'user.email', label: 'Email', format: 'text' }, + { path: 'user.phoneNumber', label: 'Phone Number', format: 'text' }, + { path: 'temporaryPassword', label: 'Temporary Password', format: 'secret' }, + ], + }, + }; + + it('overlays title/description/acknowledge and per-path field labels', () => { + const out = resolveActionResultDialog(dialogBundle, createUser, { locale: 'zh-CN' }); + expect(out?.title).toBe('用户已创建'); + expect(out?.description).toBe('请立即复制临时密码——它只显示一次,不会被存储。'); + expect(out?.acknowledge).toBe('我已保存该密码'); + expect(out?.fields?.[0]).toEqual({ path: 'user.email', label: '邮箱', format: 'text' }); + // Untranslated field keeps its literal label; formats survive the overlay. + expect(out?.fields?.[1]).toEqual({ path: 'user.phoneNumber', label: 'Phone Number', format: 'text' }); + expect(out?.fields?.[2]).toEqual({ path: 'temporaryPassword', label: '临时密码', format: 'secret' }); + // Source spec is not mutated. + expect(createUser.resultDialog.title).toBe('User Created'); + expect(createUser.resultDialog.fields[0].label).toBe('Email'); + }); + + it('falls back to the literal spec when the locale has no entry', () => { + const out = resolveActionResultDialog(dialogBundle, createUser, { locale: 'ja-JP', fallbackChain: [] }); + expect(out?.title).toBe('User Created'); + expect(out?.fields?.[0].label).toBe('Email'); + }); + + it('resolves globalActions for object-less actions', () => { + const out = resolveActionResultDialog( + dialogBundle, + { name: 'export_secrets', resultDialog: { title: 'Secrets exported' } }, + { locale: 'zh-CN' }, + ); + expect(out?.title).toBe('密钥已导出'); + }); + + it('returns undefined when the action has no resultDialog', () => { + expect( + resolveActionResultDialog(dialogBundle, { name: 'create_user', objectName: 'sys_user' }, { locale: 'zh-CN' }), + ).toBeUndefined(); + }); + + it('translateAction carries the translated resultDialog', () => { + const out = translateAction(createUser, dialogBundle, { locale: 'zh-CN' }); + expect(out.resultDialog?.title).toBe('用户已创建'); + expect(out.resultDialog?.fields?.[2].label).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 f0f2442d12..3ad83d1c74 100644 --- a/packages/spec/src/system/i18n-resolver.ts +++ b/packages/spec/src/system/i18n-resolver.ts @@ -46,6 +46,17 @@ export interface ActionLike { successMessage?: string; /** When omitted, the action is treated as global. */ objectName?: string; + /** Post-success reveal dialog (see `Action.resultDialog` in ui/action.zod). */ + resultDialog?: ResultDialogLike; +} + +/** Minimal result-dialog shape consumed by `resolveActionResultDialog`. */ +export interface ResultDialogLike { + title?: string; + description?: string; + acknowledge?: string; + fields?: Array<{ path: string; label?: string; [key: string]: unknown }>; + [key: string]: unknown; } /** Optional resolver settings. */ @@ -216,6 +227,77 @@ export function resolveActionConfirm( return lookupActionField(bundle, action, 'confirmText', opts) ?? action.confirmText; } +/** + * Look up the translated `resultDialog` node for an action in a single + * locale's data (object-scoped first, then global). The node's `fields` + * record is keyed by the LITERAL result-field path (`"user.email"`), so it + * is indexed directly — never split on `.`. + */ +function lookupActionResultDialogNode( + data: TranslationData | undefined, + action: ActionLike, +): { title?: string; description?: string; acknowledge?: string; fields?: Record } | undefined { + if (!data) return undefined; + const fromObject = action.objectName + ? data.objects?.[action.objectName]?._actions?.[action.name]?.resultDialog + : undefined; + if (fromObject) return fromObject; + return data.globalActions?.[action.name]?.resultDialog; +} + +function lookupActionResultDialogText( + bundle: TranslationBundle | undefined, + action: ActionLike, + pick: (node: NonNullable>) => string | undefined, + opts?: ResolveOptions, +): string | undefined { + if (!bundle) return undefined; + for (const code of localeChain(opts)) { + const node = lookupActionResultDialogNode(pickData(bundle, code), action); + if (!node) continue; + const value = pick(node); + if (typeof value === 'string' && value.length > 0) return value; + } + return undefined; +} + +/** + * Resolve a translated copy of an action's `resultDialog`, overlaying + * `title` / `description` / `acknowledge` and per-field `label`s (keyed by + * the literal field path) when translations exist. Returns the original + * spec untouched (same reference) when the action has no `resultDialog`; + * otherwise a shallow copy with translated strings merged in. + */ +export function resolveActionResultDialog( + bundle: TranslationBundle | undefined, + action: ActionLike & { resultDialog?: T }, + opts?: ResolveOptions, +): T | undefined { + const spec = action.resultDialog; + if (!spec) return spec; + const title = lookupActionResultDialogText(bundle, action, (n) => n.title, opts) ?? spec.title; + const description = + lookupActionResultDialogText(bundle, action, (n) => n.description, opts) ?? spec.description; + const acknowledge = + lookupActionResultDialogText(bundle, action, (n) => n.acknowledge, opts) ?? spec.acknowledge; + const fields = Array.isArray(spec.fields) + ? spec.fields.map((field) => { + if (!field || typeof field.path !== 'string') return field; + const label = + lookupActionResultDialogText(bundle, action, (n) => n.fields?.[field.path], opts) ?? + field.label; + return label !== undefined ? { ...field, label } : field; + }) + : spec.fields; + return { + ...spec, + ...(title !== undefined ? { title } : {}), + ...(description !== undefined ? { description } : {}), + ...(acknowledge !== undefined ? { acknowledge } : {}), + ...(fields !== undefined ? { fields } : {}), + }; +} + /** * Resolve a translated success message for an action, returning `undefined` * if neither the bundle nor the action defines one. @@ -250,8 +332,9 @@ export function translateView( /** * Apply the active locale to an action metadata document by overwriting - * `label`, `confirmText`, and `successMessage` with translated values when - * available. The original document is not mutated; a shallow copy is returned. + * `label`, `confirmText`, `successMessage`, and the `resultDialog` copy with + * translated values when available. The original document is not mutated; a + * shallow copy is returned. */ export function translateAction( action: T, @@ -261,11 +344,13 @@ export function translateAction( const label = resolveActionLabel(bundle, action, opts); const confirmText = resolveActionConfirm(bundle, action, opts); const successMessage = resolveActionSuccess(bundle, action, opts); + const resultDialog = resolveActionResultDialog(bundle, action, opts); return { ...action, label, ...(confirmText !== undefined ? { confirmText } : {}), ...(successMessage !== undefined ? { successMessage } : {}), + ...(resultDialog !== undefined ? { resultDialog } : {}), }; } diff --git a/packages/spec/src/system/translation.zod.ts b/packages/spec/src/system/translation.zod.ts index a68944a59b..7d40307f33 100644 --- a/packages/spec/src/system/translation.zod.ts +++ b/packages/spec/src/system/translation.zod.ts @@ -26,6 +26,34 @@ export const FieldTranslationSchema = lazySchema(() => z.object({ export type FieldTranslation = z.infer; +/** + * Action Result-Dialog Translation Schema + * + * Translations for an action's post-success `resultDialog` (the one-shot + * reveal of secrets like temporary passwords, client secrets, or backup + * codes). Shared by object `_actions`, `globalActions`, and the + * object-first `ObjectTranslationNode._actions`. + * + * Convention: + * …_actions..resultDialog.title + * …_actions..resultDialog.description + * …_actions..resultDialog.acknowledge + * …_actions..resultDialog.fields. + * + * `fields` is keyed by the **literal** `resultDialog.fields[].path` from the + * action metadata (e.g. `"user.email"`, `"temporaryPassword"`). Keys may + * contain dots — resolvers must index the record directly, not split on `.`. + */ +export const ActionResultDialogTranslationSchema = lazySchema(() => z.object({ + title: z.string().optional().describe('Translated result dialog title'), + description: z.string().optional().describe('Translated result dialog description'), + acknowledge: z.string().optional().describe('Translated acknowledge button label'), + fields: z.record(z.string(), z.string()).optional() + .describe('Result field labels keyed by the literal field path declared in the action metadata (keys may contain dots)'), +}).describe('Translations for an action result dialog')); + +export type ActionResultDialogTranslation = z.infer; + /** * Object Translation Data Schema * @@ -80,6 +108,7 @@ export const ObjectTranslationDataSchema = lazySchema(() => z.object({ * objects.._actions..label * objects.._actions..confirmText * objects.._actions..successMessage + * objects.._actions..resultDialog.* */ _actions: z.record(z.string(), z.object({ label: z.string().optional().describe('Translated action label'), @@ -91,6 +120,8 @@ export const ObjectTranslationDataSchema = lazySchema(() => z.object({ placeholder: z.string().optional().describe('Translated action parameter placeholder'), options: z.record(z.string(), z.string()).optional().describe('Param select option value to translated label'), })).optional().describe('Action parameter translations keyed by parameter name'), + resultDialog: ActionResultDialogTranslationSchema.optional() + .describe('Translations for the action result dialog'), })).optional().describe('Action translations keyed by action name'), /** @@ -152,6 +183,7 @@ export const TranslationDataSchema = lazySchema(() => z.object({ * globalActions..label * globalActions..confirmText * globalActions..successMessage + * globalActions..resultDialog.* */ globalActions: z.record(z.string(), z.object({ label: z.string().optional().describe('Translated action label'), @@ -163,6 +195,8 @@ export const TranslationDataSchema = lazySchema(() => z.object({ placeholder: z.string().optional().describe('Translated action parameter placeholder'), options: z.record(z.string(), z.string()).optional().describe('Param select option value to translated label'), })).optional().describe('Action parameter translations keyed by parameter name'), + resultDialog: ActionResultDialogTranslationSchema.optional() + .describe('Translations for the action result dialog'), })).optional().describe('Global action translations keyed by action name'), /** @@ -505,6 +539,8 @@ export const ObjectTranslationNodeSchema = lazySchema(() => z.object({ placeholder: z.string().optional().describe('Translated action parameter placeholder'), options: z.record(z.string(), z.string()).optional().describe('Param select option value to translated label'), })).optional().describe('Action parameter translations keyed by parameter name'), + resultDialog: ActionResultDialogTranslationSchema.optional() + .describe('Translations for the action result dialog'), })).optional().describe('Action translations keyed by action name'), /** Notification message translations keyed by notification name */