From 5850230f89d5379af1db8f44e1e1dec0ca161354 Mon Sep 17 00:00:00 2001 From: Amruth Pillai Date: Sat, 5 Sep 2026 18:33:11 -0700 Subject: [PATCH] feat(builder): add hidden section recovery (#3462) * feat(builder): recover hidden sections * fix(builder): reopen hidden section recovery --- apps/web/locales/en-US.po | 9 + .../resume/builder/section-recovery.test.tsx | 229 ++++++++++++++++++ .../resume/builder/section-recovery.tsx | 155 ++++++++++++ .../builder/$resumeId/-sidebar/left/index.tsx | 17 +- .../-sidebar/left/sections/custom.tsx | 8 +- packages/resume/package.json | 1 + .../resume/src/section-availability.test.ts | 70 ++++++ packages/resume/src/section-availability.ts | 39 +++ tests/e2e/specs/section-recovery.spec.ts | 199 +++++++++++++++ 9 files changed, 711 insertions(+), 16 deletions(-) create mode 100644 apps/web/src/features/resume/builder/section-recovery.test.tsx create mode 100644 apps/web/src/features/resume/builder/section-recovery.tsx create mode 100644 packages/resume/src/section-availability.test.ts create mode 100644 packages/resume/src/section-availability.ts create mode 100644 tests/e2e/specs/section-recovery.spec.ts diff --git a/apps/web/locales/en-US.po b/apps/web/locales/en-US.po index a934b31f6..19aaebc51 100644 --- a/apps/web/locales/en-US.po +++ b/apps/web/locales/en-US.po @@ -2761,6 +2761,11 @@ msgstr "Here's your new API key" msgid "Hidden" msgstr "Hidden" +#: src/features/resume/builder/section-recovery.tsx +msgid "Hidden sections" +msgstr "Hidden sections" + +#: src/features/resume/builder/section-recovery.tsx #: src/routes/builder/$resumeId/-sidebar/left/sections/custom.tsx #: src/routes/builder/$resumeId/-sidebar/left/shared/section-item.tsx #: src/routes/builder/$resumeId/-sidebar/left/shared/section-menu.tsx @@ -5041,6 +5046,10 @@ msgstr "Show" msgid "Show {0} more" msgstr "Show {0} more" +#: src/features/resume/builder/section-recovery.tsx +msgid "Show {title} section" +msgstr "Show {title} section" + #: src/routes/builder/$resumeId/-sidebar/right/sections/sharing.tsx msgid "Show Download Buttons" msgstr "Show Download Buttons" diff --git a/apps/web/src/features/resume/builder/section-recovery.test.tsx b/apps/web/src/features/resume/builder/section-recovery.test.tsx new file mode 100644 index 000000000..446ea3833 --- /dev/null +++ b/apps/web/src/features/resume/builder/section-recovery.test.tsx @@ -0,0 +1,229 @@ +// @vitest-environment happy-dom + +import type { ResumeData } from "@reactive-resume/schema/resume/data"; +import type { Resume } from "./draft"; +import { act, cleanup, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { i18n } from "@lingui/core"; +import { I18nProvider } from "@lingui/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { sampleResumeData } from "@reactive-resume/schema/resume/sample"; +import { ConfirmDialogProvider } from "@/hooks/use-confirm"; +import { CustomSectionBuilder } from "@/routes/builder/$resumeId/-sidebar/left/sections/custom"; +import { useResumeStore } from "./draft"; +import { + focusLeftSidebarSection, + getVisibleLeftSidebarSections, + SectionEditorList, + SectionRecovery, +} from "./section-recovery"; + +const routerParams = vi.hoisted(() => ({ resumeId: "section-recovery" })); + +vi.mock("@tanstack/react-router", () => ({ + useParams: () => routerParams, +})); + +vi.mock("@/libs/orpc/client", () => ({ + orpc: { + resume: { + getById: { queryOptions: () => ({ queryKey: ["resume", "section-recovery"] }) }, + patch: { call: vi.fn() }, + update: { call: vi.fn(() => new Promise(() => undefined)) }, + }, + }, + streamClient: { resume: { updates: { subscribe: vi.fn() } } }, +})); + +vi.mock("@reactive-resume/ui/components/toast", () => ({ + toast: { add: vi.fn(), close: vi.fn() }, +})); + +function makeResume(data: ResumeData, isLocked = false): Resume { + return { + id: routerParams.resumeId, + name: "Section Recovery", + slug: "section-recovery", + tags: [], + data, + isLocked, + updatedAt: new Date("2026-09-06T00:00:00.000Z"), + }; +} + +function makeHiddenData(): ResumeData { + const data = structuredClone(sampleResumeData); + data.summary.hidden = true; + data.sections.experience.hidden = true; + data.sections.experience.title = "Work History"; + data.customSections[0].hidden = true; + data.customSections[0].title = "Earlier Roles"; + return data; +} + +function renderRecovery(data = makeHiddenData(), isLocked = false) { + useResumeStore.getState().initialize(makeResume(data, isLocked)); + return render( + + +
+ +
+
+
, + ); +} + +beforeAll(() => { + i18n.loadAndActivate({ locale: "en", messages: {} }); +}); + +beforeEach(() => { + vi.useFakeTimers(); + useResumeStore.getState().reset(); +}); + +afterEach(() => { + cleanup(); + vi.clearAllTimers(); + vi.useRealTimers(); + useResumeStore.getState().reset(); +}); + +describe("hidden section recovery", () => { + it("keeps Picture, Basics, and Custom editors while removing hidden printable editors", () => { + const visible = getVisibleLeftSidebarSections(makeHiddenData()); + + expect(visible).toContain("picture"); + expect(visible).toContain("basics"); + expect(visible).toContain("custom"); + expect(visible).not.toContain("summary"); + expect(visible).not.toContain("experience"); + }); + + it("does not mount full editors for hidden printable sections", () => { + useResumeStore.getState().initialize(makeResume(makeHiddenData())); + render( + + +
} /> + + , + ); + + expect(screen.getByTestId("editor-picture")).toBeInTheDocument(); + expect(screen.getByTestId("editor-basics")).toBeInTheDocument(); + expect(screen.getByTestId("editor-custom")).toBeInTheDocument(); + expect(screen.queryByTestId("editor-summary")).not.toBeInTheDocument(); + expect(screen.queryByTestId("editor-experience")).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Show Work History section" })).toBeInTheDocument(); + }); + + it("keeps the custom editor container while omitting only hidden custom children", () => { + const data = makeHiddenData(); + useResumeStore.getState().initialize(makeResume(data)); + render( + + + + + + + , + ); + + expect(screen.queryByText("Earlier Roles")).not.toBeInTheDocument(); + expect(screen.getAllByText("Cover Letter").length).toBeGreaterThan(0); + expect(screen.getByRole("button", { name: "Add a new custom section" })).toBeInTheDocument(); + }); + + it("lists hidden built-in, summary, and custom sections by effective title", () => { + renderRecovery(); + + expect(screen.getByRole("region", { name: "Hidden sections" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Show Summary section" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Show Work History section" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Show Earlier Roles section" })).toBeInTheDocument(); + }); + + it("shows via keyboard, changes only hidden state, and participates in undo and redo", async () => { + const data = makeHiddenData(); + const before = structuredClone(data); + vi.useRealTimers(); + const user = userEvent.setup(); + renderRecovery(data); + const show = screen.getByRole("button", { name: "Show Work History section" }); + + show.focus(); + await user.keyboard("{Enter}"); + + const shown = useResumeStore.getState().resume?.data; + expect(shown?.sections.experience.hidden).toBe(false); + expect({ ...shown?.sections.experience, hidden: true }).toEqual(before.sections.experience); + expect(shown?.metadata.layout).toEqual(before.metadata.layout); + expect(screen.queryByRole("button", { name: "Show Work History section" })).not.toBeInTheDocument(); + + act(() => useResumeStore.getState().undo()); + expect(useResumeStore.getState().resume?.data.sections.experience.hidden).toBe(true); + expect(screen.getByRole("button", { name: "Show Work History section" })).toBeInTheDocument(); + + act(() => useResumeStore.getState().redo()); + expect(useResumeStore.getState().resume?.data.sections.experience.hidden).toBe(false); + }); + + it("shows an unplaced hidden section without choosing a layout location", () => { + const data = makeHiddenData(); + for (const page of data.metadata.layout.pages) { + page.main = page.main.filter((id) => id !== "experience"); + page.sidebar = page.sidebar.filter((id) => id !== "experience"); + } + const layoutBefore = structuredClone(data.metadata.layout); + renderRecovery(data); + + act(() => screen.getByRole("button", { name: "Show Work History section" }).click()); + + expect(useResumeStore.getState().resume?.data.sections.experience.hidden).toBe(false); + expect(useResumeStore.getState().resume?.data.metadata.layout).toEqual(layoutBefore); + }); + + it("disables recovery actions for a locked resume", () => { + renderRecovery(makeHiddenData(), true); + const show = screen.getByRole("button", { name: "Show Work History section" }); + + expect(show).toBeDisabled(); + show.click(); + expect(useResumeStore.getState().resume?.data.sections.experience.hidden).toBe(true); + expect(useResumeStore.getState().undoStack).toHaveLength(0); + }); + + it("reopens a collapsed recovery group before focusing and scrolling the hidden section", async () => { + vi.useRealTimers(); + const user = userEvent.setup(); + const scrollIntoView = vi.fn(); + Object.defineProperty(HTMLElement.prototype, "scrollIntoView", { + configurable: true, + value: scrollIntoView, + }); + renderRecovery(); + const trigger = screen.getByRole("button", { name: "Hidden sections" }); + + await user.click(trigger); + await waitFor(() => expect(document.getElementById("sidebar-hidden-experience")).toBeNull()); + + focusLeftSidebarSection("experience"); + + await waitFor(() => expect(trigger).toHaveAttribute("aria-expanded", "true")); + const recoveryEntry = await waitFor(() => { + const entry = document.getElementById("sidebar-hidden-experience"); + expect(entry).not.toBeNull(); + return entry; + }); + await waitFor(() => expect(recoveryEntry).toHaveFocus()); + expect(scrollIntoView).toHaveBeenCalledWith({ + block: "start", + inline: "nearest", + behavior: "smooth", + }); + }); +}); diff --git a/apps/web/src/features/resume/builder/section-recovery.tsx b/apps/web/src/features/resume/builder/section-recovery.tsx new file mode 100644 index 000000000..581261739 --- /dev/null +++ b/apps/web/src/features/resume/builder/section-recovery.tsx @@ -0,0 +1,155 @@ +import type { ResumeData, SectionType } from "@reactive-resume/schema/resume/data"; +import type { ReactNode } from "react"; +import type { LeftSidebarSection } from "@/libs/resume/section"; +import { t } from "@lingui/core/macro"; +import { Trans } from "@lingui/react/macro"; +import { EyeClosedIcon, EyeIcon } from "@phosphor-icons/react"; +import { Fragment } from "react"; +import { getSectionAvailability } from "@reactive-resume/resume/section-availability"; +import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@reactive-resume/ui/components/accordion"; +import { Button } from "@reactive-resume/ui/components/button"; +import { Separator } from "@reactive-resume/ui/components/separator"; +import { useCurrentBuilderResumeSelector, useUpdateResumeData } from "@/features/resume/builder/draft"; +import { getSectionTitle, leftSidebarSections } from "@/libs/resume/section"; + +export function getVisibleLeftSidebarSections(data: ResumeData): LeftSidebarSection[] { + const hiddenSectionIds = new Set( + getSectionAvailability(data) + .filter((section) => section.hidden) + .map((section) => section.sectionId), + ); + + return leftSidebarSections.filter( + (section) => + section === "picture" || section === "basics" || section === "custom" || !hiddenSectionIds.has(section), + ); +} + +export function focusLeftSidebarSection(section: LeftSidebarSection): void { + const editorTarget = document.getElementById(`sidebar-${section}`); + if (editorTarget) { + editorTarget.scrollIntoView({ block: "start", inline: "nearest", behavior: "smooth" }); + return; + } + + const recoveryTargetId = `sidebar-hidden-${section}`; + const focusRecoveryTarget = () => { + const recoveryTarget = document.getElementById(recoveryTargetId); + if (!recoveryTarget) return; + + recoveryTarget.focus({ preventScroll: true }); + recoveryTarget.scrollIntoView({ block: "start", inline: "nearest", behavior: "smooth" }); + }; + + const trigger = document.getElementById("sidebar-hidden-sections-trigger"); + if (trigger?.getAttribute("aria-expanded") === "false") { + trigger.click(); + requestAnimationFrame(focusRecoveryTarget); + return; + } + + focusRecoveryTarget(); +} + +type SectionEditorListProps = { + renderSection: (section: LeftSidebarSection) => ReactNode; +}; + +export function SectionEditorList({ renderSection }: SectionEditorListProps) { + const sectionKey = useCurrentBuilderResumeSelector((resume) => getVisibleLeftSidebarSections(resume.data).join(",")); + const sections = sectionKey.split(",") as LeftSidebarSection[]; + + return ( + <> + {sections.map((section) => ( + + {renderSection(section)} + + + ))} + + + ); +} + +function getRecoverySectionTitle(data: ResumeData, sectionId: string): string { + if (sectionId === "summary") return data.summary.title || getSectionTitle("summary"); + + if (Object.hasOwn(data.sections, sectionId)) { + const type = sectionId as SectionType; + return data.sections[type].title || getSectionTitle(type); + } + + const customSection = data.customSections.find((section) => section.id === sectionId); + return customSection?.title || (customSection ? getSectionTitle(customSection.type) : sectionId); +} + +export function SectionRecovery() { + const data = useCurrentBuilderResumeSelector((resume) => resume.data); + const updateResumeData = useUpdateResumeData(); + const hiddenSections = getSectionAvailability(data).filter((section) => section.hidden); + + if (hiddenSections.length === 0) return null; + + const showSection = (sectionId: string) => { + updateResumeData((draft) => { + if (sectionId === "summary") { + draft.summary.hidden = false; + return; + } + + if (Object.hasOwn(draft.sections, sectionId)) { + draft.sections[sectionId as SectionType].hidden = false; + return; + } + + const customSection = draft.customSections.find((section) => section.id === sectionId); + if (customSection) customSection.hidden = false; + }); + }; + + return ( +
+ + + + + + + +
    + {hiddenSections.map(({ sectionId }) => { + const title = getRecoverySectionTitle(data, sectionId); + + return ( +
  • + {title} + +
  • + ); + })} +
+
+
+
+
+ ); +} diff --git a/apps/web/src/routes/builder/$resumeId/-sidebar/left/index.tsx b/apps/web/src/routes/builder/$resumeId/-sidebar/left/index.tsx index 3ddb2344c..d4b88b88d 100644 --- a/apps/web/src/routes/builder/$resumeId/-sidebar/left/index.tsx +++ b/apps/web/src/routes/builder/$resumeId/-sidebar/left/index.tsx @@ -3,17 +3,17 @@ import { t } from "@lingui/core/macro"; import { Trans } from "@lingui/react/macro"; import { LockSimpleIcon } from "@phosphor-icons/react"; import { useMutation } from "@tanstack/react-query"; -import { Fragment, useCallback, useRef } from "react"; +import { useCallback, useRef } from "react"; import { match } from "ts-pattern"; import { Avatar, AvatarFallback, AvatarImage } from "@reactive-resume/ui/components/avatar"; import { Button } from "@reactive-resume/ui/components/button"; import { ScrollArea } from "@reactive-resume/ui/components/scroll-area"; -import { Separator } from "@reactive-resume/ui/components/separator"; import { toast } from "@reactive-resume/ui/components/toast"; import { Tooltip, TooltipContent, TooltipTrigger } from "@reactive-resume/ui/components/tooltip"; import { getInitials } from "@reactive-resume/utils/string"; import { CoverLetterLibraryDialog } from "@/features/cover-letters/library"; import { useCurrentResume, useIsResumeLocked, usePatchResume, useResumeStore } from "@/features/resume/builder/draft"; +import { focusLeftSidebarSection, SectionEditorList } from "@/features/resume/builder/section-recovery"; import { UserDropdownMenu } from "@/features/user/dropdown-menu"; import { getResumeErrorMessage } from "@/libs/error-message"; import { orpc } from "@/libs/orpc/client"; @@ -74,12 +74,7 @@ export function BuilderSidebarLeft() {
- {leftSidebarSections.map((section) => ( - - {getSectionComponent(section)} - - - ))} +
@@ -132,11 +127,7 @@ function SidebarEdge() { const scrollToSection = useCallback( (section: LeftSidebarSection) => { toggleSidebar("left", true); - // Section ids are globally unique; document.getElementById reliably resolves the scroll target - // (querying through the ScrollArea ref did not — its ref does not expose the scroll container). - document - .getElementById(`sidebar-${section}`) - ?.scrollIntoView({ block: "start", inline: "nearest", behavior: "smooth" }); + focusLeftSidebarSection(section); }, [toggleSidebar], ); diff --git a/apps/web/src/routes/builder/$resumeId/-sidebar/left/sections/custom.tsx b/apps/web/src/routes/builder/$resumeId/-sidebar/left/sections/custom.tsx index ad0d57eba..8e8d34438 100644 --- a/apps/web/src/routes/builder/$resumeId/-sidebar/left/sections/custom.tsx +++ b/apps/web/src/routes/builder/$resumeId/-sidebar/left/sections/custom.tsx @@ -127,9 +127,11 @@ export function CustomSectionBuilder() { return ( - {customSections.map((section) => ( - - ))} + {customSections + .filter((section) => !section.hidden) + .map((section) => ( + + ))} {/* Add Custom Section Button */} diff --git a/packages/resume/package.json b/packages/resume/package.json index 98175b0a0..fa0bf05a2 100644 --- a/packages/resume/package.json +++ b/packages/resume/package.json @@ -11,6 +11,7 @@ "./icons": "./src/icons.ts", "./markdown": "./src/markdown.ts", "./patch": "./src/patch.ts", + "./section-availability": "./src/section-availability.ts", "./social-meta": "./src/social-meta.ts", "./stylesheet": "./src/stylesheet/index.ts", "./stylesheet/registry": "./src/stylesheet/registry/index.ts", diff --git a/packages/resume/src/section-availability.test.ts b/packages/resume/src/section-availability.test.ts new file mode 100644 index 000000000..ce4729545 --- /dev/null +++ b/packages/resume/src/section-availability.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from "vitest"; +import { sampleResumeData } from "@reactive-resume/schema/resume/sample"; +import { getSectionAvailability } from "./section-availability"; + +describe("section availability", () => { + it("includes every printable section and records every authored location", () => { + const data = structuredClone(sampleResumeData); + const customSection = data.customSections[0]; + const laterPage = data.metadata.layout.pages[2]; + if (!customSection || !laterPage) throw new Error("Sample resume lacks expected section fixtures."); + const customSectionId = customSection.id; + laterPage.sidebar.push(customSectionId, customSectionId, "unknown-section"); + + const availability = getSectionAvailability(data); + const sectionIds = availability.map((entry) => entry.sectionId); + + expect(sectionIds).toEqual([ + "summary", + ...Object.keys(data.sections), + ...data.customSections.map((section) => section.id), + ]); + expect(sectionIds).not.toContain("picture"); + expect(sectionIds).not.toContain("basics"); + expect(sectionIds).not.toContain("custom"); + expect(sectionIds).not.toContain("unknown-section"); + expect(availability.find((entry) => entry.sectionId === customSectionId)?.locations).toEqual([ + { pageIndex: 1, columnId: "main" }, + { pageIndex: 2, columnId: "sidebar" }, + { pageIndex: 2, columnId: "sidebar" }, + ]); + }); + + it("derives hidden and placement state independently without mutation", () => { + const data = structuredClone(sampleResumeData); + data.sections.experience.hidden = true; + data.sections.awards.items = []; + for (const page of data.metadata.layout.pages) { + page.main = page.main.filter((id) => id !== "experience"); + page.sidebar = page.sidebar.filter((id) => id !== "experience"); + page.main = page.main.filter((id) => id !== "projects"); + page.sidebar = page.sidebar.filter((id) => id !== "projects"); + } + data.summary.hidden = true; + const before = structuredClone(data); + + const availability = getSectionAvailability(data); + + expect(availability.find((entry) => entry.sectionId === "experience")).toEqual({ + sectionId: "experience", + hidden: true, + locations: [], + }); + expect(availability.find((entry) => entry.sectionId === "summary")).toEqual({ + sectionId: "summary", + hidden: true, + locations: [{ pageIndex: 0, columnId: "main" }], + }); + expect(availability.find((entry) => entry.sectionId === "projects")).toEqual({ + sectionId: "projects", + hidden: false, + locations: [], + }); + expect(availability.find((entry) => entry.sectionId === "awards")).toEqual({ + sectionId: "awards", + hidden: false, + locations: [{ pageIndex: 1, columnId: "main" }], + }); + expect(data).toEqual(before); + }); +}); diff --git a/packages/resume/src/section-availability.ts b/packages/resume/src/section-availability.ts new file mode 100644 index 000000000..d76efacb2 --- /dev/null +++ b/packages/resume/src/section-availability.ts @@ -0,0 +1,39 @@ +import type { ResumeData } from "@reactive-resume/schema/resume/data"; + +export type SectionLocation = { + pageIndex: number; + columnId: "main" | "sidebar"; +}; + +export type SectionAvailability = { + sectionId: string; + hidden: boolean; + locations: SectionLocation[]; +}; + +function getSectionLocations(data: ResumeData, sectionId: string): SectionLocation[] { + const locations: SectionLocation[] = []; + + for (const [pageIndex, page] of data.metadata.layout.pages.entries()) { + for (const columnId of ["main", "sidebar"] as const) { + for (const id of page[columnId]) { + if (id === sectionId) locations.push({ pageIndex, columnId }); + } + } + } + + return locations; +} + +export function getSectionAvailability(data: ResumeData): SectionAvailability[] { + const sections = [ + { sectionId: "summary", hidden: data.summary.hidden }, + ...Object.entries(data.sections).map(([sectionId, section]) => ({ sectionId, hidden: section.hidden })), + ...data.customSections.map((section) => ({ sectionId: section.id, hidden: section.hidden })), + ]; + + return sections.map((section) => ({ + ...section, + locations: getSectionLocations(data, section.sectionId), + })); +} diff --git a/tests/e2e/specs/section-recovery.spec.ts b/tests/e2e/specs/section-recovery.spec.ts new file mode 100644 index 000000000..0b5a26e10 --- /dev/null +++ b/tests/e2e/specs/section-recovery.spec.ts @@ -0,0 +1,199 @@ +import type { Page, TestInfo } from "@playwright/test"; +import { readFile } from "node:fs/promises"; +import { getDocument } from "pdfjs-dist/legacy/build/pdf.mjs"; +import { Pool } from "pg"; +import { createSampleResumeFromDashboard, openResumeCardMenu, openSidebarSection } from "../fixtures/resume"; +import { expect, test } from "../fixtures/test"; + +const summaryMarker = "RECOVERY_SUMMARY_MARKER"; +const builtInMarker = "RECOVERY_BUILTIN_MARKER"; +const customMarker = "RECOVERY_CUSTOM_MARKER"; + +type AuthoredLayout = { + pages: Array<{ fullWidth: boolean; main: string[]; sidebar: string[] }>; +}; + +async function seedRecoveryResume(resumeId: string): Promise { + const pool = new Pool({ connectionString: process.env.DATABASE_URL }); + + try { + const result = await pool.query<{ data: Record }>('select data from "resume" where id = $1', [ + resumeId, + ]); + const data = result.rows[0]?.data as { + summary: { title: string; content: string; hidden: boolean }; + sections: { experience: { title: string; hidden: boolean; items: Array<{ company: string }> } }; + customSections: Array<{ + id: string; + title: string; + hidden: boolean; + items: Array<{ company?: string }>; + }>; + metadata: { + layout: AuthoredLayout; + typography: { body: { fontFamily: string }; heading: { fontFamily: string } }; + }; + }; + if (!data?.sections.experience.items[0] || !data.customSections[0]?.items[0]) { + throw new Error("Sample resume lacks recovery fixture sections."); + } + + data.summary.title = "Recovery Summary"; + data.summary.content = `

${summaryMarker}

`; + data.summary.hidden = false; + data.sections.experience.title = "Recovery Experience"; + data.sections.experience.items[0].company = builtInMarker; + data.sections.experience.hidden = false; + data.customSections[0].title = "Recovery Custom"; + data.customSections[0].items[0].company = customMarker; + data.customSections[0].hidden = false; + data.metadata.typography.body.fontFamily = "Helvetica"; + data.metadata.typography.heading.fontFamily = "Helvetica"; + const layout = structuredClone(data.metadata.layout); + + await pool.query('update "resume" set data = $2, updated_at = now() where id = $1', [resumeId, data]); + return layout; + } finally { + await pool.end(); + } +} + +async function readRecoveryState(resumeId: string) { + const pool = new Pool({ connectionString: process.env.DATABASE_URL }); + + try { + const result = await pool.query<{ + data: { + summary: { hidden: boolean }; + sections: { experience: { hidden: boolean } }; + customSections: Array<{ title: string; hidden: boolean }>; + metadata: { layout: AuthoredLayout }; + }; + }>('select data from "resume" where id = $1', [resumeId]); + const data = result.rows[0]?.data; + if (!data) throw new Error(`Resume ${resumeId} was not found.`); + return data; + } finally { + await pool.end(); + } +} + +function waitForResumeSave(page: Page) { + return page.waitForResponse((response) => { + if (!response.url().includes("/api/rpc") || response.request().method() !== "POST") return false; + if (!response.ok()) return false; + return response.request().postData()?.includes('"data"') ?? false; + }); +} + +async function hideStandardSection(page: Page, navigationTitle: string, title: string) { + await page.getByRole("button", { name: navigationTitle, exact: true }).first().click(); + const heading = page.getByRole("heading", { name: title, exact: true }).filter({ visible: true }).first(); + await expect(heading).toBeVisible(); + await heading.locator("xpath=../..").getByRole("button", { name: "Section options" }).click(); + const saved = waitForResumeSave(page); + await page.getByRole("menuitem", { name: "Hide", exact: true }).click(); + await saved; +} + +async function hideCustomSection(page: Page, title: string) { + await openSidebarSection(page, "Custom Sections"); + const titleElement = page.getByText(title, { exact: true }).filter({ visible: true }).first(); + const card = titleElement.locator("xpath=../../.."); + await card.getByRole("button", { name: "Section options" }).click(); + const saved = waitForResumeSave(page); + await page.getByRole("menuitem", { name: "Hide", exact: true }).click(); + await saved; +} + +async function showSection(page: Page, title: string) { + const saved = waitForResumeSave(page); + await page.getByRole("button", { name: `Show ${title} section` }).click(); + await saved; +} + +async function downloadPdfText(page: Page, testInfo: TestInfo, name: string) { + await openSidebarSection(page, "Export"); + await page.getByRole("button", { name: /Choose PDF, DOCX, Markdown, or JSON/ }).click(); + const pending = page.waitForEvent("download"); + await page.getByRole("button", { name: "Download PDF", exact: true }).click(); + const download = await pending; + const path = testInfo.outputPath(`${name}.pdf`); + await download.saveAs(path); + await page.keyboard.press("Escape"); + + const loading = getDocument({ data: new Uint8Array(await readFile(path)), useSystemFonts: true }); + try { + const pdf = await loading.promise; + const text: string[] = []; + for (let pageNumber = 1; pageNumber <= pdf.numPages; pageNumber++) { + const pdfPage = await pdf.getPage(pageNumber); + text.push(...(await pdfPage.getTextContent()).items.flatMap((item) => ("str" in item ? [item.str] : []))); + } + return text.join(" "); + } finally { + await loading.destroy(); + } +} + +test("recovers hidden printable sections without changing authored placement", async ({ authPage: page }, testInfo) => { + test.setTimeout(120_000); + const resumeName = await createSampleResumeFromDashboard(page, testInfo); + const resumeId = new URL(page.url()).pathname.split("/").at(-1); + if (!resumeId) throw new Error("Builder URL lacks resume id."); + const authoredLayout = await seedRecoveryResume(resumeId); + await page.reload(); + + await hideStandardSection(page, "Summary", "Recovery Summary"); + await hideStandardSection(page, "Experience", "Recovery Experience"); + await hideCustomSection(page, "Recovery Custom"); + await page.reload(); + + for (const title of ["Recovery Summary", "Recovery Experience", "Recovery Custom"]) { + await expect(page.getByRole("button", { name: `Show ${title} section` })).toBeVisible(); + } + await expect( + page.getByRole("heading", { name: "Recovery Summary", exact: true }).filter({ visible: true }), + ).toHaveCount(0); + await expect( + page.getByRole("heading", { name: "Recovery Experience", exact: true }).filter({ visible: true }), + ).toHaveCount(0); + await expect(page.getByText("Recovery Custom", { exact: true }).filter({ visible: true })).toHaveCount(1); + + const hiddenPdf = await downloadPdfText(page, testInfo, "section-recovery-hidden"); + expect(hiddenPdf).not.toContain(summaryMarker); + expect(hiddenPdf).not.toContain(builtInMarker); + expect(hiddenPdf).not.toContain(customMarker); + + await showSection(page, "Recovery Summary"); + await showSection(page, "Recovery Experience"); + await showSection(page, "Recovery Custom"); + const shownState = await readRecoveryState(resumeId); + expect(shownState.metadata.layout).toEqual(authoredLayout); + expect(shownState.summary.hidden).toBe(false); + expect(shownState.sections.experience.hidden).toBe(false); + expect(shownState.customSections.find((section) => section.title === "Recovery Custom")?.hidden).toBe(false); + + const shownPdf = await downloadPdfText(page, testInfo, "section-recovery-shown"); + expect(shownPdf).toContain(summaryMarker); + expect(shownPdf).toContain(builtInMarker); + expect(shownPdf).toContain(customMarker); + + await page.getByRole("button", { name: "Undo", exact: true }).click(); + await expect(page.getByRole("button", { name: "Show Recovery Custom section" })).toBeVisible(); + await page.getByRole("button", { name: "Redo", exact: true }).click(); + await expect(page.getByRole("button", { name: "Show Recovery Custom section" })).toHaveCount(0); + + const saved = waitForResumeSave(page); + await page.getByRole("button", { name: "Undo", exact: true }).click(); + await saved; + await openResumeCardMenu(page, resumeName); + const locked = page.waitForResponse((response) => (response.request().postData() ?? "").includes('"isLocked":true')); + await page.getByRole("menuitem", { name: "Lock" }).click(); + await page.getByRole("alertdialog").getByRole("button", { name: "Confirm" }).click(); + await locked; + await page.goto(`/builder/${resumeId}`); + + await expect(page.getByRole("button", { name: "Show Recovery Custom section" })).toBeDisabled(); + expect((await readRecoveryState(resumeId)).metadata.layout).toEqual(authoredLayout); +});