mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-08-21 22:11:42 +10:00
fix(web): confirm before the import dialog's provider link navigates away (#3308)
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user