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
7 changes: 7 additions & 0 deletions .changeset/12755-navigation-no-toast.md
Original file line number Diff line number Diff line change
@@ -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.
6 changes: 6 additions & 0 deletions .changeset/12755-react-result-pin.md
Original file line number Diff line number Diff line change
@@ -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.
39 changes: 35 additions & 4 deletions packages/core/src/actions/ActionRunner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -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 };
}

/**
Expand Down Expand Up @@ -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 });
}

/**
Expand All @@ -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));
}

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -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' }));
});
});
3 changes: 2 additions & 1 deletion packages/react/src/hooks/__tests__/useActionRunner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Loading