fix: link cover letters to templates and tidy builder UI

- Fix Grid/Compact/List tab overlap on the resumes dashboard: the fixed
  three-column grid forced cells narrower than their labels, so tab content
  spilled into neighboring cells.
- Replace the "Resume styling" resume picker with a template picker in the
  cover-letter create form and editor. The API accepts a `template` on create
  and update, and refreshing style from a resume no longer overwrites it. The
  resume control remains in the editor as "Sender details" since it is the
  only source for the letter header.
- Remove the cover-letter library button from the builder sidebar and add an
  "Import from library" option to the create-cover-letter dialog. Resume to
  library copying stays in the library with its own resume picker.
- Remove the authored-pages/PDF-overflow note from the layout sidebar.
This commit is contained in:
Amruth Pillai
2026-09-06 10:26:12 +02:00
parent e2cb6f111f
commit df2e21ef9e
9 changed files with 128 additions and 170 deletions
@@ -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={<Trans>Create</Trans>}
singleColumn
>
<ImportFromLibrary
onImport={(letter) => {
form.setFieldValue("recipient", letter.recipient);
form.setFieldValue("content", letter.content);
}}
/>
<CoverLetterForm form={form} />
</SectionItemDialog>
);
}
type ImportFromLibraryProps = {
onImport: (letter: { recipient: string; content: string }) => void;
};
function ImportFromLibrary({ onImport }: ImportFromLibraryProps) {
const [selectedId, setSelectedId] = useState<string | null>(null);
const letters = useQuery(orpc.coverLetters.list.queryOptions({ input: { limit: 100 } }));
if (!letters.data?.items.length) return null;
return (
<FormItem>
<FormLabel>
<Trans>Import from library</Trans>
</FormLabel>
<FormControl
render={
<Combobox
className="w-full"
value={selectedId}
placeholder={t`Choose a saved cover letter`}
options={letters.data.items.map((letter) => ({ value: letter.id, label: letter.name }))}
onValueChange={(id) => {
setSelectedId(id);
const letter = letters.data.items.find((item) => item.id === id);
if (letter) onImport(letter);
}}
/>
}
/>
</FormItem>
);
}
export function UpdateCoverLetterDialog({ data }: DialogProps<"resume.sections.cover-letter.update">) {
const closeDialog = useDialogStore((state) => state.closeDialog);
const updateResumeData = useUpdateResumeData();
@@ -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<string | null>(letter.sourceResumeId);
const [selectedApplicationId, setSelectedApplicationId] = useState<string | null>(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({
<Trans>Delete</Trans>
</Button>
</div>
<div className="space-y-2">
<Label htmlFor={templateId}>
<Trans>Template</Trans>
</Label>
<Combobox
id={templateId}
className="w-full"
disabled={disabled}
options={templateOptions}
value={letter.style.metadata.template}
onValueChange={(value) => {
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,
}),
);
});
}}
/>
</div>
<div className="space-y-2">
<Label htmlFor={styleId}>
<Trans>Resume styling</Trans>
<Trans>Sender details</Trans>
</Label>
<div className="flex flex-wrap gap-2">
<Combobox
@@ -294,7 +323,7 @@ function CoverLetterActions({
})
}
>
<Trans>Refresh from resume</Trans>
<Trans>Copy from resume</Trans>
</Button>
</div>
<p className="text-muted-foreground text-xs">
+34 -58
View File
@@ -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<HTMLInputElement>(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<string | null>(initialResumeId ?? null);
const [template, setTemplate] = useState<string | null>(null);
const [embeddedKey, setEmbeddedKey] = useState<string | null>(null);
const [selectedId, setSelectedId] = useState<string | null>(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<void>) => {
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
/>
</div>
<div className="space-y-2">
<Label htmlFor={resumeInputId}>
<Trans>Resume styling</Trans>
<Label htmlFor={templateInputId}>
<Trans>Template</Trans>
</Label>
<Combobox
id={resumeInputId}
id={templateInputId}
disabled={busy}
className="w-full"
options={(resumes.data ?? []).map((resume) => ({ value: resume.id, label: resume.name }))}
value={resumeId}
onValueChange={(id) => {
setResumeId(id);
setEmbeddedKey(null);
}}
options={templateOptions}
value={template}
onValueChange={setTemplate}
showClear
placeholder={t`Default styling`}
placeholder={t`Default template`}
/>
</div>
<Button type="submit" disabled={busy || !name.trim() || !sourceReady}>
@@ -178,16 +173,29 @@ export function CoverLetterLibrary({ initialResumeId, resumeReady = true, onEdit
</Button>
</fieldset>
</form>
{resumeId && (
<div className="space-y-2">
<Label htmlFor={resumeInputId}>
<Trans>Copy an existing letter from a resume</Trans>
</Label>
<div className="space-y-2">
<Label htmlFor={embeddedId}>
<Trans>Copy an existing letter from this resume</Trans>
</Label>
<Combobox
id={resumeInputId}
className="w-full"
disabled={busy}
options={(resumes.data ?? []).map((resume) => ({ value: resume.id, label: resume.name }))}
value={resumeId}
onValueChange={(id) => {
setResumeId(id);
setEmbeddedKey(null);
}}
showClear
placeholder={t`Choose a resume`}
/>
<div className="flex flex-wrap gap-2">
<Combobox
id={embeddedId}
className="min-w-48 flex-1"
disabled={busy || !sourceReady}
disabled={busy || !sourceReady || !resumeId}
options={embedded}
value={embeddedKey}
onValueChange={setEmbeddedKey}
@@ -196,10 +204,10 @@ export function CoverLetterLibrary({ initialResumeId, resumeReady = true, onEdit
/>
<Button
variant="outline"
disabled={busy || !sourceReady || !selectedEmbedded}
disabled={busy || !sourceReady || !selectedEmbedded || !resumeId}
onClick={() =>
void run(async () => {
if (!selectedEmbedded) return;
if (!selectedEmbedded || !resumeId) return;
const letter = await orpc.coverLetters.copyEmbedded.call({
resumeId,
sectionId: selectedEmbedded.sectionId,
@@ -225,7 +233,7 @@ export function CoverLetterLibrary({ initialResumeId, resumeReady = true, onEdit
<p role="alert">{getReadableErrorMessage(source.error, t`Could not load the selected resume.`)}</p>
)}
</div>
)}
</div>
</div>
)}
{query.isPending ? (
@@ -293,35 +301,3 @@ export function CoverLetterLibrary({ initialResumeId, resumeReady = true, onEdit
</div>
);
}
type CoverLetterLibraryDialogProps = Pick<CoverLetterLibraryProps, "initialResumeId" | "resumeReady">;
export function CoverLetterLibraryDialog(props: CoverLetterLibraryDialogProps) {
const [open, setOpen] = useState(false);
const [editing, setEditing] = useState(false);
return (
<>
<Button variant="outline" className="w-full" onClick={() => setOpen(true)}>
<Trans>Cover-letter library</Trans>
</Button>
<Dialog
open={open}
onOpenChange={(next) => {
if (!editing) setOpen(next);
}}
>
<DialogContent className="lg:max-w-3xl xl:max-w-4xl">
<DialogHeader>
<DialogTitle>
<Trans>Cover Letters</Trans>
</DialogTitle>
<DialogDescription>
<Trans>Create and edit letters using your resume’s styling.</Trans>
</DialogDescription>
</DialogHeader>
{open && <CoverLetterLibrary {...props} onEditingChange={setEditing} />}
</DialogContent>
</Dialog>
</>
);
}
@@ -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<HTMLDivElement | null>(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() {
<ScrollArea ref={scrollAreaRef} className="@container h-[calc(100svh-3.5rem)] bg-background sm:ms-12">
<div className="space-y-4 p-4">
{isLocked && <LockBanner />}
<CoverLetterLibraryDialog initialResumeId={resume.id} resumeReady={resumeReady} />
<fieldset disabled={isLocked} className="m-0 min-w-0 space-y-4 border-0 p-0">
<SectionEditorList renderSection={getSectionComponent} />
@@ -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(
<I18nProvider i18n={i18n}>
<LayoutPages />
</I18nProvider>,
);
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();
});
});
@@ -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)}
>
<div className="flex flex-col gap-4">
<AuthoredPageGuidance />
{layout.pages.map((page, pageIndex) => (
<PageContainer
key={`page-${pageIndex}`}
@@ -279,33 +275,6 @@ export function LayoutPages() {
);
}
function AuthoredPageGuidance() {
const titleId = useId();
return (
<Alert role="note" aria-labelledby={titleId}>
<InfoIcon />
<AlertTitle id={titleId}>
<Trans>Authored pages and PDF overflow</Trans>
</AlertTitle>
<AlertDescription>
<p>
<Trans>
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.
</Trans>
</p>
<p>
<Trans>
For a controlled continuation, open a section's menu and choose Move to → New Page, then turn on Full Width
for that authored page.
</Trans>
</p>
</AlertDescription>
</Alert>
);
}
type PageContainerProps = {
pageIndex: number;
page: { fullWidth: boolean; main: string[]; sidebar: string[] };
@@ -157,7 +157,7 @@ function RouteComponent() {
)}
<Tabs className="w-full sm:w-auto ltr:sm:ms-auto rtl:sm:me-auto" value={view}>
<TabsList className="grid w-full grid-cols-3 sm:inline-flex sm:w-fit">
<TabsList className="w-full max-w-full overflow-x-auto sm:w-fit">
<TabsTrigger
value="grid"
nativeButton={false}
+6 -1
View File
@@ -4,6 +4,7 @@ import {
coverLetterDocumentSchema,
coverLetterSchema,
} from "@reactive-resume/schema/cover-letter/data";
import { templateSchema } from "@reactive-resume/schema/templates";
const idSchema = z.object({ id: z.string().min(1) });
const revisionSchema = idSchema.extend({ expectedRevision: z.number().int().min(1) });
@@ -29,10 +30,14 @@ export const coverLetterDto = {
content: editableSchema.shape.content.default(""),
resumeId: z.string().min(1).optional(),
applicationId: z.string().min(1).optional(),
template: templateSchema.optional(),
}),
output: coverLetterSchema,
},
update: { input: revisionSchema.extend(editableSchema.partial().shape), output: coverLetterSchema },
update: {
input: revisionSchema.extend(editableSchema.partial().shape).extend({ template: templateSchema.optional() }),
output: coverLetterSchema,
},
refreshStyle: { input: revisionSchema.extend({ resumeId: z.string().min(1) }), output: coverLetterSchema },
duplicate: { input: idSchema.extend({ name: editableSchema.shape.name.optional() }), output: coverLetterSchema },
delete: { input: revisionSchema, output: z.void() },
@@ -1,4 +1,5 @@
import type { CoverLetter, CoverLetterDocument, CoverLetterStyle } from "@reactive-resume/schema/cover-letter/data";
import type { Template } from "@reactive-resume/schema/templates";
import type { CoverLetterListInput, CoverLetterUpdateInput } from "../../dto/cover-letter";
import { ORPCError } from "@orpc/client";
import { and, count, desc, eq, ilike, sql } from "drizzle-orm";
@@ -24,6 +25,7 @@ type CreateInput = {
content?: string | undefined;
resumeId?: string | undefined;
applicationId?: string | undefined;
template?: Template | undefined;
};
async function getById(input: OwnedId): Promise<CoverLetter> {
@@ -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<typeof schema.coverLetter.$inferInsert> = {};
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 }) => {