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
21 changes: 21 additions & 0 deletions .changeset/i18n-extract-action-params.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
---
'@objectstack/cli': minor
'@objectstack/platform-objects': patch
---

feat(cli): `os i18n extract` now emits action param keys (`o.<object>._actions.<action>.params.<param>.*`) so action-dialog forms are translatable (#3030)

The console client already resolves param labels, help text, placeholders and
option labels from `o.<object>._actions.<action>.params.*`, but the extractor
never walked `actions[].params`, so those keys were absent from generated
bundles and dialogs like Setup → Create User rendered raw English under any
locale. The extractor now emits:

- inline params → `label` / `helpText` / `placeholder` / `options.<value>`;
- field-backed params (`{ field: '…' }`) → only when they carry a literal
override (field translations already cover them at runtime);
- both object actions and top-level (global) actions.

`@objectstack/platform-objects` regenerates its en/zh-CN/ja-JP/es-ES bundles
with the new keys filled (user admin actions, sys_jwks fields, page variable
forms). Re-running extract with `--merge` stays idempotent.
54 changes: 54 additions & 0 deletions packages/cli/src/utils/i18n-extract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,10 @@
* objects.<name>._actions.<action>.label
* objects.<name>._actions.<action>.confirmText
* objects.<name>._actions.<action>.successMessage
* objects.<name>._actions.<action>.params.<param>.label / .helpText / .placeholder
* objects.<name>._actions.<action>.params.<param>.options.<value>
* globalActions.<action>.label / .confirmText / .successMessage
* globalActions.<action>.params.<param>.* (same shape as object actions)
* apps.<app>.label / .description
* apps.<app>.navigation.<id>.label
* dashboards.<dash>.label / .description
Expand Down Expand Up @@ -147,6 +150,55 @@ function pushEntry(
out.push({ path, sourceValue, source, ...extra });
}

/**
* Emit `params.<param>.{label,helpText,placeholder}` and
* `params.<param>.options.<value>` entries under an action's translation root.
* Mirrors the client-side resolver convention (`actionParamText` /
* `actionParamOptionLabel` in @object-ui/i18n) and the `params` slot on
* `ObjectTranslationDataSchema._actions`.
*
* Field-backed params (`{ field: 'email' }`) inherit the referenced field's
* translated label/help at runtime, so a label entry is emitted only when the
* author overrode it with a literal string. Inline params (name-based) always
* emit a label — falling back to the param name, matching the dialog render.
* Localized-map labels (`{ en, 'zh-CN' }`) are already multilingual and are
* skipped.
*/
function pushActionParams(
out: ExpectedEntry[],
actionRoot: string[],
action: any,
kind: ExpectedEntry['source'],
objectName?: string,
): void {
if (!Array.isArray(action?.params)) return;
for (const param of action.params) {
if (!param || typeof param !== 'object') continue;
const pname = param.name ?? param.field;
if (typeof pname !== 'string' || pname.length === 0) continue;
const base = [...actionRoot, 'params', pname];
const literalLabel = typeof param.label === 'string' ? param.label : undefined;
if (param.field) {
if (literalLabel) pushEntry(out, [...base, 'label'], literalLabel, kind, { objectName });
} else {
pushEntry(out, [...base, 'label'], literalLabel ?? pname, kind, { objectName });
}
if (typeof param.helpText === 'string' && param.helpText.length > 0) {
pushEntry(out, [...base, 'helpText'], param.helpText, kind, { objectName });
}
if (typeof param.placeholder === 'string' && param.placeholder.length > 0) {
pushEntry(out, [...base, 'placeholder'], param.placeholder, kind, { objectName });
}
if (Array.isArray(param.options)) {
for (const opt of param.options) {
if (opt && typeof opt === 'object' && 'value' in opt && typeof opt.label === 'string') {
pushEntry(out, [...base, 'options', String(opt.value)], opt.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 @@ -233,6 +285,7 @@ export function collectExpectedEntries(config: any): ExpectedEntry[] {
if (action.successMessage) {
pushEntry(out, ['objects', objectName, '_actions', aname, 'successMessage'], action.successMessage, 'action', { objectName });
}
pushActionParams(out, ['objects', objectName, '_actions', aname], action, 'action', objectName);
}
}
}
Expand Down Expand Up @@ -266,6 +319,7 @@ export function collectExpectedEntries(config: any): ExpectedEntry[] {
if (action.successMessage) {
pushEntry(out, [...root, 'successMessage'], action.successMessage, kind, { objectName });
}
pushActionParams(out, root, action, kind, objectName);
}

// ── Apps + navigation ────────────────────────────────────────────
Expand Down
53 changes: 52 additions & 1 deletion packages/cli/test/i18n-extract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,31 @@ const config: any = {
active: { label: 'Active', name: 'active' },
all: { label: 'All' },
},
actions: [
{
name: 'set_password',
label: 'Set Password',
params: [
{ field: 'label' },
{ field: 'active', label: 'Enabled Override' },
{
name: 'generatePassword',
label: 'Generate Temporary Password',
type: 'boolean',
helpText: 'Leave checked to auto-generate.',
},
{
name: 'mode',
type: 'select',
placeholder: 'Pick a mode',
options: [
{ value: 'auto', label: 'Auto' },
{ value: 'manual', label: 'Manual' },
],
},
],
},
],
},
],
actions: [
Expand All @@ -43,7 +68,12 @@ const config: any = {
confirmText: 'Merge?',
successMessage: 'Merged.',
},
{ name: 'export_csv', label: 'Export CSV', successMessage: 'Done.' },
{
name: 'export_csv',
label: 'Export CSV',
successMessage: 'Done.',
params: [{ name: 'delimiter', label: 'Delimiter' }],
},
],
translations: [
{
Expand Down Expand Up @@ -92,6 +122,27 @@ describe('collectExpectedEntries', () => {
expect(byPath['objects.sys_position._actions.merge.label']).toBe('Merge');
expect(byPath['metadataForms.flow.fields.name.label']).toBe('Name');
});

it('emits action param entries (inline + top-level), skipping field-backed labels without overrides', () => {
const entries = collectExpectedEntries(config);
const byPath = Object.fromEntries(entries.map((e) => [e.path.join('.'), e.sourceValue]));
const base = 'objects.sys_position._actions.set_password.params';

// Field-backed param with no literal override → no label entry (field
// translations cover it at runtime).
expect(byPath[`${base}.label.label`]).toBeUndefined();
// Field-backed param WITH a literal override → entry under the field name.
expect(byPath[`${base}.active.label`]).toBe('Enabled Override');
// Inline params emit label / helpText / placeholder / options.
expect(byPath[`${base}.generatePassword.label`]).toBe('Generate Temporary Password');
expect(byPath[`${base}.generatePassword.helpText`]).toBe('Leave checked to auto-generate.');
expect(byPath[`${base}.mode.label`]).toBe('mode'); // no label → falls back to name
expect(byPath[`${base}.mode.placeholder`]).toBe('Pick a mode');
expect(byPath[`${base}.mode.options.auto`]).toBe('Auto');
expect(byPath[`${base}.mode.options.manual`]).toBe('Manual');
// Top-level (global) actions get the same treatment.
expect(byPath['globalActions.export_csv.params.delimiter.label']).toBe('Delimiter');
});
});

describe('extractTranslations', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -793,6 +793,22 @@ export const enMetadataForms: NonNullable<TranslationData['metadataForms']> = {
label: "Variables",
helpText: "Local page state variables"
},
"variables.name": {
label: "Name",
helpText: "Variable name — exposed to expressions as `page.<name>`"
},
"variables.type": {
label: "Type",
helpText: "Value type"
},
"variables.defaultValue": {
label: "Default Value",
helpText: "Initial value (defaults to a type-appropriate empty value)"
},
"variables.source": {
label: "Source",
helpText: "Component (by id) that writes this variable — e.g. an element:record_picker"
},
regions: {
label: "Regions",
helpText: "Layout regions (header, main, sidebar, footer) with components"
Expand Down
Loading