Skip to content
Original file line number Diff line number Diff line change
@@ -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`.
6 changes: 5 additions & 1 deletion packages/cli/test/i18n-extract-outcome-messages.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
15 changes: 13 additions & 2 deletions packages/lint/src/validate-predicate-path-refs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
// `<form>::<field>::<source>`, 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);
Expand Down Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1736,6 +1736,22 @@ export const enMetadataForms: NonNullable<TranslationData['metadataForms']> = {
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"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1736,6 +1736,22 @@ export const esESMetadataForms: NonNullable<TranslationData['metadataForms']> =
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"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1736,6 +1736,22 @@ export const jaJPMetadataForms: NonNullable<TranslationData['metadataForms']> =
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: "アクション完了後にリスト/ページを更新"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1736,6 +1736,22 @@ export const zhCNMetadataForms: NonNullable<TranslationData['metadataForms']> =
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: "执行完成后刷新当前列表/页面"
Expand Down
52 changes: 25 additions & 27 deletions packages/spec/src/system/metadata-form-zod-reconciliation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -295,8 +295,9 @@ const LEDGER: ReadonlyArray<OmitEntry | SubsetEntry> = [
// 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
Expand Down Expand Up @@ -391,27 +392,6 @@ const LEDGER: ReadonlyArray<OmitEntry | SubsetEntry> = [
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;
Expand All @@ -433,7 +413,11 @@ const LEDGER: ReadonlyArray<OmitEntry | SubsetEntry> = [
// 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',
Expand Down Expand Up @@ -462,6 +446,13 @@ const LEDGER: ReadonlyArray<OmitEntry | SubsetEntry> = [
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
Expand Down Expand Up @@ -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
Expand All @@ -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',
Expand All @@ -1447,6 +1444,7 @@ const RULED_EDITORS: Readonly<Record<string, { type: string } | { surface: strin
'object.listViews': { type: 'view' },
'page.slots': { surface: 'the page designer' },
'object.external': { surface: 'the import flow' },
'field.picklist': { surface: "the object designer's shared-picklist picker" },
};

/** Is `type.key` a key the ruling put in the class `reason` names? */
Expand Down
Loading
Loading