Skip to content

Commit 0c1ffeb

Browse files
committed
fix(app-shell): the record header and more-menu open an api action's newTabUrl tab
The record page registers its own `api` handler, which also never read `opensInNewTab` + `newTabUrl`. It now goes through the same `withNewTabUrlFastPath` composition against the page's server-action handler. Tests drive the real header and more-menu clicks and the shared runtime's runner; a changeset records the patch. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QsR2jSH8FbKjEztmGT1sbB
1 parent 1e2741e commit 0c1ffeb

4 files changed

Lines changed: 467 additions & 2 deletions

File tree

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
---
4+
5+
fix(app-shell): a `type: 'api'` action declaring `opensInNewTab` + `newTabUrl` opens the tab and sends no request
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
The spec documents `newTabUrl` as the zero-roundtrip target of a new-tab action: on click the console pre-opens a tab and navigates it to `newTabUrl` (with `{recordId}` substituted), and no action request is sent. The console's server-action handler did this, but its `type: 'api'` handlers never read the pair. A `type: 'api'` action declaring both therefore opened no tab and POSTed to its `target` instead, from the record header, its more-menu and the list toolbar.
11+
12+
What changes:
13+
14+
- On the record page (header row and the more-menu) and on every surface the shared console runtime serves (the list toolbar and the console's root action provider), a `type: 'api'` action with `opensInNewTab: true` and a `newTabUrl` now takes that path: the record id resolves the way that surface resolves it, the pre-opened tab goes to `newTabUrl`, and nothing is sent to `target`. A list launch with several rows selected is refused before the tab opens, as for a `script` action.
15+
- A `type: 'api'` action without `newTabUrl` sends its request to `target` as before.
16+
17+
What to do: nothing. An api action that declares the pair now does what it declares.
Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
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+
* objectstack#26747 — the shared console action runtime (list pages, the
11+
* list toolbar, the console root provider) honours `opensInNewTab` +
12+
* `newTabUrl` on a `type: 'api'` action: the pre-opened tab goes to
13+
* `newTabUrl` with the selected row's id substituted, and no request is sent.
14+
*
15+
* Driven through a real `ActionRunner` carrying the runtime's own context and
16+
* registered handler set, exactly as `ActionProvider` wires them, and through
17+
* the `apiHandler` the hook returns (what `ConsoleShell` registers).
18+
*
19+
* CONTROL: without `newTabUrl` the api action still sends its request to
20+
* `target`. The fast path's row-count refusal is the server-action one: two
21+
* selected rows refuse before any tab opens.
22+
*/
23+
24+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
25+
import { renderHook, act } from '@testing-library/react';
26+
27+
vi.mock('react-router-dom', () => ({ useNavigate: () => vi.fn() }));
28+
29+
const authFetchSpy = vi.fn();
30+
vi.mock('@object-ui/auth', async (importOriginal) => ({
31+
...(await importOriginal<Record<string, unknown>>()),
32+
useAuth: () => ({ user: { id: 'u1', name: 'User', image: null }, activeOrganization: null }),
33+
createAuthenticatedFetch: () => authFetchSpy,
34+
}));
35+
36+
vi.mock('@object-ui/i18n', async (importOriginal) => ({
37+
...(await importOriginal<Record<string, unknown>>()),
38+
useObjectLabel: () => ({
39+
fieldLabel: (_o: any, _n: any, l: any) => l,
40+
fieldOptionLabel: (_o: any, _f: any, _v: any, l: any) => l,
41+
actionParamText: (_o: any, _a: any, _p: any, _attr: any, fallback: any) => fallback,
42+
actionParamOptionLabel: (_o: any, _a: any, _p: any, _v: any, fallback: any) => fallback,
43+
actionDescription: (_o: any, _a: any, fallback: any) => fallback,
44+
}),
45+
useObjectTranslation: () => ({
46+
t: (key: string, options?: any) => String(options?.defaultValue ?? key),
47+
}),
48+
}));
49+
50+
vi.mock('../useActionModal', () => ({
51+
useActionModal: () => ({
52+
modalHandler: vi.fn(async () => ({ success: true })),
53+
modalElement: null,
54+
closeModal: () => {},
55+
resolveModalTarget: vi.fn(async () => null),
56+
}),
57+
}));
58+
59+
vi.mock('../../views/ActionConfirmDialog', () => ({ ActionConfirmDialog: () => null }));
60+
vi.mock('../../views/ActionParamDialog', () => ({ ActionParamDialog: () => null }));
61+
vi.mock('../../views/ActionResultDialog', () => ({ ActionResultDialog: () => null }));
62+
vi.mock('../../views/FlowRunner', () => ({ FlowRunner: () => null }));
63+
64+
vi.mock('sonner', () => {
65+
const fn: any = vi.fn();
66+
fn.error = vi.fn();
67+
fn.success = vi.fn();
68+
return { toast: fn };
69+
});
70+
71+
import { ActionRunner } from '@object-ui/core';
72+
import { useConsoleActionRuntime } from '../useConsoleActionRuntime';
73+
74+
const TARGET = '/api/v1/export/gapdata_item';
75+
const NEW_TAB_ACTION = {
76+
name: 'export_tab',
77+
label: 'Export',
78+
type: 'api',
79+
target: TARGET,
80+
opensInNewTab: true,
81+
newTabUrl: '/export/{recordId}',
82+
locations: ['list_toolbar'],
83+
};
84+
85+
function stubWindowOpen() {
86+
const tab = {
87+
document: { write: vi.fn(), close: vi.fn() },
88+
location: { href: 'about:blank' },
89+
close: vi.fn(),
90+
};
91+
const open = vi.spyOn(window, 'open').mockImplementation(() => tab as unknown as Window);
92+
return { tab, open };
93+
}
94+
95+
/** A real runner wired to the runtime the way `ActionProvider` wires it. */
96+
function runnerFor(selectedRecords: Array<Record<string, unknown>>) {
97+
const { result } = renderHook(() =>
98+
useConsoleActionRuntime({ dataSource: {}, objects: [], objectName: 'gapdata_item' }),
99+
);
100+
const props = result.current.actionProviderProps;
101+
const runner = new ActionRunner({ ...props.context, selectedRecords } as any);
102+
for (const [type, handler] of Object.entries(props.handlers ?? {})) {
103+
runner.registerHandler(type, handler as any);
104+
}
105+
return { runner, runtime: result.current };
106+
}
107+
108+
beforeEach(() => {
109+
authFetchSpy.mockReset();
110+
authFetchSpy.mockImplementation(async () =>
111+
new Response(JSON.stringify({ success: true, data: {} }), {
112+
status: 200,
113+
headers: { 'content-type': 'application/json' },
114+
}),
115+
);
116+
vi.stubGlobal('fetch', vi.fn(async () => new Response('{}', { status: 200 })));
117+
});
118+
119+
afterEach(() => {
120+
vi.unstubAllGlobals();
121+
vi.restoreAllMocks();
122+
});
123+
124+
describe('objectstack#26747 — console runtime: an api action with opensInNewTab + newTabUrl opens the tab and sends nothing', () => {
125+
it('list toolbar, one row selected: the pre-opened tab goes to the interpolated newTabUrl, and no request is sent', async () => {
126+
const { runner } = runnerFor([{ id: 'row 7' }]);
127+
const { tab, open } = stubWindowOpen();
128+
129+
let result: any;
130+
await act(async () => {
131+
result = await runner.execute({ ...NEW_TAB_ACTION } as any);
132+
});
133+
134+
expect(result.success).toBe(true);
135+
expect(open).toHaveBeenCalledTimes(1);
136+
expect(open).toHaveBeenCalledWith('about:blank', '_blank');
137+
expect(tab.location.href).toBe(`${window.location.origin}/export/row%207`);
138+
expect(authFetchSpy).not.toHaveBeenCalled();
139+
expect(globalThis.fetch).not.toHaveBeenCalled();
140+
});
141+
142+
it('the apiHandler the hook returns (what ConsoleShell registers) takes the same path', async () => {
143+
const { runtime } = runnerFor([]);
144+
const { tab } = stubWindowOpen();
145+
146+
await act(async () => {
147+
await runtime.apiHandler({ ...NEW_TAB_ACTION, params: { recordId: 'rec-1' } } as any);
148+
});
149+
150+
expect(tab.location.href).toBe(`${window.location.origin}/export/rec-1`);
151+
expect(authFetchSpy).not.toHaveBeenCalled();
152+
});
153+
154+
it('two rows selected: refused before any tab opens, with nothing sent', async () => {
155+
const { runner } = runnerFor([{ id: 'a' }, { id: 'b' }]);
156+
const { open } = stubWindowOpen();
157+
158+
let result: any;
159+
await act(async () => {
160+
result = await runner.execute({ ...NEW_TAB_ACTION } as any);
161+
});
162+
163+
expect(result.success).toBe(false);
164+
expect(open).not.toHaveBeenCalled();
165+
expect(authFetchSpy).not.toHaveBeenCalled();
166+
});
167+
168+
it('CONTROL: without newTabUrl the api action still sends its request to the target', async () => {
169+
const { newTabUrl: _dropped, ...withoutNewTabUrl } = NEW_TAB_ACTION;
170+
const { runner } = runnerFor([{ id: 'row 7' }]);
171+
const { open } = stubWindowOpen();
172+
173+
await act(async () => {
174+
await runner.execute({ ...withoutNewTabUrl } as any);
175+
});
176+
177+
expect(authFetchSpy).toHaveBeenCalledTimes(1);
178+
const [url, init] = authFetchSpy.mock.calls[0] as unknown as [string, RequestInit];
179+
expect(url.endsWith(TARGET)).toBe(true);
180+
expect(init.method).toBe('POST');
181+
expect(open).not.toHaveBeenCalledWith('about:blank', '_blank');
182+
});
183+
});

0 commit comments

Comments
 (0)