Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .changeset/26747-api-action-new-tab-url.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
'@object-ui/app-shell': patch
---

fix(app-shell): a `type: 'api'` action declaring `opensInNewTab` + `newTabUrl` opens the tab and sends no request

Clause-②: no
Metadata-protocol: none

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.

What changes:

- 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.
- A `type: 'api'` action without `newTabUrl` sends its request to `target` as before.

What to do: nothing. An api action that declares the pair now does what it declares.
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

/**
* objectstack#26747 — the shared console action runtime (list pages, the
* list toolbar, the console root provider) honours `opensInNewTab` +
* `newTabUrl` on a `type: 'api'` action: the pre-opened tab goes to
* `newTabUrl` with the selected row's id substituted, and no request is sent.
*
* Driven through a real `ActionRunner` carrying the runtime's own context and
* registered handler set, exactly as `ActionProvider` wires them, and through
* the `apiHandler` the hook returns (what `ConsoleShell` registers).
*
* CONTROL: without `newTabUrl` the api action still sends its request to
* `target`. The fast path's row-count refusal is the server-action one: two
* selected rows refuse before any tab opens.
*/

import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { renderHook, act } from '@testing-library/react';

vi.mock('react-router-dom', () => ({ useNavigate: () => vi.fn() }));

const authFetchSpy = vi.fn();
vi.mock('@object-ui/auth', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
useAuth: () => ({ user: { id: 'u1', name: 'User', image: null }, activeOrganization: null }),
createAuthenticatedFetch: () => authFetchSpy,
}));

vi.mock('@object-ui/i18n', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
useObjectLabel: () => ({
fieldLabel: (_o: any, _n: any, l: any) => l,
fieldOptionLabel: (_o: any, _f: any, _v: any, l: any) => l,
actionParamText: (_o: any, _a: any, _p: any, _attr: any, fallback: any) => fallback,
actionParamOptionLabel: (_o: any, _a: any, _p: any, _v: any, fallback: any) => fallback,
actionDescription: (_o: any, _a: any, fallback: any) => fallback,
}),
useObjectTranslation: () => ({
t: (key: string, options?: any) => String(options?.defaultValue ?? key),
}),
}));

vi.mock('../useActionModal', () => ({
useActionModal: () => ({
modalHandler: vi.fn(async () => ({ success: true })),
modalElement: null,
closeModal: () => {},
resolveModalTarget: vi.fn(async () => null),
}),
}));

vi.mock('../../views/ActionConfirmDialog', () => ({ ActionConfirmDialog: () => null }));
vi.mock('../../views/ActionParamDialog', () => ({ ActionParamDialog: () => null }));
vi.mock('../../views/ActionResultDialog', () => ({ ActionResultDialog: () => null }));
vi.mock('../../views/FlowRunner', () => ({ FlowRunner: () => null }));

vi.mock('sonner', () => {
const fn: any = vi.fn();
fn.error = vi.fn();
fn.success = vi.fn();
return { toast: fn };
});

import { ActionRunner } from '@object-ui/core';
import { useConsoleActionRuntime } from '../useConsoleActionRuntime';

const TARGET = '/api/v1/export/gapdata_item';
const NEW_TAB_ACTION = {
name: 'export_tab',
label: 'Export',
type: 'api',
target: TARGET,
opensInNewTab: true,
newTabUrl: '/export/{recordId}',
locations: ['list_toolbar'],
};

function stubWindowOpen() {
const tab = {
document: { write: vi.fn(), close: vi.fn() },
location: { href: 'about:blank' },
close: vi.fn(),
};
const open = vi.spyOn(window, 'open').mockImplementation(() => tab as unknown as Window);
return { tab, open };
}

/** A real runner wired to the runtime the way `ActionProvider` wires it. */
function runnerFor(selectedRecords: Array<Record<string, unknown>>) {
const { result } = renderHook(() =>
useConsoleActionRuntime({ dataSource: {}, objects: [], objectName: 'gapdata_item' }),
);
const props = result.current.actionProviderProps;
const runner = new ActionRunner({ ...props.context, selectedRecords } as any);
for (const [type, handler] of Object.entries(props.handlers ?? {})) {
runner.registerHandler(type, handler as any);
}
return { runner, runtime: result.current };
}

beforeEach(() => {
authFetchSpy.mockReset();
authFetchSpy.mockImplementation(async () =>
new Response(JSON.stringify({ success: true, data: {} }), {
status: 200,
headers: { 'content-type': 'application/json' },
}),
);
vi.stubGlobal('fetch', vi.fn(async () => new Response('{}', { status: 200 })));
});

afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});

describe('objectstack#26747 — console runtime: an api action with opensInNewTab + newTabUrl opens the tab and sends nothing', () => {
it('list toolbar, one row selected: the pre-opened tab goes to the interpolated newTabUrl, and no request is sent', async () => {
const { runner } = runnerFor([{ id: 'row 7' }]);
const { tab, open } = stubWindowOpen();

let result: any;
await act(async () => {
result = await runner.execute({ ...NEW_TAB_ACTION } as any);
});

expect(result.success).toBe(true);
expect(open).toHaveBeenCalledTimes(1);
expect(open).toHaveBeenCalledWith('about:blank', '_blank');
expect(tab.location.href).toBe(`${window.location.origin}/export/row%207`);
expect(authFetchSpy).not.toHaveBeenCalled();
expect(globalThis.fetch).not.toHaveBeenCalled();
});

it('the apiHandler the hook returns (what ConsoleShell registers) takes the same path', async () => {
const { runtime } = runnerFor([]);
const { tab } = stubWindowOpen();

await act(async () => {
await runtime.apiHandler({ ...NEW_TAB_ACTION, params: { recordId: 'rec-1' } } as any);
});

expect(tab.location.href).toBe(`${window.location.origin}/export/rec-1`);
expect(authFetchSpy).not.toHaveBeenCalled();
});

it('two rows selected: refused before any tab opens, with nothing sent', async () => {
const { runner } = runnerFor([{ id: 'a' }, { id: 'b' }]);
const { open } = stubWindowOpen();

let result: any;
await act(async () => {
result = await runner.execute({ ...NEW_TAB_ACTION } as any);
});

expect(result.success).toBe(false);
expect(open).not.toHaveBeenCalled();
expect(authFetchSpy).not.toHaveBeenCalled();
});

it('CONTROL: without newTabUrl the api action still sends its request to the target', async () => {
const { newTabUrl: _dropped, ...withoutNewTabUrl } = NEW_TAB_ACTION;
const { runner } = runnerFor([{ id: 'row 7' }]);
const { open } = stubWindowOpen();

await act(async () => {
await runner.execute({ ...withoutNewTabUrl } as any);
});

expect(authFetchSpy).toHaveBeenCalledTimes(1);
const [url, init] = authFetchSpy.mock.calls[0] as unknown as [string, RequestInit];
expect(url.endsWith(TARGET)).toBe(true);
expect(init.method).toBe('POST');
expect(open).not.toHaveBeenCalledWith('about:blank', '_blank');
});
});
17 changes: 14 additions & 3 deletions ui/packages/app-shell/src/hooks/useConsoleActionRuntime.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ import { entitlementDialogFromError, type EntitlementDialogSpec } from '../envir
import { resolvePageVarTokens } from '../utils/resolvePageVarTokens.js';
import { launchConsoleFlow } from '../utils/flowLaunch.js';
import { createConsoleServerActionHandler } from '../utils/consoleServerAction.js';
import { withNewTabUrlFastPath } from '../utils/newTabUrlFastPath.js';
import { modalTargetRefusalMessage } from '../utils/modalTargetDiagnostics.js';
import { actionContextOrg } from '../utils/actionContextOrg.js';
import { resolveHostAppSegment } from '../utils/appRoute.js';
Expand Down Expand Up @@ -803,6 +804,14 @@ export function useConsoleActionRuntime(opts: ConsoleActionRuntimeOptions): Cons
[flowDispatch, serverActionHandler],
);

// The registered `api` handler: {@link apiHandler}, except that an action
// declaring `opensInNewTab` + `newTabUrl` takes the server-action handler's
// zero-roundtrip new-tab path, which sends no request (objectstack#26747).
const apiActionHandler = useMemo(
() => withNewTabUrlFastPath(apiHandler, serverActionHandler),
[apiHandler, serverActionHandler],
);

// Client-side modal transport, shared with RecordDetailView so a
// `type: 'modal'` action behaves the SAME on a list page, an SDUI page, a
// declared-actions bar and a record page. Before this, only RecordDetailView
Expand Down Expand Up @@ -883,10 +892,10 @@ export function useConsoleActionRuntime(opts: ConsoleActionRuntimeOptions): Cons
onParamCollection: paramCollectionHandler,
onResultDialog: resultDialogHandler,
onModal: modalHandler,
handlers: { api: apiHandler, flow: flowHandler, script: serverActionHandler, modal: modalActionHandler },
handlers: { api: apiActionHandler, flow: flowHandler, script: serverActionHandler, modal: modalActionHandler },
}), [
objectName, objectDef, currentUser, activeOrganization, confirmHandler, toastHandler,
navigateHandler, paramCollectionHandler, resultDialogHandler, apiHandler,
navigateHandler, paramCollectionHandler, resultDialogHandler, apiActionHandler,
flowHandler, serverActionHandler, modalHandler, modalActionHandler,
]);

Expand Down Expand Up @@ -982,7 +991,9 @@ export function useConsoleActionRuntime(opts: ConsoleActionRuntimeOptions): Cons
navigateHandler,
paramCollectionHandler,
resultDialogHandler,
apiHandler,
// The composed handler, so every host that registers it (ConsoleShell's
// root provider included) honours the `newTabUrl` fast path.
apiHandler: apiActionHandler,
flowHandler,
serverActionHandler,
modalActionHandler,
Expand Down
5 changes: 3 additions & 2 deletions ui/packages/app-shell/src/utils/consoleServerAction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ import {
type ServerActionHandler,
type ServerActionRecordIdResolver,
} from '@object-ui/core';
import { takesNewTabUrlFastPath } from './newTabUrlFastPath.js';

/**
* i18next-style translate function: `t(key, englishDefault)` returns the
Expand Down Expand Up @@ -185,8 +186,8 @@ export function createConsoleServerActionHandler(opts: ConsoleServerActionOption
// until the (possibly slow) endpoint commits its redirect. Record-id
// resolution runs BEFORE the pre-open so a blocked resolution
// (multi-select, empty selection) never flashes a tab.
const newTabUrl = typeof action.newTabUrl === 'string' ? action.newTabUrl : '';
if (action.opensInNewTab && newTabUrl) {
if (takesNewTabUrlFastPath(action)) {
const newTabUrl = action.newTabUrl;
// Both refusals below are shown in the session's language
// (objectui#13397): the resolver's through the pack key it names
// (objectui#13384), this wrapper's own through `console.serverAction.*`.
Expand Down
62 changes: 62 additions & 0 deletions ui/packages/app-shell/src/utils/newTabUrlFastPath.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

/**
* The `newTabUrl` fast path's routing rule, shared by the console server-action
* handler (`consoleServerAction`) and the hosts that register a `type: 'api'`
* handler beside it (`useConsoleActionRuntime`, `RecordDetailView`).
*
* It lives apart from `consoleServerAction` on purpose: the hosts' tests stub
* that module with a factory, and a rule kept there would vanish under every
* such stub. Kept here, an api action is routed by the same rule under test as
* in the console.
*/

import type { ActionContext, ActionDef, ActionResult, ServerActionHandler } from '@object-ui/core';

/**
* Whether `action` takes the zero-roundtrip `newTabUrl` fast path:
* `opensInNewTab` plus a non-empty `newTabUrl`. The spec gives that pair one
* meaning whatever the action's `type` — navigate the pre-opened tab, no
* request — so a host's `type: 'api'` handler asks this and hands such an
* action to the handler `createConsoleServerActionHandler` builds,
* instead of sending its own request to `target`.
*/
export function takesNewTabUrlFastPath(action: ActionDef): action is ActionDef & { newTabUrl: string } {
return !!action.opensInNewTab && typeof action.newTabUrl === 'string' && action.newTabUrl !== '';
}

/**
* A host's registered `type: 'api'` handler, made to honour the `newTabUrl`
* fast path: an action that {@link takesNewTabUrlFastPath} goes to
* `consoleHandler` (the handler `createConsoleServerActionHandler`
* built for the same surface, so the record id resolves the way that surface
* resolves it), and every other action to `apiHandler` unchanged.
*
* Without it the console's api handlers sent their request to `target` and
* never read the pair, so a `type: 'api'` action declaring `opensInNewTab` +
* `newTabUrl` opened no tab and POSTed instead, from the record header, its
* more-menu and the list toolbar alike (objectstack#26747). The pair's early
* row-count refusal is the fast path's own, forwarded only for those actions;
* an ordinary api action keeps having none.
*/
export function withNewTabUrlFastPath(
apiHandler: (action: ActionDef, context?: ActionContext) => Promise<ActionResult>,
consoleHandler: ServerActionHandler,
): ServerActionHandler {
return Object.assign(
(action: ActionDef, context?: ActionContext) => (takesNewTabUrlFastPath(action)
? consoleHandler(action, context)
: apiHandler(action, context)),
{
refuseBeforeInput: (action: ActionDef, context?: ActionContext) => (takesNewTabUrlFastPath(action)
? consoleHandler.refuseBeforeInput?.(action, context)
: undefined),
},
);
}
Loading
Loading