mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-08-24 23:32:19 +10:00
feat(export): separate resume/cover-letter downloads, redesign dialog, add Markdown export (#3217)
* feat(export): separate resume/cover-letter downloads, redesign dialog, add Markdown Let people export the resume and cover letter as distinct documents, and add a Markdown format alongside PDF / DOCX / JSON (handy for AI agents). - Server/API: scope PDF generation and download URLs to a resume/cover-letter target. - Export domain: getResumeExportData + resumeHasCoverLetter in @reactive-resume/resume. - Redesign the download dialog: one global "What to export" scope toggle (Tabs) plus flattened per-format rows, reusing existing UI components and design language. - Add Markdown export (@reactive-resume/resume/markdown) with a small tiptap-HTML converter. - Fix blank section headings in DOCX and Markdown by injecting the locale-aware section-title resolver (titles are stored empty and resolved at render time). - Locale catalogs updated for the new strings. * test(e2e): open the download dialog before exporting JSON The JSON export moved into the redesigned download dialog, so the spec now opens the dialog from the Export sidebar section before clicking "Download JSON".
This commit is contained in:
@@ -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