Skip to content

Commit 89eb682

Browse files
authored
fix(console): resolve a modal action's target as a page, not an object (#3530) (#2826)
The console read a `type: 'modal'` action's `target` as an OBJECT name and opened a create form for it, so a target naming a page issued `GET /meta/object/<page>` — which 400s — and the dialog rendered ModalForm's "Error loading form — Bad Request" instead of the page. - `normalizeModalSchema` no longer guesses "object" for a string target; the new `useActionModal.resolveModalTarget` resolves it against metadata: page first (what the spec says the name means), then object for back-compat. Resolution uses `getItem(type, name)`, a single-item fetch, so it never eagerly loads the lazy page/object lists. - The `create_x` / `edit_x` prefix convention is now only a fallback: a page actually named `create_opportunity` wins over the object `opportunity`. - An unresolvable target reports that, instead of surfacing an HTTP error. - `useConsoleActionRuntime` and `RecordDetailView` now share one modal dispatch rule, so the same button no longer behaves differently per surface.
1 parent 6720008 commit 89eb682

7 files changed

Lines changed: 448 additions & 27 deletions

File tree

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
---
2+
"@object-ui/app-shell": patch
3+
---
4+
5+
fix(console): resolve a modal action's `target` as a page, not an object (#3530)
6+
7+
Submitting a `type: 'modal'` action failed with "Error loading form — Bad
8+
Request". The console read the action's `target` as an OBJECT name and opened a
9+
create form for it, so a target naming a page issued `GET
10+
/meta/object/<page>` — which 400s — and the dialog rendered `<ModalForm>`'s
11+
error state instead of the page. Every modal action in an app hit this; the only
12+
workaround was re-authoring each one as a screen flow.
13+
14+
The spec is explicit that for `type: 'modal'`, `target` is "the modal/page name
15+
to open".
16+
17+
- `normalizeModalSchema` no longer guesses "object" for a string target. It
18+
records the raw name and `useActionModal.resolveModalTarget` (new) resolves it
19+
against metadata: **page first**, then object for back-compat. Resolution uses
20+
`getItem(type, name)`, a single-item fetch, so it never eagerly loads the lazy
21+
page/object lists — this hook is mounted at the console root.
22+
- The `create_x` / `edit_x` prefix convention still yields an object form, but
23+
now only as a fallback: a page actually named `create_opportunity` wins over
24+
the object `opportunity` the name would otherwise be parsed into.
25+
- A target that names neither reports what is wrong ("Modal target "x" matches
26+
no page or object") instead of surfacing a downstream HTTP error.
27+
28+
Modal dispatch is also now the same on every console surface. `type: 'modal'`
29+
was wired straight to the server-action POST in `useConsoleActionRuntime` (list
30+
pages, SDUI pages, the declared-actions bar) while `RecordDetailView` opened
31+
modals client-side — the same button did two different things depending on where
32+
it was mounted. Both now run one rule: render `target` when it names a page or
33+
object, otherwise complete the action through its server-side handler, so a
34+
modal action bound to `engine.registerAction(...)` keeps working.
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
/**
10+
* Regression (framework#3530 — "Console: modal-typed actions resolve `target` as
11+
* an object"): a `type: 'modal'` action's `target` names the PAGE to open, but
12+
* the console read it as an OBJECT name and opened a create form for it. That
13+
* issued `GET /meta/object/<page>`, which 400s, so the modal body was replaced
14+
* with ModalForm's "Error loading form — Bad Request" and the action never
15+
* completed.
16+
*
17+
* These pin the resolution CONTRACT: page first (what the spec says the name
18+
* means), object second (back-compat), `null` last so the console runtimes can
19+
* fall through to the action's server-side handler. The pure normalization
20+
* step is covered separately in `useActionModal.test.ts`.
21+
*/
22+
23+
import { describe, it, expect, vi, beforeEach } from 'vitest';
24+
import { renderHook, act } from '@testing-library/react';
25+
import React from 'react';
26+
import { MetadataCtx } from '@object-ui/react';
27+
import { useActionModal } from '../useActionModal';
28+
29+
const LOG_CALL_PAGE = { name: 'log_call', type: 'utility', label: 'Log a Call' };
30+
const CONTACT_OBJECT = { name: 'contact', label: 'Contact', fields: {} };
31+
32+
/**
33+
* Stand-in metadata context. `getItem(type, name)` is the ONLY lookup the
34+
* resolver may use — reading the whole `pages` list would eagerly load a lazy
35+
* metadata type at the console root, which is what `getItem` exists to avoid.
36+
*/
37+
function makeWrapper(items: Record<string, any[]>) {
38+
const getItem = vi.fn(async (type: string, name: string) =>
39+
(items[type] ?? []).find((i) => i.name === name) ?? null);
40+
const value: any = {
41+
apps: [], objects: items.object ?? [], dashboards: [], reports: [], pages: [],
42+
loading: false, error: null,
43+
refresh: async () => {}, invalidate: () => {}, ensureType: async () => [],
44+
getItem,
45+
getItemsByType: () => [],
46+
getTypeStatus: () => 'ready',
47+
};
48+
const wrapper = ({ children }: { children: React.ReactNode }) => (
49+
<MetadataCtx.Provider value={value}>{children}</MetadataCtx.Provider>
50+
);
51+
return { wrapper, getItem };
52+
}
53+
54+
beforeEach(() => vi.clearAllMocks());
55+
56+
describe('useActionModal — modal target resolution (framework#3530)', () => {
57+
it('resolves a string target to the PAGE of that name, never GET /meta/object', async () => {
58+
const { wrapper, getItem } = makeWrapper({ page: [LOG_CALL_PAGE] });
59+
const { result } = renderHook(() => useActionModal(), { wrapper });
60+
61+
let d: any;
62+
await act(async () => { d = await result.current.resolveModalTarget('log_call'); });
63+
64+
// Rendered as page CONTENT (same shape PageView feeds SchemaRenderer),
65+
// not as an object form.
66+
expect(d.content).toMatchObject({ name: 'log_call', type: 'utility' });
67+
expect(d.objectName).toBeUndefined();
68+
expect(d.title).toBe('Log a Call');
69+
70+
// The object lookup that produced the 400 must never happen.
71+
expect(getItem).toHaveBeenCalledWith('page', 'log_call');
72+
expect(getItem).not.toHaveBeenCalledWith('object', 'log_call');
73+
});
74+
75+
it('falls back to an object create form when no page owns the name', async () => {
76+
const { wrapper } = makeWrapper({ page: [], object: [CONTACT_OBJECT] });
77+
const { result } = renderHook(() => useActionModal(), { wrapper });
78+
79+
let d: any;
80+
await act(async () => { d = await result.current.resolveModalTarget('contact'); });
81+
82+
expect(d).toMatchObject({ objectName: 'contact', mode: 'create' });
83+
expect(d.content).toBeUndefined();
84+
});
85+
86+
it('prefers a page over the object a create_ prefix would parse into', async () => {
87+
const { wrapper } = makeWrapper({
88+
page: [{ name: 'create_opportunity', type: 'utility', label: 'New Opportunity' }],
89+
object: [{ name: 'opportunity' }],
90+
});
91+
const { result } = renderHook(() => useActionModal(), { wrapper });
92+
93+
let d: any;
94+
await act(async () => { d = await result.current.resolveModalTarget('create_opportunity'); });
95+
96+
expect(d.content).toMatchObject({ name: 'create_opportunity' });
97+
expect(d.objectName).toBeUndefined();
98+
});
99+
100+
it('still honors the create_ prefix when no page owns the name', async () => {
101+
const { wrapper } = makeWrapper({ page: [], object: [{ name: 'opportunity' }] });
102+
const { result } = renderHook(() => useActionModal(), { wrapper });
103+
104+
let d: any;
105+
await act(async () => { d = await result.current.resolveModalTarget('create_opportunity'); });
106+
107+
expect(d).toMatchObject({ objectName: 'opportunity', mode: 'create' });
108+
});
109+
110+
it('returns null when the target names neither a page nor an object', async () => {
111+
// Not an error on its own — the console runtimes read null as "not a
112+
// client-rendered modal" and run the action server-side instead.
113+
const { wrapper } = makeWrapper({ page: [], object: [] });
114+
const { result } = renderHook(() => useActionModal(), { wrapper });
115+
116+
let d: any;
117+
await act(async () => { d = await result.current.resolveModalTarget('schedule_followup'); });
118+
expect(d).toBeNull();
119+
});
120+
121+
it('passes an explicit { objectName, mode } descriptor through without lookups', async () => {
122+
// The lookup field's inline "create the referenced record" path.
123+
const { wrapper, getItem } = makeWrapper({ page: [], object: [] });
124+
const { result } = renderHook(() => useActionModal(), { wrapper });
125+
126+
let d: any;
127+
await act(async () => {
128+
d = await result.current.resolveModalTarget({ objectName: 'customers', mode: 'create' });
129+
});
130+
131+
expect(d).toMatchObject({ objectName: 'customers', mode: 'create' });
132+
expect(getItem).not.toHaveBeenCalled();
133+
});
134+
135+
it('modalHandler reports an unresolvable target instead of opening a broken form', async () => {
136+
const { wrapper } = makeWrapper({ page: [], object: [] });
137+
const { result } = renderHook(() => useActionModal(), { wrapper });
138+
139+
let r: any;
140+
await act(async () => { r = await result.current.modalHandler('schedule_followup'); });
141+
142+
expect(r.success).toBe(false);
143+
expect(r.error).toContain('schedule_followup');
144+
});
145+
});

packages/app-shell/src/hooks/__tests__/useActionModal.test.ts

Lines changed: 24 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,19 +2,34 @@ import { describe, it, expect } from 'vitest';
22
import { normalizeModalSchema } from '../useActionModal';
33

44
describe('normalizeModalSchema', () => {
5-
it('maps create_/new_/add_ string targets to a create object-form', () => {
6-
expect(normalizeModalSchema('create_opportunity')).toEqual({ objectName: 'opportunity', mode: 'create' });
7-
expect(normalizeModalSchema('new_task')).toEqual({ objectName: 'task', mode: 'create' });
8-
expect(normalizeModalSchema('add_note')).toEqual({ objectName: 'note', mode: 'create' });
5+
it('keeps a string target UNRESOLVED — page-vs-object is a metadata question', () => {
6+
// framework#3530: assuming "object" here sent every page-targeting modal
7+
// action to GET /meta/object/<page>, which 400s and rendered ModalForm's
8+
// "Error loading form — Bad Request". `resolveModalTarget` asks the
9+
// metadata service instead (see useActionModal.resolve.test.tsx).
10+
expect(normalizeModalSchema('log_call')).toEqual({ targetName: 'log_call' });
11+
expect(normalizeModalSchema('contact')).toEqual({ targetName: 'contact' });
912
});
1013

11-
it('maps edit_/update_ string targets to an edit object-form', () => {
12-
expect(normalizeModalSchema('edit_account')).toEqual({ objectName: 'account', mode: 'edit' });
13-
expect(normalizeModalSchema('update_lead')).toEqual({ objectName: 'lead', mode: 'edit' });
14+
it('carries a create_/new_/add_ prefix guess ALONGSIDE the raw target', () => {
15+
expect(normalizeModalSchema('create_opportunity')).toEqual({
16+
targetName: 'create_opportunity', objectName: 'opportunity', mode: 'create',
17+
});
18+
expect(normalizeModalSchema('new_task')).toEqual({
19+
targetName: 'new_task', objectName: 'task', mode: 'create',
20+
});
21+
expect(normalizeModalSchema('add_note')).toEqual({
22+
targetName: 'add_note', objectName: 'note', mode: 'create',
23+
});
1424
});
1525

16-
it('treats a bare string as a create form for that object', () => {
17-
expect(normalizeModalSchema('contact')).toEqual({ objectName: 'contact', mode: 'create' });
26+
it('carries an edit_/update_ prefix guess as an edit form', () => {
27+
expect(normalizeModalSchema('edit_account')).toEqual({
28+
targetName: 'edit_account', objectName: 'account', mode: 'edit',
29+
});
30+
expect(normalizeModalSchema('update_lead')).toEqual({
31+
targetName: 'update_lead', objectName: 'lead', mode: 'edit',
32+
});
1833
});
1934

2035
it('treats a bare SchemaNode (has type, no descriptor keys) as content', () => {

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

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,22 @@ vi.mock('@object-ui/i18n', () => ({
3737
}),
3838
}));
3939

40+
// The client modal transport is stubbed for the same reason — importing it for
41+
// real drags in <ModalForm> and the whole plugin-form graph. `resolveTargetSpy`
42+
// stands in for the page/object lookup so the modal-dispatch tests below can
43+
// choose whether a target resolves. Its real resolution rules are covered in
44+
// useActionModal.resolve.test.tsx.
45+
const modalHandlerSpy = vi.fn(async () => ({ success: true }));
46+
const resolveTargetSpy = vi.fn(async (_schema: any): Promise<any> => null);
47+
vi.mock('../useActionModal', () => ({
48+
useActionModal: () => ({
49+
modalHandler: modalHandlerSpy,
50+
modalElement: null,
51+
closeModal: () => {},
52+
resolveModalTarget: resolveTargetSpy,
53+
}),
54+
}));
55+
4056
// The dialogs/flow-runner are not exercised here — keep them as inert stubs so
4157
// the hook module imports cheaply.
4258
vi.mock('../../views/ActionConfirmDialog', () => ({ ActionConfirmDialog: () => null }));
@@ -61,6 +77,9 @@ import { useAction, usePageVariables, PageVariablesProvider, PageVariableActionB
6177
beforeEach(() => {
6278
authFetchSpy.mockReset();
6379
navigateSpy.mockReset();
80+
modalHandlerSpy.mockClear();
81+
resolveTargetSpy.mockReset();
82+
resolveTargetSpy.mockResolvedValue(null);
6483
(toast as any).mockClear?.();
6584
(toast as any).error.mockClear();
6685
(toast as any).success.mockClear();
@@ -295,6 +314,68 @@ describe('useConsoleActionRuntime — authenticated handlers', () => {
295314
expect(Object.keys(props.handlers).sort()).toEqual(['api', 'flow', 'modal', 'script']);
296315
expect(typeof props.onConfirm).toBe('function');
297316
expect(typeof props.onParamCollection).toBe('function');
317+
expect(typeof props.onModal).toBe('function');
318+
});
319+
});
320+
321+
/**
322+
* framework#3530 — `type: 'modal'` used to be wired straight to
323+
* `serverActionHandler` here, while RecordDetailView opened modals client-side.
324+
* The same button therefore did two different things depending on which surface
325+
* mounted it. Both now run this rule: render `target` when it names a page (or
326+
* object), else complete the action server-side.
327+
*/
328+
describe('modalActionHandler — open the target, else run it server-side', () => {
329+
it('opens the resolved target client-side and never POSTs to /actions', async () => {
330+
resolveTargetSpy.mockResolvedValue({ content: { name: 'log_call', type: 'utility' } });
331+
const { result } = renderHook(() =>
332+
useConsoleActionRuntime({ dataSource: {}, objects: [], objectName: 'crm_call' }),
333+
);
334+
335+
await act(async () => {
336+
await result.current.modalActionHandler({ name: 'log_call', type: 'modal', target: 'log_call' } as any);
337+
});
338+
339+
expect(resolveTargetSpy).toHaveBeenCalledWith('log_call');
340+
expect(modalHandlerSpy).toHaveBeenCalledWith({ content: { name: 'log_call', type: 'utility' } });
341+
expect(authFetchSpy).not.toHaveBeenCalled();
342+
});
343+
344+
it('falls back to the server action when the target names no page or object', async () => {
345+
// The modal was the param dialog the runner already collected — the action
346+
// still has to run, so it goes to its registered server handler.
347+
resolveTargetSpy.mockResolvedValue(null);
348+
authFetchSpy.mockResolvedValue({ ok: true, json: async () => ({ success: true, data: { ok: 1 } }) });
349+
const { result } = renderHook(() =>
350+
useConsoleActionRuntime({ dataSource: {}, objects: [], objectName: 'crm_call' }),
351+
);
352+
353+
let r: any;
354+
await act(async () => {
355+
r = await result.current.modalActionHandler({
356+
name: 'log_call', type: 'modal', target: 'log_call', params: { subject: 'Intro' },
357+
} as any);
358+
});
359+
360+
expect(modalHandlerSpy).not.toHaveBeenCalled();
361+
expect(authFetchSpy).toHaveBeenCalledTimes(1);
362+
expect(authFetchSpy.mock.calls[0][0]).toContain('/api/v1/actions/crm_call/log_call');
363+
expect(r.success).toBe(true);
364+
});
365+
366+
it('prefers an inline `modal` descriptor over `target`', async () => {
367+
resolveTargetSpy.mockResolvedValue({ objectName: 'customers', mode: 'create' });
368+
const { result } = renderHook(() =>
369+
useConsoleActionRuntime({ dataSource: {}, objects: [], objectName: 'crm_call' }),
370+
);
371+
372+
await act(async () => {
373+
await result.current.modalActionHandler({
374+
name: 'x', type: 'modal', target: 'ignored', modal: { objectName: 'customers', mode: 'create' },
375+
} as any);
376+
});
377+
378+
expect(resolveTargetSpy).toHaveBeenCalledWith({ objectName: 'customers', mode: 'create' });
298379
});
299380
});
300381

0 commit comments

Comments
 (0)