mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-10-03 18:23:47 +10:00
fix: finalize v6 migrations and document workflows
This commit is contained in:
@@ -30,13 +30,13 @@
|
||||
"@ai-sdk/xai": "^5.0.14",
|
||||
"@aws-sdk/client-s3": "^3.1144.0",
|
||||
"@better-auth/api-key": "catalog:",
|
||||
"@better-auth/drizzle-adapter": "^1.7.7",
|
||||
"@better-auth/drizzle-adapter": "1.7.7",
|
||||
"@better-auth/infra": "catalog:",
|
||||
"@better-auth/oauth-provider": "catalog:",
|
||||
"@better-auth/passkey": "catalog:",
|
||||
"@bramus/specificity": "^2.4.2",
|
||||
"@formepdf/core": "0.25.0",
|
||||
"@formepdf/react": "0.25.0",
|
||||
"@formepdf/core": "0.26.0",
|
||||
"@formepdf/react": "0.26.0",
|
||||
"@hono/node-server": "^2.1.3",
|
||||
"@modelcontextprotocol/sdk": "^1.31.0",
|
||||
"@orpc/client": "catalog:",
|
||||
@@ -68,7 +68,6 @@
|
||||
"fast-json-patch": "^3.1.1",
|
||||
"fast-png": "^8.0.0",
|
||||
"fflate": "catalog:",
|
||||
"firecrawl": "^4.42.1",
|
||||
"hono": "^4.13.12",
|
||||
"ioredis": "catalog:",
|
||||
"jose": "^6.2.12",
|
||||
|
||||
@@ -2,6 +2,7 @@ import { gunzipSync } from "node:zlib";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
trustedProxies: [] as string[],
|
||||
handleAuth: vi.fn(),
|
||||
handleOAuth: vi.fn(),
|
||||
handleRpc: vi.fn(),
|
||||
@@ -23,6 +24,11 @@ const mocks = vi.hoisted(() => ({
|
||||
handleWebApp: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@reactive-resume/env/server", async (original) => {
|
||||
const { env } = await original<typeof import("@reactive-resume/env/server")>();
|
||||
return { env: { ...env, TRUSTED_PROXIES: mocks.trustedProxies } };
|
||||
});
|
||||
|
||||
vi.mock("./auth", () => ({
|
||||
handleAuth: mocks.handleAuth,
|
||||
handleOAuth: mocks.handleOAuth,
|
||||
@@ -82,6 +88,7 @@ const transportEnv = (remoteAddress: string) =>
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.trustedProxies.length = 0;
|
||||
mocks.handleAuth.mockResolvedValue(new Response("auth"));
|
||||
mocks.handleOAuth.mockResolvedValue(new Response("oauth"));
|
||||
mocks.handleRpc.mockResolvedValue(new Response("rpc"));
|
||||
@@ -104,6 +111,24 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
describe("createApp", () => {
|
||||
it.each([
|
||||
["127.0.0.1", "127.0.0.1", "198.51.100.1", "198.51.100.1"],
|
||||
["127.0.0.1", "::ffff:127.0.0.1", "198.51.100.1", "198.51.100.1"],
|
||||
["10.0.0.0/8", "10.2.3.4", "198.51.100.1, 10.3.4.5", "198.51.100.1"],
|
||||
["::1/128", "::1", "2001:db8::1", "2001:db8::1"],
|
||||
["127.0.0.1", "127.0.0.1", "192.0.2.99, 198.51.100.1", "198.51.100.1"],
|
||||
["127.0.0.1", "203.0.113.9", "198.51.100.1", "203.0.113.9"],
|
||||
["127.0.0.1", "127.0.0.1", "invalid, 198.51.100.1", "127.0.0.1"],
|
||||
])("resolves auth client through trusted %s from socket %s", async (proxy, peer, forwarded, expected) => {
|
||||
mocks.trustedProxies.push(proxy);
|
||||
const { createApp } = await import("./app");
|
||||
const request = new Request("http://localhost/api/auth/sign-in/email", {
|
||||
headers: { "x-forwarded-for": forwarded },
|
||||
});
|
||||
await createApp().fetch(request, transportEnv(peer));
|
||||
expect(mocks.handleAuth).toHaveBeenCalledWith(request, expected);
|
||||
});
|
||||
|
||||
it("routes /api/auth/oauth to the OAuth bridge before the Better Auth wildcard", async () => {
|
||||
const { createApp } = await import("./app");
|
||||
const app = createApp();
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import type { Http2Bindings, HttpBindings } from "@hono/node-server";
|
||||
import type { Context } from "hono";
|
||||
import { isIP } from "node:net";
|
||||
import { BlockList, isIP } from "node:net";
|
||||
import { getConnInfo } from "@hono/node-server/conninfo";
|
||||
import { Hono } from "hono";
|
||||
import { compress } from "hono/compress";
|
||||
import { prepareStagedBody, withStagedBody } from "@reactive-resume/api/features/storage/transport";
|
||||
import { env } from "@reactive-resume/env/server";
|
||||
import { handleMcp } from "../mcp/handler";
|
||||
import { handleOpenApi } from "../openapi/handler";
|
||||
import {
|
||||
@@ -26,10 +27,22 @@ import { handleResumePdfDownload } from "./resume-pdf";
|
||||
|
||||
type ServerEnvironment = { Bindings: HttpBindings | Http2Bindings };
|
||||
|
||||
const getTrustedClient = (context: Context<ServerEnvironment>): string => {
|
||||
const getTrustedClient = (context: Context<ServerEnvironment>, proxies: BlockList): string => {
|
||||
try {
|
||||
const address = getConnInfo(context).remote.address?.trim();
|
||||
return address && isIP(address) ? address : "unknown";
|
||||
if (!address || !isIP(address)) return "unknown";
|
||||
const trusted = (ip: string) => proxies.check(ip, isIP(ip) === 4 ? "ipv4" : "ipv6");
|
||||
if (!trusted(address)) return address;
|
||||
const forwarded = context.req.header("x-forwarded-for");
|
||||
if (!forwarded) return address;
|
||||
const chain = forwarded.split(",").map((ip) => ip.trim());
|
||||
if (chain.some((ip) => !isIP(ip))) return address;
|
||||
let client = address;
|
||||
for (const hop of chain.reverse()) {
|
||||
if (!trusted(client)) break;
|
||||
client = hop;
|
||||
}
|
||||
return client;
|
||||
} catch {
|
||||
return "unknown";
|
||||
}
|
||||
@@ -42,7 +55,15 @@ type AppOptions = {
|
||||
|
||||
export function createApp(options: AppOptions = {}) {
|
||||
const app = new Hono<ServerEnvironment>();
|
||||
const client = (c: Context<ServerEnvironment>) => options.trustedClient?.(c.req.raw) ?? getTrustedClient(c);
|
||||
const proxies = new BlockList();
|
||||
for (const range of env.TRUSTED_PROXIES) {
|
||||
const [address, prefix] = range.split("/");
|
||||
if (!address) continue;
|
||||
const family = isIP(address) === 4 ? "ipv4" : "ipv6";
|
||||
if (prefix === undefined) proxies.addAddress(address, family);
|
||||
else proxies.addSubnet(address, Number(prefix), family);
|
||||
}
|
||||
const client = (c: Context<ServerEnvironment>) => options.trustedClient?.(c.req.raw) ?? getTrustedClient(c, proxies);
|
||||
|
||||
app.use("/auth/*", async (c, next) => {
|
||||
await next();
|
||||
|
||||
+1414
-1381
File diff suppressed because it is too large
Load Diff
+1414
-1381
File diff suppressed because it is too large
Load Diff
+1414
-1381
File diff suppressed because it is too large
Load Diff
+1414
-1381
File diff suppressed because it is too large
Load Diff
+1414
-1381
File diff suppressed because it is too large
Load Diff
+1414
-1381
File diff suppressed because it is too large
Load Diff
+1414
-1381
File diff suppressed because it is too large
Load Diff
+1414
-1381
File diff suppressed because it is too large
Load Diff
+1414
-1381
File diff suppressed because it is too large
Load Diff
+1416
-1383
File diff suppressed because it is too large
Load Diff
+1414
-1381
File diff suppressed because it is too large
Load Diff
+1414
-1381
File diff suppressed because it is too large
Load Diff
@@ -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
File diff suppressed because it is too large
Load Diff
+1414
-1381
File diff suppressed because it is too large
Load Diff
+1414
-1381
File diff suppressed because it is too large
Load Diff
+1414
-1381
File diff suppressed because it is too large
Load Diff
+1414
-1381
File diff suppressed because it is too large
Load Diff
+1414
-1381
File diff suppressed because it is too large
Load Diff
+1414
-1381
File diff suppressed because it is too large
Load Diff
+1415
-1382
File diff suppressed because it is too large
Load Diff
+1414
-1381
File diff suppressed because it is too large
Load Diff
+1414
-1381
File diff suppressed because it is too large
Load Diff
+1414
-1381
File diff suppressed because it is too large
Load Diff
+1414
-1381
File diff suppressed because it is too large
Load Diff
+1414
-1381
File diff suppressed because it is too large
Load Diff
+1414
-1381
File diff suppressed because it is too large
Load Diff
+1414
-1381
File diff suppressed because it is too large
Load Diff
+1414
-1381
File diff suppressed because it is too large
Load Diff
+1414
-1381
File diff suppressed because it is too large
Load Diff
+1414
-1381
File diff suppressed because it is too large
Load Diff
+1414
-1381
File diff suppressed because it is too large
Load Diff
+1414
-1381
File diff suppressed because it is too large
Load Diff
+1415
-1382
File diff suppressed because it is too large
Load Diff
+1414
-1381
File diff suppressed because it is too large
Load Diff
+1416
-1383
File diff suppressed because it is too large
Load Diff
+1415
-1382
File diff suppressed because it is too large
Load Diff
+1414
-1381
File diff suppressed because it is too large
Load Diff
+1414
-1381
File diff suppressed because it is too large
Load Diff
+1414
-1381
File diff suppressed because it is too large
Load Diff
+1414
-1381
File diff suppressed because it is too large
Load Diff
+1414
-1381
File diff suppressed because it is too large
Load Diff
+1414
-1381
File diff suppressed because it is too large
Load Diff
+1415
-1382
File diff suppressed because it is too large
Load Diff
+1414
-1381
File diff suppressed because it is too large
Load Diff
+1414
-1381
File diff suppressed because it is too large
Load Diff
+1414
-1381
File diff suppressed because it is too large
Load Diff
+1414
-1381
File diff suppressed because it is too large
Load Diff
+1414
-1381
File diff suppressed because it is too large
Load Diff
+1414
-1381
File diff suppressed because it is too large
Load Diff
+1414
-1381
File diff suppressed because it is too large
Load Diff
+1414
-1381
File diff suppressed because it is too large
Load Diff
+1414
-1381
File diff suppressed because it is too large
Load Diff
+1414
-1381
File diff suppressed because it is too large
Load Diff
@@ -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 ""
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 }));
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user