From ed908887ea1b50f6cd995e51d043f85e7696a02c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 11:40:49 +0000 Subject: [PATCH 1/3] fix(app-shell): a flow action whose run pauses closes with the action's own successMessage A flow action that declares a successMessage stays silent at a screen pause, as ruled, and its run then completed on FlowRunner's terminal toast, which knew only the flow's words. The launch hosts now hand the action's message over with the run (ScreenFlowState.actionSuccessMessage), and the terminal-success toast reads it first, then the flow's own successMessage, then the flow-completed sentence. Claude-Session: https://claude.ai/code/session_01G5TSaEwbEQsKMjhzTB4rEd Co-authored-by: Claude --- .../12234-flow-action-success-message.md | 11 + ...me.flowActionSuccessMessage-12234.test.tsx | 161 +++++++++++++ .../src/hooks/useConsoleActionRuntime.tsx | 5 +- packages/app-shell/src/views/FlowRunner.tsx | 52 ++++- ...ew.flowActionSuccessMessage-12234.test.tsx | 212 ++++++++++++++++++ .../app-shell/src/views/RecordDetailView.tsx | 5 +- ...Runner.actionSuccessMessage-12234.test.tsx | 181 +++++++++++++++ 7 files changed, 622 insertions(+), 5 deletions(-) create mode 100644 .changeset/12234-flow-action-success-message.md create mode 100644 packages/app-shell/src/hooks/__tests__/useConsoleActionRuntime.flowActionSuccessMessage-12234.test.tsx create mode 100644 packages/app-shell/src/views/RecordDetailView.flowActionSuccessMessage-12234.test.tsx create mode 100644 packages/app-shell/src/views/__tests__/FlowRunner.actionSuccessMessage-12234.test.tsx diff --git a/.changeset/12234-flow-action-success-message.md b/.changeset/12234-flow-action-success-message.md new file mode 100644 index 0000000000..7da09d2bc5 --- /dev/null +++ b/.changeset/12234-flow-action-success-message.md @@ -0,0 +1,11 @@ +--- +'@object-ui/app-shell': patch +--- + +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. + +The exported `ScreenFlowState` type gains one optional member, `actionSuccessMessage`, which carries that message from the launching host to `FlowRunner`. `FlowRunner`'s props are unchanged. diff --git a/packages/app-shell/src/hooks/__tests__/useConsoleActionRuntime.flowActionSuccessMessage-12234.test.tsx b/packages/app-shell/src/hooks/__tests__/useConsoleActionRuntime.flowActionSuccessMessage-12234.test.tsx new file mode 100644 index 0000000000..6c6faebbe5 --- /dev/null +++ b/packages/app-shell/src/hooks/__tests__/useConsoleActionRuntime.flowActionSuccessMessage-12234.test.tsx @@ -0,0 +1,161 @@ +/** + * 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>()), + 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) { + 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' }); + +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) { + const objects = [{ name: OBJECT, label: 'Case', fields: {}, actions: [declared] }]; + let result: any; + function Probe() { + const { execute } = useAction(); + return ( + + ); + } + render( + + + + + , + ); + 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) { + 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((toast as any).success.mock.calls).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((toast as any).success.mock.calls).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((toast as any).success.mock.calls).toEqual([['Flow "Escalate" completed']]); + }); +}); diff --git a/packages/app-shell/src/hooks/useConsoleActionRuntime.tsx b/packages/app-shell/src/hooks/useConsoleActionRuntime.tsx index 2fdb547fd1..2fa7f77fd9 100644 --- a/packages/app-shell/src/hooks/useConsoleActionRuntime.tsx +++ b/packages/app-shell/src/hooks/useConsoleActionRuntime.tsx @@ -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, diff --git a/packages/app-shell/src/views/FlowRunner.tsx b/packages/app-shell/src/views/FlowRunner.tsx index 481cb37d67..6f892093d6 100644 --- a/packages/app-shell/src/views/FlowRunner.tsx +++ b/packages/app-shell/src/views/FlowRunner.tsx @@ -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 @@ -316,6 +338,27 @@ export interface ScreenFlowState extends Pick { 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; } /** @@ -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', diff --git a/packages/app-shell/src/views/RecordDetailView.flowActionSuccessMessage-12234.test.tsx b/packages/app-shell/src/views/RecordDetailView.flowActionSuccessMessage-12234.test.tsx new file mode 100644 index 0000000000..33ec675179 --- /dev/null +++ b/packages/app-shell/src/views/RecordDetailView.flowActionSuccessMessage-12234.test.tsx @@ -0,0 +1,212 @@ +/** + * 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 RECORD-PAGE flow action that pauses on a screen ends with + * its own `successMessage`, not the flow-completed sentence. + * + * The record page builds its own action runtime (its own flow handler, its own + * `FlowRunner` mount), so it is pinned separately from the list route + * (`useConsoleActionRuntime.flowActionSuccessMessage-12234`). It is the host + * the reported case actions (升级工单 / 关闭工单 / 认领工单) run through: a + * declared action on the record's object, started through the action door, + * that pauses for input. + * + * Harness, as in `RecordDetailView.refusedLaunch-9973.test.tsx`: the real + * `RecordDetailView` renders against a stub data source; the `ActionProvider` + * it mounts is captured on the way through (and still rendered), and its + * context, toast sink and handler set are wired into a real `ActionRunner`. + * The flow handler, the launch module and the mounted `FlowRunner` are real, + * under the real en pack. The network answers by route, so no unrelated fetch + * from the page's chrome can consume a flow answer. + */ + +import * as React from 'react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, waitFor, act, cleanup, within, fireEvent } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; + +const authFetchSpy = vi.fn(); +vi.mock('@object-ui/auth', async (importOriginal) => ({ + ...(await importOriginal>()), + useAuth: () => ({ user: { id: 'u1', name: 'Ada', image: null }, activeOrganization: null }), + createAuthenticatedFetch: () => authFetchSpy, +})); + +vi.mock('@object-ui/collaboration', async (importOriginal) => ({ + ...(await importOriginal>()), + useRecordPresence: () => [], + PresenceAvatars: () => null, +})); + +vi.mock('sonner', () => ({ + toast: Object.assign(vi.fn(), { + success: vi.fn(), + error: vi.fn(), + info: vi.fn(), + warning: vi.fn(), + loading: vi.fn(), + dismiss: vi.fn(), + }), +})); + +vi.mock('./MetadataInspector', () => ({ + MetadataPanel: () => null, + useMetadataInspector: () => ({ showDebug: false, toggle: () => {} }), +})); + +// Capture every 's props while KEEPING the real provider, so +// the record page's own runtime (the one whose context carries this record) +// can be driven directly. +const captured: any[] = []; +vi.mock('@object-ui/react', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + ActionProvider: (props: any) => { + captured.push(props); + return React.createElement(actual.ActionProvider as any, props); + }, + SchemaRenderer: () => null, + }; +}); + +import { toast } from 'sonner'; +import { I18nProvider, MetadataCtx } from '@object-ui/react'; +import { ActionRunner } from '@object-ui/core'; +import { RecordDetailView } from './RecordDetailView'; + +const OBJECT_NAME = 'service_case'; +const RECORD_ID = 'case-41'; +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 jsonResponse(body: unknown) { + return new Response(JSON.stringify(body), { status: 200, headers: { 'content-type': 'application/json' } }); +} +const PAUSED = { + success: true, + data: { + success: true, + status: 'paused', + runId: 'run-1', + flowLabel: 'Escalate', + screen: { nodeId: 'reason', title: 'Escalation reason', fields: [] }, + }, +}; +const COMPLETED = { success: true, data: { success: true, status: 'completed', flowLabel: 'Escalate' } }; + +beforeEach(() => { + cleanup(); + captured.length = 0; + authFetchSpy.mockReset(); + authFetchSpy.mockImplementation(async (url: string) => { + if (String(url).includes(`/api/v1/actions/${OBJECT_NAME}/escalate_case`)) return jsonResponse(PAUSED); + if (String(url).includes(`/api/v1/automation/${FLOW}/runs/run-1/resume`)) return jsonResponse(COMPLETED); + return jsonResponse({ data: [] }); + }); + (toast as any).success.mockClear(); + (toast as any).error.mockClear(); + // Unrelated chrome on this view reaches for the platform API; answer it + // locally so the only asynchrony left is the record load. + vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({ data: [] }))); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +function recordPageProps() { + return [...captured].reverse().find((c) => c.handlers && c.context?.record?.id === RECORD_ID); +} + +/** Mount the record page with `declared` on its object, launch it through a real runner, and submit the screen. */ +async function launchAndSubmit(declared: Record) { + const objects = [ + { + name: OBJECT_NAME, + label: 'Case', + fields: { id: { type: 'text', label: 'Id' }, name: { type: 'text', label: 'Name' } }, + actions: [declared], + }, + ]; + const metadata = { + objects, + pages: [], + loading: false, + error: null, + refresh: async () => {}, + invalidate: () => {}, + ensureType: async () => [], + getItem: async () => null, + getItemsByType: () => [], + } as any; + render( + + + + ({ data: [] })), + findOne: vi.fn(async () => ({ id: RECORD_ID, name: 'CASE-00041' })), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + } as any} + objects={objects} + onEdit={() => {}} + objectNameOverride={OBJECT_NAME} + recordIdOverride={RECORD_ID} + embedded + /> + + + , + ); + await waitFor(() => expect(recordPageProps()).toBeTruthy()); + const props = recordPageProps(); + (toast as any).success.mockClear(); + + const runner = new ActionRunner(props.context); + runner.setToastHandler(props.onToast); + for (const [type, handler] of Object.entries(props.handlers)) runner.registerHandler(type, handler as any); + + let result: any; + await act(async () => { + result = await runner.execute({ ...declared } as any); + }); + const doorCalls = authFetchSpy.mock.calls.filter(([url]) => String(url).includes('/api/v1/actions/')); + expect(doorCalls.map(([url]) => String(url))).toEqual([`/api/v1/actions/${OBJECT_NAME}/escalate_case`]); + + // The pause: silent, as ruled — no success toast yet. + expect(result).toEqual({ success: true, silent: true }); + expect((toast as any).success).not.toHaveBeenCalled(); + + const dialog = await screen.findByRole('dialog'); + expect(within(dialog).getByRole('heading', { name: 'Escalation reason' })).toBeTruthy(); + fireEvent.click(within(dialog).getByRole('button', { name: 'Submit' })); + await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()); +} + +describe('objectui#12234 — a paused record-page action completes with the action message', () => { + it('toasts the action message once at completion', async () => { + await launchAndSubmit(DECLARED); + + expect((toast as any).success.mock.calls).toEqual([[ACTION_MESSAGE]]); + expect((toast as any).error).not.toHaveBeenCalled(); + }); + + it('control: an action that declares no successMessage keeps the flow-completed toast', async () => { + await launchAndSubmit(DECLARED_WITHOUT_MESSAGE); + + expect((toast as any).success.mock.calls).toEqual([['Flow "Escalate" completed']]); + }); +}); diff --git a/packages/app-shell/src/views/RecordDetailView.tsx b/packages/app-shell/src/views/RecordDetailView.tsx index 9387eba81e..0d3380187c 100644 --- a/packages/app-shell/src/views/RecordDetailView.tsx +++ b/packages/app-shell/src/views/RecordDetailView.tsx @@ -1329,13 +1329,16 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri }); 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, diff --git a/packages/app-shell/src/views/__tests__/FlowRunner.actionSuccessMessage-12234.test.tsx b/packages/app-shell/src/views/__tests__/FlowRunner.actionSuccessMessage-12234.test.tsx new file mode 100644 index 0000000000..ae76217184 --- /dev/null +++ b/packages/app-shell/src/views/__tests__/FlowRunner.actionSuccessMessage-12234.test.tsx @@ -0,0 +1,181 @@ +/** + * 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 run launched by an action that declares a + * `successMessage` completes with THAT message, not the flow-completed sentence. + * + * Reported on hosted HotCRM (zh-CN): the case actions 升级工单 / 关闭工单 / + * 认领工单 pause on a screen, and on Submit the run completed with + * 「流程「升级工单」已完成」 — the runner's terminal toast, which knew only the + * flow's words. The action's own message had been held back at the pause + * (`silent`, pinned by objectui#7707) and was never shown afterwards. + * + * What each pin is a fact about: + * + * 1. **The action's copy closes the run.** Handed over on + * `ScreenFlowState.actionSuccessMessage`, it is the terminal toast — and it + * wins over the flow's own `successMessage`, because it is what the same + * click shows when the run does not pause. + * 2. **Only the TERMINAL toast moves.** A multi-step run still toasts the + * next-step notice between screens; a run that ends `refused` or fails after + * the pause still shows no success toast at all. + * 3. **The controls keep today's toast.** No action copy (absent, blank, or an + * `I18nLabel` map no localizer resolved) ⇒ the flow's own message, else the + * catalog sentence — asserted in zh with the reported wording, so the + * control is the defect's own reading. + * + * `toast.success` is asserted by its full call list, never "was called with": + * a runner that toasted BOTH sentences would pass a `toHaveBeenCalledWith`. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { I18nProvider } from '@object-ui/react'; + +const toastError = vi.fn(); +const toastSuccess = vi.fn(); +vi.mock('sonner', () => ({ toast: { error: toastError, success: toastSuccess } })); + +const { FlowRunner } = await import('../FlowRunner'); +type ScreenFlowState = import('../FlowRunner').ScreenFlowState; + +const ACTION_MESSAGE = '工单已升级'; +const FLOW_LABEL = '升级工单'; + +/** A message-only screen: no fields, so Submit resumes at once. */ +const confirmScreen = (nodeId: string, title: string) => ({ nodeId, title, fields: [] as never[] }); + +const stateWith = (actionSuccessMessage?: unknown): ScreenFlowState => ({ + flowName: 'case_escalate', + flowLabel: FLOW_LABEL, + runId: 'run_1', + screen: confirmScreen('screen_reason', '升级原因'), + ...(actionSuccessMessage !== undefined ? { actionSuccessMessage: actionSuccessMessage as string } : {}), +}); + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } }); +} + +const completed = (extra: Record = {}) => + jsonResponse({ success: true, data: { success: true, status: 'completed', flowLabel: FLOW_LABEL, ...extra } }); + +/** Mount under the real zh pack, so the catalog sentence is the reported one. */ +function mount(state: ScreenFlowState, answers: Response[]) { + const authFetch = vi.fn(async () => answers.shift() ?? completed()); + const onComplete = vi.fn(); + render( + + + , + ); + return { authFetch, onComplete }; +} + +async function submit() { + await userEvent.setup().click(await screen.findByRole('button', { name: '提交' })); +} + +beforeEach(() => { + toastError.mockClear(); + toastSuccess.mockClear(); +}); + +describe("objectui#12234 — the invoking action's successMessage closes the run", () => { + it("toasts the action's message at completion, and only that", async () => { + const { onComplete } = mount(stateWith(ACTION_MESSAGE), [completed()]); + await submit(); + + await waitFor(() => expect(onComplete).toHaveBeenCalledTimes(1)); + expect(toastSuccess.mock.calls).toEqual([[ACTION_MESSAGE]]); + expect(toastError).not.toHaveBeenCalled(); + }); + + it("wins over the flow's own successMessage", async () => { + const { onComplete } = mount(stateWith(ACTION_MESSAGE), [completed({ successMessage: 'Escalation recorded' })]); + await submit(); + + await waitFor(() => expect(onComplete).toHaveBeenCalledTimes(1)); + expect(toastSuccess.mock.calls).toEqual([[ACTION_MESSAGE]]); + }); + + it('a multi-step run keeps the next-step notice between screens and closes with the action message', async () => { + const { onComplete } = mount(stateWith(ACTION_MESSAGE), [ + jsonResponse({ + success: true, + data: { success: true, status: 'paused', runId: 'run_1', flowLabel: FLOW_LABEL, screen: confirmScreen('screen_confirm', '确认') }, + }), + completed(), + ]); + await submit(); + await screen.findByRole('heading', { name: '确认' }); + await submit(); + + await waitFor(() => expect(onComplete).toHaveBeenCalledTimes(1)); + expect(toastSuccess.mock.calls).toEqual([['已保存 — 下一步'], [ACTION_MESSAGE]]); + }); + + it('a run that ends refused after the pause still shows no success toast', async () => { + const { onComplete } = mount(stateWith(ACTION_MESSAGE), [ + jsonResponse({ success: true, data: { success: true, status: 'refused', refusalMessage: 'Refused: already escalated' } }), + ]); + await submit(); + + expect(await screen.findByText('Refused: already escalated')).toBeTruthy(); + expect(toastSuccess).not.toHaveBeenCalled(); + expect(toastError).not.toHaveBeenCalled(); + expect(onComplete).not.toHaveBeenCalled(); + }); + + it('a run that fails after the pause shows the failure, never the action message', async () => { + const { onComplete } = mount(stateWith(ACTION_MESSAGE), [ + jsonResponse({ success: true, data: { success: false, status: 'failed', error: 'Node exploded' } }), + ]); + await submit(); + + await waitFor(() => expect(toastError).toHaveBeenCalledTimes(1)); + expect(toastSuccess).not.toHaveBeenCalled(); + expect(onComplete).not.toHaveBeenCalled(); + }); +}); + +describe('objectui#12234 — controls: with no usable action copy the toast is unchanged', () => { + it('no action message ⇒ the reported flow-completed sentence', async () => { + const { onComplete } = mount(stateWith(), [completed()]); + await submit(); + + await waitFor(() => expect(onComplete).toHaveBeenCalledTimes(1)); + expect(toastSuccess.mock.calls).toEqual([['流程「升级工单」已完成']]); + }); + + it("no action message ⇒ the flow's own successMessage when it declares one", async () => { + const { onComplete } = mount(stateWith(), [completed({ successMessage: 'Escalation recorded' })]); + await submit(); + + await waitFor(() => expect(onComplete).toHaveBeenCalledTimes(1)); + expect(toastSuccess.mock.calls).toEqual([['Escalation recorded']]); + }); + + it('a blank action message counts as none', async () => { + const { onComplete } = mount(stateWith(' '), [completed()]); + await submit(); + + await waitFor(() => expect(onComplete).toHaveBeenCalledTimes(1)); + expect(toastSuccess.mock.calls).toEqual([['流程「升级工单」已完成']]); + }); + + it('an unresolved I18nLabel map counts as none (never handed to the toast as an object)', async () => { + const { onComplete } = mount(stateWith({ en: 'Case escalated', 'zh-CN': '工单已升级' }), [completed()]); + await submit(); + + await waitFor(() => expect(onComplete).toHaveBeenCalledTimes(1)); + expect(toastSuccess.mock.calls).toEqual([['流程「升级工单」已完成']]); + }); +}); From a015abaa7806e1e7018b18bc871f27cc0702ce3f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 11:45:19 +0000 Subject: [PATCH 2/3] test(app-shell): compare success toast texts, not raw call arguments The runner's toast sink passes an options bag beside the text and FlowRunner passes none, so the host pins compare the texts in order. Claude-Session: https://claude.ai/code/session_01G5TSaEwbEQsKMjhzTB4rEd Co-authored-by: Claude --- ...nRuntime.flowActionSuccessMessage-12234.test.tsx | 13 ++++++++++--- ...tailView.flowActionSuccessMessage-12234.test.tsx | 11 +++++++++-- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/packages/app-shell/src/hooks/__tests__/useConsoleActionRuntime.flowActionSuccessMessage-12234.test.tsx b/packages/app-shell/src/hooks/__tests__/useConsoleActionRuntime.flowActionSuccessMessage-12234.test.tsx index 6c6faebbe5..7eb31f1063 100644 --- a/packages/app-shell/src/hooks/__tests__/useConsoleActionRuntime.flowActionSuccessMessage-12234.test.tsx +++ b/packages/app-shell/src/hooks/__tests__/useConsoleActionRuntime.flowActionSuccessMessage-12234.test.tsx @@ -69,6 +69,13 @@ const PAUSED = answered({ }); 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(); @@ -125,7 +132,7 @@ describe('objectui#12234 — a paused action-door launch completes with the acti await submitWith(COMPLETED); await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()); - expect((toast as any).success.mock.calls).toEqual([[ACTION_MESSAGE]]); + expect(successTexts()).toEqual([ACTION_MESSAGE]); expect((toast as any).error).not.toHaveBeenCalled(); }); @@ -135,7 +142,7 @@ describe('objectui#12234 — a paused action-door launch completes with the acti const result = await click(DECLARED); expect(result).toMatchObject({ success: true }); - expect((toast as any).success.mock.calls).toEqual([[ACTION_MESSAGE]]); + expect(successTexts()).toEqual([ACTION_MESSAGE]); expect(screen.queryByRole('dialog')).toBeNull(); }); @@ -156,6 +163,6 @@ describe('objectui#12234 — a paused action-door launch completes with the acti await submitWith(COMPLETED); await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()); - expect((toast as any).success.mock.calls).toEqual([['Flow "Escalate" completed']]); + expect(successTexts()).toEqual(['Flow "Escalate" completed']); }); }); diff --git a/packages/app-shell/src/views/RecordDetailView.flowActionSuccessMessage-12234.test.tsx b/packages/app-shell/src/views/RecordDetailView.flowActionSuccessMessage-12234.test.tsx index 33ec675179..8c6d1d96c3 100644 --- a/packages/app-shell/src/views/RecordDetailView.flowActionSuccessMessage-12234.test.tsx +++ b/packages/app-shell/src/views/RecordDetailView.flowActionSuccessMessage-12234.test.tsx @@ -104,6 +104,13 @@ const PAUSED = { }; const COMPLETED = { success: true, data: { 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(() => { cleanup(); captured.length = 0; @@ -200,13 +207,13 @@ describe('objectui#12234 — a paused record-page action completes with the acti it('toasts the action message once at completion', async () => { await launchAndSubmit(DECLARED); - expect((toast as any).success.mock.calls).toEqual([[ACTION_MESSAGE]]); + expect(successTexts()).toEqual([ACTION_MESSAGE]); expect((toast as any).error).not.toHaveBeenCalled(); }); it('control: an action that declares no successMessage keeps the flow-completed toast', async () => { await launchAndSubmit(DECLARED_WITHOUT_MESSAGE); - expect((toast as any).success.mock.calls).toEqual([['Flow "Escalate" completed']]); + expect(successTexts()).toEqual(['Flow "Escalate" completed']); }); }); From b3c549c3155b448f9d7cb0752bf8612b57fd5819 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 12:30:04 +0000 Subject: [PATCH 3/3] chore(changeset): objectui#12234 takes minor for the additive ScreenFlowState member The exported ScreenFlowState type gains an optional member, which widens a published surface (Clause-2 yes per the claim amendment), so the app-shell bump is minor rather than patch. Claude-Session: https://claude.ai/code/session_01G5TSaEwbEQsKMjhzTB4rEd Co-authored-by: Claude --- .changeset/12234-flow-action-success-message.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.changeset/12234-flow-action-success-message.md b/.changeset/12234-flow-action-success-message.md index 7da09d2bc5..a9831cf511 100644 --- a/.changeset/12234-flow-action-success-message.md +++ b/.changeset/12234-flow-action-success-message.md @@ -1,5 +1,5 @@ --- -'@object-ui/app-shell': patch +'@object-ui/app-shell': minor --- A flow action whose run pauses for input now closes with the action's own `successMessage` (objectui#12234). @@ -8,4 +8,4 @@ An action that launches a flow and declares a `successMessage` showed that messa 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. -The exported `ScreenFlowState` type gains one optional member, `actionSuccessMessage`, which carries that message from the launching host to `FlowRunner`. `FlowRunner`'s props are unchanged. +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.