Skip to content

Commit 8e35895

Browse files
feat(spec): offer onSuccess and outcomeMessages in Studio's action form; field.picklist joins the own-editor class (#21901)
Fixes #21863 Clause-②: no This lands the director seat's ruling 1A / 2B on the card (record `5995552118`, maintainer 「同意」). Studio's action form now offers `onSuccess` and `outcomeMessages`, using the controls ruling `5861442317` already gave their shapes. The `field.picklist` ledger row moves into the ruled class "authored through its own editor". No Zod key, liveness row or gate logic changes. ## What changed - **The form rows** (`packages/spec/src/ui/action.form.ts`), in the Behavior section after `successMessage` / `errorMessage`: - `outcomeMessages` is one `widget: 'json'` row, spelled like the file's `patch` and `bodyExtra` rows (G1: a map-shaped value takes `json`). Its helpText says each key is a snake_case `outcome` value the handler returns, and each value is the label shown for that outcome. It also says what happens with no entry, and that a key the handler never returns is never shown. - `onSuccess` is one `composite` row (G2: an object-shaped value takes curated sub-rows), the face the `body` row above already uses. It has two sub-rows: - `navigate` is `type: 'text'`, `required: true`, because the Zod requires it inside the block. Its helpText names the route or URL template and its three interpolation scopes (`${param.*}`, `${ctx.*}`, `${result.*}`), taken from the key's describe. - `openIn` is `type: 'select'` with no inline `options`. The member `newTab` has a capital letter, and `FormSelectOptionSchema.value` is a lowercase system identifier, so an inline list cannot spell it. The select reads its two members (`self`, `newTab`) off the served schema instead. Their meanings are in the helpText, and nothing is retyped. - Both rows carry `visibleWhen: "data.type == 'api' || data.type == 'script'"`. These are the two types the parse accepts them on (the `onSuccess` type refinement and `refuseInertOutcomeMessages` in `action.zod.ts`), so the control is shown where the value is accepted. - No designed control and no new widget. - **The ledger** (`packages/spec/src/system/metadata-form-zod-reconciliation.test.ts`): - The `action.onSuccess` and `action.outcomeMessages` `omit` rows are deleted from the "Declared, not enforced yet" group. - **2B:** `field.picklist` is added to the "authored through its own editor" key list in `RULED_ROOT_REASONS`, and `RULED_EDITORS` gains `'field.picklist': { surface: "the object designer's shared-picklist picker" }`. - The picklist row moves into that class's group and its `why` is rewritten. It states the `live` verdict (the server resolves the named picklist onto the served field's `options` and judges a write against that set). It names the surface, and gives the class's citation with the placement ruling inside the same parenthetical: `(ruling record 5861442317, #19332; the picker placed there by ruling record 5755653853, #18164 batch #209 item 1 A)`. - Comments only: the four comment blocks that described the class as holding exactly ruling `5861442317`'s keys now name `field.picklist` and the ruling that admitted it (`5995552118`). - `object.externalSharingModel` and `view.groups` are untouched. - **The catalogs**: the four `packages/platform-objects/src/apps/translations/*.metadata-forms.generated.ts` files were regenerated with `node scripts/check-i18n-bundles.mjs --write --filter=platform-objects`, following PR #21854: - The first `--write` added the four new keys (`outcomeMessages`, `onSuccess`, `onSuccess.navigate`, `onSuccess.openIn`). The zh-CN / ja-JP / es-ES `label` and `helpText` leaves were then written in place. - A second `--write` kept those leaves and dropped the eight provenance entries per locale that the first extract had added to the `*.source-hashes.generated.ts` companions. - The three companions are byte-identical to `BASE`: blob `2decb02bf6f1` (es-ES), `a96e8eb503d5` (ja-JP) and `31882e433b83` (zh-CN), before and after. - **Three count pins in `platform-objects`, each re-taken with one comment line naming the rows:** - The translated row-label positive control in `object-lifecycle-panel-echo-decisions.test.ts`: 661 → 665 per locale (four new row labels). - In `action-body-panel-echo-decisions.test.ts`, the count of composite children the repeater pin does not walk: 5 → 7 (`onSuccess.navigate`, `onSuccess.openIn`). - In the same file, the set of their parents: `['body']` → `['body', 'onSuccess']`. The same file's echo rule ("no leaf on this panel reads its `en` source unless the ledger decided it is an echo") stays green, so all eight new leaves are authored in all three locales. - **Changeset**: `@objectstack/spec` patch and `@objectstack/platform-objects` patch, in one file (see What ships). ## Controls (reverse verification, from the committed state) Both legs ran against HEAD `a5106c1c78`, in a throwaway detached worktree at that commit, through `scripts/ablation-replace.mjs` in wrap mode. Each leg also had its own `trap` that restores from `HEAD`. Command: `pnpm --filter @objectstack/spec exec vitest run --maxWorkers=2 src/system/metadata-form-zod-reconciliation.test.ts`, through `scripts/pm/os-verify-lock.sh`. | state | reading | |---|---| | baseline (unmutated) | 1 file, **76 passed (76)** | | the `onSuccess` form row deleted (anchor x1 → x0, blob `da0842c71614` → `0c90358ff49f`) | **1 failed / 75 passed**: `action.(root): accepted by the Zod but unauthorable in the form — offer it, or add a root ledger entry that records why it is not offered: expected [ 'onSuccess' ] to deeply equal []`. Restored: blob `da0842c71614` == HEAD, `git diff HEAD` empty | | `field.picklist` removed from the class's key list (anchor x1 → x0, injected text x0 → x1 read on disk during the run, blob `c3bc2007ee93` → `aa5ba299af81`) | **1 failed / 75 passed**: `"authored through its own editor": the root rows giving this reason are not the keys ruling record 5861442317 put in it. A key the ruling did not name needs a ruling of its own; …: expected [ 'field.picklist', …(4) ] to deeply equal [ 'object.actions', …(3) ]`. Restored: blob `c3bc2007ee93` == HEAD, `git diff HEAD` empty, `git status --porcelain` 0 lines | The first run of leg 1 timed out in the lock queue (exit 99, never acquired). The tool restored the file then (blob == HEAD), and the leg was re-run once the lock freed; the reading above is the re-run. The test imports only relative `src` paths and the spec package has no workspace dependencies, so no `dist/` is involved in either leg. ## What ships Measured with `npm pack --dry-run --json --ignore-scripts`, with a positive control: - `@objectstack/spec`: `src/ui/action.form.ts` is not in `files[]` (0 entries; `src/ui/action.zod.ts`, the control, is 1). The form ships compiled, though. The new `onSuccess` and `outcomeMessages` helpTexts are each in 6 packed `dist/` files, and the control, `successMessage`'s helpText, is in 6. - `@objectstack/platform-objects`: the new en helpText is in 6 packed files, against 6 for the control. The zh-CN and ja-JP labels are in 6 each, matched as unicode-escaped text, and the es-ES label is in 6. The control, zh-CN `successMessage`, is in 6. Both packages publish the change, so `skip-changeset` does not apply. ## Verification (head `a5106c1c78`) All suites ran through `scripts/pm/os-verify-lock.sh`. - `pnpm --filter @objectstack/spec exec vitest run --project local --maxWorkers=2`: **618 files passed, 18450 tests passed, 1 todo**. - `pnpm --filter @objectstack/spec exec vitest run --project repo --maxWorkers=2`: **53 files passed, 902 tests passed**. - `pnpm --filter @objectstack/platform-objects exec vitest run --maxWorkers=2`: **59 files, 949 passed**. Run before the pins were re-taken, it gave exactly the three failures above (`expected 7 to be 5`, `expected [ 'body', 'onSuccess' ] to deeply equal [ 'body' ]`, `zh-CN positive control: expected 665 to be 661`). - `pnpm --filter @objectstack/metadata-protocol exec vitest run --maxWorkers=2`: **215 files passed, 3 skipped; 27906 tests passed, 19 skipped**. No served-schema count moved: the change adds no Zod key. - `pnpm --filter @objectstack/spec run typecheck`: exit 0. `tsc -p tsconfig.test.json --listFilesOnly` compiles both changed spec files. - `pnpm --filter @objectstack/platform-objects run typecheck`: exit 0. Its `tsconfig.test.json` compiles both changed test files. - `pnpm check:i18n`: exit 0, `platform-objects in sync (11 bundle(s))`. - `pnpm check:i18n-coverage`: exit 0, `13 config(s), 621 baselined untranslated string(s), none new`. The first attempt exited 3 (PREREQUISITE NOT MET, the example closure unbuilt), and the reading here is from after the build. - `pnpm --filter @objectstack/spec check:generated`: exit 0 (15 artifacts up to date). - **Gates:** `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 87 commands from 9 paths against merge base `607463d73`. All 87 ran on `a5106c1c78`. `--ran` reconciliation: `87 derived famil(ies) accounted for — 87 run, 0 NOT-MEASURED`. `pnpm check:dual-build-cjs-loads` first exited 3 (PREREQUISITE NOT MET, no `dist/` for nine packages), and exited 0 after a full workspace build. The record keeps that last reading. - **eslint:** `--no-inline-config --format json` on the 8 changed TS files gave 8 files, 0 errors, 0 warnings. The population is `eslint.config.mjs:971` (`**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}`). The config never enables type-aware linting (`eslint.config.mjs:326-328`: no `parserOptions.project`), so the verdict on an untouched file cannot move. - **Patch round 1, at `bff24d6190`** (claim revisions `5999807822` and `6000249318`; one commit on `a5106c1c78`): the two red shards on `a5106c1c78` were this PR's own pins. - `Test Core (4/6)`: `packages/lint/src/validate-predicate-path-refs.test.ts` re-takes the shipped-corpus pins 83 → 85 (predicates) and 58 → 62 (`==` / `!=` literal comparisons), each with a history comment. Measured against merge base `607463d736` by `<form>::<field>::<source>`: added exactly `action :: outcomeMessages` and `action :: onSuccess`, both `data.type == 'api' || data.type == 'script'`; none removed. - `Test Core (2/6)`: `packages/cli/test/i18n-extract-outcome-messages.test.ts` leaves the `metadataForms.` subtree out of its `.outcomeMessages.` selection. The new row's `metadataForms.action.fields.outcomeMessages.{label,helpText,placeholder}` paths are form-row text, so the equality stays over the four action-outcome keys. Ablation control: with the clause made a no-op, the file reddens exactly as CI did (`expected [ …(7) ] to deeply equal [ …(4) ]`). - Local runs after a full build: `@objectstack/cli` 352 files, 4683 passed, 2 skipped; `@objectstack/lint` 119 files, 5629 passed; plus `objectql`, `service-automation`, `plugin-approvals`, `cloud-connection`, `http-conformance`, `plugin-dev`, `example-embed-objectql`, `connector-rest`, `connector-slack` and `rest`, all green. `dispatch-gates` derived 89 commands from 11 paths, and all 89 exited 0. ESLint 0 / 0 on the 2 edited files. No source line moves in `packages/lint` or `packages/cli`. ## Acceptance notes - **The picklist citation.** The gate requires every row of a ruled class to contain `5861442317` ("cite the ruling record that decided the reason"). The dispatch asked that the row name the placement ruling (`5755653853`). So the row carries both, in the one parenthetical the class's citation form uses. The two-ruling spelling follows the `object.listViews` row in the same class (`ruling record 5861442317, #19332; per-arm view forms under the #19330 ruling, letter A`). The ruling that admitted the key to the class (`5995552118`) is cited in code comments only. - **The picker does not exist yet.** At the objectui pin `0abd4f9f87`, `ObjectFieldInspector` has the inline option editor and no shared-picklist picker; the picker is objectui#10202 phase 2. The row's `why` therefore says the offer "was decided as that picker", not that the picker is there. Until it lands, `field.picklist` is authorable only in source. - **Renderer reading, not a browser run.** Read at the objectui pin, not run in a browser. `SchemaForm.tsx` resolves a composite sub-row's schema from the parent's `properties`. `select` is a passthrough widget, and with no `fieldSpec.options` the select falls back to the sub-schema's `enum`, so `openIn` should render `self` / `newTab`. The JSON Schema this head emits (`packages/spec/json-schema/ui/Action.json`) carries `onSuccess` as a plain object, with `navigate` required and `openIn.enum` equal to `self` and `newTab`, which is the shape that fallback reads. `json` is a passthrough widget too, so `outcomeMessages` takes the same face as `patch` and `bodyExtra`. NOT MEASURED: a browser run of either face. - **Surface, stated.** The claim named `action.form.ts`, the four catalogs, the reconciliation test, any `platform-objects` count pin, and a changeset. `action-body-panel-echo-decisions.test.ts` is the second `platform-objects` pin the new composite moves. It falls under the claim's "any `platform-objects` count pin" and under the cross-lane declaration `5998582591` on #6367. No metadata-protocol pin moved. - `origin/main` gained one commit after the branch point (`87712ab823`, global search in `metadata-protocol`). It touches none of this PR's paths, so it was not merged. --- _Generated by [Claude Code](https://claude.ai/code/session_01T9u38rswFp5Rw8DswRUReJ)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 3dbd084 commit 8e35895

11 files changed

Lines changed: 146 additions & 33 deletions
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@objectstack/spec': patch
3+
'@objectstack/platform-objects': patch
4+
---
5+
6+
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`.

‎packages/cli/test/i18n-extract-outcome-messages.test.ts‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,11 @@ describe('i18n extraction — outcomeMessages (#21095)', () => {
7070
});
7171

7272
it('emits nothing for an outcome nobody declared, and nothing for an action without the map', () => {
73-
const outcomeKeys = paths().filter((k) => k.includes('.outcomeMessages.'));
73+
// The `metadataForms.` subtree is left out: the action form's own
74+
// `outcomeMessages` row puts its label / helpText / placeholder strings at
75+
// `metadataForms.action.fields.outcomeMessages.*`, which are form-row text,
76+
// not the copy of any declared outcome.
77+
const outcomeKeys = paths().filter((k) => k.includes('.outcomeMessages.') && !k.startsWith('metadataForms.'));
7478
expect(outcomeKeys.sort()).toEqual([
7579
'globalActions.check_app_updates.outcomeMessages.up_to_date',
7680
'objects.sys_environment._actions.archive_environment.outcomeMessages.already_archived',

‎packages/lint/src/validate-predicate-path-refs.test.ts‎

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -630,7 +630,15 @@ describe('#7010 corpus — shipped METADATA_FORM_REGISTRY', () => {
630630
// shipped `*.form.ts` corpus differenced against the merge base
631631
// `d78a0bda07` adds exactly `field :: useGrouping :: data.type == 'number'`
632632
// and removes none.
633-
expect(predicates, 'the shipped metadata forms carry no predicates at all').toBe(83);
633+
// It is 85 today, an ADDITION of TWO: #21863 offered the action form's
634+
// `outcomeMessages` and `onSuccess` rows, each gated on the two action types
635+
// whose parse accepts the key. Measured, not inferred: the shipped
636+
// `*.form.ts` corpus differenced against the merge base `607463d736` by
637+
// `<form>::<field>::<source>`, 83 → 85, adds exactly
638+
// `action :: outcomeMessages` and `action :: onSuccess`, both
639+
// `data.type == 'api' || data.type == 'script'`, and removes none. The two
640+
// `onSuccess` sub-rows carry no predicate.
641+
expect(predicates, 'the shipped metadata forms carry no predicates at all').toBe(85);
634642

635643
const findings = validatePredicatePathRefs(corrupted);
636644
expect(findings).toHaveLength(predicates);
@@ -731,7 +739,10 @@ describe('#7010 corpus — shipped METADATA_FORM_REGISTRY', () => {
731739
// literal.
732740
// It is 58 today: the field form's `useGrouping` row is gated on
733741
// `data.type == 'number'`, a single-quoted literal comparison.
734-
expect(comparisons, 'no shipped predicate carries an `==`/`!=` literal comparison').toBe(58);
742+
// It is 62 today: #21863's two new predicates each hold two single-quoted
743+
// literal comparisons, `data.type == 'api'` and `data.type == 'script'`,
744+
// on `action :: outcomeMessages` and on `action :: onSuccess`.
745+
expect(comparisons, 'no shipped predicate carries an `==`/`!=` literal comparison').toBe(62);
735746

736747
const rhsFindings = validatePredicatePathRefs(corrupted)
737748
.filter((f) => f.rule === PREDICATE_RHS_PATH_SHAPED);

‎packages/platform-objects/src/apps/translations/action-body-panel-echo-decisions.test.ts‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -659,7 +659,9 @@ describe('#19403 round 5 — the blind spot, FIFTH shape: one schema, two forms,
659659
// whatever the declared type is. The pin does not: it filters
660660
// `type === 'repeater'`, and `body` is a `composite`. All five echoed in
661661
// all three locales, and they are five of the six keys this round decides.
662-
expect(ACTION_ENUMERATED.skipped.length).toBe(5);
662+
// 7 since the action form offers the `onSuccess` composite — its two
663+
// children, `navigate` and `openIn`, authored in all three locales.
664+
expect(ACTION_ENUMERATED.skipped.length).toBe(7);
663665
for (const child of SHARED_BODY_CHILDREN) {
664666
expect(ACTION_ENUMERATED.skipped).toContain(`body.${child}`);
665667
expect(
@@ -712,7 +714,8 @@ describe('#19403 round 5 — the blind spot, FIFTH shape: one schema, two forms,
712714
const coveredParents = new Set(ACTION_ENUMERATED.covered.map((k) => k.replace(/\.[^.]+$/, '')));
713715
const skippedParents = new Set(ACTION_ENUMERATED.skipped.map((k) => k.replace(/\.[^.]+$/, '')));
714716
expect([...coveredParents]).toEqual(['params']);
715-
expect([...skippedParents]).toEqual(['body']);
717+
// `onSuccess` since the action form offers it as a composite beside `body`.
718+
expect([...skippedParents]).toEqual(['body', 'onSuccess']);
716719
for (const parent of ['params', 'body']) {
717720
expect(ACTION_ENUMERATED.covered.includes(parent), `${parent} is not itself a row property`).toBe(false);
718721
expect(ACTION_ENUMERATED.skipped.includes(parent), `${parent} is not itself an unwalked child`).toBe(false);

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

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1736,6 +1736,22 @@ export const enMetadataForms: NonNullable<TranslationData['metadataForms']> = {
17361736
label: "Error Message",
17371737
helpText: "Error message shown when the action fails, in place of the raw error."
17381738
},
1739+
outcomeMessages: {
1740+
label: "Outcome Messages",
1741+
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'`."
1742+
},
1743+
onSuccess: {
1744+
label: "On Success",
1745+
helpText: "Navigation after the action succeeds: the route to open, and where to open it."
1746+
},
1747+
"onSuccess.navigate": {
1748+
label: "Navigate",
1749+
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."
1750+
},
1751+
"onSuccess.openIn": {
1752+
label: "Open In",
1753+
helpText: "Where the navigation opens: 'self' (the default) navigates in place; 'newTab' opens a new browser tab."
1754+
},
17391755
refreshAfter: {
17401756
label: "Refresh After",
17411757
helpText: "Refresh the list/page after action completes"

‎packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1736,6 +1736,22 @@ export const esESMetadataForms: NonNullable<TranslationData['metadataForms']> =
17361736
label: "Mensaje de error",
17371737
helpText: "Mensaje de error que se muestra cuando la acción falla, en lugar del error original."
17381738
},
1739+
outcomeMessages: {
1740+
label: "Mensajes por resultado",
1741+
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'`."
1742+
},
1743+
onSuccess: {
1744+
label: "Al completarse",
1745+
helpText: "Navegación tras completarse la acción: la ruta que se abre y dónde se abre."
1746+
},
1747+
"onSuccess.navigate": {
1748+
label: "Navegar a",
1749+
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."
1750+
},
1751+
"onSuccess.openIn": {
1752+
label: "Abrir navegación en",
1753+
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."
1754+
},
17391755
refreshAfter: {
17401756
label: "Actualizar después",
17411757
helpText: "Actualiza la lista/página tras completar la acción"

‎packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1736,6 +1736,22 @@ export const jaJPMetadataForms: NonNullable<TranslationData['metadataForms']> =
17361736
label: "エラーメッセージ",
17371737
helpText: "アクションが失敗したときに、元のエラーの代わりに表示するエラーメッセージ。"
17381738
},
1739+
outcomeMessages: {
1740+
label: "結果別の成功メッセージ",
1741+
helpText: "ハンドラーの結果ごとの成功メッセージ(JSON で記述)。各キーはハンドラーが成功時のペイロードで返す snake_case の `outcome` 値、各値はその結果に表示するラベルです(例:{\"archived\": \"アーカイブしました\", \"already_archived\": \"すでにアーカイブ済みです\"})。ラベルには ${result.*} を埋め込めます。対応する項目がない結果には成功メッセージが表示され、ハンドラーが返さないキーは表示されることがありません。結果ダイアログや `operation: 'update'` とは併用できません。"
1742+
},
1743+
onSuccess: {
1744+
label: "成功後の遷移",
1745+
helpText: "アクション成功後の遷移。開くルートと、それを開く場所を指定します。"
1746+
},
1747+
"onSuccess.navigate": {
1748+
label: "遷移先",
1749+
helpText: "アクション成功後に開くルートまたは URL テンプレート。${param.*}(パラメーターダイアログで収集した値)、${ctx.*}(origin、apiBase、user、org、recordId、selection)、${result.*}(サーバーの応答。例:${result.id})を埋め込めます。相対値はアプリ内のルートです。"
1750+
},
1751+
"onSuccess.openIn": {
1752+
label: "遷移を開く場所",
1753+
helpText: "遷移をどこで開くか。'self'(既定)はその場で遷移し、'newTab' は新しいブラウザタブで開きます。"
1754+
},
17391755
refreshAfter: {
17401756
label: "完了後に更新",
17411757
helpText: "アクション完了後にリスト/ページを更新"

‎packages/platform-objects/src/apps/translations/object-lifecycle-panel-echo-decisions.test.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1171,7 +1171,10 @@ describe('#19403 round 10 — the verdicts, on the live bundles', () => {
11711171
// `nameField` — one new row label, authored in all three locales.
11721172
// 661 since the action form offers `requiresMembershipReach` beside
11731173
// `requiresFeature` — one new row label, authored in all three locales.
1174-
expect(translated.length, `${locale} positive control`).toBe(661);
1174+
// 665 since the action form offers `outcomeMessages` and the `onSuccess`
1175+
// composite (with its `navigate` and `openIn` sub-rows) beside
1176+
// `successMessage` — four new row labels, authored in all three locales.
1177+
expect(translated.length, `${locale} positive control`).toBe(665);
11751178
}
11761179
// ⭐ DARK — the blindness, executable. On a synthetic two-locale catalog the
11771180
// all-three predicate returns 0 while the per-locale one returns 1, so the

‎packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1736,6 +1736,22 @@ export const zhCNMetadataForms: NonNullable<TranslationData['metadataForms']> =
17361736
label: "错误消息",
17371737
helpText: "动作失败时显示的错误消息,替代原始错误信息。"
17381738
},
1739+
outcomeMessages: {
1740+
label: "按结果的成功消息",
1741+
helpText: "按处理程序结果显示的成功消息,以 JSON 填写:每个键是处理程序在成功返回中给出的 snake_case `outcome` 值,每个值是该结果显示的文字,例如 {\"archived\": \"已归档\", \"already_archived\": \"此前已归档\"}。文字中可插入 ${result.*}。没有对应条目的结果显示成功消息;处理程序从不返回的键永远不会显示。不能与结果对话框或 `operation: 'update'` 同时使用。"
1742+
},
1743+
onSuccess: {
1744+
label: "成功后跳转",
1745+
helpText: "动作成功后的跳转:要打开的路由,以及在哪里打开。"
1746+
},
1747+
"onSuccess.navigate": {
1748+
label: "跳转目标",
1749+
helpText: "动作成功后打开的路由或 URL 模板。可插入 ${param.*}(参数对话框收集的值)、${ctx.*}(origin、apiBase、user、org、recordId、selection)和 ${result.*}(服务器响应,例如 ${result.id})。相对值是应用内路由。"
1750+
},
1751+
"onSuccess.openIn": {
1752+
label: "跳转打开位置",
1753+
helpText: "跳转在哪里打开——'self'(默认)在当前页跳转,'newTab' 打开新的浏览器标签页。"
1754+
},
17391755
refreshAfter: {
17401756
label: "完成后刷新",
17411757
helpText: "执行完成后刷新当前列表/页面"

‎packages/spec/src/system/metadata-form-zod-reconciliation.test.ts‎

Lines changed: 25 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -295,8 +295,9 @@ const LEDGER: ReadonlyArray<OmitEntry | SubsetEntry> = [
295295
// its group below. Three more were ruled rather than read (ruling record
296296
// 5861442317, #19332): authored through its own editor, code-declared
297297
// platform configuration, and no registered widget spells this union. Each
298-
// holds exactly the keys that ruling put in it, and the test at the end of
299-
// this file also reads what this package can read of each.
298+
// holds exactly the keys that ruling put in it, plus any key a later ruling
299+
// of its own added (`field.picklist`, record 5995552118), and the test at the
300+
// end of this file also reads what this package can read of each.
300301
//
301302
// `view` rows are here although `view` is outside the top-level direction
302303
// until its per-arm forms exist: each reason holds on every arm, so none of
@@ -391,27 +392,6 @@ const LEDGER: ReadonlyArray<OmitEntry | SubsetEntry> = [
391392
key: 'externalSharingModel',
392393
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',
393394
},
394-
{
395-
kind: 'omit',
396-
type: 'field',
397-
path: ROOT_PATH,
398-
key: 'picklist',
399-
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',
400-
},
401-
{
402-
kind: 'omit',
403-
type: 'action',
404-
path: ROOT_PATH,
405-
key: 'onSuccess',
406-
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',
407-
},
408-
{
409-
kind: 'omit',
410-
type: 'action',
411-
path: ROOT_PATH,
412-
key: 'outcomeMessages',
413-
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",
414-
},
415395

416396
// Renderer-owned vocabulary — a key is admitted here only when all three
417397
// hold: its `describe()` says the spec declares no vocabulary for its value;
@@ -433,7 +413,11 @@ const LEDGER: ReadonlyArray<OmitEntry | SubsetEntry> = [
433413
// the editor is another registered metadata type, the key stores that
434414
// type's own node, and the test at the end of this file reads it. The page
435415
// designer and the import flow live outside this package, so those two
436-
// rows rest on the ruling alone.
416+
// rows rest on the ruling alone. `field.picklist` joined the class by a
417+
// ruling of its own (record 5995552118, #21863 2B); its editor, the object
418+
// designer's shared-picklist picker, lives in objectui (objectui#10202
419+
// phase 2), so that row rests on that ruling and on the one that placed the
420+
// picker there (record 5755653853, #18164 batch #209 item 1 A).
437421
{
438422
kind: 'omit',
439423
type: 'object',
@@ -462,6 +446,13 @@ const LEDGER: ReadonlyArray<OmitEntry | SubsetEntry> = [
462446
key: 'external',
463447
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',
464448
},
449+
{
450+
kind: 'omit',
451+
type: 'field',
452+
path: ROOT_PATH,
453+
key: 'picklist',
454+
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",
455+
},
465456

466457
// Code-declared platform configuration (ruling record 5861442317, #19332):
467458
// 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', ()
14111402
// subset row rests on the same reading (its full editor is the `field`
14121403
// type). The page designer and the import flow live in objectui and in the
14131404
// datasource service, out of this package's reach, and are held to the
1414-
// ruling's word.
1405+
// ruling's word. So is the object designer's shared-picklist picker, in
1406+
// objectui, held to the word of the ruling that admitted `field.picklist`.
14151407
// - No registered widget spells this union. The node is a union, and one of
14161408
// its arms has a value `FormSelectOptionSchema` refuses as an option
14171409
// `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', ()
14211413
// declared in other packages. The ruling's key list is its only admission.
14221414
// ────────────────────────────────────────────────────────────────────────────
14231415

1424-
/** The ruling's three classes, each with the keys it put there, transcribed from the record. */
1416+
/**
1417+
* The ruling's three classes, each with the keys it put there, transcribed from
1418+
* the record. One key was added later by a ruling of its own, the admission the
1419+
* test below asks for: `field.picklist`, into the own-editor class (record
1420+
* 5995552118, #21863 2B).
1421+
*/
14251422
const RULED_ROOT_REASONS: ReadonlyArray<{ reason: string; keys: readonly string[] }> = [
14261423
{
14271424
reason: 'authored through its own editor',
1428-
keys: ['object.actions', 'object.listViews', 'object.external', 'page.slots'],
1425+
keys: ['object.actions', 'object.listViews', 'object.external', 'page.slots', 'field.picklist'],
14291426
},
14301427
{
14311428
reason: 'code-declared platform configuration',
@@ -1447,6 +1444,7 @@ const RULED_EDITORS: Readonly<Record<string, { type: string } | { surface: strin
14471444
'object.listViews': { type: 'view' },
14481445
'page.slots': { surface: 'the page designer' },
14491446
'object.external': { surface: 'the import flow' },
1447+
'field.picklist': { surface: "the object designer's shared-picklist picker" },
14501448
};
14511449

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

0 commit comments

Comments
 (0)