diff --git a/.changeset/12755-navigation-no-toast.md b/.changeset/12755-navigation-no-toast.md new file mode 100644 index 0000000000..0b15f6e055 --- /dev/null +++ b/.changeset/12755-navigation-no-toast.md @@ -0,0 +1,7 @@ +--- +'@object-ui/core': patch +--- + +Clause-②: no + +**A navigation no longer claims a success (objectui#12755).** A `type: 'url'` action, its `navigation` alias and a `type: 'form'` action only change the route, yet each raised the generic success toast ("Action completed successfully" / 「操作已成功完成」) after it navigated, so a button that merely opened a page read as a completed operation. `ActionRunner` now marks such a result `silent` (it keeps `success: true`), so it announces nothing unless the action declares its own `successMessage`, which still shows. Actions that write (api, script, field update, flow) keep their success toast. diff --git a/.changeset/12755-react-result-pin.md b/.changeset/12755-react-result-pin.md new file mode 100644 index 0000000000..bb43d6e7c2 --- /dev/null +++ b/.changeset/12755-react-result-pin.md @@ -0,0 +1,6 @@ +--- +--- + +Clause-②: no + +Test-only (objectui#12755): `useActionRunner`'s navigation test now expects the result `{ success: true, silent: true }`, because `@object-ui/core` marks a navigation-only result `silent` so it raises no success toast. `@object-ui/react`'s own source is unchanged. diff --git a/packages/core/src/actions/ActionRunner.ts b/packages/core/src/actions/ActionRunner.ts index 1c0fd2412e..be83176243 100644 --- a/packages/core/src/actions/ActionRunner.ts +++ b/packages/core/src/actions/ActionRunner.ts @@ -961,6 +961,15 @@ function isUpdateOperationAction(action: ActionDef): boolean { return action.operation === 'update'; } +/** + * Whether the action declares its own success copy, read the way + * `composeSuccessMessage` reads its `successMessage` rung: a non-empty string. + * A navigation toasts only then (objectui#12755). + */ +function declaresSuccessMessage(action: ActionDef): boolean { + return typeof action.successMessage === 'string' && action.successMessage.trim() !== ''; +} + /** * The fields an `operation: 'update'` action writes, and their prior values. * @@ -1931,7 +1940,29 @@ export class ActionRunner { if (!rawUrl) { return { success: false, error: 'No URL provided for url action' }; } - return this.navigateTo(action, rawUrl, action); + return this.announceNavigation(action, await this.navigateTo(action, rawUrl, action)); + } + + /** + * A navigation announces nothing unless its action declares the copy + * (objectui#12755). + * + * `type: 'url'`, its `navigation` alias and `type: 'form'` only change the + * route: nothing was written, so there is nothing to confirm. Their + * `{ success: true }` used to reach `handlePostExecution` like a write's, and + * the generic `completedSuccessfully` toast ("操作已成功完成") told the user an + * operation had completed when a page had merely opened (objectstack-ai/cloud#2909: + * an "invite member" button that only navigated read as "invitation sent"). + * + * The result keeps `success: true` (callers read it as "the navigation + * happened") and is marked `silent`, the flag that already means "no success + * toast for this result". An author who wants a toast after the navigation + * declares `successMessage`, and that copy still shows: the declaration is the + * only signal, so there is no separate switch to forget. + */ + private announceNavigation(action: ActionDef, result: ActionResult): ActionResult { + if (!result.success || declaresSuccessMessage(action)) return result; + return { ...result, silent: true }; } /** @@ -2203,9 +2234,9 @@ export class ActionRunner { if (this.navigationHandler) { this.navigationHandler(to, { external: false }); - return { success: true }; + return this.announceNavigation(action, { success: true }); } - return { success: true, redirect: to }; + return this.announceNavigation(action, { success: true, redirect: to }); } /** @@ -2224,7 +2255,7 @@ export class ActionRunner { if (!rawUrl) { return { success: false, error: 'No URL provided for navigation action' }; } - return this.navigateTo(action, rawUrl, nav); + return this.announceNavigation(action, await this.navigateTo(action, rawUrl, nav)); } /** diff --git a/packages/core/src/actions/__tests__/ActionRunner.navigationNoToast-12755.test.ts b/packages/core/src/actions/__tests__/ActionRunner.navigationNoToast-12755.test.ts new file mode 100644 index 0000000000..10b202b340 --- /dev/null +++ b/packages/core/src/actions/__tests__/ActionRunner.navigationNoToast-12755.test.ts @@ -0,0 +1,67 @@ +/** + * A navigation announces nothing unless its action declares the copy + * (objectui#12755). + * + * `type: 'url'` (and its `navigation` alias, and `type: 'form'`) only change + * the route. Their success used to reach the generic `completedSuccessfully` + * toast, so a button that merely opened a page told the user an operation had + * completed (objectstack-ai/cloud#2909). A declared `successMessage` still + * shows, and an action that writes keeps its toast. + */ +import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest'; +import { ActionRunner } from '../ActionRunner'; + +describe('ActionRunner: a navigation raises no success toast unless it declares one (objectui#12755)', () => { + let runner: ActionRunner; + let toast: Mock<(message: string, options?: unknown) => void>; + let navigate: Mock<(url: string, options?: unknown) => void>; + + beforeEach(() => { + runner = new ActionRunner({ record: { id: 7 } }); + toast = vi.fn(); + navigate = vi.fn(); + runner.setToastHandler(toast); + runner.setNavigationHandler(navigate); + }); + + it('a bare url action navigates and stays silent', async () => { + const result = await runner.execute({ type: 'url', target: '/apps/cloud/plans' }); + + expect(navigate).toHaveBeenCalledWith('/apps/cloud/plans', expect.objectContaining({ external: false })); + expect(result.success).toBe(true); + expect(toast).not.toHaveBeenCalled(); + }); + + it('a bare navigation-alias action and a form action stay silent too', async () => { + await runner.execute({ type: 'navigation', to: '/apps/cloud/members' }); + await runner.execute({ type: 'form', target: 'member_invite' }); + + expect(navigate).toHaveBeenCalledTimes(2); + expect(toast).not.toHaveBeenCalled(); + }); + + it('a url action that declares successMessage toasts that copy', async () => { + await runner.execute({ type: 'url', target: '/apps/cloud/plans', successMessage: 'Opened plans' }); + + expect(navigate).toHaveBeenCalledTimes(1); + expect(toast).toHaveBeenCalledTimes(1); + expect(toast).toHaveBeenCalledWith('Opened plans', expect.objectContaining({ type: 'success' })); + }); + + it('a refused navigation still reports its error', async () => { + const result = await runner.execute({ type: 'url', target: 'javascript:alert(1)' }); + + expect(result.success).toBe(false); + expect(navigate).not.toHaveBeenCalled(); + expect(toast).toHaveBeenCalledWith(expect.any(String), expect.objectContaining({ type: 'error' })); + }); + + it('CONTROL: an action that writes keeps its generic success toast', async () => { + runner.registerHandler('api', async () => ({ success: true, data: { id: 7 } })); + + await runner.execute({ type: 'api', target: 'invite_member' }); + + expect(toast).toHaveBeenCalledTimes(1); + expect(toast).toHaveBeenCalledWith(expect.any(String), expect.objectContaining({ type: 'success' })); + }); +}); diff --git a/packages/react/src/hooks/__tests__/useActionRunner.test.ts b/packages/react/src/hooks/__tests__/useActionRunner.test.ts index 96154ccb50..32309dd53d 100644 --- a/packages/react/src/hooks/__tests__/useActionRunner.test.ts +++ b/packages/react/src/hooks/__tests__/useActionRunner.test.ts @@ -81,7 +81,8 @@ describe('useActionRunner', () => { }); }); - expect(result.current.result).toEqual({ success: true }); + // A navigation succeeds silently: it announces no toast (objectui#12755). + expect(result.current.result).toEqual({ success: true, silent: true }); expect(result.current.error).toBeNull(); expect(result.current.loading).toBe(false); expect(onNavigate).toHaveBeenCalledWith(