mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-08-24 23:32:19 +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 { Spinner } from "@reactive-resume/ui/components/spinner";
|
||||||
import { Combobox } from "@/components/ui/combobox";
|
import { Combobox } from "@/components/ui/combobox";
|
||||||
import { useHasUsableAiProvider } from "@/features/settings/integrations/hooks/use-has-usable-ai-provider";
|
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 { useFormBlocker } from "@/hooks/use-form-blocker";
|
||||||
import { getOrpcErrorMessage } from "@/libs/error-message";
|
import { getOrpcErrorMessage } from "@/libs/error-message";
|
||||||
import { client, orpc } from "@/libs/orpc/client";
|
import { client, orpc } from "@/libs/orpc/client";
|
||||||
@@ -120,6 +121,7 @@ async function detectImportType(file: File): Promise<ImportType> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ImportResumeDialog(_: DialogProps<"resume.import">) {
|
export function ImportResumeDialog(_: DialogProps<"resume.import">) {
|
||||||
|
const confirm = useConfirm();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const closeDialog = useDialogStore((state) => state.closeDialog);
|
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.
|
// #6: only warn about unsaved changes once a file has actually been chosen — not on a bare type selection.
|
||||||
useFormBlocker(form, { shouldBlock: () => Boolean(file) });
|
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 (
|
return (
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
@@ -388,7 +415,11 @@ export function ImportResumeDialog(_: DialogProps<"resume.import">) {
|
|||||||
size="sm"
|
size="sm"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
nativeButton={false}
|
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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user