Skip to content

Commit 20d7166

Browse files
committed
feat(spec): ActionSchema.onSuccess — post-success navigation with ${result.*} scope (#9566, #9474)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fs18A2DdXLVN2h8PaaFBcP
1 parent 8d98268 commit 20d7166

5 files changed

Lines changed: 358 additions & 0 deletions

File tree

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
feat(spec): `ActionSchema.onSuccess` — post-success navigation for `api`/`script` actions, with `${result.*}` joining the navigate template's interpolation scope (#9566, #9474)
6+
7+
<!-- adr-0087: not-required (accept-set expansion) One new CLOSED optional key
8+
on an existing shape; nothing authorable is renamed, retired or tombstoned, so
9+
there is no conversion to register. Previously-refused spellings stay refused —
10+
three of them now carry guidance pointing at the new key. -->
11+
12+
The maintainer's 2026-08-18 ruling (recorded on #9566, mirrored on #9474)
13+
declares ONE post-success navigation contract for both server-executing action
14+
types instead of two per-type conventions:
15+
16+
- `onSuccess: { navigate, openIn? }` — a strict object, read for
17+
`type: 'api'` and `type: 'script'` only (a refinement refuses it on
18+
`url`/`modal`/`flow`/`form`, where no success event exists for it to ride —
19+
the ADR-0078 posture, same enforcement shape as the `body`-on-non-script
20+
refinement).
21+
- `navigate` is a route/URL template. Its documented interpolation scope is
22+
`${param.*}` + `${ctx.*}` (existing) + **`${result.*}` — NEW: the action's
23+
server response payload** (an `api` action's response body, a `script`
24+
handler's return value), which is what makes "server clones a record → jump
25+
to the new record" declarable: `navigate: '/apps/crm/tasks/${result.id}'`.
26+
The interpolation ENGINE stays the renderer's (objectui `interpolateTarget`);
27+
the spec records the contract.
28+
- `openIn` is the closed enum `'self' | 'newTab'`, defaulting **`'self'`**
29+
(materialized, the file's default convention) — no general navigation DSL.
30+
- The shipped handler-return convention (`{ redirectUrl, openIn? }`,
31+
objectui#2967/#2904) keeps its 17.0.0 semantics: absent `openIn` still means
32+
new-tab (no silent behavior flip for existing handlers); a handler may return
33+
`openIn: 'self'` explicitly.
34+
35+
The console consumer is the downstream objectui half (SPA navigation branch,
36+
`executeAPI` navigation handling, `${result.*}` interpolation), filed
37+
Blocked-by these cards; the liveness ledger records the key at `planned`
38+
strength with the amend-on-landing instruction.

‎packages/spec/authorable-surface/ui.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
"ui/Action:name",
3434
"ui/Action:newTabUrl",
3535
"ui/Action:objectName",
36+
"ui/Action:onSuccess",
3637
"ui/Action:openIn",
3738
"ui/Action:opensInNewTab",
3839
"ui/Action:order",

‎packages/spec/liveness/action.json‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,12 @@
158158
"evidence": "objectui ActionRunner.executeUrl (objectui issue #2043)",
159159
"note": "Declarative new-tab control for STATIC type:'url' targets. ActionRunner.executeUrl reads action.openIn with priority over the legacy params.newTab/external-URL heuristic; action-button/icon/menu/group + basic/elements renderers forward it. Distinct from opensInNewTab/newTabUrl (async SSO pre-open)."
160160
},
161+
"onSuccess": {
162+
"status": "planned",
163+
"verifiedAt": "2026-08-18",
164+
"evidenceScope": "cross-repo",
165+
"note": "PLANNED, deliberately not `live` — the #9340 map / #9463 viewMode convention for a spec-first contract-split key. Declared by the #9566/#9474 maintainer ruling (2026-08-18, one navigation contract for both cards): `{ navigate, openIn: 'self'|'newTab' }` post-success navigation for type:'api'/'script' actions, with `${result.*}` (the server response) joining the navigate template's interpolation scope. No console consumer reads it yet: objectui's consoleServerAction.ts drives only the handler-return `{ redirectUrl }` convention (new-tab, objectui#2967/#2904), executeAPI returns {success,data} and never navigates, and interpolateTarget's scope has no `result` member — the SPA-navigation branch, executeAPI navigation handling and result-scope interpolation are the downstream objectui card(s) filed Blocked-by #9566/#9474 at this key's landing. Amend to `live` citing the consoleServerAction/executeAPI read and the interpolateTarget result member when that half lands — measured objectui per the #9566 issue audit at spec/console 17.0.0, 2026-08-18."
166+
},
161167
"aria": {
162168
"status": "live",
163169
"note": "PARTIAL — honored by a few objectui renderers, not the core action buttons/menus."
Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
// #9566 / #9474 — `onSuccess` post-success navigation (maintainer ruling
2+
// 2026-08-18, recorded on #9566): one CLOSED key covering both server-executing
3+
// action types, `navigate` (route/URL template whose scope gains `${result.*}`,
4+
// the server response) + `openIn: 'self' | 'newTab'` defaulting `'self'`.
5+
// These pins hold the ruled shape: the accept set, the materialized default,
6+
// the closed enum, the strict inner object, and the api/script type scope.
7+
import { describe, it, expect } from 'vitest';
8+
import { ActionSchema, InlineActionSchema } from './action.zod';
9+
import { getMetadataTypeSchema } from '../kernel/metadata-type-schemas';
10+
11+
const base = { name: 'copy_as_new_version', label: 'Copy as new version' };
12+
13+
describe('ActionSchema.onSuccess (#9566/#9474)', () => {
14+
describe('accept pins', () => {
15+
it('accepts the full shape on a type:api action', () => {
16+
const r = ActionSchema.safeParse({
17+
...base,
18+
type: 'api',
19+
target: '/api/v1/actions/task_version/clone',
20+
onSuccess: { navigate: '/apps/mfg/task_version/${result.id}', openIn: 'newTab' },
21+
});
22+
expect(r.success, JSON.stringify((r as { error?: unknown }).error)).toBe(true);
23+
expect((r.data as { onSuccess: unknown }).onSuccess)
24+
.toEqual({ navigate: '/apps/mfg/task_version/${result.id}', openIn: 'newTab' });
25+
});
26+
27+
it('accepts the minimal shape on a type:script action', () => {
28+
const r = ActionSchema.safeParse({
29+
...base,
30+
type: 'script',
31+
target: 'cloneVersion',
32+
onSuccess: { navigate: '/apps/mfg/task_version/${result.id}' },
33+
});
34+
expect(r.success, JSON.stringify((r as { error?: unknown }).error)).toBe(true);
35+
});
36+
37+
it('reaches the same shape through the registered `action` metadata schema (the parsing door)', () => {
38+
const schema = getMetadataTypeSchema('action');
39+
expect(schema).toBeDefined();
40+
const r = schema!.safeParse({
41+
...base,
42+
type: 'api',
43+
target: '/api/v1/actions/task_version/clone',
44+
onSuccess: { navigate: '/apps/mfg/task_version/${result.id}' },
45+
});
46+
expect(r.success, JSON.stringify((r as { error?: unknown }).error)).toBe(true);
47+
});
48+
});
49+
50+
describe('default pin — openIn materializes to self', () => {
51+
it('parse output carries openIn "self" when the author omits it', () => {
52+
// The ruled default is MATERIALIZED (`.default('self')`), so a consumer
53+
// reads the resolved member off the parse output and never needs its own
54+
// fallback — declared = enforced. This is the observable being pinned.
55+
const out = ActionSchema.parse({
56+
...base,
57+
type: 'api',
58+
target: '/api/v1/actions/task_version/clone',
59+
onSuccess: { navigate: '/x/${result.id}' },
60+
}) as { onSuccess?: { navigate: string; openIn: string } };
61+
expect(out.onSuccess?.openIn).toBe('self');
62+
});
63+
64+
it('an explicit openIn survives untouched', () => {
65+
const out = ActionSchema.parse({
66+
...base,
67+
type: 'script',
68+
target: 'cloneVersion',
69+
onSuccess: { navigate: '/x', openIn: 'newTab' },
70+
}) as { onSuccess?: { openIn: string } };
71+
expect(out.onSuccess?.openIn).toBe('newTab');
72+
});
73+
});
74+
75+
describe('refusal pins — the closed enum', () => {
76+
it('rejects an out-of-vocabulary openIn', () => {
77+
const r = ActionSchema.safeParse({
78+
...base,
79+
type: 'api',
80+
target: '/t',
81+
onSuccess: { navigate: '/x', openIn: 'modal' },
82+
});
83+
expect(r.success).toBe(false);
84+
});
85+
86+
it("names the camelCase member when the author writes the sibling key's kebab spelling", () => {
87+
// The top-level `openIn` (type:'url') spells its member 'new-tab'; the
88+
// handler-return convention and this key spell it 'newTab'. The enum's
89+
// error map catches exactly the crossover spelling (the
90+
// ActionLocationSchema issue.input precedent) — every other wrong value
91+
// keeps zod's own enum error.
92+
const r = ActionSchema.safeParse({
93+
...base,
94+
type: 'api',
95+
target: '/t',
96+
onSuccess: { navigate: '/x', openIn: 'new-tab' },
97+
});
98+
expect(r.success).toBe(false);
99+
const msg = r.error!.issues.map((i) => i.message).join('\n');
100+
expect(msg).toContain("'newTab'");
101+
expect(msg).toContain('new-tab');
102+
});
103+
});
104+
105+
describe('refusal pins — the strict inner object', () => {
106+
const innerIssue = (onSuccess: Record<string, unknown>) => {
107+
const r = ActionSchema.safeParse({ ...base, type: 'api', target: '/t', onSuccess });
108+
expect(r.success).toBe(false);
109+
return r.error!.issues.find((i) => i.code === 'unrecognized_keys');
110+
};
111+
112+
it('rejects an undeclared key instead of silently dropping it', () => {
113+
const issue = innerIssue({ navigate: '/x', notAKey: 1 });
114+
expect(issue).toBeDefined();
115+
expect(issue!.message).toContain('`notAKey`');
116+
});
117+
118+
it("points the handler-return spelling `redirectUrl` at `navigate`", () => {
119+
expect(innerIssue({ redirectUrl: '/x' })!.message)
120+
.toContain('`redirectUrl` → `navigate`');
121+
});
122+
123+
it('points the generic destination spellings at `navigate`', () => {
124+
for (const key of ['url', 'to', 'route', 'path', 'target']) {
125+
expect(innerIssue({ [key]: '/x' })!.message)
126+
.toContain(`\`${key}\` → \`navigate\``);
127+
}
128+
});
129+
130+
it('tells an author reaching for `opensInNewTab` that the tab choice here is openIn', () => {
131+
expect(innerIssue({ navigate: '/x', opensInNewTab: true })!.message)
132+
.toContain("openIn: 'newTab'");
133+
});
134+
135+
it('requires `navigate` — an empty onSuccess block is not a declaration', () => {
136+
const r = ActionSchema.safeParse({ ...base, type: 'api', target: '/t', onSuccess: {} });
137+
expect(r.success).toBe(false);
138+
});
139+
});
140+
141+
describe('type scope — api and script only (the #4352 enforcement shape)', () => {
142+
it.each(['url', 'modal', 'flow', 'form'] as const)('refuses onSuccess on a type:%s action', (type) => {
143+
const r = ActionSchema.safeParse({
144+
...base,
145+
type,
146+
target: type === 'form' ? 'edit_form' : '/t',
147+
onSuccess: { navigate: '/x' },
148+
});
149+
expect(r.success).toBe(false);
150+
const msg = r.error!.issues.map((i) => i.message).join('\n');
151+
expect(msg).toContain('onSuccess');
152+
expect(msg).toContain("'api'");
153+
expect(msg).toContain("'script'");
154+
});
155+
});
156+
157+
describe('the pre-existing probes now land on prescriptions, not bare rejections (#9474)', () => {
158+
it('a top-level `redirect` names the onSuccess shape', () => {
159+
const r = ActionSchema.safeParse({ ...base, type: 'api', target: '/t', redirect: '/x' });
160+
expect(r.success).toBe(false);
161+
const issue = r.error!.issues.find((i) => i.code === 'unrecognized_keys');
162+
expect(issue!.message).toContain('onSuccess');
163+
expect(issue!.message).toContain('navigate');
164+
});
165+
166+
it('a top-level `redirectUrl` says it is the handler-return convention, not an authorable key', () => {
167+
const r = ActionSchema.safeParse({ ...base, type: 'api', target: '/t', redirectUrl: '/x' });
168+
expect(r.success).toBe(false);
169+
const issue = r.error!.issues.find((i) => i.code === 'unrecognized_keys');
170+
expect(issue!.message).toContain('HANDLER-RETURN');
171+
expect(issue!.message).toContain('onSuccess');
172+
});
173+
});
174+
175+
describe('strictness posture unchanged elsewhere', () => {
176+
it('InlineActionSchema does not pick onSuccess — unknown key inline', () => {
177+
// The inline surface widens when a renderer widens (the file's own rule);
178+
// element:button's forward list has no onSuccess hop, so the key is
179+
// registered-actions-only until the objectui half lands.
180+
const r = InlineActionSchema.safeParse({
181+
type: 'api',
182+
target: '/t',
183+
onSuccess: { navigate: '/x' },
184+
});
185+
expect(r.success).toBe(false);
186+
});
187+
188+
it('an action WITHOUT onSuccess still parses exactly as before', () => {
189+
const out = ActionSchema.parse({ ...base, type: 'api', target: '/t' }) as Record<string, unknown>;
190+
expect('onSuccess' in out && out.onSuccess !== undefined).toBe(false);
191+
});
192+
});
193+
});

0 commit comments

Comments
 (0)