mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-10-03 02:04:31 +10:00
feat(builder): add hidden section recovery (#3462)
* feat(builder): recover hidden sections * fix(builder): reopen hidden section recovery
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<I18nProvider i18n={i18n}>
|
||||
<fieldset disabled={isLocked}>
|
||||
<SectionRecovery />
|
||||
</fieldset>
|
||||
</I18nProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
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(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<I18nProvider i18n={i18n}>
|
||||
<SectionEditorList renderSection={(section) => <div data-testid={`editor-${section}`} />} />
|
||||
</I18nProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
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(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<I18nProvider i18n={i18n}>
|
||||
<ConfirmDialogProvider>
|
||||
<CustomSectionBuilder />
|
||||
</ConfirmDialogProvider>
|
||||
</I18nProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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) => (
|
||||
<Fragment key={section}>
|
||||
{renderSection(section)}
|
||||
<Separator />
|
||||
</Fragment>
|
||||
))}
|
||||
<SectionRecovery />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<section>
|
||||
<Accordion defaultValue={["hidden-sections"]}>
|
||||
<AccordionItem value="hidden-sections" className="rounded-md border px-3">
|
||||
<AccordionTrigger
|
||||
id="sidebar-hidden-sections-trigger"
|
||||
className="items-center no-underline hover:no-underline"
|
||||
>
|
||||
<span className="flex items-center gap-x-2">
|
||||
<EyeClosedIcon aria-hidden="true" />
|
||||
<Trans>Hidden sections</Trans>
|
||||
</span>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="pb-3">
|
||||
<ul className="space-y-2">
|
||||
{hiddenSections.map(({ sectionId }) => {
|
||||
const title = getRecoverySectionTitle(data, sectionId);
|
||||
|
||||
return (
|
||||
<li
|
||||
key={sectionId}
|
||||
id={`sidebar-hidden-${sectionId}`}
|
||||
tabIndex={-1}
|
||||
className="flex items-center justify-between gap-x-3 rounded-md bg-secondary/40 px-3 py-2 outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
<span className="min-w-0 truncate font-medium text-sm">{title}</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
aria-label={t`Show ${title} section`}
|
||||
onClick={() => showSection(sectionId)}
|
||||
>
|
||||
<EyeIcon aria-hidden="true" />
|
||||
<Trans>Show</Trans>
|
||||
</Button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -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() {
|
||||
<CoverLetterLibraryDialog initialResumeId={resume.id} resumeReady={resumeReady} />
|
||||
|
||||
<fieldset disabled={isLocked} className="m-0 min-w-0 space-y-4 border-0 p-0">
|
||||
{leftSidebarSections.map((section) => (
|
||||
<Fragment key={section}>
|
||||
{getSectionComponent(section)}
|
||||
<Separator />
|
||||
</Fragment>
|
||||
))}
|
||||
<SectionEditorList renderSection={getSectionComponent} />
|
||||
</fieldset>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
@@ -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],
|
||||
);
|
||||
|
||||
@@ -127,9 +127,11 @@ export function CustomSectionBuilder() {
|
||||
return (
|
||||
<SectionBase type="custom" className={cn("space-y-4", customSections.length === 0 && "border-dashed")}>
|
||||
<AnimatePresence>
|
||||
{customSections.map((section) => (
|
||||
<CustomSectionContainer key={section.id} section={section} />
|
||||
))}
|
||||
{customSections
|
||||
.filter((section) => !section.hidden)
|
||||
.map((section) => (
|
||||
<CustomSectionContainer key={section.id} section={section} />
|
||||
))}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Add Custom Section Button */}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
}));
|
||||
}
|
||||
@@ -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<AuthoredLayout> {
|
||||
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
|
||||
|
||||
try {
|
||||
const result = await pool.query<{ data: Record<string, unknown> }>('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 = `<p>${summaryMarker}</p>`;
|
||||
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);
|
||||
});
|
||||
Reference in New Issue
Block a user