diff --git a/.changeset/21863-action-on-success-outcome-messages-form-rows.md b/.changeset/21863-action-on-success-outcome-messages-form-rows.md new file mode 100644 index 00000000000..79751a21386 --- /dev/null +++ b/.changeset/21863-action-on-success-outcome-messages-form-rows.md @@ -0,0 +1,6 @@ +--- +'@objectstack/spec': patch +'@objectstack/platform-objects': patch +--- + +Studio's action form now offers `onSuccess` (the route an `api` or `script` action opens once it succeeds, and whether it opens in place or in a new tab) and `outcomeMessages` (a JSON map from each `outcome` the handler returns to the success message shown for it), with their labels and help text translated for `zh-CN`, `ja-JP` and `es-ES`. diff --git a/packages/cli/test/i18n-extract-outcome-messages.test.ts b/packages/cli/test/i18n-extract-outcome-messages.test.ts index d2ed0146be3..31bcbfca440 100644 --- a/packages/cli/test/i18n-extract-outcome-messages.test.ts +++ b/packages/cli/test/i18n-extract-outcome-messages.test.ts @@ -70,7 +70,11 @@ describe('i18n extraction — outcomeMessages (#21095)', () => { }); it('emits nothing for an outcome nobody declared, and nothing for an action without the map', () => { - const outcomeKeys = paths().filter((k) => k.includes('.outcomeMessages.')); + // The `metadataForms.` subtree is left out: the action form's own + // `outcomeMessages` row puts its label / helpText / placeholder strings at + // `metadataForms.action.fields.outcomeMessages.*`, which are form-row text, + // not the copy of any declared outcome. + const outcomeKeys = paths().filter((k) => k.includes('.outcomeMessages.') && !k.startsWith('metadataForms.')); expect(outcomeKeys.sort()).toEqual([ 'globalActions.check_app_updates.outcomeMessages.up_to_date', 'objects.sys_environment._actions.archive_environment.outcomeMessages.already_archived', diff --git a/packages/lint/src/validate-predicate-path-refs.test.ts b/packages/lint/src/validate-predicate-path-refs.test.ts index 6fdf7489d8b..82c9798b58b 100644 --- a/packages/lint/src/validate-predicate-path-refs.test.ts +++ b/packages/lint/src/validate-predicate-path-refs.test.ts @@ -630,7 +630,15 @@ describe('#7010 corpus — shipped METADATA_FORM_REGISTRY', () => { // shipped `*.form.ts` corpus differenced against the merge base // `d78a0bda07` adds exactly `field :: useGrouping :: data.type == 'number'` // and removes none. - expect(predicates, 'the shipped metadata forms carry no predicates at all').toBe(83); + // It is 85 today, an ADDITION of TWO: #21863 offered the action form's + // `outcomeMessages` and `onSuccess` rows, each gated on the two action types + // whose parse accepts the key. Measured, not inferred: the shipped + // `*.form.ts` corpus differenced against the merge base `607463d736` by + // `
::::`, 83 → 85, adds exactly + // `action :: outcomeMessages` and `action :: onSuccess`, both + // `data.type == 'api' || data.type == 'script'`, and removes none. The two + // `onSuccess` sub-rows carry no predicate. + expect(predicates, 'the shipped metadata forms carry no predicates at all').toBe(85); const findings = validatePredicatePathRefs(corrupted); expect(findings).toHaveLength(predicates); @@ -731,7 +739,10 @@ describe('#7010 corpus — shipped METADATA_FORM_REGISTRY', () => { // literal. // It is 58 today: the field form's `useGrouping` row is gated on // `data.type == 'number'`, a single-quoted literal comparison. - expect(comparisons, 'no shipped predicate carries an `==`/`!=` literal comparison').toBe(58); + // It is 62 today: #21863's two new predicates each hold two single-quoted + // literal comparisons, `data.type == 'api'` and `data.type == 'script'`, + // on `action :: outcomeMessages` and on `action :: onSuccess`. + expect(comparisons, 'no shipped predicate carries an `==`/`!=` literal comparison').toBe(62); const rhsFindings = validatePredicatePathRefs(corrupted) .filter((f) => f.rule === PREDICATE_RHS_PATH_SHAPED); diff --git a/packages/platform-objects/src/apps/translations/action-body-panel-echo-decisions.test.ts b/packages/platform-objects/src/apps/translations/action-body-panel-echo-decisions.test.ts index 706af281023..a3e9535f4b8 100644 --- a/packages/platform-objects/src/apps/translations/action-body-panel-echo-decisions.test.ts +++ b/packages/platform-objects/src/apps/translations/action-body-panel-echo-decisions.test.ts @@ -659,7 +659,9 @@ describe('#19403 round 5 — the blind spot, FIFTH shape: one schema, two forms, // whatever the declared type is. The pin does not: it filters // `type === 'repeater'`, and `body` is a `composite`. All five echoed in // all three locales, and they are five of the six keys this round decides. - expect(ACTION_ENUMERATED.skipped.length).toBe(5); + // 7 since the action form offers the `onSuccess` composite — its two + // children, `navigate` and `openIn`, authored in all three locales. + expect(ACTION_ENUMERATED.skipped.length).toBe(7); for (const child of SHARED_BODY_CHILDREN) { expect(ACTION_ENUMERATED.skipped).toContain(`body.${child}`); expect( @@ -712,7 +714,8 @@ describe('#19403 round 5 — the blind spot, FIFTH shape: one schema, two forms, const coveredParents = new Set(ACTION_ENUMERATED.covered.map((k) => k.replace(/\.[^.]+$/, ''))); const skippedParents = new Set(ACTION_ENUMERATED.skipped.map((k) => k.replace(/\.[^.]+$/, ''))); expect([...coveredParents]).toEqual(['params']); - expect([...skippedParents]).toEqual(['body']); + // `onSuccess` since the action form offers it as a composite beside `body`. + expect([...skippedParents]).toEqual(['body', 'onSuccess']); for (const parent of ['params', 'body']) { expect(ACTION_ENUMERATED.covered.includes(parent), `${parent} is not itself a row property`).toBe(false); expect(ACTION_ENUMERATED.skipped.includes(parent), `${parent} is not itself an unwalked child`).toBe(false); diff --git a/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts index 83aece2ce91..09fd11a305d 100644 --- a/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts @@ -1736,6 +1736,22 @@ export const enMetadataForms: NonNullable = { label: "Error Message", helpText: "Error message shown when the action fails, in place of the raw error." }, + outcomeMessages: { + label: "Outcome Messages", + helpText: "Success message per handler outcome, as JSON: each key is a snake_case `outcome` value the handler returns in its success payload, and each value is the label shown for that outcome, e.g. {\"archived\": \"Archived\", \"already_archived\": \"Already archived\"}. A label may interpolate ${result.*}. An outcome with no entry shows the success message; a key the handler never returns is never shown. Not allowed beside a result dialog or `operation: 'update'`." + }, + onSuccess: { + label: "On Success", + helpText: "Navigation after the action succeeds: the route to open, and where to open it." + }, + "onSuccess.navigate": { + label: "Navigate", + helpText: "Route or URL template opened after the action succeeds. It may interpolate ${param.*} (the values the params dialog collected), ${ctx.*} (origin, apiBase, user, org, recordId, selection) and ${result.*} (the server response, e.g. ${result.id}). A relative value is an in-app route." + }, + "onSuccess.openIn": { + label: "Open In", + helpText: "Where the navigation opens: 'self' (the default) navigates in place; 'newTab' opens a new browser tab." + }, refreshAfter: { label: "Refresh After", helpText: "Refresh the list/page after action completes" diff --git a/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts index 02f85bf5d8c..670cc2688af 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts @@ -1736,6 +1736,22 @@ export const esESMetadataForms: NonNullable = label: "Mensaje de error", helpText: "Mensaje de error que se muestra cuando la acción falla, en lugar del error original." }, + outcomeMessages: { + label: "Mensajes por resultado", + helpText: "Mensaje de éxito por cada resultado del manejador, en JSON: cada clave es un valor `outcome` en snake_case que el manejador devuelve en su carga útil de éxito, y cada valor es la etiqueta que se muestra para ese resultado, p. ej., {\"archived\": \"Archivado\", \"already_archived\": \"Ya estaba archivado\"}. Una etiqueta puede interpolar ${result.*}. Un resultado sin entrada muestra el mensaje de éxito; una clave que el manejador nunca devuelve no se muestra nunca. No se admite junto a un diálogo de resultado ni con `operation: 'update'`." + }, + onSuccess: { + label: "Al completarse", + helpText: "Navegación tras completarse la acción: la ruta que se abre y dónde se abre." + }, + "onSuccess.navigate": { + label: "Navegar a", + helpText: "Ruta o plantilla de URL que se abre tras completarse la acción. Puede interpolar ${param.*} (los valores que recogió el diálogo de parámetros), ${ctx.*} (origin, apiBase, user, org, recordId, selection) y ${result.*} (la respuesta del servidor, p. ej., ${result.id}). Un valor relativo es una ruta dentro de la aplicación." + }, + "onSuccess.openIn": { + label: "Abrir navegación en", + helpText: "Dónde se abre la navegación: 'self' (el valor predeterminado) navega en la misma pestaña y 'newTab' abre una pestaña nueva del navegador." + }, refreshAfter: { label: "Actualizar después", helpText: "Actualiza la lista/página tras completar la acción" diff --git a/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts index 4c8ee80e928..a73acc441f0 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts @@ -1736,6 +1736,22 @@ export const jaJPMetadataForms: NonNullable = label: "エラーメッセージ", helpText: "アクションが失敗したときに、元のエラーの代わりに表示するエラーメッセージ。" }, + outcomeMessages: { + label: "結果別の成功メッセージ", + helpText: "ハンドラーの結果ごとの成功メッセージ(JSON で記述)。各キーはハンドラーが成功時のペイロードで返す snake_case の `outcome` 値、各値はその結果に表示するラベルです(例:{\"archived\": \"アーカイブしました\", \"already_archived\": \"すでにアーカイブ済みです\"})。ラベルには ${result.*} を埋め込めます。対応する項目がない結果には成功メッセージが表示され、ハンドラーが返さないキーは表示されることがありません。結果ダイアログや `operation: 'update'` とは併用できません。" + }, + onSuccess: { + label: "成功後の遷移", + helpText: "アクション成功後の遷移。開くルートと、それを開く場所を指定します。" + }, + "onSuccess.navigate": { + label: "遷移先", + helpText: "アクション成功後に開くルートまたは URL テンプレート。${param.*}(パラメーターダイアログで収集した値)、${ctx.*}(origin、apiBase、user、org、recordId、selection)、${result.*}(サーバーの応答。例:${result.id})を埋め込めます。相対値はアプリ内のルートです。" + }, + "onSuccess.openIn": { + label: "遷移を開く場所", + helpText: "遷移をどこで開くか。'self'(既定)はその場で遷移し、'newTab' は新しいブラウザタブで開きます。" + }, refreshAfter: { label: "完了後に更新", helpText: "アクション完了後にリスト/ページを更新" diff --git a/packages/platform-objects/src/apps/translations/object-lifecycle-panel-echo-decisions.test.ts b/packages/platform-objects/src/apps/translations/object-lifecycle-panel-echo-decisions.test.ts index 1cc49d48b68..63748a94e4c 100644 --- a/packages/platform-objects/src/apps/translations/object-lifecycle-panel-echo-decisions.test.ts +++ b/packages/platform-objects/src/apps/translations/object-lifecycle-panel-echo-decisions.test.ts @@ -1171,7 +1171,10 @@ describe('#19403 round 10 — the verdicts, on the live bundles', () => { // `nameField` — one new row label, authored in all three locales. // 661 since the action form offers `requiresMembershipReach` beside // `requiresFeature` — one new row label, authored in all three locales. - expect(translated.length, `${locale} positive control`).toBe(661); + // 665 since the action form offers `outcomeMessages` and the `onSuccess` + // composite (with its `navigate` and `openIn` sub-rows) beside + // `successMessage` — four new row labels, authored in all three locales. + expect(translated.length, `${locale} positive control`).toBe(665); } // ⭐ DARK — the blindness, executable. On a synthetic two-locale catalog the // all-three predicate returns 0 while the per-locale one returns 1, so the diff --git a/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts index bed694af11b..cfc39e06ffb 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts @@ -1736,6 +1736,22 @@ export const zhCNMetadataForms: NonNullable = label: "错误消息", helpText: "动作失败时显示的错误消息,替代原始错误信息。" }, + outcomeMessages: { + label: "按结果的成功消息", + helpText: "按处理程序结果显示的成功消息,以 JSON 填写:每个键是处理程序在成功返回中给出的 snake_case `outcome` 值,每个值是该结果显示的文字,例如 {\"archived\": \"已归档\", \"already_archived\": \"此前已归档\"}。文字中可插入 ${result.*}。没有对应条目的结果显示成功消息;处理程序从不返回的键永远不会显示。不能与结果对话框或 `operation: 'update'` 同时使用。" + }, + onSuccess: { + label: "成功后跳转", + helpText: "动作成功后的跳转:要打开的路由,以及在哪里打开。" + }, + "onSuccess.navigate": { + label: "跳转目标", + helpText: "动作成功后打开的路由或 URL 模板。可插入 ${param.*}(参数对话框收集的值)、${ctx.*}(origin、apiBase、user、org、recordId、selection)和 ${result.*}(服务器响应,例如 ${result.id})。相对值是应用内路由。" + }, + "onSuccess.openIn": { + label: "跳转打开位置", + helpText: "跳转在哪里打开——'self'(默认)在当前页跳转,'newTab' 打开新的浏览器标签页。" + }, refreshAfter: { label: "完成后刷新", helpText: "执行完成后刷新当前列表/页面" diff --git a/packages/spec/src/system/metadata-form-zod-reconciliation.test.ts b/packages/spec/src/system/metadata-form-zod-reconciliation.test.ts index 2a7c49dca95..c3bc2007ee9 100644 --- a/packages/spec/src/system/metadata-form-zod-reconciliation.test.ts +++ b/packages/spec/src/system/metadata-form-zod-reconciliation.test.ts @@ -295,8 +295,9 @@ const LEDGER: ReadonlyArray = [ // its group below. Three more were ruled rather than read (ruling record // 5861442317, #19332): authored through its own editor, code-declared // platform configuration, and no registered widget spells this union. Each - // holds exactly the keys that ruling put in it, and the test at the end of - // this file also reads what this package can read of each. + // holds exactly the keys that ruling put in it, plus any key a later ruling + // of its own added (`field.picklist`, record 5995552118), and the test at the + // end of this file also reads what this package can read of each. // // `view` rows are here although `view` is outside the top-level direction // until its per-arm forms exist: each reason holds on every arm, so none of @@ -391,27 +392,6 @@ const LEDGER: ReadonlyArray = [ key: 'externalSharingModel', why: 'declared, not enforced yet — liveness verdict `planned` (ADR-0090 D11: validated at authoring time only; the audience-aware evaluator branch that would honour it is scheduled, not built). No offer until it is enforced; whether to offer it then is a ruling for the enforcement, not for this gate', }, - { - kind: 'omit', - type: 'field', - path: ROOT_PATH, - key: 'picklist', - why: 'declared, not enforced yet — liveness verdict `planned` (the server-side resolution that serves a picklist-bound field its options is not landed). No offer until it is enforced; the field designer offering a picklist is a later Studio phase', - }, - { - kind: 'omit', - type: 'action', - path: ROOT_PATH, - key: 'onSuccess', - why: 'declared, not enforced yet — both of its children (`navigate`, `openIn`) carry the liveness verdict `planned`: no console consumer reads the block yet. No offer until it is enforced; whether to offer it then is a ruling for the enforcement, not for this gate', - }, - { - kind: 'omit', - type: 'action', - path: ROOT_PATH, - key: 'outcomeMessages', - why: "declared, not enforced yet — liveness verdict `planned` (#21095: the console reader that picks the entry named by the handler's `outcome` is a later link of the same ruling). No offer until it is enforced; whether to offer it then is a ruling for the enforcement, not for this gate", - }, // Renderer-owned vocabulary — a key is admitted here only when all three // hold: its `describe()` says the spec declares no vocabulary for its value; @@ -433,7 +413,11 @@ const LEDGER: ReadonlyArray = [ // the editor is another registered metadata type, the key stores that // type's own node, and the test at the end of this file reads it. The page // designer and the import flow live outside this package, so those two - // rows rest on the ruling alone. + // rows rest on the ruling alone. `field.picklist` joined the class by a + // ruling of its own (record 5995552118, #21863 2B); its editor, the object + // designer's shared-picklist picker, lives in objectui (objectui#10202 + // phase 2), so that row rests on that ruling and on the one that placed the + // picker there (record 5755653853, #18164 batch #209 item 1 A). { kind: 'omit', type: 'object', @@ -462,6 +446,13 @@ const LEDGER: ReadonlyArray = [ key: 'external', why: 'authored through its own editor — the import flow (ruling record 5861442317, #19332): the external-datasource import, which the Studio import dialog and `os datasource introspect` both reach through `POST /datasources/:name/external/tables/:remote/draft`, drafts the binding from the remote table it read, so the binding comes from the remote table rather than from a name typed into this form', }, + { + kind: 'omit', + type: 'field', + path: ROOT_PATH, + key: 'picklist', + why: "authored through its own editor — the object designer's shared-picklist picker (ruling record 5861442317, #19332; the picker placed there by ruling record 5755653853, #18164 batch #209 item 1 A): the key is enforced, liveness verdict `live` (the server resolves the named picklist onto the served field's `options` and judges a write against that set), and its offer was decided as that picker in the object designer's select-field editor, so a text row in this form would be a second, weaker entry beside it, where an author types a list name instead of picking one", + }, // Code-declared platform configuration (ruling record 5861442317, #19332): // this is the `lifecycle.*.onlyWhen` precedent applied at the root. Every @@ -1411,7 +1402,8 @@ describe('the ledger has a root coordinate, and the overlay is not surface', () // subset row rests on the same reading (its full editor is the `field` // type). The page designer and the import flow live in objectui and in the // datasource service, out of this package's reach, and are held to the -// ruling's word. +// ruling's word. So is the object designer's shared-picklist picker, in +// objectui, held to the word of the ruling that admitted `field.picklist`. // - No registered widget spells this union. The node is a union, and one of // its arms has a value `FormSelectOptionSchema` refuses as an option // `value`. That no widget renders the whole union is a reading of objectui's @@ -1421,11 +1413,16 @@ describe('the ledger has a root coordinate, and the overlay is not surface', () // declared in other packages. The ruling's key list is its only admission. // ──────────────────────────────────────────────────────────────────────────── -/** The ruling's three classes, each with the keys it put there, transcribed from the record. */ +/** + * The ruling's three classes, each with the keys it put there, transcribed from + * the record. One key was added later by a ruling of its own, the admission the + * test below asks for: `field.picklist`, into the own-editor class (record + * 5995552118, #21863 2B). + */ const RULED_ROOT_REASONS: ReadonlyArray<{ reason: string; keys: readonly string[] }> = [ { reason: 'authored through its own editor', - keys: ['object.actions', 'object.listViews', 'object.external', 'page.slots'], + keys: ['object.actions', 'object.listViews', 'object.external', 'page.slots', 'field.picklist'], }, { reason: 'code-declared platform configuration', @@ -1447,6 +1444,7 @@ const RULED_EDITORS: Readonly