diff --git a/apps/web/locales/en-US.po b/apps/web/locales/en-US.po index b25202311..4a6aef8d6 100644 --- a/apps/web/locales/en-US.po +++ b/apps/web/locales/en-US.po @@ -1103,6 +1103,10 @@ msgstr "Command Palette - {currentPage}" msgid "Community" msgstr "Community" +#: src/routes/dashboard/resumes/index.tsx +msgid "Compact" +msgstr "Compact" + #: src/dialogs/resume/sections/experience.tsx #: src/features/applications/components/application-form-sheet.tsx msgid "Company" diff --git a/apps/web/src/routes/dashboard/resumes/-components/grid-view.tsx b/apps/web/src/routes/dashboard/resumes/-components/grid-view.tsx index d1a27a6f5..908f8135e 100644 --- a/apps/web/src/routes/dashboard/resumes/-components/grid-view.tsx +++ b/apps/web/src/routes/dashboard/resumes/-components/grid-view.tsx @@ -1,6 +1,7 @@ import type { RouterOutput } from "@/libs/orpc/client"; import { Trans } from "@lingui/react/macro"; import { AnimatePresence, m } from "motion/react"; +import { cn } from "@reactive-resume/utils/style"; import { CreateResumeCard } from "./cards/create-card"; import { ImportResumeCard } from "./cards/import-card"; import { ResumeCard } from "./cards/resume-card"; @@ -10,9 +11,17 @@ type Resume = RouterOutput["resume"]["list"][number]; type Props = { resumes: Resume[]; hasResumes: boolean; + compact?: boolean; }; -export function GridView({ resumes, hasResumes }: Props) { +export function GridView({ resumes, hasResumes, compact = false }: Props) { + const gridClassName = cn( + "grid gap-4", + compact + ? "3xl:grid-cols-8 grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6" + : "3xl:grid-cols-6 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5", + ); + if (resumes.length === 0 && hasResumes) { return (

@@ -23,7 +32,7 @@ export function GridView({ resumes, hasResumes }: Props) { if (resumes.length === 0) { return ( -

+
+
{resumes.map((resume, index) => ( { + sessionStorage.clear(); +}); +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); + +describe("resume view preference", () => { + it("restores the selected view after leaving and returning during the session", async () => { + const first = renderHook(() => useResumeView("compact", "owner")); + await waitFor(() => expect(sessionStorage.getItem("resume-view:owner")).toBe('"compact"')); + first.unmount(); + const second = renderHook(() => useResumeView(undefined, "owner")); + await waitFor(() => expect(second.result.current).toBe("compact")); + }); + it("honors explicit grid in the URL over a saved compact preference", async () => { + sessionStorage.setItem("resume-view:owner", '"compact"'); + const { result } = renderHook(() => useResumeView("grid", "owner")); + expect(result.current).toBe("grid"); + await waitFor(() => expect(sessionStorage.getItem("resume-view:owner")).toBe('"grid"')); + }); + it("keeps the preference scoped to its account", async () => { + sessionStorage.setItem("resume-view:first", '"list"'); + const { result, rerender } = renderHook(({ user }) => useResumeView(undefined, user), { + initialProps: { user: "first" }, + }); + await waitFor(() => expect(result.current).toBe("list")); + rerender({ user: "second" }); + await waitFor(() => expect(result.current).toBe("grid")); + }); + it("ignores unknown stored views", () => { + sessionStorage.setItem("resume-view:owner", '"unknown"'); + const { result } = renderHook(() => useResumeView(undefined, "owner")); + expect(result.current).toBe("grid"); + }); + it("renders the default when session storage is unavailable", () => { + vi.spyOn(Storage.prototype, "getItem").mockImplementation(() => { + throw new DOMException("Blocked", "SecurityError"); + }); + vi.spyOn(console, "warn").mockImplementation(() => {}); + const { result } = renderHook(() => useResumeView(undefined, "owner")); + expect(result.current).toBe("grid"); + }); +}); diff --git a/apps/web/src/routes/dashboard/resumes/-components/view-mode.ts b/apps/web/src/routes/dashboard/resumes/-components/view-mode.ts new file mode 100644 index 000000000..e9c4f0cbc --- /dev/null +++ b/apps/web/src/routes/dashboard/resumes/-components/view-mode.ts @@ -0,0 +1,18 @@ +import { useEffect } from "react"; +import { useSessionStorage } from "usehooks-ts"; +import z from "zod"; + +export const resumeViewSchema = z.enum(["grid", "compact", "list"]); +type ResumeView = z.infer; + +export function useResumeView(view: ResumeView | undefined, userId: string): ResumeView { + const [storedView, setStoredView] = useSessionStorage(`resume-view:${userId}`, "grid", { + initializeWithValue: false, + }); + + useEffect(() => { + if (view !== undefined && storedView !== view) setStoredView(view); + }, [view, storedView, setStoredView]); + + return view ?? resumeViewSchema.catch("grid").parse(storedView); +} diff --git a/apps/web/src/routes/dashboard/resumes/index.tsx b/apps/web/src/routes/dashboard/resumes/index.tsx index 171aae85c..9db6bf077 100644 --- a/apps/web/src/routes/dashboard/resumes/index.tsx +++ b/apps/web/src/routes/dashboard/resumes/index.tsx @@ -4,6 +4,7 @@ import { Trans } from "@lingui/react/macro"; import { DownloadSimpleIcon, GridFourIcon, + GridNineIcon, ListIcon, MagnifyingGlassIcon, PlusIcon, @@ -25,6 +26,7 @@ import { orpc } from "@/libs/orpc/client"; import { DashboardHeader } from "../-components/header"; import { GridView } from "./-components/grid-view"; import { ListView } from "./-components/list-view"; +import { resumeViewSchema, useResumeView } from "./-components/view-mode"; type SortOption = "lastUpdatedAt" | "createdAt" | "name"; @@ -32,12 +34,12 @@ const searchSchema = z.object({ search: z.string().default(""), tags: z.array(z.string()).default([]), sort: z.enum(["lastUpdatedAt", "createdAt", "name"]).default("lastUpdatedAt"), - view: z.enum(["grid", "list"]).default("grid"), + view: resumeViewSchema.optional().catch(undefined), }); type Search = z.output; -const defaultSearch: Search = { search: "", tags: [], sort: "lastUpdatedAt", view: "grid" }; +const defaultSearch: Search = { search: "", tags: [], sort: "lastUpdatedAt" }; export const Route = createFileRoute("/dashboard/resumes/")({ component: RouteComponent, @@ -49,7 +51,9 @@ export const Route = createFileRoute("/dashboard/resumes/")({ function RouteComponent() { const { i18n } = useLingui(); - const { search, tags, sort, view } = Route.useSearch(); + const { search, tags, sort, view: searchView } = Route.useSearch(); + const { session } = Route.useRouteContext(); + const view = useResumeView(searchView, session.user.id); const navigate = useNavigate({ from: Route.fullPath }); const { openDialog } = useDialogStore(); @@ -153,7 +157,7 @@ function RouteComponent() { )} - + Grid + ({ ...prev, view: "compact" })} />} + > + + Compact + + 0} /> ) : ( - 0} /> + 0} compact={view === "compact"} /> )}
); diff --git a/tests/e2e/specs/resume-views.spec.ts b/tests/e2e/specs/resume-views.spec.ts new file mode 100644 index 000000000..5405f08c4 --- /dev/null +++ b/tests/e2e/specs/resume-views.spec.ts @@ -0,0 +1,46 @@ +import { createSampleResumeFromDashboard } from "../fixtures/resume"; +import { expect, test } from "../fixtures/test"; + +test("keeps compact and list preferences through navigation and reload, with explicit URL overrides", async ({ + authPage: page, +}, testInfo) => { + test.setTimeout(60_000); + await page.setViewportSize({ width: 1440, height: 1000 }); + const name = await createSampleResumeFromDashboard(page, testInfo); + await page.goto("/dashboard/resumes"); + const card = page.getByRole("link").filter({ hasText: name }); + await expect(card).toBeVisible(); + const gridWidth = (await card.boundingBox())?.width; + if (!gridWidth) throw new Error("Resume card has no width."); + await page.getByRole("tab", { name: "Compact", exact: true }).click(); + await expect(page.getByRole("tab", { name: "Compact", exact: true })).toHaveAttribute("aria-selected", "true"); + await expect.poll(async () => (await card.boundingBox())?.width ?? gridWidth).toBeLessThan(gridWidth); + await card.click(); + await page.waitForURL(/\/builder\/.+/); + await page.goto("/dashboard/resumes"); + await expect(page.getByRole("tab", { name: "Compact", exact: true })).toHaveAttribute("aria-selected", "true"); + await page.reload(); + await expect(page.getByRole("tab", { name: "Compact", exact: true })).toHaveAttribute("aria-selected", "true"); + await expect(card.locator('[style*="background-image: url("]')).toBeVisible({ timeout: 30_000 }); + await page.screenshot({ path: testInfo.outputPath("compact-resumes.png"), animations: "disabled" }); + + await page.getByRole("tab", { name: "Grid", exact: true }).click(); + await expect(page).toHaveURL(/view=grid/); + await expect(page.getByRole("tab", { name: "Grid", exact: true })).toHaveAttribute("aria-selected", "true"); + await expect.poll(async () => (await card.boundingBox())?.width).toBe(gridWidth); + await page.getByRole("tab", { name: "List", exact: true }).click(); + await expect(page.getByRole("tab", { name: "List", exact: true })).toHaveAttribute("aria-selected", "true"); + await page.goto("/dashboard/resumes"); + await expect(page.getByRole("tab", { name: "List", exact: true })).toHaveAttribute("aria-selected", "true"); + await page.goto("/dashboard/resumes?view=grid"); + await expect(page.getByRole("tab", { name: "Grid", exact: true })).toHaveAttribute("aria-selected", "true"); + await page.goto("/dashboard/resumes?view=invalid"); + await expect(page.getByRole("tab", { name: "Grid", exact: true })).toHaveAttribute("aria-selected", "true"); + + await page.setViewportSize({ width: 390, height: 844 }); + await page.getByRole("tab", { name: "Compact", exact: true }).click(); + await expect(page.getByRole("tab", { name: "Compact", exact: true })).toHaveAttribute("aria-selected", "true"); + await expect(card).toBeVisible(); + await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(390); + await page.screenshot({ path: testInfo.outputPath("compact-resumes-mobile.png"), animations: "disabled" }); +});