Skip to content

Commit a136322

Browse files
authored
fix(app-shell)!: a modal action is client-side only — drop the server fallthrough (objectstack#3959) (#2973)
modalActionHandler fell through to serverActionHandler when the action's target resolved to neither a page nor an object, documented as 'how a modal action bound to engine.registerAction(...) still runs'. It never ran: the framework's headlessActionTypeError rejects type:'modal' over REST with a 400, since a modal action has no server dispatch. The fallthrough only converted an authoring mistake into a confusing round-trip, and it let apps ship handlers no declaration could address. An unresolvable target is now reported as such, naming the action, the dud target and the way out. To collect input and run server-side, declare type:'script' with params. Pairs with objectstack#3974 (merged).
1 parent 1c07e6a commit a136322

3 files changed

Lines changed: 58 additions & 18 deletions

File tree

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
'@object-ui/app-shell': major
3+
---
4+
5+
**[objectstack#3959] A `type: 'modal'` action is client-side only — the server fallthrough is removed.**
6+
7+
`modalActionHandler` fell through to `serverActionHandler` when the action's
8+
target resolved to neither a page nor an object, documented as "how a modal
9+
action bound to `engine.registerAction(...)` still runs". It never ran: the
10+
framework's `headlessActionTypeError` rejects `type: 'modal'` over REST with a
11+
400, because a modal action has no server dispatch. The fallthrough only turned
12+
an authoring mistake — a target naming no page — into a confusing round-trip,
13+
and it let apps ship handlers that no declaration could address (app-todo's
14+
`deferTask` / `setReminder` sat dead for exactly this reason).
15+
16+
An unresolvable target is now reported as what it is, naming the action, the
17+
dud target, and the way out. To collect input and then run server-side,
18+
declare `type: 'script'` with `params` — the runner collects the same dialog
19+
and the handler runs with those values.

packages/app-shell/src/hooks/__tests__/useConsoleActionRuntime.test.tsx

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -463,7 +463,7 @@ describe('useConsoleActionRuntime — authenticated handlers', () => {
463463
* mounted it. Both now run this rule: render `target` when it names a page (or
464464
* object), else complete the action server-side.
465465
*/
466-
describe('modalActionHandler — open the target, else run it server-side', () => {
466+
describe('modalActionHandler — a modal action is CLIENT-SIDE ONLY (objectstack#3959)', () => {
467467
it('opens the resolved target client-side and never POSTs to /actions', async () => {
468468
resolveTargetSpy.mockResolvedValue({ content: { name: 'log_call', type: 'utility' } });
469469
const { result } = renderHook(() =>
@@ -479,11 +479,14 @@ describe('modalActionHandler — open the target, else run it server-side', () =
479479
expect(authFetchSpy).not.toHaveBeenCalled();
480480
});
481481

482-
it('falls back to the server action when the target names no page or object', async () => {
483-
// The modal was the param dialog the runner already collected — the action
484-
// still has to run, so it goes to its registered server handler.
482+
// This test used to assert the opposite — that an unresolvable target fell
483+
// back to POSTing /actions, "how a modal action bound to registerAction still
484+
// runs". It never ran: the framework rejects type:'modal' over REST with a
485+
// 400 (headlessActionTypeError), so the fallthrough turned an authoring
486+
// mistake into a confusing round-trip and let apps ship handlers no
487+
// declaration could address (objectstack#3959).
488+
it('reports an unresolvable target instead of POSTing to /actions', async () => {
485489
resolveTargetSpy.mockResolvedValue(null);
486-
authFetchSpy.mockResolvedValue({ ok: true, json: async () => ({ success: true, data: { ok: 1 } }) });
487490
const { result } = renderHook(() =>
488491
useConsoleActionRuntime({ dataSource: {}, objects: [], objectName: 'crm_call' }),
489492
);
@@ -496,9 +499,11 @@ describe('modalActionHandler — open the target, else run it server-side', () =
496499
});
497500

498501
expect(modalHandlerSpy).not.toHaveBeenCalled();
499-
expect(authFetchSpy).toHaveBeenCalledTimes(1);
500-
expect(authFetchSpy.mock.calls[0][0]).toContain('/api/v1/actions/crm_call/log_call');
501-
expect(r.success).toBe(true);
502+
expect(authFetchSpy).not.toHaveBeenCalled();
503+
expect(r.success).toBe(false);
504+
// The message must name the action, the dud target, and the way out.
505+
expect(String(r.error)).toContain('log_call');
506+
expect(String(r.error)).toMatch(/type:'script' with params/);
502507
});
503508

504509
it('prefers an inline `modal` descriptor over `target`', async () => {

packages/app-shell/src/hooks/useConsoleActionRuntime.tsx

Lines changed: 26 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -662,20 +662,36 @@ export function useConsoleActionRuntime(opts: ConsoleActionRuntimeOptions): Cons
662662
const { modalHandler, modalElement, resolveModalTarget } = useActionModal(dataSource);
663663

664664
/**
665-
* `type: 'modal'` dispatch. The action's `target` names the page to open
666-
* (spec: "the modal/page name to open"), so try to render it client-side
667-
* first. When it names neither a page nor an object there is nothing to
668-
* render — the modal was the param dialog the runner already collected — so
669-
* complete the action through its server-side handler, which is how a modal
670-
* action bound to `engine.registerAction(...)` (or an inline `body`) still
671-
* runs.
665+
* `type: 'modal'` dispatch — CLIENT-SIDE ONLY. The action's `target` names
666+
* the page to open (spec: "the modal/page name to open"); rendering it is
667+
* the whole of what a modal action does.
668+
*
669+
* [objectstack#3959] This used to fall through to `serverActionHandler` when
670+
* the target resolved to neither a page nor an object, documented as "how a
671+
* modal action bound to `engine.registerAction(...)` still runs". It never
672+
* ran: the framework's `headlessActionTypeError` rejects `type: 'modal'`
673+
* over REST with a 400, because a modal has no server dispatch. The
674+
* fallthrough only converted an authoring mistake — a target naming no
675+
* page — into a confusing round-trip, and it let apps ship handlers no
676+
* declaration could address (app-todo's `deferTask` / `setReminder` sat dead
677+
* for exactly this reason).
678+
*
679+
* An unresolvable target is now reported as what it is. To collect input and
680+
* then run server-side, declare `type: 'script'` with `params`: the runner
681+
* collects the same dialog and the handler runs with those values.
672682
*/
673-
const modalActionHandler = useCallback(async (action: ActionDef, context?: ActionContext): Promise<ActionResult> => {
683+
const modalActionHandler = useCallback(async (action: ActionDef, _context?: ActionContext): Promise<ActionResult> => {
674684
const schema = (action as any).modal ?? action.target ?? (action as any).params?.schema;
675685
const descriptor = schema != null ? await resolveModalTarget(schema) : null;
676686
if (descriptor) return modalHandler(descriptor);
677-
return serverActionHandler(action, context);
678-
}, [resolveModalTarget, modalHandler, serverActionHandler]);
687+
return {
688+
success: false,
689+
error:
690+
`Action "${action.name}" is type:'modal' but its target ` +
691+
`${schema != null ? `"${String(schema)}" ` : ''}names no page or object to open. ` +
692+
`Point it at a page, or use type:'script' with params to collect input and run a handler.`,
693+
};
694+
}, [resolveModalTarget, modalHandler]);
679695

680696
const actionProviderProps = useMemo(() => ({
681697
context: {

0 commit comments

Comments
 (0)