Skip to content

Commit 6289ec3

Browse files
baozhoutaoclaude
andauthored
feat(i18n): make action resultDialog copy translatable (schema slot + resolver + platform bundles) (#3347)
* feat(i18n): make action resultDialog copy translatable (schema slot + resolver + platform bundles) The one-shot secret-reveal dialogs (create-user temporary password, 2FA backup codes, OAuth client secrets) always rendered hardcoded English: the translation protocol had no slot for Action.resultDialog, so neither the bundles nor any resolver could localize the dialog's title, description, acknowledge button, or field labels. - spec: add ActionResultDialogTranslationSchema and hang it off object `_actions`, `globalActions`, and the object-first `_actions` node. `fields` is keyed by the LITERAL result-field path ("user.email") — keys may contain dots, resolvers index the record directly. New resolveActionResultDialog overlay resolver, wired into translateAction. - cli: `os i18n extract` emits resultDialog.title/description/acknowledge and resultDialog.fields.<path> entries (dotted paths stay one segment). - platform-objects: fill the en / zh-CN / ja-JP / es-ES bundles for all six shipped resultDialogs (sys_user create_user + set_user_password, sys_two_factor enable + regenerate backup codes, sys_oauth_application register + rotate secret). Client-side rendering lands in objectui (@object-ui/i18n actionResultDialog + result-dialog handlers). Verified end-to-end against a live showcase instance: the create-user and set-password dialogs render fully localized in zh-CN. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(ci): escape NUL separator in extract test; regenerate translation reference doc - The dots-preserved assertion in i18n-extract.test.ts embedded two raw NUL bytes (0x00) as join separators, tripping the raw-NUL-byte guard -- use the unicode escape sequence instead (byte-identical at runtime). - Regenerate content/docs/references/system/translation.mdx for the new resultDialog translation node (gen:schema + gen:docs). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore(spec): record ActionResultDialogTranslation additions in the API surface snapshot Additive only (4 new exports, 0 breaking) -- the resultDialog translation schema/type and its slots on the action translation nodes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent dc5c733 commit 6289ec3

13 files changed

Lines changed: 577 additions & 13 deletions

File tree

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/cli": patch
4+
"@objectstack/platform-objects": patch
5+
---
6+
7+
feat(i18n): translation slot for action `resultDialog` copy — the one-shot secret-reveal dialogs are now localizable
8+
9+
The post-success `resultDialog` (temporary passwords, 2FA backup codes, OAuth
10+
client secrets) had no slot in the translation protocol, so its title /
11+
description / acknowledge button / field labels always rendered the hardcoded
12+
English metadata literals even on fully-translated locales.
13+
14+
- **spec.** `_actions.<action>` (object + object-first node) and
15+
`globalActions.<action>` gain an optional `resultDialog` translation node
16+
(`ActionResultDialogTranslationSchema`): `title`, `description`,
17+
`acknowledge`, and `fields` keyed by the **literal** result-field path
18+
(e.g. `"user.email"` — keys may contain dots; resolvers index the record
19+
directly, never split on `.`). New `resolveActionResultDialog` overlay
20+
resolver, wired into `translateAction` for API-boundary translation.
21+
- **cli.** `os i18n extract` emits the new `resultDialog.*` keys (title /
22+
description / acknowledge / `fields.<path>` for labelled fields), so
23+
coverage and skeleton generation see them.
24+
- **platform-objects.** en / zh-CN / ja-JP / es-ES bundles ship the
25+
resultDialog copy for all six shipped dialogs: `sys_user.create_user`,
26+
`sys_user.set_user_password`, `sys_two_factor.enable_two_factor`,
27+
`sys_two_factor.regenerate_backup_codes`,
28+
`sys_oauth_application.create_oauth_application`, and
29+
`sys_oauth_application.rotate_client_secret`.
30+
31+
Client-side rendering lands in objectui (`actionResultDialog` resolver in
32+
`@object-ui/i18n` + result-dialog handlers). Purely additive — untranslated
33+
locales keep falling back to the metadata literals.

content/docs/references/system/translation.mdx

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,29 @@ Translation data for a single field.
1616
## TypeScript Usage
1717

1818
```typescript
19-
import { AppTranslationBundle, CoverageBreakdownEntry, FieldTranslation, Locale, MessageFormat, ObjectTranslationData, ObjectTranslationNode, TranslationBundle, TranslationConfig, TranslationCoverageResult, TranslationData, TranslationDiffItem, TranslationDiffStatus, TranslationFileOrganization } from '@objectstack/spec/system';
20-
import type { AppTranslationBundle, CoverageBreakdownEntry, FieldTranslation, Locale, MessageFormat, ObjectTranslationData, ObjectTranslationNode, TranslationBundle, TranslationConfig, TranslationCoverageResult, TranslationData, TranslationDiffItem, TranslationDiffStatus, TranslationFileOrganization } from '@objectstack/spec/system';
19+
import { ActionResultDialogTranslation, AppTranslationBundle, CoverageBreakdownEntry, FieldTranslation, Locale, MessageFormat, ObjectTranslationData, ObjectTranslationNode, TranslationBundle, TranslationConfig, TranslationCoverageResult, TranslationData, TranslationDiffItem, TranslationDiffStatus, TranslationFileOrganization } from '@objectstack/spec/system';
20+
import type { ActionResultDialogTranslation, AppTranslationBundle, CoverageBreakdownEntry, FieldTranslation, Locale, MessageFormat, ObjectTranslationData, ObjectTranslationNode, TranslationBundle, TranslationConfig, TranslationCoverageResult, TranslationData, TranslationDiffItem, TranslationDiffStatus, TranslationFileOrganization } from '@objectstack/spec/system';
2121

2222
// Validate data
23-
const result = AppTranslationBundle.parse(data);
23+
const result = ActionResultDialogTranslation.parse(data);
2424
```
2525

26+
---
27+
28+
## ActionResultDialogTranslation
29+
30+
Translations for an action result dialog
31+
32+
### Properties
33+
34+
| Property | Type | Required | Description |
35+
| :--- | :--- | :--- | :--- |
36+
| **title** | `string` | optional | Translated result dialog title |
37+
| **description** | `string` | optional | Translated result dialog description |
38+
| **acknowledge** | `string` | optional | Translated acknowledge button label |
39+
| **fields** | `Record<string, string>` | optional | Result field labels keyed by the literal field path declared in the action metadata (keys may contain dots) |
40+
41+
2642
---
2743

2844
## AppTranslationBundle
@@ -110,7 +126,7 @@ Translation data for a single object
110126
| **description** | `string` | optional | Translated object description |
111127
| **fields** | `Record<string, { label?: string; help?: string; placeholder?: string; options?: Record<string, string> }>` | optional | Field-level translations |
112128
| **_views** | `Record<string, { label?: string; description?: string; emptyState?: object }>` | optional | View translations keyed by view name |
113-
| **_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 |
129+
| **_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 |
114130
| **_sections** | `Record<string, { label?: string; description?: string }>` | optional | Section translations keyed by section name |
115131

116132

@@ -132,7 +148,7 @@ Object-first aggregated translation node
132148
| **_options** | `Record<string, Record<string, string>>` | optional | Object-scoped picklist option translations keyed by field name |
133149
| **_views** | `Record<string, { label?: string; description?: string; emptyState?: object }>` | optional | View translations keyed by view name |
134150
| **_sections** | `Record<string, { label?: string }>` | optional | Section translations keyed by section name |
135-
| **_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 |
151+
| **_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 |
136152
| **_notifications** | `Record<string, { title?: string; body?: string }>` | optional | Notification translations keyed by notification name |
137153
| **_errors** | `Record<string, string>` | optional | Error message translations keyed by error code |
138154

@@ -195,7 +211,7 @@ Translation data for objects, apps, and UI messages
195211
| **apps** | `Record<string, { label: string; description?: string; navigation?: Record<string, { label: string }> }>` | optional | App translations keyed by app name |
196212
| **messages** | `Record<string, string>` | optional | UI message translations keyed by message ID |
197213
| **validationMessages** | `Record<string, string>` | optional | Translatable validation error messages keyed by rule name (e.g., `{"discount_limit": "折扣不能超过40%"}`) |
198-
| **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 |
214+
| **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 |
199215
| **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 |
200216
| **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 |
201217
| **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 |

packages/cli/src/utils/i18n-extract.ts

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,10 @@
2727
* objects.<name>._actions.<action>.successMessage
2828
* objects.<name>._actions.<action>.params.<param>.label / .helpText / .placeholder
2929
* objects.<name>._actions.<action>.params.<param>.options.<value>
30+
* objects.<name>._actions.<action>.resultDialog.title / .description / .acknowledge
31+
* objects.<name>._actions.<action>.resultDialog.fields.<path>
3032
* globalActions.<action>.label / .confirmText / .successMessage
31-
* globalActions.<action>.params.<param>.* (same shape as object actions)
33+
* globalActions.<action>.params.<param>.* / .resultDialog.* (same shape as object actions)
3234
* apps.<app>.label / .description
3335
* apps.<app>.navigation.<id>.label
3436
* dashboards.<dash>.label / .description
@@ -199,6 +201,43 @@ function pushActionParams(
199201
}
200202
}
201203

204+
/**
205+
* Emit `resultDialog.{title,description,acknowledge}` and
206+
* `resultDialog.fields.<path>` entries under an action's translation root.
207+
* Mirrors `ActionResultDialogTranslationSchema` in @objectstack/spec and the
208+
* client-side `actionResultDialog` resolver. Field entries are keyed by the
209+
* LITERAL result-field path (`"user.email"`) — the dot stays inside a single
210+
* path segment, matching how resolvers index the record without splitting.
211+
*/
212+
function pushActionResultDialog(
213+
out: ExpectedEntry[],
214+
actionRoot: string[],
215+
action: any,
216+
kind: ExpectedEntry['source'],
217+
objectName?: string,
218+
): void {
219+
const dialog = action?.resultDialog;
220+
if (!dialog || typeof dialog !== 'object') return;
221+
const base = [...actionRoot, 'resultDialog'];
222+
if (typeof dialog.title === 'string' && dialog.title.length > 0) {
223+
pushEntry(out, [...base, 'title'], dialog.title, kind, { objectName });
224+
}
225+
if (typeof dialog.description === 'string' && dialog.description.length > 0) {
226+
pushEntry(out, [...base, 'description'], dialog.description, kind, { objectName });
227+
}
228+
if (typeof dialog.acknowledge === 'string' && dialog.acknowledge.length > 0) {
229+
pushEntry(out, [...base, 'acknowledge'], dialog.acknowledge, kind, { objectName });
230+
}
231+
if (Array.isArray(dialog.fields)) {
232+
for (const field of dialog.fields) {
233+
if (!field || typeof field !== 'object') continue;
234+
if (typeof field.path !== 'string' || field.path.length === 0) continue;
235+
if (typeof field.label !== 'string' || field.label.length === 0) continue;
236+
pushEntry(out, [...base, 'fields', field.path], field.label, kind, { objectName });
237+
}
238+
}
239+
}
240+
202241
/** Collect every translatable entry from a normalized stack config. */
203242
export function collectExpectedEntries(config: any): ExpectedEntry[] {
204243
const out: ExpectedEntry[] = [];
@@ -286,6 +325,7 @@ export function collectExpectedEntries(config: any): ExpectedEntry[] {
286325
pushEntry(out, ['objects', objectName, '_actions', aname, 'successMessage'], action.successMessage, 'action', { objectName });
287326
}
288327
pushActionParams(out, ['objects', objectName, '_actions', aname], action, 'action', objectName);
328+
pushActionResultDialog(out, ['objects', objectName, '_actions', aname], action, 'action', objectName);
289329
}
290330
}
291331
}
@@ -320,6 +360,7 @@ export function collectExpectedEntries(config: any): ExpectedEntry[] {
320360
pushEntry(out, [...root, 'successMessage'], action.successMessage, kind, { objectName });
321361
}
322362
pushActionParams(out, root, action, kind, objectName);
363+
pushActionResultDialog(out, root, action, kind, objectName);
323364
}
324365

325366
// ── Apps + navigation ────────────────────────────────────────────

packages/cli/test/i18n-extract.test.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,16 @@ const config: any = {
3737
{
3838
name: 'set_password',
3939
label: 'Set Password',
40+
resultDialog: {
41+
title: 'Password Updated',
42+
description: 'Copy the temporary password now — it is shown only once.',
43+
acknowledge: 'Done',
44+
fields: [
45+
{ path: 'user.email', label: 'Email', format: 'text' },
46+
{ path: 'temporaryPassword', label: 'Temporary Password', format: 'secret' },
47+
{ path: 'unlabeled' }, // no label → no entry
48+
],
49+
},
4050
params: [
4151
{ field: 'label' },
4252
{ field: 'active', label: 'Enabled Override' },
@@ -143,6 +153,24 @@ describe('collectExpectedEntries', () => {
143153
// Top-level (global) actions get the same treatment.
144154
expect(byPath['globalActions.export_csv.params.delimiter.label']).toBe('Delimiter');
145155
});
156+
157+
it('emits resultDialog entries keyed by the literal field path (dots preserved)', () => {
158+
const entries = collectExpectedEntries(config);
159+
const base = 'objects.sys_position._actions.set_password.resultDialog';
160+
const byPath = Object.fromEntries(entries.map((e) => [e.path.join('.'), e.sourceValue]));
161+
162+
expect(byPath[`${base}.title`]).toBe('Password Updated');
163+
expect(byPath[`${base}.description`]).toBe('Copy the temporary password now — it is shown only once.');
164+
expect(byPath[`${base}.acknowledge`]).toBe('Done');
165+
expect(byPath[`${base}.fields.temporaryPassword`]).toBe('Temporary Password');
166+
// The dotted path stays ONE segment ('user.email'), never split.
167+
const emailEntry = entries.find(
168+
(e) => e.path.join('\u0000') === ['objects', 'sys_position', '_actions', 'set_password', 'resultDialog', 'fields', 'user.email'].join('\u0000'),
169+
);
170+
expect(emailEntry?.sourceValue).toBe('Email');
171+
// Fields without a label emit nothing.
172+
expect(byPath[`${base}.fields.unlabeled`]).toBeUndefined();
173+
});
146174
});
147175

148176
describe('extractTranslations', () => {

packages/platform-objects/src/apps/translations/en.objects.generated.ts

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,16 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
168168
mustChangePassword: {
169169
label: "Require Password Change On First Login"
170170
}
171+
},
172+
resultDialog: {
173+
title: "User Created",
174+
description: "Copy the temporary password now — it is shown only once and never stored.",
175+
acknowledge: "I have saved this password",
176+
fields: {
177+
"user.email": "Email",
178+
"user.phoneNumber": "Phone Number",
179+
temporaryPassword: "Temporary Password"
180+
}
171181
}
172182
},
173183
set_user_password: {
@@ -183,6 +193,14 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
183193
mustChangePassword: {
184194
label: "Require Password Change On Next Login"
185195
}
196+
},
197+
resultDialog: {
198+
title: "Password Updated",
199+
description: "If a temporary password was generated, copy it now — it is shown only once and never stored.",
200+
acknowledge: "Done",
201+
fields: {
202+
temporaryPassword: "Temporary Password"
203+
}
186204
}
187205
},
188206
set_user_role: {
@@ -988,6 +1006,15 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
9881006
password: {
9891007
label: "Current Password"
9901008
}
1009+
},
1010+
resultDialog: {
1011+
title: "Two-factor authentication enabled",
1012+
description: "Scan the QR code with your authenticator app, then save the backup codes somewhere safe. The backup codes are shown only once.",
1013+
acknowledge: "I have saved my backup codes",
1014+
fields: {
1015+
totpURI: "Authenticator URI",
1016+
backupCodes: "Backup Codes"
1017+
}
9911018
}
9921019
},
9931020
disable_two_factor: {
@@ -1007,6 +1034,14 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
10071034
password: {
10081035
label: "Current Password"
10091036
}
1037+
},
1038+
resultDialog: {
1039+
title: "New backup codes generated",
1040+
description: "Previous backup codes are now invalid. Save these new codes somewhere safe — they are shown only once.",
1041+
acknowledge: "I have saved the new codes",
1042+
fields: {
1043+
backupCodes: "Backup Codes"
1044+
}
10101045
}
10111046
}
10121047
}
@@ -1287,11 +1322,28 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
12871322
public: "Public"
12881323
}
12891324
}
1325+
},
1326+
resultDialog: {
1327+
title: "OAuth application registered",
1328+
description: "Save the client_secret now — it is shown only once and cannot be recovered. You can rotate it later if it leaks.",
1329+
acknowledge: "I have saved the client secret",
1330+
fields: {
1331+
"client.client_id": "Client ID",
1332+
"client.client_secret": "Client Secret"
1333+
}
12901334
}
12911335
},
12921336
rotate_client_secret: {
12931337
label: "Rotate Client Secret",
1294-
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."
1338+
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.",
1339+
resultDialog: {
1340+
title: "Client secret rotated",
1341+
description: "Save the new secret now — it is shown only once. Update every integration before the previous secret's grace period ends.",
1342+
acknowledge: "I have updated my integrations",
1343+
fields: {
1344+
client_secret: "New Client Secret"
1345+
}
1346+
}
12951347
},
12961348
delete_oauth_application: {
12971349
label: "Delete OAuth Application",

0 commit comments

Comments
 (0)