diff --git a/apps/web/src/dialogs/resume/sections/cover-letter.tsx b/apps/web/src/dialogs/resume/sections/cover-letter.tsx index ea22d497e..6dcb6646a 100644 --- a/apps/web/src/dialogs/resume/sections/cover-letter.tsx +++ b/apps/web/src/dialogs/resume/sections/cover-letter.tsx @@ -1,14 +1,19 @@ import type z from "zod"; import type { DialogProps } from "@/dialogs/store"; +import { t } from "@lingui/core/macro"; import { Trans } from "@lingui/react/macro"; import { PencilSimpleLineIcon, PlusIcon } from "@phosphor-icons/react"; import { useStore } from "@tanstack/react-form"; +import { useQuery } from "@tanstack/react-query"; +import { useState } from "react"; import { coverLetterItemSchema } from "@reactive-resume/schema/resume/data"; import { FormControl, FormItem, FormLabel, FormMessage } from "@reactive-resume/ui/components/form"; import { RichInput } from "@/components/input/rich-input"; +import { Combobox } from "@/components/ui/combobox"; import { useDialogStore } from "@/dialogs/store"; import { useUpdateResumeData } from "@/features/resume/builder/draft"; import { useFormBlocker } from "@/hooks/use-form-blocker"; +import { orpc } from "@/libs/orpc/client"; import { makeSectionItem } from "@/libs/resume/make-section-item"; import { useAppForm, withForm } from "@/libs/tanstack-form"; import { SectionItemDialog } from "./section-item-dialog"; @@ -55,11 +60,51 @@ export function CreateCoverLetterDialog({ data }: DialogProps<"resume.sections.c submitLabel={Create} singleColumn > + { + form.setFieldValue("recipient", letter.recipient); + form.setFieldValue("content", letter.content); + }} + /> ); } +type ImportFromLibraryProps = { + onImport: (letter: { recipient: string; content: string }) => void; +}; + +function ImportFromLibrary({ onImport }: ImportFromLibraryProps) { + const [selectedId, setSelectedId] = useState(null); + const letters = useQuery(orpc.coverLetters.list.queryOptions({ input: { limit: 100 } })); + + if (!letters.data?.items.length) return null; + + return ( + + + Import from library + + ({ value: letter.id, label: letter.name }))} + onValueChange={(id) => { + setSelectedId(id); + const letter = letters.data.items.find((item) => item.id === id); + if (letter) onImport(letter); + }} + /> + } + /> + + ); +} + export function UpdateCoverLetterDialog({ data }: DialogProps<"resume.sections.cover-letter.update">) { const closeDialog = useDialogStore((state) => state.closeDialog); const updateResumeData = useUpdateResumeData(); diff --git a/apps/web/src/features/cover-letters/editor-dialog.tsx b/apps/web/src/features/cover-letters/editor-dialog.tsx index 36f184908..9e9e62085 100644 --- a/apps/web/src/features/cover-letters/editor-dialog.tsx +++ b/apps/web/src/features/cover-letters/editor-dialog.tsx @@ -5,6 +5,7 @@ import { useQuery, useQueryClient } from "@tanstack/react-query"; import { ClientOnly, useBlocker } from "@tanstack/react-router"; import { lazy, Suspense, useCallback, useId, useMemo, useRef, useState } from "react"; import { createCoverLetterResumeData } from "@reactive-resume/resume/cover-letter"; +import { templateSchema } from "@reactive-resume/schema/templates"; import { Button } from "@reactive-resume/ui/components/button"; import { Dialog, @@ -19,6 +20,7 @@ import { Spinner } from "@reactive-resume/ui/components/spinner"; import { toast } from "@reactive-resume/ui/components/toast"; import { downloadWithAnchor, generateFilename } from "@reactive-resume/utils/file"; import { Combobox } from "@/components/ui/combobox"; +import { templates } from "@/dialogs/resume/template/data"; import { useConfirm } from "@/hooks/use-confirm"; import { getReadableErrorMessage } from "@/libs/error-message"; import { orpc } from "@/libs/orpc/client"; @@ -180,6 +182,7 @@ function CoverLetterActions({ const queryClient = useQueryClient(); const confirm = useConfirm(); const styleId = useId(); + const templateId = useId(); const applicationId = useId(); const [resumeId, setResumeId] = useState(letter.sourceResumeId); const [selectedApplicationId, setSelectedApplicationId] = useState(letter.sourceApplicationId); @@ -188,6 +191,7 @@ function CoverLetterActions({ const resumes = useQuery(orpc.resume.list.queryOptions({ input: {} })); const applications = useQuery(orpc.applications.list.queryOptions({ input: { includeArchived: false } })); const data = useMemo(() => createCoverLetterResumeData(letter), [letter]); + const templateOptions = templateSchema.options.map((value) => ({ value, label: templates[value].name })); const createPdf = async () => { const { createResumePdfBlob } = await import("@/features/resume/export/pdf-document"); return createResumePdfBlob(data, undefined, { includeCoverLetterHeader: true }); @@ -259,9 +263,34 @@ function CoverLetterActions({ Delete +
+ + { + const template = templateSchema.safeParse(value); + if (!template.success) return; + void run(async () => { + onUpdated( + await orpc.coverLetters.update.call({ + id: letter.id, + expectedRevision: letter.revision, + template: template.data, + }), + ); + }); + }} + /> +
- Refresh from resume + Copy from resume

diff --git a/apps/web/src/features/cover-letters/library.tsx b/apps/web/src/features/cover-letters/library.tsx index 938842456..b5981eb26 100644 --- a/apps/web/src/features/cover-letters/library.tsx +++ b/apps/web/src/features/cover-letters/library.tsx @@ -3,19 +3,14 @@ import { Trans } from "@lingui/react/macro"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useEffect, useId, useRef, useState } from "react"; import { coverLetterDocumentSchema } from "@reactive-resume/schema/cover-letter/data"; +import { templateSchema } from "@reactive-resume/schema/templates"; import { Button } from "@reactive-resume/ui/components/button"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, -} from "@reactive-resume/ui/components/dialog"; import { Input } from "@reactive-resume/ui/components/input"; import { Label } from "@reactive-resume/ui/components/label"; import { Spinner } from "@reactive-resume/ui/components/spinner"; import { toast } from "@reactive-resume/ui/components/toast"; import { Combobox } from "@/components/ui/combobox"; +import { templates } from "@/dialogs/resume/template/data"; import { getReadableErrorMessage } from "@/libs/error-message"; import { orpc } from "@/libs/orpc/client"; import { CoverLetterEditorDialog } from "./editor-dialog"; @@ -30,6 +25,7 @@ export function CoverLetterLibrary({ initialResumeId, resumeReady = true, onEdit const queryClient = useQueryClient(); const nameId = useId(); const resumeInputId = useId(); + const templateInputId = useId(); const embeddedId = useId(); const importInput = useRef(null); const running = useRef(false); @@ -40,6 +36,7 @@ export function CoverLetterLibrary({ initialResumeId, resumeReady = true, onEdit const [creating, setCreating] = useState(false); const [name, setName] = useState(""); const [resumeId, setResumeId] = useState(initialResumeId ?? null); + const [template, setTemplate] = useState(null); const [embeddedKey, setEmbeddedKey] = useState(null); const [selectedId, setSelectedId] = useState(null); useEffect(() => { @@ -65,6 +62,7 @@ export function CoverLetterLibrary({ initialResumeId, resumeReady = true, onEdit : [], ); const selectedEmbedded = embedded.find((item) => item.value === embeddedKey); + const templateOptions = templateSchema.options.map((value) => ({ value, label: templates[value].name })); const sourceReady = resumeId !== initialResumeId || resumeReady; const run = async (action: () => Promise) => { if (running.current) return; @@ -136,7 +134,7 @@ export function CoverLetterLibrary({ initialResumeId, resumeReady = true, onEdit void run(async () => { const letter = await orpc.coverLetters.create.call({ name: name.trim(), - ...(resumeId ? { resumeId } : {}), + ...(templateSchema.safeParse(template).success ? { template: templateSchema.parse(template) } : {}), }); await created(letter.id); }); @@ -156,21 +154,18 @@ export function CoverLetterLibrary({ initialResumeId, resumeReady = true, onEdit />

-
- {resumeId && ( +
+
- + ({ value: resume.id, label: resume.name }))} + value={resumeId} + onValueChange={(id) => { + setResumeId(id); + setEmbeddedKey(null); + }} + showClear + placeholder={t`Choose a resume`} + />
- )} +
)} {query.isPending ? ( @@ -293,35 +301,3 @@ export function CoverLetterLibrary({ initialResumeId, resumeReady = true, onEdit ); } - -type CoverLetterLibraryDialogProps = Pick; - -export function CoverLetterLibraryDialog(props: CoverLetterLibraryDialogProps) { - const [open, setOpen] = useState(false); - const [editing, setEditing] = useState(false); - return ( - <> - - { - if (!editing) setOpen(next); - }} - > - - - - Cover Letters - - - Create and edit letters using your resume’s styling. - - - {open && } - - - - ); -} 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 d4b88b88d..4ce4b3305 100644 --- a/apps/web/src/routes/builder/$resumeId/-sidebar/left/index.tsx +++ b/apps/web/src/routes/builder/$resumeId/-sidebar/left/index.tsx @@ -11,8 +11,7 @@ import { ScrollArea } from "@reactive-resume/ui/components/scroll-area"; 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 { useCurrentResume, useIsResumeLocked, usePatchResume } 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"; @@ -61,8 +60,6 @@ function getSectionComponent(type: LeftSidebarSection) { export function BuilderSidebarLeft() { const scrollAreaRef = useRef(null); const isLocked = useIsResumeLocked(); - const resume = useCurrentResume(); - const resumeReady = useResumeStore((state) => state.saveStatus !== "saving" && state.saveStatus !== "error"); return ( <> @@ -71,7 +68,6 @@ export function BuilderSidebarLeft() {
{isLocked && } -
diff --git a/apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/layout/pages.test.tsx b/apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/layout/pages.test.tsx index 58023317c..fe31c49f5 100644 --- a/apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/layout/pages.test.tsx +++ b/apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/layout/pages.test.tsx @@ -1,43 +1,7 @@ -// @vitest-environment happy-dom - -import type { ResumeData } from "@reactive-resume/schema/resume/data"; -import { render, screen } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; -import { i18n } from "@lingui/core"; -import { I18nProvider } from "@lingui/react"; -import { defaultResumeData } from "@reactive-resume/schema/resume/default"; +import { describe, expect, it } from "vitest"; const source = await import("./pages?raw").then((module) => module.default); -const mocks = vi.hoisted(() => ({ - resume: undefined as unknown as { data: ResumeData; isLocked: boolean }, - updateResumeData: vi.fn(), -})); - -vi.mock("@/features/resume/builder/draft", () => ({ - useCurrentResume: () => mocks.resume, - useUpdateResumeData: () => mocks.updateResumeData, -})); - -const { LayoutPages } = await import("./pages"); - -beforeAll(() => i18n.loadAndActivate({ locale: "en", messages: {} })); - -beforeEach(() => { - vi.clearAllMocks(); - const data = structuredClone(defaultResumeData); - data.metadata.layout.pages = [{ fullWidth: false, main: ["summary"], sidebar: ["profiles"] }]; - mocks.resume = { data, isLocked: false }; -}); - -const renderLayoutPages = () => - render( - - - , - ); - describe("layout page header", () => { it("uses container queries to prevent narrow sidebar control collisions", () => { expect(source).toContain("@container bg-secondary/50"); @@ -46,37 +10,3 @@ describe("layout page header", () => { expect(source).toContain("flex min-w-0 flex-wrap"); }); }); - -describe("authored page guidance", () => { - it("explains why one authored page can produce multiple non-editable PDF overflow pages without changing layout data", () => { - const physicalRenderEvidence = { pageCount: 3 }; - const pagesBeforeRender = structuredClone(mocks.resume.data.metadata.layout.pages); - - expect(mocks.resume.data.metadata.layout.pages).toHaveLength(1); - expect(physicalRenderEvidence.pageCount).toBeGreaterThan(mocks.resume.data.metadata.layout.pages.length); - - renderLayoutPages(); - - const guidance = screen.getByRole("note", { name: "Authored pages and PDF overflow" }); - expect(guidance).toHaveTextContent( - "Pages listed here are authored pages saved with your resume. A long authored page may continue onto extra PDF pages automatically; those overflow pages are not saved or editable separately.", - ); - expect(guidance).toHaveTextContent("Move to"); - expect(guidance).toHaveTextContent("New Page"); - expect(guidance).toHaveTextContent("Full Width"); - expect(mocks.updateResumeData).not.toHaveBeenCalled(); - expect(mocks.resume.data.metadata.layout.pages).toEqual(pagesBeforeRender); - }); - - it("keeps guidance available in locked resumes and out of keyboard tab order", async () => { - mocks.resume.isLocked = true; - const user = userEvent.setup(); - - renderLayoutPages(); - - expect(screen.getByRole("note", { name: "Authored pages and PDF overflow" })).toBeVisible(); - await user.tab(); - expect(screen.getByRole("switch", { name: "Full Width" })).toHaveFocus(); - expect(mocks.updateResumeData).not.toHaveBeenCalled(); - }); -}); diff --git a/apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/layout/pages.tsx b/apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/layout/pages.tsx index fc9f2b8f5..0618ce5ad 100644 --- a/apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/layout/pages.tsx +++ b/apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/layout/pages.tsx @@ -19,14 +19,12 @@ import { DotsSixVerticalIcon, DotsThreeVerticalIcon, FileIcon, - InfoIcon, PlusCircleIcon, PlusIcon, TrashIcon, } from "@phosphor-icons/react"; import { useCallback, useId, useState } from "react"; import { match } from "ts-pattern"; -import { Alert, AlertDescription, AlertTitle } from "@reactive-resume/ui/components/alert"; import { Button } from "@reactive-resume/ui/components/button"; import { DropdownMenu, @@ -250,8 +248,6 @@ export function LayoutPages() { onDragCancel={() => setActiveId(null)} >
- - {layout.pages.map((page, pageIndex) => ( - - - Authored pages and PDF overflow - - -

- - Pages listed here are authored pages saved with your resume. A long authored page may continue onto extra - PDF pages automatically; those overflow pages are not saved or editable separately. - -

-

- - For a controlled continuation, open a section's menu and choose Move to → New Page, then turn on Full Width - for that authored page. - -

-
- - ); -} - type PageContainerProps = { pageIndex: number; page: { fullWidth: boolean; main: string[]; sidebar: string[] }; diff --git a/apps/web/src/routes/dashboard/resumes/index.tsx b/apps/web/src/routes/dashboard/resumes/index.tsx index 9db6bf077..070d3a569 100644 --- a/apps/web/src/routes/dashboard/resumes/index.tsx +++ b/apps/web/src/routes/dashboard/resumes/index.tsx @@ -157,7 +157,7 @@ function RouteComponent() { )} - + { @@ -119,6 +121,7 @@ export const coverLetterService = { create: async (input: CreateInput) => { await assertOwnedApplication(input.userId, input.applicationId); const style = await getResumeStyle(input.userId, input.resumeId); + if (input.template) style.metadata.template = input.template; return insert({ userId: input.userId, name: input.name, @@ -129,8 +132,12 @@ export const coverLetterService = { sourceApplicationId: input.applicationId ?? null, }); }, - update: (input: CoverLetterUpdateInput & { userId: string }) => { + update: async (input: CoverLetterUpdateInput & { userId: string }) => { const changes: Partial = {}; + if (input.template) { + const letter = await getById(input); + changes.style = { ...letter.style, metadata: { ...letter.style.metadata, template: input.template } }; + } if (input.name !== undefined) changes.name = coverLetterContentSchema.shape.name.parse(input.name); if (input.recipient !== undefined) changes.recipient = sanitizeCoverLetterHtml(coverLetterContentSchema.shape.recipient.parse(input.recipient)); @@ -141,6 +148,7 @@ export const coverLetterService = { refreshStyle: async (input: RevisionInput & { resumeId: string }) => { const letter = await getById(input); const style = await getResumeStyle(input.userId, input.resumeId, letter.style.sectionId, letter.style.itemId); + style.metadata.template = letter.style.metadata.template; return updateRevision(input, { style, sourceResumeId: input.resumeId }); }, duplicate: async (input: OwnedId & { name?: string | undefined }) => {