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
11 changes: 11 additions & 0 deletions .changeset/13384-record-action-row-guard.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
'@object-ui/core': patch
'@object-ui/i18n': patch
'@object-ui/app-shell': patch
---

A single-record action launched with 0 or several rows selected is refused before any dialog opens, and the refusal is translated (objectui#13384).

`ActionRunner` now asks the handler it will dispatch to for an early refusal (`ActionRunnerHandler`'s optional `refuseBeforeInput`) before the confirmation and the param dialog. The handlers `createServerActionHandler` builds answer it with the row-count check the dispatch already runs, so "select a row first" and "select exactly one row" are shown before the user fills anything in, and nothing is sent. With exactly one row the dialog behaves as before. A host that wraps the built handler in its own function keeps the early refusal only by forwarding `refuseBeforeInput`; the console's `createConsoleServerActionHandler` now forwards it, so on a list page (HotCRM's "Add to Campaign" on My Leads) the refusal toast shows and no dialog opens.

Both refusals now carry a pack key (`actions.selectRowFirst`, `actions.selectExactlyOneRow`, defined in every shipped pack). `resolveServerActionRecordId` returns it as `errorKey` beside the English `error`, and the runner's early refusal goes through its translator by that key.
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ vi.mock('sonner', () => {
});

import { toast } from 'sonner';
import { ActionRunner } from '@object-ui/core';
import { createConsoleServerActionHandler } from '../consoleServerAction';

/** A pre-openable tab stub with the surface the wrapper drives. */
Expand Down Expand Up @@ -419,3 +420,75 @@ describe('a declared onSuccess block defers to the runner (objectui#5221)', () =
expect(openSpy).toHaveBeenCalledWith('/app/crm/contacts/rec_42', '_blank');
});
});

describe('the early record-id refusal is forwarded to the runner (objectui#13384)', () => {
const ADD_TO_CAMPAIGN = {
type: 'script',
name: 'add_to_campaign',
objectName: 'crm_lead',
locations: ['list_toolbar', 'list_item'],
actionParams: [{ name: 'campaign', label: 'Campaign', type: 'text', required: true }],
};
const launch = () => JSON.parse(JSON.stringify(ADD_TO_CAMPAIGN));

it("the wrapper answers core's refusal, with its pack key, and nothing for one row", () => {
const { handler } = makeHandler();

expect(handler.refuseBeforeInput?.(launch(), { selectedRecords: [] })).toEqual({
error: 'This action runs on a single record — select a row first.',
errorKey: 'actions.selectRowFirst',
});
expect(handler.refuseBeforeInput?.(launch(), { selectedRecords: [{ id: 'a' }, { id: 'b' }] })).toEqual({
error: 'This action runs on a single record — select exactly one row.',
errorKey: 'actions.selectExactlyOneRow',
});
expect(handler.refuseBeforeInput?.(launch(), { selectedRecords: [{ id: 'a' }] })).toBeUndefined();
});

it('an injected resolveRecordId (record-page policy) decides the early refusal too', () => {
const { handler } = makeHandler({ resolveRecordId: () => ({ recordId: 'page_rec_1' }) });

expect(handler.refuseBeforeInput?.(launch(), { selectedRecords: [] })).toBeUndefined();
});

it.each([
['0 rows', [] as Record<string, unknown>[], 'This action runs on a single record — select a row first.'],
['2 rows', [{ id: 'a' }, { id: 'b' }], 'This action runs on a single record — select exactly one row.'],
])('through a real runner, %s: refused before the param dialog, with no tab and no request', async (_label, rows, refusal) => {
const openSpy = vi.spyOn(window, 'open');
const { handler, fetch } = makeHandler();
const runner = new ActionRunner({ selectedRecords: rows });
const toastSpy = vi.fn();
const dialog = vi.fn(async () => ({ campaign: 'cmp_webinar' }));
runner.setToastHandler(toastSpy);
runner.setParamCollectionHandler(dialog);
runner.registerHandler('script', handler);

const result = await runner.execute({ ...launch(), opensInNewTab: true });

expect(result).toEqual({ success: false, error: refusal });
expect(dialog).not.toHaveBeenCalled();
expect(fetch).not.toHaveBeenCalled();
expect(openSpy).not.toHaveBeenCalled();
expect(toastSpy).toHaveBeenCalledWith(refusal, expect.objectContaining({ type: 'error' }));
});

it('through a real runner, 1 row: the param dialog opens and the request carries that row', async () => {
const { handler, fetch } = makeHandler();
const runner = new ActionRunner({ selectedRecords: [{ id: 'lead_1' }] });
const dialog = vi.fn(async () => ({ campaign: 'cmp_webinar' }));
runner.setToastHandler(vi.fn());
runner.setParamCollectionHandler(dialog);
runner.registerHandler('script', handler);

const result = await runner.execute(launch());

expect(result.success).toBe(true);
expect(dialog).toHaveBeenCalledTimes(1);
expect(fetch).toHaveBeenCalledTimes(1);
expect(JSON.parse((fetch.mock.calls[0] as unknown as [string, { body: string }])[1].body)).toEqual({
recordId: 'lead_1',
params: { campaign: 'cmp_webinar' },
});
});
});
9 changes: 8 additions & 1 deletion packages/app-shell/src/utils/consoleServerAction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,7 @@ export function createConsoleServerActionHandler(opts: ConsoleServerActionOption
const resolveRecordId = opts.resolveRecordId ?? resolveServerActionRecordId;
const t = opts.t ?? defaultTranslate;

return async (action: ActionDef, context?: ActionContext) => {
const handler: ServerActionHandler = async (action: ActionDef, context?: ActionContext) => {
// ── Zero-roundtrip fast path ────────────────────────────────────────
// `newTabUrl` names a GET endpoint that performs ALL auth/authz itself
// (e.g. /sso-open re-runs every check the POST half would have done),
Expand Down Expand Up @@ -272,4 +272,11 @@ export function createConsoleServerActionHandler(opts: ConsoleServerActionOption
return { success: false, error: (error as Error).message };
}
};
// Core's early record-id refusal (objectui#13384), forwarded: the runner
// asks the handler it dispatches to — this wrapper, not core's handler
// inside it — before it shows a confirmation or a param dialog. Without the
// forward a single-record action launched with 0 or several rows selected
// ran its whole dialog and was refused only after Confirm.
handler.refuseBeforeInput = dispatch.refuseBeforeInput;
return handler;
}
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import * as React from 'react';
import { describe, it, expect, vi, afterEach } from 'vitest';
import { render, cleanup, screen, fireEvent, waitFor, act, within } from '@testing-library/react';
import { MemoryRouter, Routes, Route } from 'react-router-dom';
import { toast } from 'sonner';
import { createI18n, I18nProvider } from '@object-ui/i18n';
import { NAV_RUN_ACTION_PARAM } from '@object-ui/layout';
// Module scope, per AGENTS.md's test-discipline rule: the param dialog reaches
Expand Down Expand Up @@ -80,6 +81,7 @@ vi.mock('./metadata-admin/useMetadata', async (importOriginal) => ({

import { ObjectView } from './ObjectView';
import { ExpressionProvider } from '../providers/ExpressionProvider';
import { ConsoleToaster } from '../chrome/ConsoleToaster';

/** HotCRM's `CreateCampaignAction`, the keys this chain reads: record-scoped. */
const CREATE_CAMPAIGN = {
Expand Down Expand Up @@ -138,6 +140,7 @@ const OBJECTS = [
];

afterEach(() => {
act(() => { toast.dismiss(); });
cleanup();
vi.unstubAllGlobals();
window.history.replaceState(null, '', '/');
Expand All @@ -157,6 +160,7 @@ function mountLeads(viewName: keyof typeof LIST_VIEWS) {
} as never;
return render(
<I18nProvider instance={i18n}>
<ConsoleToaster />
<ExpressionProvider user={{ id: 'u1', name: 'Sales Manager' }}>
<MemoryRouter initialEntries={[`/apps/crm/crm_lead/view/${viewName}`]}>
<Routes>
Expand Down Expand Up @@ -252,6 +256,14 @@ describe('objectui#12462 — a bulk-wired, record-scoped list_toolbar action lea
});
});

/**
* What an ARMED link shows. `create_campaign` is record-scoped and the link
* runs it as the page mounts, with no row selected, so the run is refused for
* its row count before its param dialog opens (objectui#13384). This toast is
* the run's observable: the link armed and the action ran.
*/
const ZERO_ROW_REFUSAL = 'This action runs on a single record — select a row first.';

describe('objectui#12462 — the deep link arms only what the header draws', () => {
const linkTo = (viewName: string) =>
window.history.replaceState(null, '', `/apps/crm/crm_lead/view/${viewName}?${NAV_RUN_ACTION_PARAM}=create_campaign`);
Expand All @@ -268,14 +280,18 @@ describe('objectui#12462 — the deep link arms only what the header draws', ()
await rowBoxes();

expect(screen.queryByRole('dialog')).toBeNull();
expect(screen.queryByText(ZERO_ROW_REFUSAL)).toBeNull();
expect(linkStillInUrl()).toBe('create_campaign');
});

it('CONTROL — on a view that does not wire it, the same link arms: the param dialog opens and the link is consumed', async () => {
it('CONTROL — on a view that does not wire it, the same link arms: the action runs and the link is consumed', async () => {
linkTo('my_leads');
mountLeads('my_leads');

expect(await screen.findByRole('dialog', undefined, { timeout: 5000 })).toBeInTheDocument();
// It ran: with no row selected, the record-scoped action is refused before
// its param dialog (objectui#13384), so no dialog opens.
expect(await screen.findByText(ZERO_ROW_REFUSAL, undefined, { timeout: 5000 })).toBeInTheDocument();
expect(screen.queryByRole('dialog')).toBeNull();
await waitFor(() => expect(linkStillInUrl()).toBeNull(), { timeout: 5000 });
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,12 @@
* comes after a picker was used. It refuses and reports exactly as the plain
* fixture does.
*
* objectui#13384 moved the refusal in front of the dialog: a launch the
* dispatch would refuse for its row count is refused before the param dialog
* opens (the runner asks the console handler's forwarded
* `refuseBeforeInput`), so the 0- and 2-row cases below assert that no dialog
* opened, beside the toast and the absent request. One row still opens it.
*
* "Record-scoped" is a declaration, not a guess: an action is single-record
* when its `locations` include a record placement (`isRecordScopedAction`). An
* action declared at `list_toolbar` alone is object-level, so zero rows
Expand Down Expand Up @@ -214,22 +220,31 @@ async function launchFromHeader(): Promise<void> {
await act(async () => { fireEvent.click(within(dialog).getByRole('button', { name: 'Confirm' })); });
}

/**
* Click the HEADER toolbar's "Add to Campaign" for a launch that is refused for
* its row count (objectui#13384): the refusal shows, and no dialog opened.
*/
async function launchRefusedFromHeader(refusal: string): Promise<void> {
const toolbars = await screen.findAllByRole('toolbar', undefined, { timeout: 5000 });
fireEvent.click(await within(toolbars[0]).findByRole('button', { name: /Add to Campaign/ }, { timeout: 5000 }));
expect(await screen.findByText(refusal, undefined, { timeout: 5000 })).toBeInTheDocument();
expect(screen.queryByRole('dialog')).toBeNull();
}

describe('objectui#12331 — a record-scoped script action at list_toolbar, with a param', () => {
it('0 rows: the refusal shows, and no request is sent', async () => {
it('0 rows: the refusal shows before any dialog, and no request is sent', async () => {
mountLeads(RECORD_SCOPED);
await tickRows(0);
await launchFromHeader();
await launchRefusedFromHeader(ZERO_ROW_REFUSAL);

expect(await screen.findByText(ZERO_ROW_REFUSAL, undefined, { timeout: 5000 })).toBeInTheDocument();
expect(actionCalls()).toHaveLength(0);
});

it('2 rows: the refusal shows, and no request is sent', async () => {
it('2 rows: the refusal shows before any dialog, and no request is sent', async () => {
mountLeads(RECORD_SCOPED);
await tickRows(2);
await launchFromHeader();
await launchRefusedFromHeader(MULTI_ROW_REFUSAL);

expect(await screen.findByText(MULTI_ROW_REFUSAL, undefined, { timeout: 5000 })).toBeInTheDocument();
expect(actionCalls()).toHaveLength(0);
});

Expand All @@ -254,12 +269,11 @@ describe('objectui#12331 — a record-scoped script action at list_toolbar, with
});

describe('objectui#12331 — the refusal follows the `locations` declaration', () => {
it('declared at list_toolbar alone, 2 rows: one dispatch cannot name two records, so it refuses', async () => {
it('declared at list_toolbar alone, 2 rows: one dispatch cannot name two records, so it refuses before any dialog', async () => {
mountLeads(TOOLBAR_ONLY);
await tickRows(2);
await launchFromHeader();
await launchRefusedFromHeader(MULTI_ROW_REFUSAL);

expect(await screen.findByText(MULTI_ROW_REFUSAL, undefined, { timeout: 5000 })).toBeInTheDocument();
expect(actionCalls()).toHaveLength(0);
});

Expand Down Expand Up @@ -387,21 +401,20 @@ async function launchHotcrmFromHeader(): Promise<void> {
}

describe('objectui#12331 — HotCRM\'s declaration: list_item placement, a field-backed record-picker param', () => {
it('0 rows: the refusal shows, and no request is sent', async () => {
it('0 rows: the refusal shows before any dialog, and no request is sent', async () => {
mountHotcrmLeads();
await tickRows(0);
await launchHotcrmFromHeader();
// `list_item` also draws the action on every row; the header's toolbar is the first.
await launchRefusedFromHeader(ZERO_ROW_REFUSAL);

expect(await screen.findByText(ZERO_ROW_REFUSAL, undefined, { timeout: 5000 })).toBeInTheDocument();
expect(actionCalls()).toHaveLength(0);
});

it('2 rows: the refusal shows, and no request is sent', async () => {
it('2 rows: the refusal shows before any dialog, and no request is sent', async () => {
mountHotcrmLeads();
await tickRows(2);
await launchHotcrmFromHeader();
await launchRefusedFromHeader(MULTI_ROW_REFUSAL);

expect(await screen.findByText(MULTI_ROW_REFUSAL, undefined, { timeout: 5000 })).toBeInTheDocument();
expect(actionCalls()).toHaveLength(0);
});

Expand Down
61 changes: 56 additions & 5 deletions packages/core/src/actions/ActionRunner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -520,11 +520,26 @@ export interface ActionDef {
* handler — a different calling convention entirely (`(ctx) => unknown`, with
* validated params and a trusted engine facade on `ctx`) and a different
* execution site.
*
* `refuseBeforeInput` is optional. When the handler the runner will dispatch
* to carries it, the runner asks it BEFORE the confirmation and the param
* dialog (objectui#13384), with the same context the dispatch will get. An
* answer refuses the run there: its `error` is toasted, translated through
* {@link ActionRunner.setTranslator}'s translator when it names an `errorKey`,
* and nothing is dispatched. `undefined` lets the run go on as before. The
* server-action handler uses it for its row-count refusal, so a single-record
* action launched with 0 or several rows selected never opens a dialog only
* to refuse after Confirm.
*/
export type ActionRunnerHandler = (
export type ActionRunnerHandler = ((
action: ActionDef,
context: ActionContext
) => Promise<ActionResult> | ActionResult;
) => Promise<ActionResult> | ActionResult) & {
refuseBeforeInput?: (
action: ActionDef,
context: ActionContext,
) => { error: string; errorKey?: string } | undefined;
};

/**
* Confirmation handler — replaces window.confirm.
Expand Down Expand Up @@ -595,6 +610,10 @@ export type ToastHandler = (message: string, options?: {
* - `undo` — the label the runner hands the toast handler for an undoable
* success toast's Undo affordance (objectui#10969).
*
* The translator is also asked for one text this table does not hold: a
* handler's early refusal that names an `errorKey` (`ActionRunnerHandler`'s
* `refuseBeforeInput`, objectui#13384). Its `error` is the English source.
*
* An author's `outcomeMessages` / `successMessage` / `errorMessage` and an
* action's own error are never in this table: the host localizes authored copy
* before dispatch, and the runner only fills in its `${result.*}` tokens.
Expand All @@ -618,7 +637,7 @@ const RUNNER_TEXT = {
},
} as const;

type RunnerText = (typeof RUNNER_TEXT)[keyof typeof RUNNER_TEXT];
type RunnerText = { readonly key: string; readonly defaultValue: string };

/**
* Modal handler — consumers provide to render modal dialogs.
Expand Down Expand Up @@ -1288,7 +1307,9 @@ export class ActionRunner {
* reached the runner, and the Undo label of an undoable success toast. An
* author's `outcomeMessages` / `successMessage` / `errorMessage` and an
* action's own error never go through this translator: the host localizes
* authored copy before dispatch. With no translator the text stays English.
* authored copy before dispatch. The one handler text it does translate is
* an early refusal that names its key (`ActionRunnerHandler`'s
* `refuseBeforeInput`). With no translator the text stays English.
*/
setTranslator(translate: (key: string, options: { defaultValue: string }) => string): void {
this.translate = translate;
Expand Down Expand Up @@ -1455,6 +1476,19 @@ export class ActionRunner {
}
}

// The handler's early refusal (objectui#13384), asked before the
// confirmation and the param dialog below. A run the handler would
// refuse for its context — a single-record action launched with 0 or
// several rows selected — is refused here, with the toast and the
// post-execution the dispatch-time refusal gets, and before the user
// has typed anything or anything is sent.
const refusal = this.refusalBeforeInput(action, actionType);
if (refusal !== undefined) {
const refused: ActionResult = { success: false, error: refusal };
await this.handlePostExecution(action, refused);
return refused;
}

// Confirmation. One spelling: `confirmText` — the only one the
// translation bundle can address (`…._actions.{name}.confirmText`). The
// structured `confirm.message` read that used to OUTRANK it is retired
Expand Down Expand Up @@ -1667,7 +1701,24 @@ export class ActionRunner {
}

/**
* One entry of `RUNNER_TEXT`, in the installed translator's language. An
* The early refusal of the handler {@link dispatchCollected} would route
* this action to, in the translator's language when it names a key —
* `undefined` when there is none. An update-operation action never reaches
* a registered handler, so it is never asked.
*/
private refusalBeforeInput(action: ActionDef, actionType: string): string | undefined {
if (isUpdateOperationAction(action)) return undefined;
const handler = actionType ? this.handlers.get(actionType) : undefined;
const refusal = handler?.refuseBeforeInput?.(action, this.context);
if (!refusal?.error) return undefined;
return refusal.errorKey
? this.runnerText({ key: refusal.errorKey, defaultValue: refusal.error })
: refusal.error;
}

/**
* One entry of `RUNNER_TEXT` (or a handler's keyed early refusal), in the
* installed translator's language. An
* empty answer from the translator falls back to the English source rather
* than raising an empty toast or an empty label.
*/
Expand Down
Loading
Loading