fix: finalize v6 migrations and document workflows

This commit is contained in:
Amruth Pillai
2026-10-01 07:06:15 +02:00
parent 8105889b0c
commit bc92b8cf7f
177 changed files with 78971 additions and 131255 deletions
+1414 -1381
View File
File diff suppressed because it is too large Load Diff
+1414 -1381
View File
File diff suppressed because it is too large Load Diff
+1414 -1381
View File
File diff suppressed because it is too large Load Diff
+1414 -1381
View File
File diff suppressed because it is too large Load Diff
+1414 -1381
View File
File diff suppressed because it is too large Load Diff
+1414 -1381
View File
File diff suppressed because it is too large Load Diff
+1414 -1381
View File
File diff suppressed because it is too large Load Diff
+1414 -1381
View File
File diff suppressed because it is too large Load Diff
+1414 -1381
View File
File diff suppressed because it is too large Load Diff
+1416 -1383
View File
File diff suppressed because it is too large Load Diff
+1414 -1381
View File
File diff suppressed because it is too large Load Diff
+1414 -1381
View File
File diff suppressed because it is too large Load Diff
+36 -3
View File
@@ -1111,7 +1111,7 @@ msgstr "Anyone who opens the public URL will need this password."
msgid "Anyone with the link will be able to view your resume."
msgstr "Anyone with the link will be able to view your resume."
#: src/features/applications/components/application-detail-sheet.tsx
#: src/features/applications/components/application-notes.tsx
msgid "Anything to remember about this job"
msgstr "Anything to remember about this job"
@@ -2270,7 +2270,7 @@ msgstr "Couldn't save the application."
msgid "Couldn't save the follow-up."
msgstr "Couldn't save the follow-up."
#: src/features/applications/components/application-detail-sheet.tsx
#: src/features/applications/components/application-notes.tsx
msgid "Couldn't save the notes."
msgstr "Couldn't save the notes."
@@ -2290,6 +2290,14 @@ msgstr "Couldn't save the tags."
msgid "Couldn't save your changes. Please try again."
msgstr "Couldn't save your changes. Please try again."
#: src/features/letters/store.ts
#: src/features/resume/share/history-tab.tsx
#: src/routes/builder/letter/-components/letter-bar.tsx
#: src/routes/builder/letter/-components/share-sheet.tsx
#: src/routes/builder/letter/$coverLetterId.tsx
msgid "Couldn't save your changes. Try again before continuing."
msgstr "Couldn't save your changes. Try again before continuing."
#: src/features/applications/components/application-detail-sheet.tsx
#: src/features/applications/components/detail/sent-documents.tsx
msgid "Couldn't save."
@@ -2854,6 +2862,11 @@ msgstr "Download application details, contacts, notes, and timeline history as C
msgid "Download CSV"
msgstr "Download CSV"
#: src/features/resume/share/download-tab.tsx
#: src/routes/builder/letter/-components/share-sheet.tsx
msgid "Download failed."
msgstr "Download failed."
#: src/features/resume/public/public-resume.tsx
#: src/routes/builder/$resumeId/-components/editor-bar.tsx
#: src/routes/builder/letter/-components/letter-bar.tsx
@@ -3007,6 +3020,10 @@ msgstr "Duration"
msgid "Dutch"
msgstr "Dutch"
#: src/features/assistant/conversation.tsx
msgid "Each send starts fresh with this message and selected files. Previous conversation history stays here."
msgstr "Each send starts fresh with this message and selected files. Previous conversation history stays here."
#: src/components/input/chip-input.tsx
#: src/features/applications/components/detail/next-step-card.tsx
#: src/features/resume/editor/write/basics-card.tsx
@@ -3592,6 +3609,10 @@ msgstr "Font Weight"
msgid "Font Weights"
msgstr "Font Weights"
#: src/libs/error-message.ts
msgid "Fonts could not be loaded. Retry the export."
msgstr "Fonts could not be loaded. Retry the export."
#: src/routes/builder/letter/-components/write-panel.tsx
msgid "For"
msgstr "For"
@@ -5481,8 +5502,8 @@ msgstr "Not verified yet. Resend the link"
msgid "Note"
msgstr "Note"
#: src/features/applications/components/application-detail-sheet.tsx
#: src/features/applications/components/application-form-sheet.tsx
#: src/features/applications/components/application-notes.tsx
#: src/features/applications/components/import-applications-sheet.tsx
#: src/features/applications/components/interview-dialog.tsx
#: src/libs/resume/section.tsx
@@ -6126,6 +6147,10 @@ msgstr "Previewing {0} · click to apply"
msgid "Previous issue"
msgstr "Previous issue"
#: src/features/assistant/conversation.tsx
msgid "Previous messages, including document details, are also sent."
msgstr "Previous messages, including document details, are also sent."
#: src/features/applications/components/calendar-view.tsx
msgid "Previous month"
msgstr "Previous month"
@@ -6743,6 +6768,10 @@ msgstr "Retrieved {0}. Origin freshness is unknown unless reported."
msgid "Retry"
msgstr "Retry"
#: src/features/applications/components/application-notes.tsx
msgid "Retry save"
msgstr "Retry save"
#: src/features/resume/editor/save-status.tsx
#: src/routes/builder/letter/-components/letter-bar.tsx
msgid "Retry saving"
@@ -7517,6 +7546,10 @@ msgstr "Some glyphs have no meaning outside this file."
msgid "Some lines are stored out of reading order."
msgstr "Some lines are stored out of reading order."
#: src/libs/error-message.ts
msgid "Some PDF text could not be rendered. Choose a font containing these characters, then retry the export."
msgstr "Some PDF text could not be rendered. Choose a font containing these characters, then retry the export."
#: src/features/resume/editor/check/issues.ts
msgid "Some systems mishandle images, and photos are discouraged in some countries."
msgstr "Some systems mishandle images, and photos are discouraged in some countries."
+1414 -1381
View File
File diff suppressed because it is too large Load Diff
+1414 -1381
View File
File diff suppressed because it is too large Load Diff
+1414 -1381
View File
File diff suppressed because it is too large Load Diff
+1414 -1381
View File
File diff suppressed because it is too large Load Diff
+1414 -1381
View File
File diff suppressed because it is too large Load Diff
+1414 -1381
View File
File diff suppressed because it is too large Load Diff
+1414 -1381
View File
File diff suppressed because it is too large Load Diff
+1415 -1382
View File
File diff suppressed because it is too large Load Diff
+1414 -1381
View File
File diff suppressed because it is too large Load Diff
+1414 -1381
View File
File diff suppressed because it is too large Load Diff
+1414 -1381
View File
File diff suppressed because it is too large Load Diff
+1414 -1381
View File
File diff suppressed because it is too large Load Diff
+1414 -1381
View File
File diff suppressed because it is too large Load Diff
+1414 -1381
View File
File diff suppressed because it is too large Load Diff
+1414 -1381
View File
File diff suppressed because it is too large Load Diff
+1414 -1381
View File
File diff suppressed because it is too large Load Diff
+1414 -1381
View File
File diff suppressed because it is too large Load Diff
+1414 -1381
View File
File diff suppressed because it is too large Load Diff
+1414 -1381
View File
File diff suppressed because it is too large Load Diff
+1414 -1381
View File
File diff suppressed because it is too large Load Diff
+1415 -1382
View File
File diff suppressed because it is too large Load Diff
+1414 -1381
View File
File diff suppressed because it is too large Load Diff
+1416 -1383
View File
File diff suppressed because it is too large Load Diff
+1415 -1382
View File
File diff suppressed because it is too large Load Diff
+1414 -1381
View File
File diff suppressed because it is too large Load Diff
+1414 -1381
View File
File diff suppressed because it is too large Load Diff
+1414 -1381
View File
File diff suppressed because it is too large Load Diff
+1414 -1381
View File
File diff suppressed because it is too large Load Diff
+1414 -1381
View File
File diff suppressed because it is too large Load Diff
+1414 -1381
View File
File diff suppressed because it is too large Load Diff
+1415 -1382
View File
File diff suppressed because it is too large Load Diff
+1414 -1381
View File
File diff suppressed because it is too large Load Diff
+1414 -1381
View File
File diff suppressed because it is too large Load Diff
+1414 -1381
View File
File diff suppressed because it is too large Load Diff
+1414 -1381
View File
File diff suppressed because it is too large Load Diff
+1414 -1381
View File
File diff suppressed because it is too large Load Diff
+1414 -1381
View File
File diff suppressed because it is too large Load Diff
+1414 -1381
View File
File diff suppressed because it is too large Load Diff
+1414 -1381
View File
File diff suppressed because it is too large Load Diff
+1414 -1381
View File
File diff suppressed because it is too large Load Diff
+1414 -1381
View File
File diff suppressed because it is too large Load Diff
+36 -3
View File
@@ -1111,7 +1111,7 @@ msgstr ""
msgid "Anyone with the link will be able to view your resume."
msgstr ""
#: src/features/applications/components/application-detail-sheet.tsx
#: src/features/applications/components/application-notes.tsx
msgid "Anything to remember about this job"
msgstr ""
@@ -2270,7 +2270,7 @@ msgstr ""
msgid "Couldn't save the follow-up."
msgstr ""
#: src/features/applications/components/application-detail-sheet.tsx
#: src/features/applications/components/application-notes.tsx
msgid "Couldn't save the notes."
msgstr ""
@@ -2290,6 +2290,14 @@ msgstr ""
msgid "Couldn't save your changes. Please try again."
msgstr ""
#: src/features/letters/store.ts
#: src/features/resume/share/history-tab.tsx
#: src/routes/builder/letter/-components/letter-bar.tsx
#: src/routes/builder/letter/-components/share-sheet.tsx
#: src/routes/builder/letter/$coverLetterId.tsx
msgid "Couldn't save your changes. Try again before continuing."
msgstr ""
#: src/features/applications/components/application-detail-sheet.tsx
#: src/features/applications/components/detail/sent-documents.tsx
msgid "Couldn't save."
@@ -2854,6 +2862,11 @@ msgstr ""
msgid "Download CSV"
msgstr ""
#: src/features/resume/share/download-tab.tsx
#: src/routes/builder/letter/-components/share-sheet.tsx
msgid "Download failed."
msgstr ""
#: src/features/resume/public/public-resume.tsx
#: src/routes/builder/$resumeId/-components/editor-bar.tsx
#: src/routes/builder/letter/-components/letter-bar.tsx
@@ -3007,6 +3020,10 @@ msgstr ""
msgid "Dutch"
msgstr ""
#: src/features/assistant/conversation.tsx
msgid "Each send starts fresh with this message and selected files. Previous conversation history stays here."
msgstr ""
#: src/components/input/chip-input.tsx
#: src/features/applications/components/detail/next-step-card.tsx
#: src/features/resume/editor/write/basics-card.tsx
@@ -3592,6 +3609,10 @@ msgstr ""
msgid "Font Weights"
msgstr ""
#: src/libs/error-message.ts
msgid "Fonts could not be loaded. Retry the export."
msgstr ""
#: src/routes/builder/letter/-components/write-panel.tsx
msgid "For"
msgstr ""
@@ -5481,8 +5502,8 @@ msgstr ""
msgid "Note"
msgstr ""
#: src/features/applications/components/application-detail-sheet.tsx
#: src/features/applications/components/application-form-sheet.tsx
#: src/features/applications/components/application-notes.tsx
#: src/features/applications/components/import-applications-sheet.tsx
#: src/features/applications/components/interview-dialog.tsx
#: src/libs/resume/section.tsx
@@ -6126,6 +6147,10 @@ msgstr ""
msgid "Previous issue"
msgstr ""
#: src/features/assistant/conversation.tsx
msgid "Previous messages, including document details, are also sent."
msgstr ""
#: src/features/applications/components/calendar-view.tsx
msgid "Previous month"
msgstr ""
@@ -6743,6 +6768,10 @@ msgstr ""
msgid "Retry"
msgstr ""
#: src/features/applications/components/application-notes.tsx
msgid "Retry save"
msgstr ""
#: src/features/resume/editor/save-status.tsx
#: src/routes/builder/letter/-components/letter-bar.tsx
msgid "Retry saving"
@@ -7517,6 +7546,10 @@ msgstr ""
msgid "Some lines are stored out of reading order."
msgstr ""
#: src/libs/error-message.ts
msgid "Some PDF text could not be rendered. Choose a font containing these characters, then retry the export."
msgstr ""
#: src/features/resume/editor/check/issues.ts
msgid "Some systems mishandle images, and photos are discouraged in some countries."
msgstr ""
+2 -2
View File
@@ -4,9 +4,9 @@ Third-party notices for PDF rendering
Reactive Resume renders PDFs with the Forme engine, which runs in the browser
as WebAssembly. Its copyright and license notice follows.
@formepdf/core 0.25.0
@formepdf/core 0.26.0
---------------------
Source: https://github.com/formepdf/forme
Source: https://github.com/danmolitor/forme
License: MIT
MIT License
@@ -5,7 +5,7 @@ import { useLingui } from "@lingui/react";
import { Plural, Trans } from "@lingui/react/macro";
import { useMutation, useQuery } from "@tanstack/react-query";
import { useNavigate } from "@tanstack/react-router";
import { useEffect, useId, useRef, useState } from "react";
import { useId, useState } from "react";
import { Button } from "@reactive-resume/ui/components/button";
import {
Dialog,
@@ -24,13 +24,13 @@ import {
import { Icon } from "@reactive-resume/ui/components/icon";
import { Input } from "@reactive-resume/ui/components/input";
import { Sheet, SheetContent, SheetDescription, SheetTitle } from "@reactive-resume/ui/components/sheet";
import { Textarea } from "@reactive-resume/ui/components/textarea";
import { toast } from "@reactive-resume/ui/components/toast";
import { useBreakpoint } from "@reactive-resume/ui/hooks/use-breakpoint";
import { cn } from "@reactive-resume/utils/style";
import { daysInStage } from "../next-step";
import { getClosedReasonLabel, getNextStage, getStageColor, getStageLabel, PIPELINE } from "../stages";
import { useApplicationActions, useInvalidateApplications } from "../use-application-actions";
import { ApplicationNotes } from "./application-notes";
import { Activity } from "./detail/activity";
import { CloseDialog } from "./detail/close-dialog";
import { Contacts } from "./detail/contacts";
@@ -253,7 +253,7 @@ function Detail({ application, onEditDetails, onDeleted }: DetailProps) {
<SentDocuments application={application} disabled={remove.isPending} />
<Facts application={application} locale={i18n.locale} />
<Tags application={application} />
<Notes application={application} />
<ApplicationNotes application={application} />
<Activity application={application} onOpenInterview={(entry) => setInterview({ open: true, entry })} />
</div>
@@ -446,58 +446,6 @@ function Tags({ application }: { application: Application }) {
);
}
// Notes save a moment after typing stops.
const NOTES_SAVE_DELAY_MS = 800;
function Notes({ application }: { application: Application }) {
const id = useId();
const invalidate = useInvalidateApplications();
const [notes, setNotes] = useState(application.notes ?? "");
const saved = useRef(application.notes ?? "");
const { mutate } = useMutation({
...orpc.applications.update.mutationOptions(),
onSuccess: () => invalidate(application.id),
onError: () => toast.add({ type: "error", description: t`Couldn't save the notes.` }),
});
useEffect(() => {
if (notes === saved.current) return;
const timeout = window.setTimeout(() => {
saved.current = notes;
mutate({ id: application.id, notes: notes.trim() ? notes : null });
}, NOTES_SAVE_DELAY_MS);
return () => window.clearTimeout(timeout);
}, [notes, application.id, mutate]);
// Closing the sheet mid-sentence still saves what was typed.
const latest = useRef(notes);
useEffect(
() => () => {
if (latest.current !== saved.current)
mutate({ id: application.id, notes: latest.current.trim() ? latest.current : null });
},
[application.id, mutate],
);
return (
<section className="grid gap-2">
<label htmlFor={id} className="text-xs font-semibold text-ink-3 uppercase">
<Trans>Notes</Trans>
</label>
<Textarea
id={id}
rows={3}
value={notes}
placeholder={t`Anything to remember about this job`}
onChange={(event) => {
latest.current = event.target.value;
setNotes(event.target.value);
}}
/>
</section>
);
}
type PostingDialogProps = { application: Application; open: boolean; onOpenChange: (open: boolean) => void };
type MarkAppliedDialogProps = { application: Application; onClose: () => void };
@@ -0,0 +1,93 @@
// @vitest-environment happy-dom
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, expect, it, vi } from "vitest";
import { i18n } from "@lingui/core";
import { I18nProvider } from "@lingui/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ApplicationNotes } from "./application-notes";
const mocks = vi.hoisted(() => ({ update: vi.fn(), invalidate: vi.fn() }));
vi.mock("@/libs/orpc/client", () => ({
orpc: {
applications: {
update: { mutationOptions: () => ({ mutationFn: mocks.update }) },
getById: { queryKey: ({ input }: { input: { id: string } }) => ["application", input.id] },
},
},
}));
vi.mock("../use-application-actions", () => ({ useInvalidateApplications: () => mocks.invalidate }));
vi.mock("@reactive-resume/ui/components/toast", () => ({ toast: { add: vi.fn() } }));
afterEach(() => vi.resetAllMocks());
function open(id: string, client = new QueryClient({ defaultOptions: { mutations: { retry: false } } })) {
i18n.load("en", {});
i18n.activate("en");
const notes = client.getQueryData<{ notes: string }>(["application", id])?.notes ?? "Saved";
return render(
<QueryClientProvider client={client}>
<I18nProvider i18n={i18n}>
<ApplicationNotes application={{ id, notes }} />
</I18nProvider>
</QueryClientProvider>,
);
}
it("retains a failed note across closing and reopening, then retries it", async () => {
mocks.update.mockRejectedValue(new Error("offline"));
const view = open("failed-notes");
fireEvent.change(screen.getByRole("textbox", { name: "Notes" }), { target: { value: "Keep this draft" } });
await waitFor(() => expect(mocks.update).toHaveBeenCalled(), { timeout: 2000 });
await act(async () => {
view.unmount();
});
mocks.update.mockResolvedValue({});
open("failed-notes");
expect((screen.getByRole("textbox", { name: "Notes" }) as HTMLTextAreaElement).value).toBe("Keep this draft");
await waitFor(
() =>
expect(mocks.update).toHaveBeenLastCalledWith(
{ id: "failed-notes", notes: "Keep this draft" },
expect.anything(),
),
{ timeout: 2000 },
);
});
it("keeps acknowledged notes in the cache when the sheet reopens before refetch", async () => {
mocks.update.mockResolvedValue({});
const client = new QueryClient();
client.setQueryData(["application", "acknowledged-notes"], { notes: "Saved" });
const view = open("acknowledged-notes", client);
fireEvent.change(screen.getByRole("textbox", { name: "Notes" }), { target: { value: "Acknowledged draft" } });
await waitFor(
() =>
expect(client.getQueryData(["application", "acknowledged-notes"])).toMatchObject({ notes: "Acknowledged draft" }),
{ timeout: 2000 },
);
view.unmount();
open("acknowledged-notes", client);
expect(screen.getByRole("textbox", { name: "Notes" })).toHaveValue("Acknowledged draft");
});
it("serializes note saves so a delayed response cannot overwrite newer text", async () => {
let resolve: (value: unknown) => void = vi.fn();
const first = new Promise<unknown>((accept) => {
resolve = accept;
});
mocks.update.mockReturnValueOnce(first).mockResolvedValue({});
const view = open("ordered-notes");
fireEvent.change(screen.getByRole("textbox", { name: "Notes" }), { target: { value: "First" } });
await waitFor(() => expect(mocks.update).toHaveBeenCalledTimes(1), { timeout: 2000 });
fireEvent.change(screen.getByRole("textbox", { name: "Notes" }), { target: { value: "Second" } });
await act(async () => {
view.unmount();
});
expect(mocks.update).toHaveBeenCalledTimes(1);
await act(async () => {
resolve({});
});
await waitFor(() =>
expect(mocks.update).toHaveBeenLastCalledWith({ id: "ordered-notes", notes: "Second" }, expect.anything()),
);
});
@@ -0,0 +1,92 @@
import type { Application } from "../types";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useCallback, useEffect, useId, useState } from "react";
import { Textarea } from "@reactive-resume/ui/components/textarea";
import { toast } from "@reactive-resume/ui/components/toast";
import { useInvalidateApplications } from "../use-application-actions";
import { orpc } from "@/libs/orpc/client";
type ApplicationNotesProps = { application: Pick<Application, "id" | "notes"> };
// Notes save a moment after typing stops.
const NOTES_SAVE_DELAY_MS = 800;
const drafts = new Map<string, string>();
const saves = new Map<string, Promise<void>>();
export function ApplicationNotes({ application }: ApplicationNotesProps) {
const id = useId();
const invalidate = useInvalidateApplications();
const queryClient = useQueryClient();
const [notes, setNotes] = useState(() => drafts.get(application.id) ?? application.notes ?? "");
const { mutateAsync, isError } = useMutation({
...orpc.applications.update.mutationOptions(),
onSuccess: () => invalidate(application.id),
onError: () => toast.add({ type: "error", description: t`Couldn't save the notes.` }),
});
const save = useCallback(() => {
const pending: Promise<void> = (saves.get(application.id) ?? Promise.resolve())
.then(async () => {
const value = drafts.get(application.id);
if (value === undefined) return;
await mutateAsync({ id: application.id, notes: value.trim() ? value : null });
queryClient.setQueryData<Application>(
orpc.applications.getById.queryKey({ input: { id: application.id } }),
(current) => (current ? { ...current, notes: value.trim() ? value : null } : current),
);
if (drafts.get(application.id) === value) drafts.delete(application.id);
})
.catch(() => {}) // The mutation reports failure; retain the draft for retry.
.finally(() => {
if (saves.get(application.id) === pending) saves.delete(application.id);
});
saves.set(application.id, pending);
return pending;
}, [application.id, mutateAsync, queryClient]);
useEffect(() => {
if (!drafts.has(application.id)) return;
const timeout = window.setTimeout(() => {
void save();
}, NOTES_SAVE_DELAY_MS);
return () => window.clearTimeout(timeout);
}, [notes, application.id, save]);
// Closing the sheet mid-sentence still saves what was typed.
useEffect(
() => () => {
void save();
},
[save],
);
return (
<section className="grid gap-2">
<label htmlFor={id} className="text-xs font-semibold text-ink-3 uppercase">
<Trans>Notes</Trans>
</label>
<Textarea
id={id}
rows={3}
value={notes}
placeholder={t`Anything to remember about this job`}
onChange={(event) => {
drafts.set(application.id, event.target.value);
setNotes(event.target.value);
}}
/>
{isError && (
<button
type="button"
onClick={() => {
void save();
}}
className="justify-self-start text-sm underline"
>
<Trans>Retry save</Trans>
</button>
)}
</section>
);
}
@@ -281,17 +281,21 @@ function toProposals(
document: AssistantDocument,
): Proposal[] {
const output = part.output as ProposeEditsOutput | undefined;
return (output?.edits ?? []).map((edit) => ({
...edit,
target: {
return (output?.edits ?? []).map((edit) => {
const target = {
sectionId: edit.target.sectionId,
field: edit.target.field,
...(edit.target.itemId === undefined ? {} : { itemId: edit.target.itemId }),
},
location: document.locationOf(edit) ?? edit.location,
status: statuses.get(edit.id) ?? edit.status,
source: "assistant",
}));
...(edit.target.roleId === undefined ? {} : { roleId: edit.target.roleId }),
};
return {
...edit,
target,
location: document.locationOf({ ...edit, target }) ?? edit.location,
status: statuses.get(edit.id) ?? edit.status,
source: "assistant",
};
});
}
type MessageViewProps = {
@@ -788,7 +792,16 @@ export function Composer(props: ComposerProps) {
</button>
</div>
<p className="text-xs leading-4 text-ink-3">{disclosure}</p>
<p className="text-xs leading-4 text-ink-3">
{disclosure}{" "}
{!context.document || !context.posting ? (
<Trans>
Each send starts fresh with this message and selected files. Previous conversation history stays here.
</Trans>
) : (
<Trans>Previous messages, including document details, are also sent.</Trans>
)}
</p>
</div>
);
}
+15 -7
View File
@@ -1,4 +1,4 @@
import type { Proposal, ProposalState } from "@reactive-resume/resume/proposals";
import type { Passage, Proposal, ProposalState } from "@reactive-resume/resume/proposals";
import { t } from "@lingui/core/macro";
import { useQuery } from "@tanstack/react-query";
import { useSearch } from "@tanstack/react-router";
@@ -31,16 +31,24 @@ export type AssistantDocument = {
/** Applies proposals as one undo step, with Undo in the toast. */
accept: (proposals: readonly Proposal[]) => void;
/** Where a proposal lands, in the app's language, when its passage is still there. */
locationOf: (proposal: Pick<Proposal, "before">) => string | undefined;
locationOf: (proposal: Pick<Proposal, "before" | "target">) => string | undefined;
};
const bullet = (n: number) => t`bullet ${n}`;
const paragraph = (n: number) => t`paragraph ${n}`;
/** Finds the passage a proposal replaces, or the one an addition follows. */
function locate(passages: readonly { html: string; location: string }[], before: string) {
return passages.find((passage) => passage.html && (before === passage.html || before.startsWith(passage.html)))
?.location;
function locate(passages: readonly Passage[], { before, target }: Pick<Proposal, "before" | "target">) {
const matches = passages.filter(
(passage) =>
passage.target.sectionId === target.sectionId &&
passage.target.itemId === target.itemId &&
passage.target.roleId === target.roleId &&
passage.target.field === target.field &&
passage.html &&
(before === passage.html || before.startsWith(passage.html)),
);
return matches.length === 1 ? matches[0]?.location : undefined;
}
export function useResumeAssistantDocument(): AssistantDocument {
@@ -70,7 +78,7 @@ export function useResumeAssistantDocument(): AssistantDocument {
posting: application ? { id: application.id, company: application.company, role: application.role } : null,
stateOf: (proposal) => getProposalState(resume.data, proposal),
accept: acceptResumeProposals,
locationOf: (proposal) => locate(passages, proposal.before),
locationOf: (proposal) => locate(passages, proposal),
};
}, [resume.id, resume.name, resume.isLocked, resume.data, application]);
}
@@ -103,7 +111,7 @@ export function useLetterAssistantDocument(): AssistantDocument | null {
actionProps: { children: t`Undo`, onClick: () => edit({ content: before }) },
});
},
locationOf: (proposal) => locate(passages, proposal.before),
locationOf: (proposal) => locate(passages, proposal),
};
}, [letter, application]);
}
@@ -34,6 +34,16 @@ const letter: CoverLetter = {
const store = () => useLetterEditorStore.getState();
function deferred<T>() {
let resolve: (value: T) => void = vi.fn();
let reject: (error: unknown) => void = vi.fn();
const promise = new Promise<T>((accept, fail) => {
resolve = accept;
reject = fail;
});
return { promise, resolve, reject };
}
function* chunks(...parts: string[]) {
yield* parts;
}
@@ -44,6 +54,50 @@ beforeEach(() => {
});
describe("saving", () => {
it.each(["success", "failure"])("ignores an old session's %s while another letter is edited", async (result) => {
const request = deferred<CoverLetter>();
mocks.update.mockReturnValueOnce(request.promise).mockImplementation(async (input) => ({
...letter,
...input,
revision: 2,
}));
store().edit({ content: "<p>A's edit</p>" });
const saving = store().flush();
store().load({ ...letter, id: "B" });
store().edit({ content: "<p>B's edit</p>" });
if (result === "success") request.resolve({ ...letter, content: "<p>A's edit</p>", revision: 2 });
else request.reject(new ORPCError("CONFLICT"));
await saving;
expect(store().letter).toMatchObject({ id: "B", content: "<p>B's edit</p>" });
expect(store().pending).toEqual({ content: "<p>B's edit</p>" });
expect(store().status).toBe("saving");
await store().flush();
expect(mocks.update.mock.calls.map(([input]) => input.id)).toEqual(["letter", "B"]);
});
it("reports failed saves, retains the draft, and prevents a destructive change", async () => {
mocks.update.mockRejectedValue(new Error("offline"));
store().edit({ content: "<p>Unsaved</p>" });
expect(await store().flush()).toBe(false);
const restore = vi.fn();
await expect(store().change(restore)).rejects.toThrow();
expect(restore).not.toHaveBeenCalled();
expect(store().letter?.content).toBe("<p>Unsaved</p>");
expect(store().pending).toEqual({ content: "<p>Unsaved</p>" });
});
it("ignores a response after the same letter is reopened", async () => {
const request = deferred<CoverLetter>();
mocks.update.mockReturnValue(request.promise);
store().edit({ content: "<p>Old visit</p>" });
const saving = store().flush();
store().reset();
store().load(letter);
request.resolve({ ...letter, content: "<p>Old visit</p>", revision: 2 });
await saving;
expect(store().letter).toEqual(letter);
});
it("keeps what was typed while the server's copy comes back trimmed, and bumps the revision", async () => {
mocks.update.mockImplementation(async (input: { recipientName: string }) => ({
...letter,
+40 -15
View File
@@ -1,4 +1,5 @@
import type { CoverLetter } from "@reactive-resume/schema/cover-letter/data";
import { t } from "@lingui/core/macro";
import { ORPCError } from "@orpc/client";
import { create } from "zustand/react";
import { generateId } from "@reactive-resume/utils/string";
@@ -53,8 +54,8 @@ type LetterEditorStore = {
draft: LetterDraft;
load: (letter: CoverLetter) => void;
edit: (edits: LetterEdits) => void;
/** Saves what's pending now; resolves once everything typed so far is saved (or has failed). */
flush: () => Promise<void>;
/** Saves everything typed so far. False on failure; edits remain for retry. */
flush: () => Promise<boolean>;
/**
* Saves what's pending, then makes a change that returns the letter (a link, a template, a restore). Throws what
* the change throws, so the caller can say what went wrong.
@@ -78,7 +79,12 @@ export const useLetterEditorStore = create<LetterEditorStore>()((set, get) => ({
sessionId: generateId(),
draft: idleDraft,
load: (letter) => set({ letter, status: "saved", pending: {}, sessionId: generateId(), draft: idleDraft }),
load: (letter) => {
clearTimeout(timer);
drafting?.abort();
drafting = null;
set({ letter, status: "saved", pending: {}, sessionId: generateId(), draft: idleDraft });
},
edit: (edits) => {
set((state) =>
@@ -95,11 +101,14 @@ export const useLetterEditorStore = create<LetterEditorStore>()((set, get) => ({
},
flush: async () => {
const { sessionId } = get();
clearTimeout(timer);
if (inflight) await inflight;
if (get().sessionId !== sessionId) return false;
const { letter, pending, sessionId, status } = get();
if (!letter || !hasEdits(pending) || status === "conflict") return;
const { letter, pending, status } = get();
if (!letter || !hasEdits(pending) || status === "conflict") return status === "saved";
const current = (state: LetterEditorStore) => state.letter?.id === letter.id && state.sessionId === sessionId;
set({ pending: {}, status: "saving" });
inflight = (async () => {
@@ -112,13 +121,21 @@ export const useLetterEditorStore = create<LetterEditorStore>()((set, get) => ({
});
// What was typed stays as typed (the server trims and cleans what it stores), and so does anything typed
// since.
set((state) => ({
letter: applyEdits(saved, mergeEdits(pending, state.pending)),
status: hasEdits(state.pending) ? "saving" : "saved",
}));
set((state) =>
current(state)
? {
letter: applyEdits(saved, mergeEdits(pending, state.pending)),
status: hasEdits(state.pending) ? "saving" : "saved",
}
: state,
);
} catch (error) {
const conflict = error instanceof ORPCError && error.code === "CONFLICT";
set((state) => ({ pending: mergeEdits(pending, state.pending), status: conflict ? "conflict" : "error" }));
set((state) =>
current(state)
? { pending: mergeEdits(pending, state.pending), status: conflict ? "conflict" : "error" }
: state,
);
} finally {
inflight = null;
}
@@ -126,22 +143,30 @@ export const useLetterEditorStore = create<LetterEditorStore>()((set, get) => ({
await inflight;
// Typed while that save was on its way.
if (get().status === "saving" && hasEdits(get().pending)) await get().flush();
if (!current(get())) return false;
if (get().status === "saving" && hasEdits(get().pending)) return get().flush();
return get().status === "saved";
},
change: async (action) => {
await get().flush();
const { sessionId } = get();
if (!(await get().flush())) throw new Error(t`Couldn't save your changes. Try again before continuing.`);
const { letter } = get();
if (!letter) throw new Error("No letter is open.");
if (!letter || get().sessionId !== sessionId) throw new Error("No letter is open.");
const next = await action(letter);
set((state) => ({ letter: applyEdits(next, state.pending) }));
set((state) =>
state.letter?.id === letter.id && state.sessionId === sessionId
? { letter: applyEdits(next, state.pending) }
: state,
);
return next;
},
reset: () => {
clearTimeout(timer);
drafting?.abort();
set({ letter: null, status: "saved", pending: {}, draft: idleDraft });
drafting = null;
set({ letter: null, status: "saved", pending: {}, sessionId: generateId(), draft: idleDraft });
},
}));
@@ -0,0 +1,57 @@
// @vitest-environment happy-dom
import { act, render, screen } from "@testing-library/react";
import { expect, it, vi } from "vitest";
import { i18n } from "@lingui/core";
import { I18nProvider } from "@lingui/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useEditorStore } from "../store";
import { ParserView } from "./parser-view";
const mocks = vi.hoisted(() => ({ resumeId: "A", extract: vi.fn(async (file: File) => file.text()) }));
vi.mock("@/features/resume/builder/draft", () => ({
useCurrentBuilderResumeSelector: (select: (resume: { id: string }) => unknown) => select({ id: mocks.resumeId }),
}));
vi.mock("./use-check", () => ({ useCheck: () => null }));
vi.mock("@/features/ats-checker/extract-client", () => ({ extractPdf: mocks.extract }));
vi.mock("@reactive-resume/resume/ats-pdf", () => ({
buildExtractedDocument: (text: string) => ({ pages: [], lines: [{ text }], charCount: text.length }),
buildResumeSemantics: () => ({
contact: { nameLine: "", locationLine: "", emails: [], phones: [], textUrls: [], annotationUrls: [] },
dates: [],
headings: [],
}),
}));
it("extracts each document and each reopened render independently", async () => {
i18n.load("en", {});
i18n.activate("en");
const client = new QueryClient();
const open = (id: string, text: string) => {
mocks.resumeId = id;
useEditorStore.getState().reset();
useEditorStore.getState().setRendered({ pageCount: 1, pageMap: undefined, file: new Blob([text]) });
};
const ui = (
<QueryClientProvider client={client}>
<I18nProvider i18n={i18n}>
<ParserView />
</I18nProvider>
</QueryClientProvider>
);
open("A", "Resume Alpha");
const view = render(ui);
await screen.findByText("Resume Alpha");
view.unmount();
open("B", "Resume Beta");
const second = render(ui);
await screen.findByText("Resume Beta");
expect(screen.queryByText("Resume Alpha")).toBeNull();
second.unmount();
open("A", "Changed Alpha");
render(ui);
await screen.findByText("Changed Alpha");
expect(mocks.extract).toHaveBeenCalledTimes(3);
await act(async () => {
client.clear();
});
});
@@ -3,21 +3,19 @@ import type { AtsRuleCode } from "@reactive-resume/resume/ats";
import type { ExtractedDocument, ResumeSemantics } from "@reactive-resume/resume/ats-pdf";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import { useQuery } from "@tanstack/react-query";
import { Spinner } from "@reactive-resume/ui/components/spinner";
import { cn } from "@reactive-resume/utils/style";
import { useEditorStore } from "../store";
import { useCheck } from "./use-check";
import { extractPdf } from "@/features/ats-checker/extract-client";
import { blobToPdfFile } from "@/features/ats-checker/run-ats-check";
import { useCurrentBuilderResumeSelector } from "@/features/resume/builder/draft";
type Parsed = { doc: ExtractedDocument; semantics: ResumeSemantics };
/** Reads the PDF on the page the way a parser does: its text layer in reading order, and what it recognises. */
async function parseRenderedPdf(): Promise<Parsed> {
const file = useEditorStore.getState().rendered.file;
if (!file) throw new Error("The page hasn't rendered yet.");
async function parseRenderedPdf(file: Blob): Promise<Parsed> {
// The operator pass (hidden text, images of text) feeds the full check, not this view, so it's skipped.
const engine = import("@reactive-resume/resume/ats-pdf");
engine.catch(() => {}); // Awaited below; see run-ats-check.ts.
@@ -62,12 +60,17 @@ function IssueChip({ issue }: { issue: CheckIssue }) {
* recognises and the lines behind open issues highlighted.
*/
export function ParserView() {
const version = useEditorStore((state) => state.rendered.version);
const resumeId = useCurrentBuilderResumeSelector((resume) => resume.id);
const { file, version } = useEditorStore((state) => state.rendered);
const issues = useCheck()?.issues ?? [];
const { data, isError } = useQuery({
queryKey: ["check-parser-view", version],
queryFn: parseRenderedPdf,
placeholderData: keepPreviousData,
queryKey: ["check-parser-view", resumeId, version],
queryFn: () => {
if (!file) throw new Error("The page hasn't rendered yet.");
return parseRenderedPdf(file);
},
enabled: !!file,
placeholderData: (previous, query) => (query?.queryKey[1] === resumeId ? previous : undefined),
staleTime: Number.POSITIVE_INFINITY,
gcTime: 60_000,
retry: false,
@@ -4,6 +4,7 @@ import { t } from "@lingui/core/macro";
import { produce } from "immer";
import {
applyProposal,
applyTo,
getProposalState,
readTarget,
splitBlock,
@@ -58,14 +59,9 @@ export function markProposals(data: ResumeData, proposals: readonly Proposal[]):
return produce(data, (draft) => {
for (const proposal of pending) {
const value = readTarget(draft, proposal.target);
if (value === undefined || !value.includes(proposal.before)) continue;
const marked = markChange(proposal.before, proposal.after);
writeTarget(
draft,
proposal.target,
value.replace(proposal.before, () => marked),
);
const next = applyTo(value, { before: proposal.before, after: marked });
if (next !== undefined) writeTarget(draft, proposal.target, next);
}
});
}
+2 -1
View File
@@ -218,5 +218,6 @@ export const useEditorStore = create<EditorStore>()((set) => ({
setWritingReview: (writingReview) => set({ writingReview }),
setExportCheck: (exportCheck) => set({ exportCheck }),
setExportReportOpen: (exportReportOpen) => set({ exportReportOpen }),
reset: () => set(initialState),
reset: () =>
set((state) => ({ ...initialState, rendered: { ...initialState.rendered, version: state.rendered.version + 1 } })),
}));
@@ -0,0 +1,49 @@
// @vitest-environment happy-dom
import { act, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { expect, it, vi } from "vitest";
import { i18n } from "@lingui/core";
import { I18nProvider } from "@lingui/react";
import { RichTextEditor } from "./rich-text-editor";
const mocks = vi.hoisted(() => ({ mobile: false }));
vi.mock("@reactive-resume/ui/hooks/use-mobile", () => ({ useIsMobile: () => mocks.mobile }));
vi.mock("@reactive-resume/ui/hooks/use-keyboard-inset", () => ({ useKeyboardInset: () => 0 }));
vi.mock("@/features/settings/integrations/hooks/use-has-usable-ai-provider", () => ({
useHasUsableAiProvider: () => ({ hasUsableProvider: false }),
}));
vi.mock("@/hooks/use-confirm", () => ({ usePrompt: () => vi.fn() }));
vi.mock("./improve", () => ({ lineAtCaret: () => null, ImprovePanel: () => null }));
it.each([false, true])(
"retains formatting controls while keyboard focus enters the toolbar (mobile=%s)",
async (mobile) => {
mocks.mobile = mobile;
i18n.load("en", {});
i18n.activate("en");
render(
<I18nProvider i18n={i18n}>
<RichTextEditor label="Summary" value="<p>Example</p>" onChange={vi.fn()} />
<button type="button">Outside</button>
</I18nProvider>,
);
const editor = await screen.findByRole("textbox", { name: "Summary" });
await act(async () => {
editor.focus();
});
const bold = screen.getByRole("button", { name: "Bold" });
if (mobile)
await act(async () => {
bold.focus();
});
else await userEvent.tab();
expect(document.activeElement).toBe(bold);
expect(screen.getByRole("toolbar", { name: "Formatting" })).toBeDefined();
await userEvent.keyboard("{Enter}");
expect(screen.getByRole("button", { name: "Bold" }).getAttribute("aria-pressed")).toBe("true");
await act(async () => {
screen.getByRole("button", { name: "Outside" }).focus();
});
expect(screen.queryByRole("toolbar")).toBeNull();
},
);
@@ -5,7 +5,7 @@ import type { ReactNode } from "react";
import { t } from "@lingui/core/macro";
import { Plural, Trans } from "@lingui/react/macro";
import { EditorContent, useEditor, useEditorState } from "@tiptap/react";
import { useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { Icon } from "@reactive-resume/ui/components/icon";
import { useKeyboardInset } from "@reactive-resume/ui/hooks/use-keyboard-inset";
@@ -110,6 +110,7 @@ export function RichTextEditor({
heightClassName = "max-h-[360px] min-h-[88px]",
}: RichTextEditorProps) {
const [focused, setFocused] = useState(false);
const toolbar = useRef<HTMLDivElement>(null);
const [improving, setImproving] = useState<ImproveLine | null>(null);
const actions = useToolbarActions();
const ai = useHasUsableAiProvider();
@@ -138,8 +139,6 @@ export function RichTextEditor({
},
},
onUpdate: ({ editor }) => onChange(editor.getHTML()),
onFocus: () => setFocused(true),
onBlur: () => setFocused(false),
});
const state = useEditorState({
@@ -216,6 +215,11 @@ export function RichTextEditor({
return (
<div
onFocus={() => setFocused(true)}
onBlur={(event) => {
const next = event.relatedTarget;
if (!event.currentTarget.contains(next) && !toolbar.current?.contains(next)) setFocused(false);
}}
className={cn(
"rounded-lg border border-line-2 bg-raised transition-[border-color,box-shadow] duration-quick",
editing && "border-accent shadow-[0_0_0_3px_var(--accent-soft)]",
@@ -229,6 +233,7 @@ export function RichTextEditor({
mobile &&
createPortal(
<div
ref={toolbar}
role="toolbar"
aria-label={t`Formatting`}
style={{ bottom: keyboardInset }}
@@ -240,6 +245,7 @@ export function RichTextEditor({
onMouseDown={(event) => event.preventDefault()}
onClick={() => {
setImproving(null);
setFocused(false);
editor?.commands.blur();
}}
className="flex h-11 shrink-0 items-center rounded-md px-3 text-sm font-semibold text-accent-text"
@@ -262,7 +268,12 @@ export function RichTextEditor({
{/* Under the text, so focusing the field never moves the line you clicked. */}
{editing && !readOnlyTable && !mobile && (
<div role="toolbar" aria-label={t`Formatting`} className="flex gap-0.5 border-t border-line px-1.5 py-1">
<div
ref={toolbar}
role="toolbar"
aria-label={t`Formatting`}
className="flex gap-0.5 border-t border-line px-1.5 py-1"
>
{toolbarButtons}
</div>
)}
@@ -11,6 +11,7 @@ import { toast } from "@reactive-resume/ui/components/toast";
import { downloadWithAnchor } from "@reactive-resume/utils/file";
import { createResumePdfBlob } from "./pdf-document";
import { resolvePublicResumePdfBlob } from "@/features/resume/public/public-pdf";
import { getReadableErrorMessage } from "@/libs/error-message";
import { client } from "@/libs/orpc/client";
import { createSectionTitleResolverForLocale } from "@/libs/resume/section-title-locale";
@@ -156,8 +157,11 @@ export function useResumeExport(resume: ExportableResume | undefined, exportOpti
// Statistics are best effort and must not delay or fail a completed browser download.
void client.resume.statistics.recordDownload(exportOptions.publicResumePdf.publicResume).catch(() => undefined);
}
} catch {
toast.add({ type: "error", description: t`Could not generate the PDF. Please try again.` });
} catch (error) {
toast.add({
type: "error",
description: getReadableErrorMessage(error, t`Could not generate the PDF. Please try again.`),
});
}
setIsExporting(false);
toast.close(toastId);
@@ -173,10 +177,10 @@ export function useResumeExport(resume: ExportableResume | undefined, exportOpti
: createResumePdfBlob(resume.data);
try {
printPdf(await makeBlob());
} catch {
} catch (error) {
toast.add({
type: "error",
description: t`Could not prepare your resume for printing. Please try again.`,
description: getReadableErrorMessage(error, t`Could not prepare your resume for printing. Please try again.`),
});
}
setIsExporting(false);
@@ -2,6 +2,7 @@
import { render, screen } from "@testing-library/react";
import { expect, it } from "vitest";
import { i18n } from "@lingui/core";
import { referenceItemSchema } from "@reactive-resume/schema/resume/data";
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
import { ResumeReflow } from "./resume-reflow";
@@ -14,3 +15,37 @@ it("shows the built-in Summary content in public readable view", () => {
render(<ResumeReflow data={data} />);
expect(screen.getByText("Built-in summary remains visible.")).toBeDefined();
});
it("includes plain custom fields and reference phone numbers", () => {
const data = structuredClone(defaultResumeData);
data.basics.customFields = [{ id: "authorization", icon: "", text: "Authorized to work", link: "" }];
data.sections.references.items = [
referenceItemSchema.parse({
id: "ref",
hidden: false,
name: "Dana",
position: "Lead",
phone: "+49 123 456",
description: "",
website: { url: "", label: "" },
}),
];
data.metadata.layout.pages = [{ fullWidth: true, main: ["references"], sidebar: [] }];
render(<ResumeReflow data={data} />);
expect(screen.getByText("Authorized to work").closest("a")).toBeNull();
expect(screen.getByRole("link", { name: "+49 123 456" }).getAttribute("href")).toBe("tel:+49123456");
});
it.each([
["ar", "ltr", "rtl"],
["en-US", "rtl", "ltr"],
] as const)("sets %s document direction independently of interface direction", (locale, parent, direction) => {
const data = structuredClone(defaultResumeData);
data.metadata.page.locale = locale;
const { container } = render(
<div dir={parent}>
<ResumeReflow data={data} />
</div>,
);
expect(container.querySelector(`[lang="${locale}"]`)?.getAttribute("dir")).toBe(direction);
});
@@ -3,6 +3,7 @@ import type { IconName } from "@reactive-resume/ui/components/icon";
import { getResumeSectionTitle } from "@reactive-resume/pdf/section-title";
import { Icon } from "@reactive-resume/ui/components/icon";
import { contrastOnWhite } from "@reactive-resume/utils/color";
import { isRTL } from "@reactive-resume/utils/locale";
import { cn } from "@reactive-resume/utils/style";
import { reflowOrder } from "./reflow";
import { RichText } from "./rich-text";
@@ -63,6 +64,7 @@ function EntryView({ type, entry }: { type: string; entry: Entry }) {
const url = typeof entry.url === "string" ? entry.url : website?.url;
const keywords = Array.isArray(entry.keywords) ? (entry.keywords as string[]).filter(Boolean) : [];
const html = text(entry, "content") || text(entry, "description");
const phone = type === "references" ? text(entry, "phone") : "";
return (
<article className="grid gap-0.5">
@@ -76,6 +78,11 @@ function EntryView({ type, entry }: { type: string; entry: Entry }) {
</a>
)}
{keywords.length > 0 && <p className="text-[14px] text-[#555]">{keywords.join(", ")}</p>}
{phone && (
<a className="w-fit text-[14px] underline" href={`tel:${phone.replace(/\s+/g, "")}`}>
{phone}
</a>
)}
{html && <RichText html={html} className={cn(RICH, "mt-1")} />}
{roles.map((role) => (
<div key={role.id} className="mt-1.5 grid gap-0.5">
@@ -88,7 +95,7 @@ function EntryView({ type, entry }: { type: string; entry: Entry }) {
);
}
type ContactPill = { icon: IconName; label: string; href: string };
type ContactPill = { icon: IconName; label: string; href?: string };
function contactPills(basics: ResumeData["basics"]): ContactPill[] {
const pills: ContactPill[] = [];
@@ -101,8 +108,12 @@ function contactPills(basics: ResumeData["basics"]): ContactPill[] {
href: basics.website.url,
});
for (const field of basics.customFields)
if (field.text && /^(https?:|mailto:|tel:)/i.test(field.link))
pills.push({ icon: "link", label: field.text, href: field.link });
if (field.text)
pills.push({
icon: "link",
label: field.text,
...(/^(https?:|mailto:|tel:)/i.test(field.link) ? { href: field.link } : {}),
});
return pills;
}
@@ -123,6 +134,7 @@ export function ResumeReflow({ data }: ResumeReflowProps) {
return (
<div
lang={data.metadata.page.locale}
dir={isRTL(data.metadata.page.locale) ? "rtl" : "ltr"}
className="grid gap-6 bg-white px-5 py-6 text-[15px] leading-[1.5] text-[#1a1a1a]"
style={{ fontFamily: `"${font}", ui-sans-serif, system-ui, sans-serif` }}
>
@@ -132,18 +144,21 @@ export function ResumeReflow({ data }: ResumeReflowProps) {
<p className="text-[#555]">{[basics.headline, basics.location].filter(Boolean).join(" · ")}</p>
)}
<ul className="flex flex-wrap gap-2">
{contactPills(basics).map((pill) => (
<li key={pill.href}>
<a
href={pill.href}
className="flex h-9 items-center gap-1.5 rounded-full border border-[#ddd] px-3 text-[14px]"
{...(pill.href.startsWith("http") ? { target: "_blank", rel: "noopener noreferrer nofollow" } : {})}
>
<Icon name={pill.icon} size={18} />
<span className="max-w-[16rem] truncate">{pill.label}</span>
</a>
</li>
))}
{contactPills(basics).map((pill, index) => {
const Tag = pill.href ? "a" : "span";
return (
<li key={`${index}:${pill.label}`}>
<Tag
href={pill.href}
className="flex h-9 items-center gap-1.5 rounded-full border border-[#ddd] px-3 text-[14px]"
{...(pill.href?.startsWith("http") ? { target: "_blank", rel: "noopener noreferrer nofollow" } : {})}
>
<Icon name={pill.icon} size={18} />
<span className="max-w-[16rem] truncate">{pill.label}</span>
</Tag>
</li>
);
})}
</ul>
</header>
@@ -21,6 +21,7 @@ import { createLetterFile, letterFileName } from "@/features/letters/export";
import { useCurrentResume } from "@/features/resume/builder/draft";
import { useOpenIssueCount } from "@/features/resume/editor/check/use-check";
import { createExportFile, getDefaultFileName, sanitizeFileName } from "@/features/resume/export/use-resume-export";
import { getReadableErrorMessage } from "@/libs/error-message";
import { ENTER_CLASS } from "@/libs/motion";
import { client } from "@/libs/orpc/client";
@@ -271,8 +272,9 @@ export function DownloadTab({ onReview }: DownloadTabProps) {
toast.add({ description: t`Downloaded ${file}` });
}
setState("done");
} catch {
} catch (error) {
setState("error");
toast.add({ type: "error", description: getReadableErrorMessage(error, t`Download failed.`) });
}
};
@@ -0,0 +1,82 @@
// @vitest-environment happy-dom
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { beforeEach, expect, it, vi } from "vitest";
import { i18n } from "@lingui/core";
import { I18nProvider } from "@lingui/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useEditorStore } from "../editor/store";
import { HistoryTab } from "./history-tab";
const mocks = vi.hoisted(() => ({
save: vi.fn(),
create: vi.fn(),
restore: vi.fn(),
replace: vi.fn(),
toast: vi.fn(),
}));
vi.mock("@/features/resume/builder/draft", () => ({
savePendingChanges: mocks.save,
useCurrentResume: () => ({ id: "resume", isLocked: false }),
useResumeStore: { getState: () => ({ replaceResumeFromServer: mocks.replace }) },
}));
vi.mock("@/hooks/use-confirm", () => ({ useConfirm: () => vi.fn(), usePrompt: () => vi.fn() }));
vi.mock("@/libs/error-message", () => ({ getResumeErrorMessage: (error: Error) => error.message }));
vi.mock("@reactive-resume/ui/components/toast", () => ({ toast: { add: mocks.toast } }));
vi.mock("@/libs/orpc/client", () => ({
orpc: {
resume: {
listVersions: {
queryKey: () => ["versions"],
queryOptions: () => ({
queryKey: ["versions"],
queryFn: async () => [{ id: "version", kind: "named", name: "Checkpoint", createdAt: new Date() }],
}),
},
getById: { queryKey: () => ["resume"] },
createVersion: { call: mocks.create },
restoreVersion: { call: mocks.restore },
},
},
}));
beforeEach(() => {
vi.clearAllMocks();
mocks.save.mockResolvedValue(false);
useEditorStore.getState().reset();
i18n.load("en", {});
i18n.activate("en");
});
it.each(["name", "restore"])("stops History %s after the save barrier fails", async (action) => {
if (action === "restore") useEditorStore.getState().setHistoryVersion("version");
const client = new QueryClient();
render(
<QueryClientProvider client={client}>
<I18nProvider i18n={i18n}>
<HistoryTab />
</I18nProvider>
</QueryClientProvider>,
);
if (action === "name") {
fireEvent.change(screen.getByRole("textbox", { name: "Name this version" }), {
target: { value: "Unsaved draft" },
});
fireEvent.click(screen.getByRole("button", { name: "Save" }));
} else fireEvent.click(await screen.findByRole("button", { name: "Restore this version" }));
await waitFor(() =>
expect(mocks.toast).toHaveBeenCalledWith({
type: "error",
description: "Couldn't save your changes. Try again before continuing.",
}),
);
expect(mocks.save).toHaveBeenCalledWith("resume");
expect(mocks.create).not.toHaveBeenCalled();
expect(mocks.restore).not.toHaveBeenCalled();
expect(mocks.replace).not.toHaveBeenCalled();
if (action === "name")
expect((screen.getByRole("textbox", { name: "Name this version" }) as HTMLInputElement).value).toBe(
"Unsaved draft",
);
else expect(useEditorStore.getState().historyVersionId).toBe("version");
client.clear();
});
@@ -66,12 +66,14 @@ function useResumeHistory(): HistorySource {
nowDetail: t`The resume as it is`,
errorMessage: getResumeErrorMessage,
save: async (name) => {
await savePendingChanges(resume.id);
if (!(await savePendingChanges(resume.id)))
throw new Error(t`Couldn't save your changes. Try again before continuing.`);
await orpc.resume.createVersion.call({ resumeId: resume.id, name });
void refresh();
},
restore: async (versionId) => {
await savePendingChanges(resume.id);
if (!(await savePendingChanges(resume.id)))
throw new Error(t`Couldn't save your changes. Try again before continuing.`);
const restored = await orpc.resume.restoreVersion.call({ resumeId: resume.id, versionId });
useResumeStore.getState().replaceResumeFromServer(restored as Resume);
queryClient.setQueryData(orpc.resume.getById.queryKey({ input: { id: resume.id } }), {
@@ -38,7 +38,6 @@ export function WebAccessSection() {
setEditing(false);
test.reset();
void queryClient.invalidateQueries({ queryKey: orpc.webAccess.status.key() });
void queryClient.invalidateQueries({ queryKey: orpc.firecrawl.status.key() });
};
const save = useMutation(orpc.webAccess.save.mutationOptions({ onSuccess: refresh }));
const remove = useMutation(orpc.webAccess.delete.mutationOptions({ onSuccess: refresh }));
+4
View File
@@ -2,6 +2,10 @@ import { t } from "@lingui/core/macro";
import { ORPCError } from "@orpc/client";
export function getReadableErrorMessage(error: unknown, fallback: string): string {
if (error instanceof Error && error.cause === "pdf-text-loss") {
if (error.message.startsWith("Fonts could not be loaded:")) return t`Fonts could not be loaded. Retry the export.`;
return t`Some PDF text could not be rendered. Choose a font containing these characters, then retry the export.`;
}
if (error instanceof ORPCError && error.code === "BAD_REQUEST") {
const data: unknown = error.data;
if (typeof data === "object" && data !== null && "issues" in data && Array.isArray(data.issues)) {
@@ -1,7 +1,9 @@
import { t } from "@lingui/core/macro";
import { useQueryClient, useSuspenseQuery } from "@tanstack/react-query";
import { createFileRoute, redirect } from "@tanstack/react-router";
import { createFileRoute, redirect, useBlocker } from "@tanstack/react-router";
import { useEffect } from "react";
import z from "zod";
import { toast } from "@reactive-resume/ui/components/toast";
import { LetterShell } from "./-components/letter-shell";
import { useLetterEditorStore } from "@/features/letters/store";
import { orpc } from "@/libs/orpc/client";
@@ -45,6 +47,23 @@ function RouteComponent() {
const queryClient = useQueryClient();
const { data: letter } = useSuspenseQuery(orpc.coverLetters.getById.queryOptions({ input: { id: coverLetterId } }));
const loaded = useLetterEditorStore((state) => state.letter?.id === coverLetterId);
useBlocker({
shouldBlockFn: async ({ next }) => {
if ("coverLetterId" in next.params && next.params.coverLetterId === coverLetterId) return false;
let timeout: ReturnType<typeof setTimeout> | undefined;
const saved = await Promise.race([
useLetterEditorStore.getState().flush(),
new Promise<boolean>((resolve) => {
timeout = setTimeout(() => resolve(false), 10_000);
}),
]);
clearTimeout(timeout);
if (!saved)
toast.add({ type: "error", description: t`Couldn't save your changes. Try again before continuing.` });
return !saved;
},
enableBeforeUnload: false,
});
// The editor owns the letter from here; later refetches don't overwrite what's being typed.
useEffect(() => {
@@ -54,11 +73,14 @@ function RouteComponent() {
// Leaving saves what's pending, then lets lists (Documents, Applications) catch up.
useEffect(
() => () => {
const sessionId = useLetterEditorStore.getState().sessionId;
void useLetterEditorStore
.getState()
.flush()
.finally(() => {
if (useLetterEditorStore.getState().letter?.id === coverLetterId) useLetterEditorStore.getState().reset();
.then((saved) => {
if (!saved) return;
const state = useLetterEditorStore.getState();
if (state.letter?.id === coverLetterId && state.sessionId === sessionId) state.reset();
void queryClient.invalidateQueries({ queryKey: orpc.coverLetters.key() });
void queryClient.invalidateQueries({ queryKey: orpc.documents.key() });
void queryClient.invalidateQueries({ queryKey: orpc.applications.key() });
@@ -27,7 +27,7 @@ import { useLetterEditorStore } from "@/features/letters/store";
import { BackLink, DocumentMenuTrigger, DrawerControls } from "@/features/resume/editor/chrome";
import { useEditorStore } from "@/features/resume/editor/store";
import { usePrompt } from "@/hooks/use-confirm";
import { getOrpcErrorMessage } from "@/libs/error-message";
import { getOrpcErrorMessage, getReadableErrorMessage } from "@/libs/error-message";
import { ENTER_CLASS } from "@/libs/motion";
import { client, orpc } from "@/libs/orpc/client";
@@ -106,8 +106,11 @@ export function useDownloadLetter() {
try {
const blob = await createLetterFile(letter, words, "pdf");
downloadWithAnchor(blob, `${letterFileName(letter, words)}.pdf`);
} catch {
toast.add({ type: "error", description: t`Could not generate the PDF. Please try again.` });
} catch (error) {
toast.add({
type: "error",
description: getReadableErrorMessage(error, t`Could not generate the PDF. Please try again.`),
});
}
setBusy(false);
toast.close(toastId);
@@ -182,7 +185,8 @@ function LetterMenu() {
const duplicate = async () => {
try {
await useLetterEditorStore.getState().flush();
if (!(await useLetterEditorStore.getState().flush()))
throw new Error(t`Couldn't save your changes. Try again before continuing.`);
const copy = await client.coverLetters.duplicate({ id });
void queryClient.invalidateQueries({ queryKey: orpc.documents.key() });
toast.add({ description: t`Duplicated` });
@@ -205,7 +209,8 @@ function LetterMenu() {
// Undoable, so it doesn't ask first: the letter waits in Trash for 30 days.
const trash = async () => {
try {
await useLetterEditorStore.getState().flush();
if (!(await useLetterEditorStore.getState().flush()))
throw new Error(t`Couldn't save your changes. Try again before continuing.`);
await client.documents.trash(ref);
void queryClient.invalidateQueries({ queryKey: orpc.documents.key() });
void navigate({ to: "/dashboard" });
@@ -24,7 +24,7 @@ import {
} from "@/features/resume/share/download-tab";
import { HistoryTimeline } from "@/features/resume/share/history-tab";
import { useClosingValue } from "@/hooks/use-closing-value";
import { getOrpcErrorMessage } from "@/libs/error-message";
import { getOrpcErrorMessage, getReadableErrorMessage } from "@/libs/error-message";
import { client, orpc } from "@/libs/orpc/client";
/**
@@ -146,8 +146,9 @@ function LetterDownloadTab() {
toast.add({ description: t`Downloaded ${file}` });
}
setState("done");
} catch {
} catch (error) {
setState("error");
toast.add({ type: "error", description: getReadableErrorMessage(error, t`Download failed.`) });
}
};
@@ -201,7 +202,8 @@ function useLetterHistory(open: boolean): HistorySource {
nowDetail: t`The letter as it is`,
errorMessage: (error) => getOrpcErrorMessage(error, { fallback: t`Something went wrong. Try again.` }),
save: async (name) => {
await useLetterEditorStore.getState().flush();
if (!(await useLetterEditorStore.getState().flush()))
throw new Error(t`Couldn't save your changes. Try again before continuing.`);
await client.coverLetters.createVersion({ id, name });
void refresh();
},