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
11 changes: 11 additions & 0 deletions .changeset/12234-flow-action-success-message.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
'@object-ui/app-shell': minor
---

A flow action whose run pauses for input now closes with the action's own `successMessage` (objectui#12234).

An action that launches a flow and declares a `successMessage` showed that message only when the flow ran straight through. When the flow paused on a screen, the action stayed silent at the pause, as it should, and the run's completion then toasted the generic flow-completed sentence (for example 「流程「升级工单」已完成」), so the action's own message was never shown.

The completion toast now reads, in order: the invoking action's `successMessage`, the flow's own `successMessage`, then the flow-completed sentence. The action's message arrives already localized by the surface that dispatched it, the same copy the straight-through run shows. Both console launch hosts hand it over: the list and page runtime, and the record page. Unchanged: the pause shows no toast, an intermediate step still toasts its next-step notice, and a refused or failed run shows no success toast.

Additive public type change: the `ScreenFlowState` type exported from the package entry gains one optional member, `actionSuccessMessage`, which carries that message from the launching host to `FlowRunner`. Existing values of the type stay valid, and `FlowRunner`'s props are unchanged.
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
/**
* 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#12234 — a declared flow action launched through the action door
* ends with ITS OWN `successMessage`, whether or not its run pauses.
*
* The list / page runtime is one of the two console launch hosts (the record
* page is the other, pinned in `RecordDetailView.flowActionSuccessMessage-12234`).
* Before the fix a run that paused on a screen held the action's message back
* at the pause (`silent`, as ruled) and then completed on the runner's
* flow-completed sentence, so the action's message was never shown.
*
* Driven end to end through the real runtime: `ConsoleActionRuntimeProvider`
* (the real `ActionProvider` / `ActionRunner` and its toast sink), the real
* flow handler and launch module, and the real `FlowRunner` it mounts, under
* the real en pack. Only the network (`authFetch`), the router and the toast
* library are stubbed. Every case asserts the FULL `toast.success` call list,
* so the pause's silence and the single terminal toast are one reading.
*/

import { describe, it, expect, beforeEach, vi } from 'vitest';
import { render, screen, fireEvent, waitFor, within } from '@testing-library/react';
import React from '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('sonner', () => {
const fn: any = vi.fn();
fn.error = vi.fn();
fn.success = vi.fn();
fn.info = vi.fn();
fn.warning = vi.fn();
return { toast: fn };
});

import { toast } from 'sonner';
import { ConsoleActionRuntimeProvider } from '../useConsoleActionRuntime';
import { I18nProvider, useAction } from '@object-ui/react';

const OBJECT = 'service_case';
const FLOW = 'case_escalate';
const ACTION_MESSAGE = 'Case escalated';
/** The declared action, as the object metadata carries it — the control declares no message. */
const DECLARED_WITHOUT_MESSAGE = { name: 'escalate_case', label: 'Escalate case', type: 'flow', target: FLOW };
const DECLARED = { ...DECLARED_WITHOUT_MESSAGE, successMessage: ACTION_MESSAGE };

function answered(data: Record<string, unknown>) {
return { ok: true, status: 200, json: async () => ({ success: true, data }) };
}
const PAUSED = answered({
success: true,
status: 'paused',
runId: 'run-1',
flowLabel: 'Escalate',
screen: { nodeId: 'reason', title: 'Escalation reason', fields: [] },
});
const COMPLETED = answered({ success: true, status: 'completed', flowLabel: 'Escalate' });

/**
* The text of every success toast, in order. The runner's sink passes an
* options bag beside the text and `FlowRunner` passes none, so the text alone
* is compared — the full list, so a second toast cannot hide.
*/
const successTexts = () => (toast as any).success.mock.calls.map((call: unknown[]) => call[0]);

beforeEach(() => {
authFetchSpy.mockReset();
(toast as any).mockClear();
(toast as any).error.mockClear();
(toast as any).success.mockClear();
});

/** Mount the runtime with `declared` on the object, and click it once through the real runner. */
async function click(declared: Record<string, unknown>) {
const objects = [{ name: OBJECT, label: 'Case', fields: {}, actions: [declared] }];
let result: any;
function Probe() {
const { execute } = useAction();
return (
<button onClick={() => {
void execute({ ...declared, params: { _rowRecord: { id: 'case-41' } } } as any).then((r) => { result = r; });
}}>
run-flow
</button>
);
}
render(
<I18nProvider config={{ defaultLanguage: 'en', detectBrowserLanguage: false }} persistLanguage={false}>
<ConsoleActionRuntimeProvider dataSource={{}} objects={objects} objectName={OBJECT}>
<Probe />
</ConsoleActionRuntimeProvider>
</I18nProvider>,
);
fireEvent.click(screen.getByText('run-flow'));
await waitFor(() => expect(result).toBeDefined());
expect(String(authFetchSpy.mock.calls[0][0])).toBe(`/api/v1/actions/${OBJECT}/escalate_case`);
return result;
}

/** Submit the paused run's screen, answering the resume with `answer`. */
async function submitWith(answer: ReturnType<typeof answered>) {
const dialog = await screen.findByRole('dialog');
expect(within(dialog).getByRole('heading', { name: 'Escalation reason' })).toBeTruthy();
authFetchSpy.mockResolvedValueOnce(answer);
fireEvent.click(within(dialog).getByRole('button', { name: 'Submit' }));
await waitFor(() => expect(authFetchSpy).toHaveBeenCalledTimes(2));
expect(String(authFetchSpy.mock.calls[1][0])).toBe(`/api/v1/automation/${FLOW}/runs/run-1/resume`);
}

describe('objectui#12234 — a paused action-door launch completes with the action message', () => {
it('stays silent at the pause, then toasts the action message once at completion', async () => {
authFetchSpy.mockResolvedValueOnce(PAUSED);

const result = await click(DECLARED);

// The pause: silent, as ruled — no toast of either colour yet.
expect(result).toEqual({ success: true, silent: true });
expect((toast as any).success).not.toHaveBeenCalled();

await submitWith(COMPLETED);
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull());
expect(successTexts()).toEqual([ACTION_MESSAGE]);
expect((toast as any).error).not.toHaveBeenCalled();
});

it('a straight-through run toasts the same action message (the path that already worked)', async () => {
authFetchSpy.mockResolvedValueOnce(COMPLETED);

const result = await click(DECLARED);

expect(result).toMatchObject({ success: true });
expect(successTexts()).toEqual([ACTION_MESSAGE]);
expect(screen.queryByRole('dialog')).toBeNull();
});

it('a run that ends refused after the pause shows its notice and no success toast', async () => {
authFetchSpy.mockResolvedValueOnce(PAUSED);
await click(DECLARED);

await submitWith(answered({ success: true, status: 'refused', refusalMessage: 'Refused: already escalated' }));
expect(await screen.findByText('Refused: already escalated')).toBeTruthy();
expect((toast as any).success).not.toHaveBeenCalled();
expect((toast as any).error).not.toHaveBeenCalled();
});

it('control: an action that declares no successMessage keeps the flow-completed toast', async () => {
authFetchSpy.mockResolvedValueOnce(PAUSED);

await click(DECLARED_WITHOUT_MESSAGE);
await submitWith(COMPLETED);

await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull());
expect(successTexts()).toEqual(['Flow "Escalate" completed']);
});
});
5 changes: 4 additions & 1 deletion packages/app-shell/src/hooks/useConsoleActionRuntime.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -665,13 +665,16 @@ export function useConsoleActionRuntime(opts: ConsoleActionRuntimeOptions): Cons
});
const judged = launch.judged;
// Paused at a `screen` node: FlowRunner renders the form + resumes, and
// refreshes on completion.
// refreshes on completion. The pause keeps the action's own toast
// silent, so its `successMessage` rides along to the run's completion
// (objectui#12234 — see FlowRunner's header).
if (judged.followUp?.kind === 'screen') {
setScreenFlow({
flowName: launch.flowName,
flowLabel: judged.followUp.flowLabel,
runId: judged.followUp.runId,
screen: judged.followUp.screen,
actionSuccessMessage: action.successMessage,
});
}
// Ended `refused`: the Close-only notice carries the engine's sentence,
Expand Down
52 changes: 49 additions & 3 deletions packages/app-shell/src/views/FlowRunner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,28 @@
* suppresses the action's `successMessage` at the ActionRunner's toast sink.
* ⛔ That last part is a thing that WORKS — it is pinned, not touched.
*
* ## The invoking action's `successMessage` closes the run (objectui#12234)
*
* The `silent` above holds the action's message back AT THE PAUSE, because
* nothing has completed yet. It used to be held back for good: the run then
* completed here, and this toast knew only the FLOW's words, so an action
* declaring "Case escalated" closed on the generic `Flow "…" completed`. The
* same action whose run did not pause got its own message from the action
* runner, so the copy a user read depended on whether the flow happened to
* ask for input.
*
* So the launch host hands the action's message over with the run
* (`ScreenFlowState.actionSuccessMessage`), and the terminal-success toast
* reads, in order: that message, the flow's own `successMessage`, the
* catalog's flow-completed sentence. The action's copy wins over the flow's
* for the reason above — it is what the same click shows when the run does
* not pause. It is handed over already localized: every surface that
* dispatches a declared action runs it through `useActionTextLocalizer`
* first, and the handler receives that copy, so there is no second
* localization step here. Nothing else moves: the pause stays silent, an
* intermediate step still toasts `flowRunner.nextStep`, and a `refused` or
* failed end still shows no success toast.
*
* ## The AUTHOR's copy is localized here; the chrome is not (objectui#5920)
*
* A screen's heading and each field's `label` / `placeholder` are the flow
Expand Down Expand Up @@ -316,6 +338,27 @@ export interface ScreenFlowState extends Pick<AutomationResult, 'flowLabel'> {
flowName: string;
runId: string;
screen: ScreenSpec;
/**
* The `successMessage` the ACTION that launched this run declares, exactly
* as that action reached the host's flow handler — so already localized by
* the surface that dispatched it, and the same value the action runner
* toasts when the run completes without pausing (objectui#12234 — see the
* header). Absent when no action launched the run or the action declares
* none; the runner then keeps the flow-completed toast it always had.
*/
actionSuccessMessage?: string;
}

/**
* The invoking action's success copy, when it is usable as a toast: a
* non-empty string. Anything else — absent, blank, or an `I18nLabel` map that
* reached the runner without passing through a localizer — counts as no copy,
* the same rule the action runner's own success toast applies to a rung, so
* the two paths cannot disagree about what "declares a successMessage" means.
*/
function invokingActionMessage(state: ScreenFlowState): string | undefined {
const copy: unknown = state.actionSuccessMessage;
return typeof copy === 'string' && copy.trim() !== '' ? copy : undefined;
}

/**
Expand Down Expand Up @@ -465,10 +508,13 @@ export function FlowRunner({ state, authFetch, baseUrl, onClose, onComplete, dat
setValues(initialScreenValues(outcome.screen));
toast.success(t('flowRunner.nextStep', { defaultValue: 'Saved — next step' }));
} else {
// Terminal success — show the flow's declared completion message, else
// the catalog's sentence naming the flow by the label this answer served.
// Terminal success — the invoking action's own `successMessage` first
// (objectui#12234 — see the header), then the flow's declared completion
// message, else the catalog's sentence naming the flow by the label this
// answer served.
toast.success(
outcome.successMessage
invokingActionMessage(state)
|| outcome.successMessage
|| t('flowRunner.completed', {
flow: displayFlowLabel(flowName, outcome.flowLabel, bundle, language),
defaultValue: 'Flow "{{flow}}" completed',
Expand Down
Loading
Loading