mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-13 11:30:58 +02:00
* 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>
130 lines
4.1 KiB
TypeScript
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();
|
|
});
|
|
});
|