Files
multica/packages/views/modals/delete-issue-confirm.test.tsx
Bohan Jiang ceba14a227 fix(issues): MUL-5362 return to the source list after deleting an issue (#5997)
* fix(issues): return to the source list after deleting an issue

Deleting an issue from its detail page always pushed the workspace
Issues list, so opening an issue from My Issues (or a project list,
search, a pin, an agent panel) and deleting it dropped the user's
navigation context — GH #5995.

Go back instead. `useBackOrReplace` steps back when the platform
reports in-app history and replaces with a fallback path when there
is none, so a shared link opened cold or a new tab never steps off
the app. Web answers via the Navigation API, falling back to counting
its own pushes; desktop reads the active tab's virtual history.

`replace`, not `push`: the deleted issue's URL must not stay in
history for the back button to land on a 404.

The not-found "Back to Issues" button loses the same context, so it
moves to the same helper and its label becomes a plain "Back".

Co-authored-by: multica-agent <github@multica.ai>

* fix(web): track history position, not push count, for canGoBack

The Navigation API fallback only ever counted pushes, so `pushes > 0`
did not mean the current entry still had an in-app page behind it.
Cold-open an issue, click Issues, press the browser's Back button, then
delete: the tracker still claimed history and `back()` would step off
Multica — the exact case the fallback exists to prevent.

Count depth instead: a push adds one, any traversal takes one away
(clamped at zero). Reaching the document's first entry requires
traversing back at least as many times as we pushed, so a positive
depth can never be claimed while sitting on it. A browser Forward is
now conservative — it reports no history where a step back would have
been fine — which costs a fallback navigation rather than an exit.

Also corrects the useBackOrReplace contract comment: stepping back
leaves the dead URL in forward history, so the guarantee is that it
never lands on the back stack, not that it leaves history entirely.

Co-authored-by: multica-agent <github@multica.ai>

* fix(web): answer canGoBack from the browser alone, never from push counts

Review found the counter still lied, one level deeper: it counted
`router.push` calls, and a call is not a committed history entry. Next
drops a push to `replaceState` when the canonical URL is unchanged
(app-router.js, the `pendingPush && href !== canonicalUrl` branch), so
clicking a self-link — the breadcrumb on an issue detail page, a pin to
the issue you are on — incremented depth with no entry behind it, and a
delete from there could still step off the app.

Fixing the count needs per-entry markers, which means depending on both
React effect ordering (our provider's effect runs before app-router's
history commit) and Next's own preserveCustomHistoryState behaviour.
Two rounds of review have now found holes in hand-rolled history
tracking; a third layer of it is not the way to buy this guarantee.

So stop deriving. `canGoBack` is the Navigation API's answer or `false`.
Browsers without it take the fallback path, which is exactly what they
did before any of this existed — nobody regresses, and the "wrong true
walks the user out of the app" failure is now unreachable by
construction.

Drops the tracker, the popstate wiring and the push wrapper: the
`multica:navigate` bridge returns to its original shape.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Bohan-J <bohan@devv.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-27 18:39:25 +08:00

130 lines
4.1 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { DeleteIssueConfirmModal } from "./delete-issue-confirm";
import { NavigationProvider } from "../navigation";
import type { NavigationAdapter } from "../navigation";
const mockDelete = vi.fn().mockResolvedValue(undefined);
vi.mock("@multica/core/issues/mutations", () => ({
useDeleteIssue: () => ({ mutateAsync: mockDelete }),
}));
vi.mock("sonner", () => ({
toast: { success: vi.fn(), error: vi.fn() },
}));
vi.mock("../i18n", () => ({
useT: () => ({
t: (sel: (x: Record<string, Record<string, string>>) => string) =>
sel({
delete_issue: {
title: "Delete issue?",
description: "This cannot be undone.",
hint: "Sub-issues are deleted too.",
cancel: "Cancel",
confirm: "Delete",
deleting: "Deleting...",
toast_deleted: "Issue deleted",
toast_delete_failed: "Delete failed",
},
}),
}),
}));
function makeAdapter(
overrides: Partial<NavigationAdapter> = {},
): NavigationAdapter {
return {
push: vi.fn(),
replace: vi.fn(),
back: vi.fn(),
pathname: "/acme/issues/issue-1",
searchParams: new URLSearchParams(),
getShareableUrl: (p) => p,
...overrides,
};
}
async function deleteWith(
adapter: NavigationAdapter,
data: Record<string, unknown> | null,
) {
const onClose = vi.fn();
render(
<NavigationProvider value={adapter}>
<DeleteIssueConfirmModal onClose={onClose} data={data} />
</NavigationProvider>,
);
fireEvent.click(screen.getByText("Delete"));
await waitFor(() => expect(mockDelete).toHaveBeenCalledWith("issue-1"));
return onClose;
}
describe("DeleteIssueConfirmModal", () => {
beforeEach(() => {
vi.clearAllMocks();
});
// The bug this guards (GH #5995): deleting from an issue opened out of My
// Issues used to hard-push the workspace Issues list, dropping the user's
// navigation context. The same applied to project lists, search and pins.
it("returns to the list the issue was opened from", async () => {
const adapter = makeAdapter({ canGoBack: () => true });
await deleteWith(adapter, {
issueId: "issue-1",
onDeletedFallbackPath: "/acme/issues",
});
await waitFor(() => expect(adapter.back).toHaveBeenCalledTimes(1));
expect(adapter.replace).not.toHaveBeenCalled();
expect(adapter.push).not.toHaveBeenCalled();
});
it("falls back to the workspace list when the issue was opened cold", async () => {
const adapter = makeAdapter({ canGoBack: () => false });
await deleteWith(adapter, {
issueId: "issue-1",
onDeletedFallbackPath: "/acme/issues",
});
await waitFor(() =>
expect(adapter.replace).toHaveBeenCalledWith("/acme/issues"),
);
expect(adapter.back).not.toHaveBeenCalled();
});
// List surfaces delete in place (row context menu, batch toolbar): there is
// no page to leave, so the modal must not navigate at all.
it("does not navigate when no fallback path is supplied", async () => {
const adapter = makeAdapter({ canGoBack: () => true });
const onClose = await deleteWith(adapter, { issueId: "issue-1" });
await waitFor(() => expect(onClose).toHaveBeenCalled());
expect(adapter.back).not.toHaveBeenCalled();
expect(adapter.replace).not.toHaveBeenCalled();
expect(adapter.push).not.toHaveBeenCalled();
});
it("stays put when the delete fails", async () => {
mockDelete.mockRejectedValueOnce(new Error("nope"));
const adapter = makeAdapter({ canGoBack: () => true });
render(
<NavigationProvider value={adapter}>
<DeleteIssueConfirmModal
onClose={vi.fn()}
data={{ issueId: "issue-1", onDeletedFallbackPath: "/acme/issues" }}
/>
</NavigationProvider>,
);
fireEvent.click(screen.getByText("Delete"));
await waitFor(() => expect(screen.getByText("Delete")).toBeInTheDocument());
expect(adapter.back).not.toHaveBeenCalled();
expect(adapter.replace).not.toHaveBeenCalled();
});
});