diff --git a/apps/web/src/dialogs/resume/import.dialog.test.tsx b/apps/web/src/dialogs/resume/import.dialog.test.tsx new file mode 100644 index 000000000..4ac905f77 --- /dev/null +++ b/apps/web/src/dialogs/resume/import.dialog.test.tsx @@ -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 . 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 & { + to: string; + children: ReactNode; +}; + +vi.mock("@tanstack/react-router", () => ({ + useNavigate: () => navigate, + // Mirrors the part of 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) => ( + { + onClick?.(event); + + if (event.defaultPrevented) return; + if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return; + + routerNavigate({ to }); + }} + {...props} + > + {children} + + ), +})); + +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 ( + + + + ); +} + +const renderDialog = () => { + useDialogStore.setState({ open: true, activeDialog: null, onBeforeClose: null }); + + return render( + + + + + + + , + ); +}; + +// 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('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(); + }); +}); diff --git a/apps/web/src/dialogs/resume/import.tsx b/apps/web/src/dialogs/resume/import.tsx index 34b845b76..9afb5f43f 100644 --- a/apps/web/src/dialogs/resume/import.tsx +++ b/apps/web/src/dialogs/resume/import.tsx @@ -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 { } 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) => { + // 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 ( @@ -388,7 +415,11 @@ export function ImportResumeDialog(_: DialogProps<"resume.import">) { size="sm" variant="secondary" nativeButton={false} - render={{t`Set up a provider`}} + render={ + + {t`Set up a provider`} + + } /> )}