mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-10-03 10:13:47 +10:00
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:
@@ -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")({
|
||||
|
||||
Reference in New Issue
Block a user