From 11fe2b7a28b1d496d7fbb9b5e10b9a00c64f8f64 Mon Sep 17 00:00:00 2001 From: Amruth Pillai Date: Tue, 29 Sep 2026 08:00:33 +0200 Subject: [PATCH] feat(web): say what an import brought in when the editor opens Opening an imported resume shows a note at the top of Write: the file, the sections and entries found, and how many fields still need a look. The counts follow the resume as flags are cleared, and the note stays until it is dismissed. --- .../documents/new-document-dialog.tsx | 13 +++-- .../src/features/resume/editor/write/model.ts | 15 ++++++ .../resume/editor/write/write-panel.test.tsx | 39 ++++++++++++++- .../resume/editor/write/write-panel.tsx | 50 ++++++++++++++++++- .../src/routes/builder/$resumeId/route.tsx | 2 + 5 files changed, 112 insertions(+), 7 deletions(-) diff --git a/apps/web/src/features/documents/new-document-dialog.tsx b/apps/web/src/features/documents/new-document-dialog.tsx index b8336c74a..0e1e08d3e 100644 --- a/apps/web/src/features/documents/new-document-dialog.tsx +++ b/apps/web/src/features/documents/new-document-dialog.tsx @@ -40,6 +40,8 @@ type Step = | { name: "failed"; file: File; message: string } | { name: "copy" }; +type OpenResumeOptions = { withAssistant?: boolean; importedFrom?: string }; + const ACCEPT = ".pdf,.doc,.docx,.json,.zip,application/pdf,application/json,application/zip"; const formatSize = (bytes: number) => @@ -61,12 +63,15 @@ export function NewDocumentDialog({ data }: { data?: NewDocumentDialogData | und const refreshDocuments = () => queryClient.invalidateQueries({ queryKey: orpc.documents.key() }); // A copy made for a job opens with the assistant ready to tailor it. - const openResume = (resumeId: string, withAssistant = false) => { + const openResume = (resumeId: string, { withAssistant = false, importedFrom }: OpenResumeOptions = {}) => { closeDialog(); void navigate({ to: "/builder/$resumeId", params: { resumeId }, - search: withAssistant ? { assistant: "new" } : {}, + search: { + ...(withAssistant ? { assistant: "new" } : {}), + ...(importedFrom ? { imported: importedFrom } : {}), + }, }); }; const openLetter = (coverLetterId: string) => { @@ -170,7 +175,7 @@ export function NewDocumentDialog({ data }: { data?: NewDocumentDialogData | und onCreated={(resumeId, forJob) => { markNew(resumeId); void refreshDocuments(); - openResume(resumeId, forJob); + openResume(resumeId, { withAssistant: forJob }); }} /> @@ -245,7 +250,7 @@ export function NewDocumentDialog({ data }: { data?: NewDocumentDialogData | und - diff --git a/apps/web/src/features/resume/editor/write/model.ts b/apps/web/src/features/resume/editor/write/model.ts index 2bca81f61..0c935ae82 100644 --- a/apps/web/src/features/resume/editor/write/model.ts +++ b/apps/web/src/features/resume/editor/write/model.ts @@ -218,6 +218,21 @@ export function countEntriesToCheck(entries: readonly Entry[]): number { return count; } +/** What an import brought in, as the editor shows it now: sections, their entries, and entries still to check. */ +export function summarizeContent(data: Data): { sections: number; entries: number; toCheck: number } { + const rows = getOutlineRows(data); + let entries = 0; + let toCheck = 0; + for (const row of rows) { + const section = resolveSection(data, row.id); + if (!section || section.kind === "summary") continue; + const items = getEntries(data, section); + entries += items.length; + toCheck += countEntriesToCheck(items); + } + return { sections: rows.length, entries, toCheck }; +} + /** Sections that have something in them. The rest wait in Add section. Custom sections always count. */ export function isSectionInUse(data: Data, id: string, recentlyAdded: ReadonlySet = new Set()): boolean { if (recentlyAdded.has(id)) return true; diff --git a/apps/web/src/features/resume/editor/write/write-panel.test.tsx b/apps/web/src/features/resume/editor/write/write-panel.test.tsx index 3e75dde17..68d86a2a5 100644 --- a/apps/web/src/features/resume/editor/write/write-panel.test.tsx +++ b/apps/web/src/features/resume/editor/write/write-panel.test.tsx @@ -14,9 +14,14 @@ import { useEditorStore } from "../store"; import { WritePanel } from "./write-panel"; const routerParams = vi.hoisted(() => ({ resumeId: "write-panel" })); +const router = vi.hoisted(() => ({ search: {} as Record, navigate: vi.fn() })); const toastState = vi.hoisted(() => ({ add: vi.fn() })); -vi.mock("@tanstack/react-router", () => ({ useParams: () => routerParams, useNavigate: () => vi.fn() })); +vi.mock("@tanstack/react-router", () => ({ + useParams: () => routerParams, + useNavigate: () => router.navigate, + useSearch: () => router.search, +})); vi.mock("@/libs/orpc/client", () => ({ orpc: { resume: { @@ -40,6 +45,8 @@ afterEach(() => { cleanup(); useEditorStore.getState().reset(); toastState.add.mockClear(); + router.search = {}; + router.navigate.mockClear(); }); function renderPanel(edit?: (data: ResumeData) => void) { @@ -134,4 +141,34 @@ describe("WritePanel", () => { expect(data().sections.skills.hidden).toBe(false); }); + + it("says what an import brought in, keeps count of fields to check, and dismisses", () => { + router.search = { imported: "resume.pdf" }; + renderPanel((draft) => { + const [first] = draft.sections.experience.items; + if (first) + first.dates = { + start: first.dates?.start ?? null, + end: first.dates?.end ?? null, + present: first.dates?.present ?? false, + raw: "Summer 2016", + }; + }); + + const note = document.querySelector('[role="status"]') as HTMLElement; + expect(note.textContent).toContain("Imported from resume.pdf."); + expect(note.textContent).toContain("1 field needs a look."); + expect(within(sectionRow("experience")).getByText("1 to check")).toBeInTheDocument(); + + fireEvent.click(document.querySelector('button[aria-label="Dismiss"]') as HTMLElement); + expect(router.navigate).toHaveBeenCalledWith(expect.objectContaining({ replace: true })); + const [options] = router.navigate.mock.calls[0] ?? []; + const { search } = options as { search: (previous: object) => object }; + expect(search({ imported: "resume.pdf", mode: "design" })).toEqual({ imported: undefined, mode: "design" }); + }); + + it("shows no import note on an ordinary visit", () => { + renderPanel(); + expect(document.body.textContent).not.toContain("Imported from"); + }); }); diff --git a/apps/web/src/features/resume/editor/write/write-panel.tsx b/apps/web/src/features/resume/editor/write/write-panel.tsx index 0387e6cb9..b4ed818cc 100644 --- a/apps/web/src/features/resume/editor/write/write-panel.tsx +++ b/apps/web/src/features/resume/editor/write/write-panel.tsx @@ -1,6 +1,7 @@ import { t } from "@lingui/core/macro"; -import { Trans } from "@lingui/react/macro"; +import { Plural, Trans } from "@lingui/react/macro"; import { useMutation } from "@tanstack/react-query"; +import { useNavigate, useSearch } from "@tanstack/react-router"; import { useMemo } from "react"; import { Alert, AlertDescription } from "@reactive-resume/ui/components/alert"; import { Button } from "@reactive-resume/ui/components/button"; @@ -18,7 +19,7 @@ import { orpc } from "@/libs/orpc/client"; import { useEditorStore } from "../store"; import { AddSectionMenu, StartSuggestions } from "./add-section"; import { BasicsCard } from "./basics-card"; -import { getOutlineRows } from "./model"; +import { getOutlineRows, summarizeContent } from "./model"; import { Outline } from "./outline"; /** @@ -38,6 +39,7 @@ export function WritePanel() { return (
{locked && } + @@ -67,6 +69,50 @@ export function WritePanel() { ); } +/** + * After an import (D2): what came in, and how many entries still ask for a look. The counts follow the resume, + * so the note keeps up as flags are cleared; it stays until dismissed. + */ +function ImportedNote() { + const { imported } = useSearch({ strict: false }) as { imported?: string }; + const navigate = useNavigate(); + const data = useCurrentBuilderResumeSelector((resume) => resume.data); + const { sections, entries, toCheck } = useMemo(() => summarizeContent(data), [data]); + if (!imported) return null; + + const dismiss = () => + void navigate({ + to: ".", + search: (previous: Record) => ({ ...previous, imported: undefined }), + replace: true, + }); + + return ( + + + + + Imported from {imported}. ,{" "} + .{" "} + {toCheck > 0 ? ( + + ) : ( + Everything was read clearly. + )} + + + + + ); +} + /** Locked resumes read as they print; Unlock is one click and reversible. */ function LockedNote() { const resume = useCurrentResume(); diff --git a/apps/web/src/routes/builder/$resumeId/route.tsx b/apps/web/src/routes/builder/$resumeId/route.tsx index 07778adcd..e11919c85 100644 --- a/apps/web/src/routes/builder/$resumeId/route.tsx +++ b/apps/web/src/routes/builder/$resumeId/route.tsx @@ -16,6 +16,8 @@ const searchSchema = z.object({ // Opens the assistant on a conversation ("new" for a fresh one), or on a question to send (⌘K Ask). assistant: z.string().optional().catch(undefined), ask: z.string().max(2_000).optional().catch(undefined), + // The file a resume was just imported from; Write says what came in until it's dismissed. + imported: z.string().max(255).optional().catch(undefined), }); export const Route = createFileRoute("/builder/$resumeId")({