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
33 changes: 33 additions & 0 deletions .changeset/action-result-dialog-i18n.md
Original file line number Diff line number Diff line change
@@ -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.<action>` (object + object-first node) and
`globalActions.<action>` 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.<path>` 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.
28 changes: 22 additions & 6 deletions content/docs/references/system/translation.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>` | optional | Result field labels keyed by the literal field path declared in the action metadata (keys may contain dots) |


---

## AppTranslationBundle
Expand Down Expand Up @@ -110,7 +126,7 @@ Translation data for a single object
| **description** | `string` | optional | Translated object description |
| **fields** | `Record<string, { label?: string; help?: string; placeholder?: string; options?: Record<string, string> }>` | optional | Field-level translations |
| **_views** | `Record<string, { label?: string; description?: string; emptyState?: object }>` | optional | View translations keyed by view name |
| **_actions** | `Record<string, { label?: string; confirmText?: string; successMessage?: string; params?: Record<string, { label?: string; helpText?: string; placeholder?: string; options?: Record<string, string> }> }>` | optional | Action translations keyed by action name |
| **_actions** | `Record<string, { label?: string; confirmText?: string; successMessage?: string; params?: Record<string, { label?: string; helpText?: string; placeholder?: string; options?: Record<string, string> }>; … }>` | optional | Action translations keyed by action name |
| **_sections** | `Record<string, { label?: string; description?: string }>` | optional | Section translations keyed by section name |


Expand All @@ -132,7 +148,7 @@ Object-first aggregated translation node
| **_options** | `Record<string, Record<string, string>>` | optional | Object-scoped picklist option translations keyed by field name |
| **_views** | `Record<string, { label?: string; description?: string; emptyState?: object }>` | optional | View translations keyed by view name |
| **_sections** | `Record<string, { label?: string }>` | optional | Section translations keyed by section name |
| **_actions** | `Record<string, { label?: string; confirmMessage?: string; params?: Record<string, { label?: string; helpText?: string; placeholder?: string; options?: Record<string, string> }> }>` | optional | Action translations keyed by action name |
| **_actions** | `Record<string, { label?: string; confirmMessage?: string; params?: Record<string, { label?: string; helpText?: string; placeholder?: string; options?: Record<string, string> }>; resultDialog?: object }>` | optional | Action translations keyed by action name |
| **_notifications** | `Record<string, { title?: string; body?: string }>` | optional | Notification translations keyed by notification name |
| **_errors** | `Record<string, string>` | optional | Error message translations keyed by error code |

Expand Down Expand Up @@ -195,7 +211,7 @@ Translation data for objects, apps, and UI messages
| **apps** | `Record<string, { label: string; description?: string; navigation?: Record<string, { label: string }> }>` | optional | App translations keyed by app name |
| **messages** | `Record<string, string>` | optional | UI message translations keyed by message ID |
| **validationMessages** | `Record<string, string>` | optional | Translatable validation error messages keyed by rule name (e.g., `{"discount_limit": "折扣不能超过40%"}`) |
| **globalActions** | `Record<string, { label?: string; confirmText?: string; successMessage?: string; params?: Record<string, { label?: string; helpText?: string; placeholder?: string; options?: Record<string, string> }> }>` | optional | Global action translations keyed by action name |
| **globalActions** | `Record<string, { label?: string; confirmText?: string; successMessage?: string; params?: Record<string, { label?: string; helpText?: string; placeholder?: string; options?: Record<string, string> }>; … }>` | optional | Global action translations keyed by action name |
| **dashboards** | `Record<string, { label?: string; description?: string; actions?: Record<string, { label?: string }>; widgets?: Record<string, { title?: string; description?: string }> }>` | optional | Dashboard translations keyed by dashboard name |
| **settings** | `Record<string, { title?: string; description?: string; groups?: Record<string, { title?: string; description?: string }>; keys?: Record<string, { label?: string; help?: string; placeholder?: string; options?: Record<string, string> }>; … }>` | optional | Settings manifest translations keyed by namespace |
| **metadataForms** | `Record<string, { label?: string; description?: string; sections?: Record<string, { label?: string; description?: string }>; fields?: Record<string, { label?: string; helpText?: string; placeholder?: string }> }>` | optional | Translations for metadata-type configuration forms keyed by metadata type |
Expand Down
43 changes: 42 additions & 1 deletion packages/cli/src/utils/i18n-extract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,10 @@
* objects.<name>._actions.<action>.successMessage
* objects.<name>._actions.<action>.params.<param>.label / .helpText / .placeholder
* objects.<name>._actions.<action>.params.<param>.options.<value>
* objects.<name>._actions.<action>.resultDialog.title / .description / .acknowledge
* objects.<name>._actions.<action>.resultDialog.fields.<path>
* globalActions.<action>.label / .confirmText / .successMessage
* globalActions.<action>.params.<param>.* (same shape as object actions)
* globalActions.<action>.params.<param>.* / .resultDialog.* (same shape as object actions)
* apps.<app>.label / .description
* apps.<app>.navigation.<id>.label
* dashboards.<dash>.label / .description
Expand Down Expand Up @@ -199,6 +201,43 @@ function pushActionParams(
}
}

/**
* Emit `resultDialog.{title,description,acknowledge}` and
* `resultDialog.fields.<path>` 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[] = [];
Expand Down Expand Up @@ -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);
}
}
}
Expand Down Expand Up @@ -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 ────────────────────────────────────────────
Expand Down
28 changes: 28 additions & 0 deletions packages/cli/test/i18n-extract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand Down Expand Up @@ -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', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,16 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
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: {
Expand All @@ -183,6 +193,14 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
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: {
Expand Down Expand Up @@ -988,6 +1006,15 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
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: {
Expand All @@ -1007,6 +1034,14 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
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"
}
}
}
}
Expand Down Expand Up @@ -1287,11 +1322,28 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
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",
Expand Down
Loading
Loading