From 92a0e3ddb8d722e675c87592175a4fd3c815c679 Mon Sep 17 00:00:00 2001 From: Amruth Pillai Date: Mon, 11 May 2026 00:59:16 +0200 Subject: [PATCH] feat: implement resume preview loading enhancements and add tests for preview components --- .../resume/preview.browser.test.tsx | 119 ++++++++++++++++++ .../src/components/resume/preview.browser.tsx | 19 ++- .../components/resume/preview.shared.test.tsx | 41 ++++++ .../src/components/resume/preview.shared.tsx | 60 +++++++-- apps/web/src/components/resume/preview.tsx | 22 +++- .../$username/-components/public-resume.tsx | 1 + 6 files changed, 248 insertions(+), 14 deletions(-) create mode 100644 apps/web/src/components/resume/preview.browser.test.tsx create mode 100644 apps/web/src/components/resume/preview.shared.test.tsx diff --git a/apps/web/src/components/resume/preview.browser.test.tsx b/apps/web/src/components/resume/preview.browser.test.tsx new file mode 100644 index 000000000..8534b17ad --- /dev/null +++ b/apps/web/src/components/resume/preview.browser.test.tsx @@ -0,0 +1,119 @@ +// @vitest-environment happy-dom +import type { ResumeData } from "@reactive-resume/schema/resume/data"; +import { render, screen, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { sampleResumeData } from "@reactive-resume/schema/resume/sample"; +import { ResumePreviewClient } from "./preview.browser"; + +const previewMock = vi.hoisted(() => ({ + builderResumeData: undefined as ResumeData | undefined, + localizedResumeData: [] as Array, + toBlob: vi.fn(async () => new Blob(["%PDF"], { type: "application/pdf" })), +})); + +const resumeDataWithPageCount = (pageCount: number): ResumeData => ({ + ...sampleResumeData, + metadata: { + ...sampleResumeData.metadata, + layout: { + ...sampleResumeData.metadata.layout, + pages: sampleResumeData.metadata.layout.pages.slice(0, pageCount), + }, + }, +}); + +vi.mock("@react-pdf/renderer", () => ({ + pdf: () => ({ toBlob: previewMock.toBlob }), +})); + +vi.mock("@/libs/resume/pdf-document", () => ({ + useLocalizedResumeDocument: (data?: ResumeData) => { + previewMock.localizedResumeData.push(data); + return data ? { type: "resume-document", data } : null; + }, +})); + +vi.mock("./builder-resume-draft", () => ({ + useResumeData: () => previewMock.builderResumeData, +})); + +vi.mock("./pdf-canvas", async () => { + const React = await import("react"); + const pdfDocument = { numPages: 1 }; + + return { + PdfCanvasDocument: ({ + children, + onLoadSuccess, + }: { + children: (document: typeof pdfDocument) => React.ReactNode; + onLoadSuccess: (document: typeof pdfDocument) => void; + }) => { + React.useEffect(() => { + onLoadSuccess(pdfDocument); + }, [onLoadSuccess]); + + return React.createElement(React.Fragment, null, children(pdfDocument)); + }, + PdfCanvasPage: ({ + onLoadSuccess, + onRenderSuccess, + pageNumber, + totalPages, + }: { + onLoadSuccess: (pageNumber: number, pageSize: { height: number; width: number }) => void; + onRenderSuccess?: () => void; + pageNumber: number; + totalPages: number; + }) => { + React.useEffect(() => { + onLoadSuccess(pageNumber, { height: 200, width: 100 }); + onRenderSuccess?.(); + }, [onLoadSuccess, onRenderSuccess, pageNumber]); + + return React.createElement( + "div", + { role: "img", "aria-label": `Resume page ${pageNumber} of ${totalPages}` }, + "Rendered page", + ); + }, + }; +}); + +describe("ResumePreviewClient", () => { + beforeEach(() => { + previewMock.builderResumeData = undefined; + previewMock.localizedResumeData = []; + previewMock.toBlob.mockReset(); + previewMock.toBlob.mockImplementation(async () => new Blob(["%PDF"], { type: "application/pdf" })); + }); + + it("renders a loading placeholder for each builder layout page while the PDF is generated", () => { + previewMock.builderResumeData = resumeDataWithPageCount(3); + previewMock.toBlob.mockImplementation(() => new Promise(() => {})); + + render(); + + expect(screen.getAllByRole("img", { name: /Loading resume page/ })).toHaveLength(3); + }); + + it("renders from explicit resume data when no builder resume is active", async () => { + render( + , + ); + + expect(await screen.findByRole("img", { name: "Resume page 1 of 1" })).toBeTruthy(); + + await waitFor(() => { + expect(previewMock.toBlob).toHaveBeenCalledTimes(1); + }); + + expect(previewMock.localizedResumeData).toContain(sampleResumeData); + }); +}); diff --git a/apps/web/src/components/resume/preview.browser.tsx b/apps/web/src/components/resume/preview.browser.tsx index 50233d949..64261d7dc 100644 --- a/apps/web/src/components/resume/preview.browser.tsx +++ b/apps/web/src/components/resume/preview.browser.tsx @@ -6,7 +6,7 @@ import { cn } from "@reactive-resume/utils/style"; import { useLocalizedResumeDocument } from "@/libs/resume/pdf-document"; import { useResumeData } from "./builder-resume-draft"; import { PdfCanvasDocument, PdfCanvasPage } from "./pdf-canvas"; -import { ResumePreviewLoader } from "./preview.shared"; +import { getResumePreviewPageCount, ResumePreviewLoader } from "./preview.shared"; type PreviewPdf = { file: Blob; @@ -83,13 +83,15 @@ const removePreviewLayer = (layers: PreviewPdf[], layerId: number) => layers.fil export function ResumePreviewClient({ className, + data, pageGap, pageLayout, pageScale, pageClassName, showPageNumbers, }: ResolvedResumePreviewProps) { - const resumeData = useResumeData(); + const builderResumeData = useResumeData(); + const resumeData = data ?? builderResumeData; const resumeDocument = useLocalizedResumeDocument(resumeData); const [previewLayers, setPreviewLayers] = useState([]); @@ -132,7 +134,18 @@ export function ResumePreviewClient({ const visiblePdf = getActivePreviewLayer(previewLayers); - if (!visiblePdf) return ; + if (!visiblePdf) { + return ( + + ); + } return (
diff --git a/apps/web/src/components/resume/preview.shared.test.tsx b/apps/web/src/components/resume/preview.shared.test.tsx new file mode 100644 index 000000000..8601631de --- /dev/null +++ b/apps/web/src/components/resume/preview.shared.test.tsx @@ -0,0 +1,41 @@ +// @vitest-environment happy-dom +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { sampleResumeData } from "@reactive-resume/schema/resume/sample"; +import { + DEFAULT_PDF_PAGE_SIZE, + getResumePreviewPageCount, + getScaledPreviewPageSize, + ResumePreviewLoader, +} from "./preview.shared"; + +describe("ResumePreviewLoader", () => { + it("uses the same scaled page dimensions as the preview page", () => { + const pageScale = 1.25; + const expectedSize = getScaledPreviewPageSize(DEFAULT_PDF_PAGE_SIZE, pageScale); + const { container } = render(); + + const page = container.querySelector("figure > div") as HTMLElement | null; + + expect(Number.parseFloat(page?.style.height ?? "0")).toBeCloseTo(expectedSize.height); + expect(Number.parseFloat(page?.style.width ?? "0")).toBeCloseTo(expectedSize.width); + }); + + it("renders one loading placeholder for each resume layout page", () => { + const pageCount = sampleResumeData.metadata.layout.pages.length; + + render(); + + expect(screen.getAllByRole("img", { name: /Loading resume page/ })).toHaveLength(pageCount); + }); +}); + +describe("getResumePreviewPageCount", () => { + it("uses the resume layout page count when resume data is available", () => { + expect(getResumePreviewPageCount(sampleResumeData)).toBe(sampleResumeData.metadata.layout.pages.length); + }); + + it("falls back to one page without resume data", () => { + expect(getResumePreviewPageCount()).toBe(1); + }); +}); diff --git a/apps/web/src/components/resume/preview.shared.tsx b/apps/web/src/components/resume/preview.shared.tsx index 7d0002cb7..892b3f187 100644 --- a/apps/web/src/components/resume/preview.shared.tsx +++ b/apps/web/src/components/resume/preview.shared.tsx @@ -1,7 +1,10 @@ +import type { ResumeData } from "@reactive-resume/schema/resume/data"; import { Spinner } from "@reactive-resume/ui/components/spinner"; +import { cn } from "@reactive-resume/utils/style"; export type ResumePreviewProps = { className?: string; + data?: ResumeData; pageGap?: React.CSSProperties["gap"]; pageLayout?: "horizontal" | "vertical"; pageScale?: number; @@ -21,6 +24,13 @@ export type PreviewPageSize = { width: number; }; +type ResumePreviewLoaderProps = Pick & { + pageCount?: number; + pageGap?: React.CSSProperties["gap"]; + pageLayout?: "horizontal" | "vertical"; + pageScale?: number; +}; + const PDF_PAGE_RENDER_SCALE = 4; const MAX_PREVIEW_CANVAS_PIXELS = 16_777_216; // 4096 * 4096 export const DEFAULT_PDF_PAGE_SIZE: PreviewPageSize = { @@ -57,14 +67,48 @@ export const getScaledPreviewPageSize = (pageSize: PreviewPageSize, pageScale: n width: pageSize.width * pageScale, }); -export function ResumePreviewLoader() { - return ( -
-
Loading...
+export const getResumePreviewPageCount = (data?: ResumeData) => Math.max(1, data?.metadata.layout.pages.length ?? 1); -
- -
-
+export function ResumePreviewLoader({ + pageCount = 1, + pageClassName, + pageGap = 40, + pageLayout = "horizontal", + pageScale = 1, + showPageNumbers = false, +}: ResumePreviewLoaderProps) { + const pageSize = getScaledPreviewPageSize(DEFAULT_PDF_PAGE_SIZE, pageScale); + + return ( +
+ {Array.from({ length: pageCount }, (_, index) => { + const pageNumber = index + 1; + + return ( +
+ {showPageNumbers ? ( +
+ Page {pageNumber} of {pageCount} +
+ ) : null} + +
+ +
+
+ ); + })} +
); } diff --git a/apps/web/src/components/resume/preview.tsx b/apps/web/src/components/resume/preview.tsx index 012839f11..6bd874496 100644 --- a/apps/web/src/components/resume/preview.tsx +++ b/apps/web/src/components/resume/preview.tsx @@ -1,7 +1,8 @@ import type { ResumePreviewProps } from "./preview.shared"; import { lazy, Suspense } from "react"; import { useIsClient } from "usehooks-ts"; -import { normalizeResumePreviewProps, ResumePreviewLoader } from "./preview.shared"; +import { useResumeData } from "./builder-resume-draft"; +import { getResumePreviewPageCount, normalizeResumePreviewProps, ResumePreviewLoader } from "./preview.shared"; const ResumePreviewClient = lazy(() => import("./preview.browser").then((module) => ({ default: module.ResumePreviewClient })), @@ -11,12 +12,27 @@ export type { ResumePreviewProps }; export function ResumePreview(props: ResumePreviewProps) { const isClient = useIsClient(); + const resolvedProps = normalizeResumePreviewProps(props); + const builderResumeData = useResumeData(); + const resumeData = resolvedProps.data ?? builderResumeData; + const pageCount = getResumePreviewPageCount(resumeData); if (!isClient) return null; return ( - }> - + + } + > + ); } diff --git a/apps/web/src/routes/$username/-components/public-resume.tsx b/apps/web/src/routes/$username/-components/public-resume.tsx index b8377f23a..d6049ce8f 100644 --- a/apps/web/src/routes/$username/-components/public-resume.tsx +++ b/apps/web/src/routes/$username/-components/public-resume.tsx @@ -45,6 +45,7 @@ export function PublicResumeRoute() { <>