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.
This commit is contained in:
Amruth Pillai
2026-09-29 08:00:33 +02:00
parent 5f7ed15a5b
commit 11fe2b7a28
5 changed files with 112 additions and 7 deletions
@@ -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 });
}}
/>
</DialogContent>
@@ -245,7 +250,7 @@ export function NewDocumentDialog({ data }: { data?: NewDocumentDialogData | und
<Button variant="secondary" onClick={closeDialog}>
<Trans>Stay here</Trans>
</Button>
<Button onClick={() => openResume(step.resumeId)}>
<Button onClick={() => openResume(step.resumeId, { importedFrom: step.file.name })}>
<Trans>Open in editor</Trans>
</Button>
</div>
@@ -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<string> = new Set()): boolean {
if (recentlyAdded.has(id)) return true;
@@ -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<string, unknown>, 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");
});
});
@@ -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 (
<div className="grid gap-4 p-4">
{locked && <LockedNote />}
<ImportedNote />
<BasicsCard locked={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<string, unknown>) => ({ ...previous, imported: undefined }),
replace: true,
});
return (
<Alert variant="success" className="items-start">
<Icon name="check_circle" size={20} />
<AlertDescription className="flex items-start justify-between gap-3">
<span role="status">
<Trans>Imported from {imported}.</Trans> <Plural value={sections} one="# section" other="# sections" />,{" "}
<Plural value={entries} one="# entry" other="# entries" />.{" "}
{toCheck > 0 ? (
<Plural value={toCheck} one="# field needs a look." other="# fields need a look." />
) : (
<Trans>Everything was read clearly.</Trans>
)}
</span>
<button
type="button"
aria-label={t`Dismiss`}
onClick={dismiss}
className="-m-1.5 flex size-8 shrink-0 items-center justify-center rounded-md hover:bg-hover"
>
<Icon name="close" size={18} />
</button>
</AlertDescription>
</Alert>
);
}
/** Locked resumes read as they print; Unlock is one click and reversible. */
function LockedNote() {
const resume = useCurrentResume();
@@ -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")({