mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-06-22 04:11:55 +10:00
feat: implement resume preview loading enhancements and add tests for preview components
This commit is contained in:
@@ -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<ResumeData | undefined>,
|
||||
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<Blob>(() => {}));
|
||||
|
||||
render(<ResumePreviewClient pageGap="1rem" pageLayout="vertical" pageScale={1.25} showPageNumbers={false} />);
|
||||
|
||||
expect(screen.getAllByRole("img", { name: /Loading resume page/ })).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("renders from explicit resume data when no builder resume is active", async () => {
|
||||
render(
|
||||
<ResumePreviewClient
|
||||
data={sampleResumeData}
|
||||
pageGap="1rem"
|
||||
pageLayout="vertical"
|
||||
pageScale={1.25}
|
||||
showPageNumbers={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(await screen.findByRole("img", { name: "Resume page 1 of 1" })).toBeTruthy();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(previewMock.toBlob).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
expect(previewMock.localizedResumeData).toContain(sampleResumeData);
|
||||
});
|
||||
});
|
||||
@@ -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<PreviewPdf[]>([]);
|
||||
@@ -132,7 +134,18 @@ export function ResumePreviewClient({
|
||||
|
||||
const visiblePdf = getActivePreviewLayer(previewLayers);
|
||||
|
||||
if (!visiblePdf) return <ResumePreviewLoader />;
|
||||
if (!visiblePdf) {
|
||||
return (
|
||||
<ResumePreviewLoader
|
||||
pageCount={getResumePreviewPageCount(resumeData)}
|
||||
pageClassName={pageClassName}
|
||||
pageGap={pageGap}
|
||||
pageLayout={pageLayout}
|
||||
pageScale={pageScale}
|
||||
showPageNumbers={showPageNumbers}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("grid", className)}>
|
||||
|
||||
@@ -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(<ResumePreviewLoader pageScale={pageScale} />);
|
||||
|
||||
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(<ResumePreviewLoader pageCount={pageCount} />);
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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<ResumePreviewProps, "pageClassName" | "showPageNumbers"> & {
|
||||
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 (
|
||||
<figure className="shrink-0">
|
||||
<figcaption className="mb-1 font-medium text-[0.625rem] text-muted-foreground">Loading...</figcaption>
|
||||
export const getResumePreviewPageCount = (data?: ResumeData) => Math.max(1, data?.metadata.layout.pages.length ?? 1);
|
||||
|
||||
<div style={DEFAULT_PDF_PAGE_SIZE} className="rounded-md bg-white">
|
||||
<Spinner className="size-10" />
|
||||
</div>
|
||||
</figure>
|
||||
export function ResumePreviewLoader({
|
||||
pageCount = 1,
|
||||
pageClassName,
|
||||
pageGap = 40,
|
||||
pageLayout = "horizontal",
|
||||
pageScale = 1,
|
||||
showPageNumbers = false,
|
||||
}: ResumePreviewLoaderProps) {
|
||||
const pageSize = getScaledPreviewPageSize(DEFAULT_PDF_PAGE_SIZE, pageScale);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex justify-start gap-(--resume-preview-page-gap)",
|
||||
pageLayout === "horizontal" ? "flex-row items-start" : "flex-col items-center",
|
||||
)}
|
||||
style={{ "--resume-preview-page-gap": pageGap } as React.CSSProperties}
|
||||
>
|
||||
{Array.from({ length: pageCount }, (_, index) => {
|
||||
const pageNumber = index + 1;
|
||||
|
||||
return (
|
||||
<figure key={pageNumber} className="shrink-0">
|
||||
{showPageNumbers ? (
|
||||
<figcaption className="mb-1 font-medium text-[0.625rem] text-muted-foreground">
|
||||
Page {pageNumber} of {pageCount}
|
||||
</figcaption>
|
||||
) : null}
|
||||
|
||||
<div
|
||||
role="img"
|
||||
aria-label={`Loading resume page ${pageNumber} of ${pageCount}`}
|
||||
style={pageSize}
|
||||
className={cn("aspect-page overflow-hidden rounded-md bg-white", pageClassName)}
|
||||
>
|
||||
<Spinner className="size-10" />
|
||||
</div>
|
||||
</figure>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<Suspense fallback={<ResumePreviewLoader />}>
|
||||
<ResumePreviewClient {...normalizeResumePreviewProps(props)} />
|
||||
<Suspense
|
||||
fallback={
|
||||
<ResumePreviewLoader
|
||||
pageCount={pageCount}
|
||||
pageClassName={resolvedProps.pageClassName}
|
||||
pageGap={resolvedProps.pageGap}
|
||||
pageLayout={resolvedProps.pageLayout}
|
||||
pageScale={resolvedProps.pageScale}
|
||||
showPageNumbers={resolvedProps.showPageNumbers}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<ResumePreviewClient {...resolvedProps} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -45,6 +45,7 @@ export function PublicResumeRoute() {
|
||||
<>
|
||||
<div className="mx-auto my-12 flex flex-col items-center gap-12 print:m-0 print:block print:max-w-full print:px-0">
|
||||
<ResumePreview
|
||||
data={resume.data}
|
||||
pageGap="1rem"
|
||||
pageScale={1.25}
|
||||
pageLayout="vertical"
|
||||
|
||||
Reference in New Issue
Block a user