Skip to content
This repository was archived by the owner on Nov 4, 2026. It is now read-only.
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
10 changes: 10 additions & 0 deletions .changeset/13397-flow-row-guard.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
'@object-ui/i18n': patch
'@object-ui/app-shell': patch
---

A record-scoped flow action launched from a list toolbar with 0 or several rows selected is refused before its param dialog opens, and its refusal is translated (objectui#13397).

The console's `flowHandler` now carries `refuseBeforeInput`, the early refusal objectui#13384 gave the `script` handler, answered by the same resolver its dispatch runs. The runner asks it before the confirmation and the param dialog, so "select a row first" and "select exactly one row" show in the session's language before the user fills anything in, and nothing is sent. With exactly one row the flow behaves as before. A direct call that is refused at dispatch time is shown through the refusal's pack key too.

The console server-action wrapper's `newTabUrl` refusal ("no record id available") now has a pack key, `console.serverAction.noRecordId`, defined in every shipped pack, and the wrapper's fast path shows the resolver's own refusal through its pack key.
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
/**
* 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.
*/

/**
* objectui#13397 — a record-scoped `type: 'flow'` action launched from a list
* toolbar with a row count other than 1 is refused BEFORE its param dialog,
* with a translated toast and no request. objectui#13384 gave the `script`
* handler that early refusal (`refuseBeforeInput`); the console `flowHandler`
* ran the same resolver only at dispatch time, after the whole dialog, and
* showed its English `error`.
*
* The session here reads zh-CN: the translator answers from the shipped zh
* pack, so a refusal that reaches the toast untranslated reads English and
* fails the text pin.
*/

import { describe, it, expect, beforeEach, vi } from 'vitest';
import { render, screen, fireEvent, waitFor, renderHook, act } from '@testing-library/react';
import React from 'react';

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

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) => {
const { builtInLocales } = await import('@object-ui/i18n/locales');
// The zh-CN pack, read by dotted key; a key it lacks answers its default.
const zhT = (key: string, options?: { defaultValue?: string }) => {
const hit = key.split('.').reduce<unknown>(
(node, part) => (node && typeof node === 'object' ? (node as Record<string, unknown>)[part] : undefined),
builtInLocales.zh,
);
return typeof hit === 'string' ? hit : String(options?.defaultValue ?? key);
};
return {
...(await importOriginal<Record<string, unknown>>()),
useObjectLabel: () => ({
fieldLabel: (_o: unknown, _n: unknown, l: string) => l,
fieldOptionLabel: (_o: unknown, _f: unknown, _v: unknown, l: string) => l,
actionParamText: (_o: unknown, _a: unknown, _p: unknown, _attr: unknown, fallback: string) => fallback,
actionParamOptionLabel: (_o: unknown, _a: unknown, _p: unknown, _v: unknown, fallback: string) => fallback,
actionDescription: (_o: unknown, _a: unknown, fallback: string) => fallback,
}),
useObjectTranslation: () => ({ t: zhT, language: 'zh' }),
};
});

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

// The param dialog renders a marker while it is open — the "did a dialog open"
// probe. The other dialogs stay inert.
vi.mock('../../views/ActionParamDialog', () => ({
ActionParamDialog: ({ state }: { state: { open?: boolean } }) =>
state?.open ? <div data-testid="param-dialog" /> : null,
}));
vi.mock('../../views/ActionConfirmDialog', () => ({ ActionConfirmDialog: () => null }));
vi.mock('../../views/ActionResultDialog', () => ({ ActionResultDialog: () => null }));
vi.mock('../../views/FlowRunner', () => ({ FlowRunner: () => null }));

vi.mock('sonner', () => ({ toast: Object.assign(vi.fn(), { error: vi.fn(), success: vi.fn() }) }));

import { toast } from 'sonner';
import { useConsoleActionRuntime, ConsoleActionRuntimeProvider } from '../useConsoleActionRuntime';
import { useAction } from '@object-ui/react';
import { resolveServerActionRecordId, type ActionContext, type ActionDef } from '@object-ui/core';

const ZH_SELECT_ROW_FIRST = '此操作针对单条记录,请先选择一行。';
const ZH_SELECT_EXACTLY_ONE_ROW = '此操作针对单条记录,请只选择一行。';

/** A record-scoped flow with a param dialog, mounted on rows AND the toolbar. */
const REASSIGN = {
type: 'flow',
name: 'reassign',
target: 'reassign_wizard',
locations: ['list_item', 'list_toolbar'],
params: [{ name: 'owner', label: 'Owner', type: 'text', required: true }],
};
const launch = (extra: Record<string, unknown> = {}): ActionDef => ({ ...JSON.parse(JSON.stringify(REASSIGN)), ...extra });
const rowsContext = (rows: Record<string, unknown>[]): ActionContext => ({ selectedRecords: rows });
const toastError = vi.mocked(toast.error);

beforeEach(() => {
authFetchSpy.mockReset();
toastError.mockClear();
vi.mocked(toast.success).mockClear();
});

describe('flowHandler.refuseBeforeInput — the resolver its dispatch runs (objectui#13397)', () => {
const runtime = () => renderHook(() => useConsoleActionRuntime({ dataSource: {}, objects: [], objectName: 'inv' })).result.current;

it.each([
['0 rows', [] as Record<string, unknown>[], 'actions.selectRowFirst'],
['2 rows', [{ id: 'a' }, { id: 'b' }], 'actions.selectExactlyOneRow'],
])('%s: answers the resolver\'s refusal with its pack key', (_label, rows, errorKey) => {
const context = rowsContext(rows);
const rule = resolveServerActionRecordId(launch(), context);

expect(runtime().flowHandler.refuseBeforeInput?.(launch(), context)).toEqual({ error: rule.error, errorKey });
});

it('1 row: no refusal', () => {
expect(runtime().flowHandler.refuseBeforeInput?.(launch(), rowsContext([{ id: 'a' }])))
.toBeUndefined();
});

it('control: an object-level toolbar flow with 0 rows is not refused', () => {
expect(runtime().flowHandler.refuseBeforeInput?.(
launch({ locations: ['list_toolbar'] }),
rowsContext([]),
)).toBeUndefined();
});

it('control: a dialog that itself collects `recordId` is not judged before it runs', () => {
const collectsRecordId = launch({ params: [{ name: 'recordId', label: 'Record', type: 'text' }] });

expect(runtime().flowHandler.refuseBeforeInput?.(collectsRecordId, rowsContext([])))
.toBeUndefined();
});
});

describe('a toolbar flow launch through the console runtime (objectui#13397)', () => {
function Probe({ rows, action }: { rows: Record<string, unknown>[]; action: ActionDef }) {
const { execute, updateContext } = useAction();
return (
<button
onClick={() => {
updateContext({ selectedRecords: rows });
void execute(action);
}}
>
run-flow
</button>
);
}
const mount = (rows: Record<string, unknown>[], action: ActionDef = launch()) => {
render(
<ConsoleActionRuntimeProvider dataSource={{}} objects={[]} objectName="inv">
<Probe rows={rows} action={action} />
</ConsoleActionRuntimeProvider>,
);
fireEvent.click(screen.getByText('run-flow'));
};

it.each([
['0 rows', [] as Record<string, unknown>[], ZH_SELECT_ROW_FIRST],
['2 rows', [{ id: 'a' }, { id: 'b' }], ZH_SELECT_EXACTLY_ONE_ROW],
])('%s: refused before the param dialog, with a zh-CN toast and no request', async (_label, rows, zh) => {
mount(rows);

await waitFor(() => expect(toastError).toHaveBeenCalledWith(zh));
expect(screen.queryByTestId('param-dialog')).toBeNull();
expect(authFetchSpy).not.toHaveBeenCalled();
});

it('1 row: the param dialog opens, as today, and nothing is refused', async () => {
mount([{ id: 'rec_1' }]);

await screen.findByTestId('param-dialog');
expect(toastError).not.toHaveBeenCalled();
expect(authFetchSpy).not.toHaveBeenCalled();
});

it('control: an object-level toolbar flow with 0 rows still opens its dialog', async () => {
mount([], launch({ locations: ['list_toolbar'] }));

await screen.findByTestId('param-dialog');
expect(toastError).not.toHaveBeenCalled();
});
});

describe('flowHandler called directly: the dispatch-time refusal is translated (objectui#13397)', () => {
it.each([
['0 rows', [] as Record<string, unknown>[], ZH_SELECT_ROW_FIRST],
['2 rows', [{ id: 'a' }, { id: 'b' }], ZH_SELECT_EXACTLY_ONE_ROW],
])('%s: refuses in zh-CN through the pack key, with no request', async (_label, rows, zh) => {
const { result } = renderHook(() => useConsoleActionRuntime({ dataSource: {}, objects: [], objectName: 'inv' }));

let res: unknown;
await act(async () => {
res = await result.current.flowHandler(launch({ params: {} }), rowsContext(rows));
});

expect(res).toEqual({ success: false, error: zh });
expect(authFetchSpy).not.toHaveBeenCalled();
});
});
55 changes: 51 additions & 4 deletions packages/app-shell/src/hooks/useConsoleActionRuntime.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import type {
NavigationHandler,
ParamCollectionHandler,
ResultDialogHandler,
ServerActionHandler,
ToastHandler,
} from '@object-ui/core';
import { actionErrorDetail, captureUpdateUndoData, resolveRecordIdParamSeed, resolveServerActionRecordId } from '@object-ui/core';
Expand Down Expand Up @@ -92,7 +93,14 @@ export interface ConsoleActionRuntime {
// narrower restatement that nothing could catch while the tests calling it
// with two arguments were unchecked (objectui#4040).
apiHandler: (action: ActionDef, context?: ActionContext) => Promise<ActionResult>;
flowHandler: (action: ActionDef, context?: ActionContext) => Promise<ActionResult>;
/**
* Carries `refuseBeforeInput` (objectui#13397), so the runner refuses a
* single-record flow launched with 0 or several rows selected before its
* param dialog opens — the member `serverActionHandler` carries since
* objectui#13384, answered by the same resolver.
*/
flowHandler: ((action: ActionDef, context?: ActionContext) => Promise<ActionResult>)
& Pick<ServerActionHandler, 'refuseBeforeInput'>;
serverActionHandler: (action: ActionDef, context?: ActionContext) => Promise<ActionResult>;
/** `type: 'modal'` — opens `target` as a page/object form, else runs the action server-side. */
modalActionHandler: (action: ActionDef, context?: ActionContext) => Promise<ActionResult>;
Expand Down Expand Up @@ -636,8 +644,11 @@ export function useConsoleActionRuntime(opts: ConsoleActionRuntimeOptions): Cons
*
* `context` is the shared ActionRunner context (registered handlers are
* invoked as `handler(action, runnerContext)`).
*
* This is the dispatch only; the registered `flowHandler` below wraps it
* with the early row-count refusal (objectui#13397).
*/
const flowHandler = useCallback(async (action: ActionDef, context?: ActionContext): Promise<ActionResult> => {
const flowDispatch = useCallback(async (action: ActionDef, context?: ActionContext): Promise<ActionResult> => {
const flowName = action.target || action.name;
if (!flowName) {
return { success: false, error: 'No flow target provided for flow action' };
Expand All @@ -659,8 +670,20 @@ export function useConsoleActionRuntime(opts: ConsoleActionRuntimeOptions): Cons
// ID"); a pure object-level toolbar flow keeps triggering with no record.
// This handler used to hold a second copy of that rule, worded for flows.
// ⛔ Never re-add one here: a rule written twice drifts.
//
// A launch through the runner is refused before its dialog by
// `flowHandler.refuseBeforeInput` below; this check stays for a caller
// that invokes the handler directly. Its refusal is shown through its
// pack key, as the runner shows the early one (objectui#13397), with
// the English source as the default and the fallback for an empty
// answer.
const resolution = resolveServerActionRecordId(action, context);
if (resolution.error) return { success: false, error: resolution.error };
if (resolution.error) {
const error = resolution.errorKey
? String(t(resolution.errorKey, { defaultValue: resolution.error })) || resolution.error
: resolution.error;
return { success: false, error };
}
const recordId = resolution.recordId;
if (recordId != null && params.recordId == null) params.recordId = recordId;
// Ask the store for the object type BEFORE the lookup, as the param
Expand Down Expand Up @@ -710,7 +733,7 @@ export function useConsoleActionRuntime(opts: ConsoleActionRuntimeOptions): Cons
} catch (error) {
return { success: false, error: (error as Error).message };
}
}, [authFetch, objApiName, objects, metadata, refresh, i18n, language]);
}, [authFetch, objApiName, objects, metadata, refresh, i18n, language, t]);

// Server-side action handler — POST /api/v1/actions/{object}/{action}, built
// from @object-ui/core's `createServerActionHandler` via the shared console
Expand Down Expand Up @@ -746,6 +769,30 @@ export function useConsoleActionRuntime(opts: ConsoleActionRuntimeOptions): Cons
[authFetch],
);

/**
* The registered flow handler: {@link flowDispatch}, plus the early
* row-count refusal the runner asks before its confirmation and param
* dialog (`ActionRunnerHandler`'s `refuseBeforeInput`, objectui#13397).
* Without it a single-record flow launched from a list toolbar with 0 or
* several rows ran its whole param dialog and was refused only after
* Confirm, in English.
*
* The member is `serverActionHandler`'s own — core's
* `createServerActionHandler` builds it (objectui#13384) and the console
* wrapper forwards it. That handler is built here with no `resolveRecordId`,
* so its member runs `resolveServerActionRecordId`, the resolver
* `flowDispatch` runs, and skips an action whose dialog itself collects
* `recordId`. ⛔ Never write a flow copy of that rule: a rule written twice
* drifts.
*/
const flowHandler = useMemo(
() => Object.assign(
(action: ActionDef, context?: ActionContext) => flowDispatch(action, context),
{ refuseBeforeInput: serverActionHandler.refuseBeforeInput },
),
[flowDispatch, 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
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@ vi.mock('sonner', () => {
});

import { toast } from 'sonner';
import { ActionRunner } from '@object-ui/core';
import { ActionRunner, type ActionDef } from '@object-ui/core';
import { builtInLocales } from '@object-ui/i18n/locales';
import { createConsoleServerActionHandler } from '../consoleServerAction';

/** A pre-openable tab stub with the surface the wrapper drives. */
Expand Down Expand Up @@ -492,3 +493,55 @@ describe('the early record-id refusal is forwarded to the runner (objectui#13384
});
});
});

describe('the newTabUrl fast path refuses in the session language (objectui#13397)', () => {
// The zh-CN pack as the wrapper's `t`, read by dotted key; a key it lacks
// answers the English default, as `useObjectTranslation().t` does.
const zhT = (key: string, englishDefault: string) => {
const hit = key.split('.').reduce<unknown>(
(node, part) => (node && typeof node === 'object' ? (node as Record<string, unknown>)[part] : undefined),
builtInLocales.zh,
);
return typeof hit === 'string' ? hit : englishDefault;
};
const SSO: ActionDef = { type: 'script', name: 'sso_as_owner', opensInNewTab: true, newTabUrl: '/sso-open/{recordId}' };

it('no resolvable record id: the refusal reads zh-CN, with no tab', async () => {
const openSpy = vi.spyOn(window, 'open');
const { handler, fetch } = makeHandler({ t: zhT });

const res = await handler({ ...SSO });

expect(res).toEqual({ success: false, error: '此操作针对单条记录,但没有可用的记录 ID。' });
expect(openSpy).not.toHaveBeenCalled();
expect(fetch).not.toHaveBeenCalled();
});

it('control: with no `t` the same refusal stays English', async () => {
const { handler } = makeHandler();

expect(await handler({ ...SSO })).toEqual({
success: false,
error: 'This action runs on a single record — no record id available.',
});
});

it("the resolver's own refusal reads zh-CN through its pack key, with no tab", async () => {
const openSpy = vi.spyOn(window, 'open');
const { handler } = makeHandler({ t: zhT });

const res = await handler({ ...SSO }, { selectedRecords: [{ id: 'a' }, { id: 'b' }] });

expect(res).toEqual({ success: false, error: '此操作针对单条记录,请只选择一行。' });
expect(openSpy).not.toHaveBeenCalled();
});

it('control: with one row the fast path still opens the tab', async () => {
const tab = makeTab();
vi.spyOn(window, 'open').mockReturnValue(tab);
const { handler } = makeHandler({ t: zhT });

expect(await handler({ ...SSO }, { selectedRecords: [{ id: 'e1' }] })).toEqual({ success: true });
expect(tab.location.href).toBe('https://api.test/sso-open/e1');
});
});
Loading
Loading