mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-07-15 15:26:59 +10:00
Merge remote-tracking branch 'origin/main' into feat/applications
This commit is contained in:
@@ -0,0 +1,183 @@
|
||||
import type { ResumeExportTarget } from "@reactive-resume/resume/export-sections";
|
||||
import type { ReactElement, ReactNode } from "react";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import {
|
||||
CircleNotchIcon,
|
||||
DownloadSimpleIcon,
|
||||
EnvelopeSimpleIcon,
|
||||
FileDocIcon,
|
||||
FileJsIcon,
|
||||
FilePdfIcon,
|
||||
FileTextIcon,
|
||||
MarkdownLogoIcon,
|
||||
} from "@phosphor-icons/react";
|
||||
import { useState } from "react";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@reactive-resume/ui/components/dialog";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@reactive-resume/ui/components/tabs";
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
import { useResumeExport } from "./use-resume-export";
|
||||
|
||||
type DownloadableResume = Parameters<typeof useResumeExport>[0];
|
||||
|
||||
type ResumeDownloadDialogProps = {
|
||||
resume: DownloadableResume;
|
||||
trigger: (disabled: boolean) => ReactElement;
|
||||
};
|
||||
|
||||
type FormatRowProps = {
|
||||
action: ReactElement;
|
||||
description: ReactNode;
|
||||
disabled?: boolean;
|
||||
icon: ReactElement;
|
||||
title: ReactNode;
|
||||
};
|
||||
|
||||
function FormatRow({ action, description, disabled, icon, title }: FormatRowProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-lg border bg-background p-3 transition-opacity",
|
||||
disabled && "opacity-45",
|
||||
)}
|
||||
>
|
||||
<div className="flex size-11 shrink-0 items-center justify-center rounded-lg bg-muted text-foreground">
|
||||
{icon}
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<h3 className="font-medium text-sm">{title}</h3>
|
||||
<p className="text-muted-foreground text-xs leading-normal">{description}</p>
|
||||
</div>
|
||||
{action}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ResumeDownloadDialog({ resume, trigger }: ResumeDownloadDialogProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [scope, setScope] = useState<ResumeExportTarget>("resume");
|
||||
const { hasCoverLetter, isExporting, onDownloadDOCX, onDownloadJSON, onDownloadMarkdown, onDownloadPDF } =
|
||||
useResumeExport(resume);
|
||||
const disabled = !resume || isExporting;
|
||||
|
||||
// Cover letter can't be the active scope when the resume has none (also guards a stale toggle).
|
||||
const activeScope: ResumeExportTarget = scope === "cover-letter" && !hasCoverLetter ? "resume" : scope;
|
||||
const jsonDisabled = activeScope === "cover-letter";
|
||||
|
||||
const run = (action: () => void | Promise<void>) => {
|
||||
setOpen(false);
|
||||
void action();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger render={trigger(disabled)} />
|
||||
<DialogContent className="gap-5 sm:max-w-lg">
|
||||
<DialogHeader className="pe-8">
|
||||
<DialogTitle>
|
||||
<Trans>Download</Trans>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
<Trans>Export your resume or cover letter in the format you need.</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Tabs value={activeScope} onValueChange={(value) => setScope(value as ResumeExportTarget)}>
|
||||
<TabsList className="h-11! w-full">
|
||||
<TabsTrigger value="resume">
|
||||
<FileTextIcon />
|
||||
<Trans>Resume</Trans>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="cover-letter" disabled={!hasCoverLetter}>
|
||||
<EnvelopeSimpleIcon />
|
||||
<Trans>Cover letter</Trans>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<FormatRow
|
||||
icon={
|
||||
isExporting ? <CircleNotchIcon className="size-5 animate-spin" /> : <FilePdfIcon className="size-5" />
|
||||
}
|
||||
title="PDF"
|
||||
description={<Trans>Best for applications, sharing, and printing.</Trans>}
|
||||
action={
|
||||
<Button
|
||||
size="sm"
|
||||
aria-label="Download PDF"
|
||||
disabled={isExporting}
|
||||
onClick={() => run(() => onDownloadPDF(activeScope))}
|
||||
>
|
||||
<DownloadSimpleIcon />
|
||||
<Trans>Download</Trans>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<FormatRow
|
||||
icon={<FileDocIcon className="size-5" />}
|
||||
title="DOCX"
|
||||
description={<Trans>Editable in Word, Google Docs, and Pages.</Trans>}
|
||||
action={
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
aria-label="Download DOCX"
|
||||
disabled={isExporting}
|
||||
onClick={() => run(() => onDownloadDOCX(activeScope))}
|
||||
>
|
||||
<DownloadSimpleIcon />
|
||||
<Trans>Download</Trans>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<FormatRow
|
||||
icon={<MarkdownLogoIcon className="size-5" />}
|
||||
title="Markdown"
|
||||
description={<Trans>Plain text, ideal for AI tools and quick edits.</Trans>}
|
||||
action={
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
aria-label="Download Markdown"
|
||||
disabled={isExporting}
|
||||
onClick={() => run(() => onDownloadMarkdown(activeScope))}
|
||||
>
|
||||
<DownloadSimpleIcon />
|
||||
<Trans>Download</Trans>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<FormatRow
|
||||
disabled={jsonDisabled}
|
||||
icon={<FileJsIcon className="size-5" />}
|
||||
title="JSON"
|
||||
description={<Trans>Full resume data for backup or import.</Trans>}
|
||||
action={
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
aria-label="Download JSON"
|
||||
disabled={jsonDisabled}
|
||||
onClick={() => run(onDownloadJSON)}
|
||||
>
|
||||
<DownloadSimpleIcon />
|
||||
<Trans>Download</Trans>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,27 @@
|
||||
import type { ResumeExportTarget } from "@reactive-resume/resume/export-sections";
|
||||
import type { ResumeData } from "@reactive-resume/schema/resume/data";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { useCallback, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { buildDocx } from "@reactive-resume/docx";
|
||||
import { getResumeSectionTitle } from "@reactive-resume/pdf/section-title";
|
||||
import { getResumeExportData, resumeHasCoverLetter } from "@reactive-resume/resume/export-sections";
|
||||
import { buildMarkdown } from "@reactive-resume/resume/markdown";
|
||||
import { downloadWithAnchor, generateFilename } from "@reactive-resume/utils/file";
|
||||
import { createSectionTitleResolverForLocale } from "@/libs/resume/section-title-locale";
|
||||
import { createResumePdfBlob } from "./pdf-document";
|
||||
|
||||
/**
|
||||
* Section titles are stored empty by default and resolved (locale-aware) at render time. PDF does
|
||||
* this via an injected resolver; DOCX and Markdown reuse the same resolution here so their section
|
||||
* headings aren't blank. Returns a `(sectionId) => title` function.
|
||||
*/
|
||||
const createSectionTitleResolver = async (data: ResumeData) => {
|
||||
const resolveSectionTitle = await createSectionTitleResolverForLocale(data.metadata.page.locale);
|
||||
const dataWithResolver = { ...data, resolveSectionTitle };
|
||||
return (sectionId: string) => getResumeSectionTitle(dataWithResolver, sectionId);
|
||||
};
|
||||
|
||||
// ponytail: loosened from Resume to Pick so public-resume (where name may be "" for non-owners) can reuse
|
||||
type ExportableResume = {
|
||||
name: string;
|
||||
@@ -14,6 +30,8 @@ type ExportableResume = {
|
||||
};
|
||||
|
||||
const getExportName = (resume: ExportableResume) => resume.name || resume.data.basics.name || resume.slug;
|
||||
const getTargetExportName = (resume: ExportableResume, target: ResumeExportTarget) =>
|
||||
target === "cover-letter" ? `${getExportName(resume)} Cover Letter` : getExportName(resume);
|
||||
|
||||
/**
|
||||
* Single source of truth for resume export (PDF / DOCX / JSON / Print). Previously duplicated verbatim
|
||||
@@ -21,6 +39,7 @@ const getExportName = (resume: ExportableResume) => resume.name || resume.data.b
|
||||
*/
|
||||
export function useResumeExport(resume: ExportableResume | undefined) {
|
||||
const [isExporting, setIsExporting] = useState(false);
|
||||
const hasCoverLetter = resume ? resumeHasCoverLetter(resume.data) : false;
|
||||
|
||||
const onDownloadJSON = useCallback(() => {
|
||||
if (!resume) return;
|
||||
@@ -28,30 +47,53 @@ export function useResumeExport(resume: ExportableResume | undefined) {
|
||||
downloadWithAnchor(blob, generateFilename(getExportName(resume), "json"));
|
||||
}, [resume]);
|
||||
|
||||
const onDownloadDOCX = useCallback(async () => {
|
||||
if (!resume) return;
|
||||
try {
|
||||
const blob = await buildDocx(resume.data);
|
||||
downloadWithAnchor(blob, generateFilename(getExportName(resume), "docx"));
|
||||
} catch {
|
||||
toast.error(t`There was a problem while generating the DOCX, please try again.`);
|
||||
}
|
||||
}, [resume]);
|
||||
const onDownloadMarkdown = useCallback(
|
||||
async (target: ResumeExportTarget = "resume") => {
|
||||
if (!resume) return;
|
||||
if (target === "cover-letter" && !resumeHasCoverLetter(resume.data)) return;
|
||||
const data = getResumeExportData(resume.data, target);
|
||||
const resolveTitle = await createSectionTitleResolver(data);
|
||||
const blob = new Blob([buildMarkdown(data, resolveTitle)], { type: "text/markdown" });
|
||||
downloadWithAnchor(blob, generateFilename(getTargetExportName(resume, target), "md"));
|
||||
},
|
||||
[resume],
|
||||
);
|
||||
|
||||
const onDownloadPDF = useCallback(async () => {
|
||||
if (!resume) return;
|
||||
const toastId = toast.loading(t`Please wait while your PDF is being generated...`);
|
||||
setIsExporting(true);
|
||||
try {
|
||||
const blob = await createResumePdfBlob(resume.data);
|
||||
downloadWithAnchor(blob, generateFilename(getExportName(resume), "pdf"));
|
||||
} catch {
|
||||
toast.error(t`There was a problem while generating the PDF, please try again.`);
|
||||
} finally {
|
||||
setIsExporting(false);
|
||||
toast.dismiss(toastId);
|
||||
}
|
||||
}, [resume]);
|
||||
const onDownloadDOCX = useCallback(
|
||||
async (target: ResumeExportTarget = "resume") => {
|
||||
if (!resume) return;
|
||||
if (target === "cover-letter" && !resumeHasCoverLetter(resume.data)) return;
|
||||
try {
|
||||
const data = getResumeExportData(resume.data, target);
|
||||
const resolveTitle = await createSectionTitleResolver(data);
|
||||
const blob = await buildDocx(data, resolveTitle);
|
||||
downloadWithAnchor(blob, generateFilename(getTargetExportName(resume, target), "docx"));
|
||||
} catch {
|
||||
toast.error(t`There was a problem while generating the DOCX, please try again.`);
|
||||
}
|
||||
},
|
||||
[resume],
|
||||
);
|
||||
|
||||
const onDownloadPDF = useCallback(
|
||||
async (target: ResumeExportTarget = "resume") => {
|
||||
if (!resume) return;
|
||||
if (target === "cover-letter" && !resumeHasCoverLetter(resume.data)) return;
|
||||
const toastId = toast.loading(t`Please wait while your PDF is being generated...`);
|
||||
setIsExporting(true);
|
||||
try {
|
||||
const data = getResumeExportData(resume.data, target);
|
||||
const blob = await createResumePdfBlob(data);
|
||||
downloadWithAnchor(blob, generateFilename(getTargetExportName(resume, target), "pdf"));
|
||||
} catch {
|
||||
toast.error(t`There was a problem while generating the PDF, please try again.`);
|
||||
} finally {
|
||||
setIsExporting(false);
|
||||
toast.dismiss(toastId);
|
||||
}
|
||||
},
|
||||
[resume],
|
||||
);
|
||||
|
||||
const onPrint = useCallback(async () => {
|
||||
if (!resume) return;
|
||||
@@ -86,5 +128,5 @@ export function useResumeExport(resume: ExportableResume | undefined) {
|
||||
}
|
||||
}, [resume]);
|
||||
|
||||
return { onDownloadJSON, onDownloadDOCX, onDownloadPDF, onPrint, isExporting };
|
||||
return { onDownloadJSON, onDownloadMarkdown, onDownloadDOCX, onDownloadPDF, onPrint, isExporting, hasCoverLetter };
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ export function PublicResumeRoute() {
|
||||
{basics.name && <h1 className="font-semibold text-2xl tracking-tight">{basics.name}</h1>}
|
||||
{basics.headline && <p className="text-muted-foreground">{basics.headline}</p>}
|
||||
</div>
|
||||
<Button onClick={onDownloadPDF} disabled={isExporting}>
|
||||
<Button onClick={() => void onDownloadPDF()} disabled={isExporting}>
|
||||
{isExporting ? (
|
||||
<CircleNotchIcon className="size-4 animate-spin" />
|
||||
) : (
|
||||
@@ -62,7 +62,7 @@ export function PublicResumeRoute() {
|
||||
size="icon-lg"
|
||||
variant="outline"
|
||||
disabled={isExporting}
|
||||
onClick={onDownloadPDF}
|
||||
onClick={() => void onDownloadPDF()}
|
||||
aria-label={t`Download PDF`}
|
||||
title={t`Download PDF`}
|
||||
className="fixed right-6 bottom-6 z-50 rounded-full bg-background/95 opacity-70 shadow-lg backdrop-blur transition-opacity hover:opacity-100 print:hidden"
|
||||
|
||||
@@ -6,13 +6,10 @@ import {
|
||||
CircleNotchIcon,
|
||||
CopySimpleIcon,
|
||||
DownloadSimpleIcon,
|
||||
FileDocIcon,
|
||||
FileJsIcon,
|
||||
HouseSimpleIcon,
|
||||
LockSimpleIcon,
|
||||
LockSimpleOpenIcon,
|
||||
PencilSimpleLineIcon,
|
||||
PrinterIcon,
|
||||
SidebarSimpleIcon,
|
||||
TrashSimpleIcon,
|
||||
WarningCircleIcon,
|
||||
@@ -31,7 +28,7 @@ import {
|
||||
} from "@reactive-resume/ui/components/dropdown-menu";
|
||||
import { useDialogStore } from "@/dialogs/store";
|
||||
import { useCurrentResume, usePatchResume, useResumeStore } from "@/features/resume/builder/draft";
|
||||
import { useResumeExport } from "@/features/resume/export/use-resume-export";
|
||||
import { ResumeDownloadDialog } from "@/features/resume/export/download-dialog";
|
||||
import { useConfirm } from "@/hooks/use-confirm";
|
||||
import { getResumeErrorMessage } from "@/libs/error-message";
|
||||
import { orpc } from "@/libs/orpc/client";
|
||||
@@ -103,63 +100,31 @@ export function BuilderHeader() {
|
||||
|
||||
function ResumeDownloadButton() {
|
||||
const resume = useCurrentResume();
|
||||
const { onDownloadPDF, onDownloadDOCX, onDownloadJSON, onPrint, isExporting } = useResumeExport(resume);
|
||||
|
||||
return (
|
||||
<div className="flex items-center">
|
||||
<Button
|
||||
size="sm"
|
||||
aria-label={t({
|
||||
comment: "Primary action in the builder header to download the resume as a PDF",
|
||||
message: "Download PDF",
|
||||
})}
|
||||
className="rounded-e-none px-2 sm:px-2.5"
|
||||
disabled={isExporting}
|
||||
onClick={onDownloadPDF}
|
||||
>
|
||||
{isExporting ? (
|
||||
<CircleNotchIcon className="animate-spin sm:me-1.5" />
|
||||
) : (
|
||||
<DownloadSimpleIcon className="sm:me-1.5" />
|
||||
)}
|
||||
<span className="hidden sm:inline">
|
||||
<Trans comment="Primary action in the builder header to download the resume as a PDF">Download PDF</Trans>
|
||||
</span>
|
||||
</Button>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={isExporting}
|
||||
aria-label={t`More download options`}
|
||||
className="rounded-s-none border-primary-foreground/20 border-s px-1.5"
|
||||
>
|
||||
<CaretDownIcon />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={onDownloadDOCX}>
|
||||
<FileDocIcon className="me-2" />
|
||||
<Trans>Download DOCX</Trans>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={onDownloadJSON}>
|
||||
<FileJsIcon className="me-2" />
|
||||
<Trans>Download JSON</Trans>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuItem onClick={onPrint}>
|
||||
<PrinterIcon className="me-2" />
|
||||
<Trans>Print</Trans>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
<ResumeDownloadDialog
|
||||
resume={resume}
|
||||
trigger={(disabled) => (
|
||||
<Button
|
||||
size="sm"
|
||||
aria-label={t({
|
||||
comment: "Primary action in the builder header to open resume download options",
|
||||
message: "Download options",
|
||||
})}
|
||||
disabled={disabled}
|
||||
className="px-2 sm:px-2.5"
|
||||
>
|
||||
{disabled ? (
|
||||
<CircleNotchIcon className="animate-spin sm:me-1.5" />
|
||||
) : (
|
||||
<DownloadSimpleIcon className="sm:me-1.5" />
|
||||
)}
|
||||
<span className="hidden sm:inline">
|
||||
<Trans comment="Primary action in the builder header to open resume download options">Download</Trans>
|
||||
</span>
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,14 +1,25 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { i18n } from "@lingui/core";
|
||||
import { I18nProvider } from "@lingui/react";
|
||||
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
|
||||
import { sampleResumeData } from "@reactive-resume/schema/resume/sample";
|
||||
|
||||
const downloadWithAnchor = vi.hoisted(() => vi.fn());
|
||||
const buildDocx = vi.hoisted(() => vi.fn().mockResolvedValue(new Blob(["x"], { type: "application/x-docx" })));
|
||||
const createResumePdfBlob = vi.hoisted(() => vi.fn().mockResolvedValue(new Blob(["x"], { type: "application/pdf" })));
|
||||
const resumeMock = vi.hoisted(() => ({
|
||||
resume: undefined as
|
||||
| undefined
|
||||
| {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
data: typeof defaultResumeData;
|
||||
},
|
||||
}));
|
||||
|
||||
type SectionBaseProps = {
|
||||
children: React.ReactNode;
|
||||
@@ -23,8 +34,13 @@ vi.mock("@reactive-resume/utils/file", () => ({
|
||||
}));
|
||||
vi.mock("@reactive-resume/docx", () => ({ buildDocx }));
|
||||
vi.mock("@/features/resume/export/pdf-document", () => ({ createResumePdfBlob }));
|
||||
// DOCX/Markdown resolve locale-aware section titles; stub the async locale resolver so exports
|
||||
// fall back to the built-in English titles without loading real locale catalogs.
|
||||
vi.mock("@/libs/resume/section-title-locale", () => ({
|
||||
createSectionTitleResolverForLocale: vi.fn().mockResolvedValue(() => undefined),
|
||||
}));
|
||||
vi.mock("@/features/resume/builder/draft", () => ({
|
||||
useResume: () => ({ id: "r1", name: "My Resume", data: defaultResumeData }),
|
||||
useResume: () => resumeMock.resume,
|
||||
}));
|
||||
|
||||
const { ExportSectionBuilder } = await import("./export");
|
||||
@@ -33,6 +49,10 @@ beforeAll(() => {
|
||||
i18n.loadAndActivate({ locale: "en", messages: {} });
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resumeMock.resume = { id: "r1", name: "My Resume", slug: "my-resume", data: defaultResumeData };
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
downloadWithAnchor.mockReset();
|
||||
buildDocx.mockClear();
|
||||
@@ -46,18 +66,40 @@ const renderExport = () =>
|
||||
</I18nProvider>,
|
||||
);
|
||||
|
||||
const openDialog = () => {
|
||||
const trigger = screen.getByText(
|
||||
"Choose PDF, DOCX, or JSON. Export your resume and cover letter separately when available.",
|
||||
);
|
||||
fireEvent.click(trigger.closest("button") as HTMLButtonElement);
|
||||
};
|
||||
|
||||
describe("ExportSectionBuilder", () => {
|
||||
it("renders JSON, DOCX, and PDF action buttons", () => {
|
||||
it("renders the PDF, DOCX, Markdown, and JSON format rows", () => {
|
||||
renderExport();
|
||||
expect(screen.getByText("JSON")).toBeInTheDocument();
|
||||
expect(screen.getByText("DOCX")).toBeInTheDocument();
|
||||
expect(screen.getByText("PDF")).toBeInTheDocument();
|
||||
openDialog();
|
||||
|
||||
expect(screen.getByRole("button", { name: "Download PDF" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Download DOCX" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Download Markdown" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Download JSON" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("downloads a Markdown blob when the Markdown button is clicked", async () => {
|
||||
renderExport();
|
||||
openDialog();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Download Markdown" }));
|
||||
|
||||
await waitFor(() => expect(downloadWithAnchor).toHaveBeenCalledTimes(1));
|
||||
// biome-ignore lint/style/noNonNullAssertion: The assertion above verifies the download call exists before destructuring it.
|
||||
const [blob, filename] = downloadWithAnchor.mock.calls[0]!;
|
||||
expect((blob as Blob).type).toBe("text/markdown");
|
||||
expect(filename).toBe("My Resume.md");
|
||||
});
|
||||
|
||||
it("downloads a JSON blob when the JSON button is clicked", () => {
|
||||
renderExport();
|
||||
const button = screen.getByText("JSON").closest("button") as HTMLButtonElement;
|
||||
fireEvent.click(button);
|
||||
openDialog();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Download JSON" }));
|
||||
|
||||
expect(downloadWithAnchor).toHaveBeenCalledTimes(1);
|
||||
// biome-ignore lint/style/noNonNullAssertion: The assertion above verifies the download call exists before destructuring it.
|
||||
@@ -69,23 +111,18 @@ describe("ExportSectionBuilder", () => {
|
||||
|
||||
it("calls buildDocx and downloads the resulting blob when DOCX is clicked", async () => {
|
||||
renderExport();
|
||||
const button = screen.getByText("DOCX").closest("button") as HTMLButtonElement;
|
||||
openDialog();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Download DOCX" }));
|
||||
|
||||
fireEvent.click(button);
|
||||
// Wait for the async callback chain to settle.
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(buildDocx).toHaveBeenCalledTimes(1);
|
||||
await waitFor(() => expect(buildDocx).toHaveBeenCalledTimes(1));
|
||||
expect(downloadWithAnchor).toHaveBeenCalledTimes(1);
|
||||
expect(downloadWithAnchor.mock.calls[0]?.[1]).toBe("My Resume.docx");
|
||||
});
|
||||
|
||||
it("calls createResumePdfBlob and downloads when PDF is clicked", async () => {
|
||||
renderExport();
|
||||
const button = screen.getByText("PDF").closest("button") as HTMLButtonElement;
|
||||
|
||||
fireEvent.click(button);
|
||||
openDialog();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Download PDF" }));
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
@@ -94,4 +131,19 @@ describe("ExportSectionBuilder", () => {
|
||||
expect(downloadWithAnchor).toHaveBeenCalledTimes(1);
|
||||
expect(downloadWithAnchor.mock.calls[0]?.[1]).toBe("My Resume.pdf");
|
||||
});
|
||||
|
||||
it("exports the cover letter when the scope is switched and a cover letter exists", async () => {
|
||||
resumeMock.resume = { id: "r1", name: "My Resume", slug: "my-resume", data: sampleResumeData };
|
||||
renderExport();
|
||||
openDialog();
|
||||
|
||||
fireEvent.click(screen.getByRole("tab", { name: "Cover letter" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Download PDF" }));
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(createResumePdfBlob).toHaveBeenCalledTimes(1);
|
||||
expect(downloadWithAnchor.mock.calls[0]?.[1]).toBe("My Resume Cover Letter.pdf");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,74 +1,37 @@
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { CircleNotchIcon, FileDocIcon, FileJsIcon, FilePdfIcon } from "@phosphor-icons/react";
|
||||
import { DownloadSimpleIcon } from "@phosphor-icons/react";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
import { useResume } from "@/features/resume/builder/draft";
|
||||
import { useResumeExport } from "@/features/resume/export/use-resume-export";
|
||||
import { ResumeDownloadDialog } from "@/features/resume/export/download-dialog";
|
||||
import { SectionBase } from "../shared/section-base";
|
||||
|
||||
export function ExportSectionBuilder() {
|
||||
const resume = useResume();
|
||||
const { onDownloadJSON, onDownloadDOCX, onDownloadPDF, isExporting } = useResumeExport(resume);
|
||||
|
||||
if (!resume) return null;
|
||||
|
||||
return (
|
||||
<SectionBase type="export" className="space-y-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onDownloadJSON}
|
||||
className="h-auto gap-x-4 whitespace-normal p-4! text-start font-normal active:scale-98"
|
||||
>
|
||||
<FileJsIcon className="size-6 shrink-0" />
|
||||
<div className="flex flex-1 flex-col gap-y-1">
|
||||
<h6 className="font-medium">JSON</h6>
|
||||
<p className="text-muted-foreground text-xs leading-normal">
|
||||
<Trans>
|
||||
Download a copy of your resume in JSON format. Use this file for backup or to import your resume into
|
||||
other applications, including AI assistants.
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onDownloadDOCX}
|
||||
className="h-auto gap-x-4 whitespace-normal p-4! text-start font-normal active:scale-98"
|
||||
>
|
||||
<FileDocIcon className="size-6 shrink-0" />
|
||||
<div className="flex flex-1 flex-col gap-y-1">
|
||||
<h6 className="font-medium">DOCX</h6>
|
||||
<p className="text-muted-foreground text-xs leading-normal">
|
||||
<Trans>
|
||||
Download a copy of your resume as a Word document. Use this file to further customize your resume in
|
||||
Microsoft Word or Google Docs.
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={isExporting}
|
||||
onClick={onDownloadPDF}
|
||||
className="h-auto gap-x-4 whitespace-normal p-4! text-start font-normal active:scale-98"
|
||||
>
|
||||
{isExporting ? (
|
||||
<CircleNotchIcon className="size-6 shrink-0 animate-spin" />
|
||||
) : (
|
||||
<FilePdfIcon className="size-6 shrink-0" />
|
||||
<ResumeDownloadDialog
|
||||
resume={resume}
|
||||
trigger={(disabled) => (
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={disabled}
|
||||
className="h-auto w-full gap-x-4 whitespace-normal p-4! text-start font-normal active:scale-98"
|
||||
>
|
||||
<DownloadSimpleIcon className="size-6 shrink-0" />
|
||||
<div className="flex flex-1 flex-col gap-y-1">
|
||||
<h6 className="font-medium">
|
||||
<Trans>Download</Trans>
|
||||
</h6>
|
||||
<p className="text-muted-foreground text-xs leading-normal">
|
||||
<Trans>Choose PDF, DOCX, or JSON. Export your resume and cover letter separately when available.</Trans>
|
||||
</p>
|
||||
</div>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<div className="flex flex-1 flex-col gap-y-1">
|
||||
<h6 className="font-medium">PDF</h6>
|
||||
<p className="text-muted-foreground text-xs leading-normal">
|
||||
<Trans>
|
||||
Download a copy of your resume in PDF format. Use this file for printing or to easily share your resume
|
||||
with recruiters.
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
</Button>
|
||||
/>
|
||||
</SectionBase>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user