fix(web): confirm before the import dialog's provider link navigates away (#3308)

This commit is contained in:
Syed Ali Abbas Zaidi
2026-08-11 10:26:04 +02:00
committed by GitHub
parent 035d94183b
commit 3a5b12e2a4
2 changed files with 208 additions and 1 deletions
@@ -0,0 +1,176 @@
// @vitest-environment happy-dom
import type { AnchorHTMLAttributes, ReactNode } from "react";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { i18n } from "@lingui/core";
import { I18nProvider } from "@lingui/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { Dialog } from "@reactive-resume/ui/components/dialog";
import { useDialogStore } from "@/dialogs/store";
import { ConfirmDialogProvider } from "@/hooks/use-confirm";
const navigate = vi.hoisted(() => vi.fn());
// Stands in for the navigation TanStack Router performs from inside <Link>. Keeping it separate
// from `navigate` lets a test tell "the router took us away" apart from "the dialog took us away".
const routerNavigate = vi.hoisted(() => vi.fn());
type MockLinkProps = AnchorHTMLAttributes<HTMLAnchorElement> & {
to: string;
children: ReactNode;
};
vi.mock("@tanstack/react-router", () => ({
useNavigate: () => navigate,
// Mirrors the part of <Link> this fix depends on: the router handles the click and navigates
// unless something already prevented the default. Without that, a missing preventDefault() in
// the dialog would go unnoticed here.
Link: ({ to, children, onClick, ...props }: MockLinkProps) => (
<a
href={to}
onClick={(event) => {
onClick?.(event);
if (event.defaultPrevented) return;
if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
routerNavigate({ to });
}}
{...props}
>
{children}
</a>
),
}));
vi.mock("@/features/settings/integrations/hooks/use-has-usable-ai-provider", () => ({
useHasUsableAiProvider: () => ({ hasUsableProvider: false, isLoading: false }),
}));
vi.mock("@/libs/orpc/client", () => ({
client: {},
orpc: { resume: { import: { mutationOptions: () => ({ mutationFn: vi.fn() }) } } },
}));
const { ImportResumeDialog } = await import("./import");
beforeAll(() => {
i18n.loadAndActivate({ locale: "en", messages: {} });
});
afterEach(() => {
navigate.mockReset();
routerNavigate.mockReset();
});
// Drive `open` from the store the way DialogManager does, so closing the dialog actually unmounts it.
function DialogHarness() {
const open = useDialogStore((state) => state.open);
return (
<Dialog open={open}>
<ImportResumeDialog />
</Dialog>
);
}
const renderDialog = () => {
useDialogStore.setState({ open: true, activeDialog: null, onBeforeClose: null });
return render(
<I18nProvider i18n={i18n}>
<QueryClientProvider client={new QueryClient()}>
<ConfirmDialogProvider>
<DialogHarness />
</ConfirmDialogProvider>
</QueryClientProvider>
</I18nProvider>,
);
};
// A real "%PDF" header so the dialog auto-detects the type and shows the provider notice.
const createPdfFile = () =>
new File([new Uint8Array([0x25, 0x50, 0x44, 0x46])], "resume.pdf", { type: "application/pdf" });
// The dialog renders through a portal, so query the document rather than the render container.
async function selectPdfFile() {
const input = document.querySelector<HTMLInputElement>('input[type="file"]');
if (!input) throw new Error("File input not found");
fireEvent.change(input, { target: { files: [createPdfFile()] } });
return await screen.findByText("Set up a provider");
}
describe("ImportResumeDialog — Set up a provider", () => {
// https://github.com/amruthpillai/reactive-resume/issues/3307
it("confirms before leaving instead of navigating behind the dialog", async () => {
renderDialog();
const link = await selectPdfFile();
fireEvent.click(link);
expect(await screen.findByText("Leave to set up an AI provider?")).toBeInTheDocument();
expect(routerNavigate).not.toHaveBeenCalled();
expect(navigate).not.toHaveBeenCalled();
expect(screen.getByText("Import an existing resume")).toBeInTheDocument();
});
it("stays put and keeps the selected file when the user cancels", async () => {
renderDialog();
const link = await selectPdfFile();
fireEvent.click(link);
fireEvent.click(await screen.findByText("Stay"));
await waitFor(() => {
expect(screen.queryByText("Leave to set up an AI provider?")).not.toBeInTheDocument();
});
expect(routerNavigate).not.toHaveBeenCalled();
expect(navigate).not.toHaveBeenCalled();
expect(useDialogStore.getState().open).toBe(true);
expect(screen.getByText("resume.pdf")).toBeInTheDocument();
});
it("closes the dialog and navigates once the user confirms", async () => {
renderDialog();
const link = await selectPdfFile();
fireEvent.click(link);
fireEvent.click(await screen.findByText("Leave"));
await waitFor(() => {
expect(navigate).toHaveBeenCalledWith({ to: "/dashboard/settings/integrations" });
});
expect(useDialogStore.getState().open).toBe(false);
await waitFor(() => {
expect(screen.queryByText("Set up a provider")).not.toBeInTheDocument();
});
});
it("leaves modifier clicks to the browser so the link can open in a new tab", async () => {
renderDialog();
const link = await selectPdfFile();
const event = new MouseEvent("click", { bubbles: true, cancelable: true, metaKey: true });
fireEvent(link, event);
expect(event.defaultPrevented).toBe(false);
expect(screen.queryByText("Leave to set up an AI provider?")).not.toBeInTheDocument();
expect(navigate).not.toHaveBeenCalled();
});
it("leaves middle clicks to the browser too", async () => {
renderDialog();
const link = await selectPdfFile();
const event = new MouseEvent("click", { bubbles: true, cancelable: true, button: 1 });
fireEvent(link, event);
expect(event.defaultPrevented).toBe(false);
expect(screen.queryByText("Leave to set up an AI provider?")).not.toBeInTheDocument();
expect(navigate).not.toHaveBeenCalled();
});
});
+32 -1
View File
@@ -27,6 +27,7 @@ import { Input } from "@reactive-resume/ui/components/input";
import { Spinner } from "@reactive-resume/ui/components/spinner";
import { Combobox } from "@/components/ui/combobox";
import { useHasUsableAiProvider } from "@/features/settings/integrations/hooks/use-has-usable-ai-provider";
import { useConfirm } from "@/hooks/use-confirm";
import { useFormBlocker } from "@/hooks/use-form-blocker";
import { getOrpcErrorMessage } from "@/libs/error-message";
import { client, orpc } from "@/libs/orpc/client";
@@ -120,6 +121,7 @@ async function detectImportType(file: File): Promise<ImportType> {
}
export function ImportResumeDialog(_: DialogProps<"resume.import">) {
const confirm = useConfirm();
const navigate = useNavigate();
const closeDialog = useDialogStore((state) => state.closeDialog);
@@ -248,6 +250,31 @@ export function ImportResumeDialog(_: DialogProps<"resume.import">) {
// #6: only warn about unsaved changes once a file has actually been chosen — not on a bare type selection.
useFormBlocker(form, { shouldBlock: () => Boolean(file) });
// The provider link navigates away while this dialog stays mounted over the new page, so the
// unsaved-changes guard (which only runs on a close attempt) fires far too late. Confirm first,
// then close and navigate ourselves.
const onSetUpProvider = async (event: React.MouseEvent<HTMLAnchorElement>) => {
// Modifier and middle clicks open a new tab: the user is not leaving this page, so let the
// browser handle the link and keep the dialog exactly as it is.
if (event.defaultPrevented || event.button !== 0) return;
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
event.preventDefault();
if (file) {
const confirmed = await confirm(t`Leave to set up an AI provider?`, {
description: t`You'll be taken to the Integrations page. The file you selected won't be imported.`,
confirmText: t`Leave`,
cancelText: t`Stay`,
});
if (!confirmed) return;
}
closeDialog();
await navigate({ to: "/dashboard/settings/integrations" });
};
return (
<DialogContent>
<DialogHeader>
@@ -388,7 +415,11 @@ export function ImportResumeDialog(_: DialogProps<"resume.import">) {
size="sm"
variant="secondary"
nativeButton={false}
render={<Link to="/dashboard/settings/integrations">{t`Set up a provider`}</Link>}
render={
<Link to="/dashboard/settings/integrations" onClick={onSetUpProvider}>
{t`Set up a provider`}
</Link>
}
/>
</div>
)}