feat(jobs): complete workspace draft parity
This commit is contained in:
@@ -45,6 +45,118 @@ test("a saved job can be created through the reviewed UI flow", async ({ page })
|
||||
await expect(page.getByText(title, { exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
test("the dedicated application workspace survives deep links, long data and unsaved navigation", async ({ page }) => {
|
||||
const suffix = Date.now().toString();
|
||||
const companyName = `Application Workspace Company With A Deliberately Long Name ${suffix}`;
|
||||
const title = `Principal Platform Reliability Engineer For Distributed Customer Systems ${suffix}`;
|
||||
|
||||
await login(page);
|
||||
const headers = await csrfHeader(page);
|
||||
let companyResponse = await page.request.post(`${apiUrl}/companies`, {
|
||||
headers,
|
||||
data: { name: companyName, location: "Oslo and remote across Europe", source: "direct" },
|
||||
});
|
||||
for (let attempt = 0; attempt < 2 && !companyResponse.ok(); attempt += 1) {
|
||||
await page.waitForTimeout(250);
|
||||
companyResponse = await page.request.post(`${apiUrl}/companies`, {
|
||||
headers,
|
||||
data: { name: companyName, location: "Oslo and remote across Europe", source: "direct" },
|
||||
});
|
||||
}
|
||||
const company = await companyResponse.json();
|
||||
expect(companyResponse.ok(), `company create failed: ${companyResponse.status()} ${JSON.stringify(company)}`).toBeTruthy();
|
||||
|
||||
const jobData = {
|
||||
jobTitle: title,
|
||||
companyId: company.id,
|
||||
status: "Applied",
|
||||
location: "Oslo and remote across Europe",
|
||||
salary: null,
|
||||
salaryMin: null,
|
||||
salaryMax: null,
|
||||
salaryCurrency: null,
|
||||
salaryPeriod: null,
|
||||
nextAction: "Prepare a concise application answer",
|
||||
followUpAt: null,
|
||||
notes: "Ask about platform ownership and the incident response rotation.",
|
||||
description: `Build reliable distributed systems. ${"Long responsibility text ".repeat(80)} https://example.test/${"very-long-path-segment/".repeat(12)}`,
|
||||
translatedDescription: null,
|
||||
descriptionLanguage: "en",
|
||||
tags: JSON.stringify([".NET", "Kubernetes", "Incident response"]),
|
||||
deadline: null,
|
||||
coverLetterText: null,
|
||||
jobUrl: "https://example.test/jobs/platform-reliability",
|
||||
dateApplied: new Date().toISOString(),
|
||||
feedbackRequestedAt: null,
|
||||
source: "direct",
|
||||
countryCode: "NO",
|
||||
};
|
||||
let jobResponse = await page.request.post(`${apiUrl}/jobapplications`, { headers, data: jobData });
|
||||
for (let attempt = 0; attempt < 2 && !jobResponse.ok(); attempt += 1) {
|
||||
await page.waitForTimeout(250);
|
||||
jobResponse = await page.request.post(`${apiUrl}/jobapplications`, { headers, data: jobData });
|
||||
}
|
||||
expect(jobResponse.ok()).toBeTruthy();
|
||||
const job = await jobResponse.json();
|
||||
|
||||
await page.goto("/jobs");
|
||||
const applicationRow = page.getByRole("row", { name: new RegExp(`Open ${title}`, "i") });
|
||||
await applicationRow.focus();
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(page).toHaveURL(new RegExp(`/jobs/${job.id}$`));
|
||||
await expect(page.getByRole("heading", { name: title })).toBeVisible();
|
||||
|
||||
await page.goBack();
|
||||
await expect(page).toHaveURL(/\/jobs$/);
|
||||
await page.goForward();
|
||||
await expect(page).toHaveURL(new RegExp(`/jobs/${job.id}$`));
|
||||
|
||||
const workspaceNav = page.getByRole("navigation", { name: "Workspace sections" });
|
||||
await workspaceNav.getByRole("button", { name: "Cover Letter", exact: true }).click();
|
||||
await expect(page).toHaveURL(new RegExp(`/jobs/${job.id}\\?section=cover-letter$`));
|
||||
await page.getByLabel("Application answer").fill("A reviewed answer that must not be lost.");
|
||||
await workspaceNav.getByRole("button", { name: "Match", exact: true }).click();
|
||||
await expect(page.getByRole("dialog", { name: "Unsaved application changes" })).toBeVisible();
|
||||
await page.getByRole("button", { name: "Keep editing" }).click();
|
||||
await expect(page.getByLabel("Application answer")).toHaveValue("A reviewed answer that must not be lost.");
|
||||
await page.getByRole("button", { name: "Save application drafts" }).click();
|
||||
await expect(page.getByText("Unsaved changes")).toHaveCount(0);
|
||||
|
||||
await page.evaluate(() => window.localStorage.setItem("jobtracker.themeMode", "light"));
|
||||
await page.reload();
|
||||
for (const width of [375, 768, 1440]) {
|
||||
await page.setViewportSize({ width, height: 900 });
|
||||
await expect(page.getByRole("heading", { name: title })).toBeVisible();
|
||||
const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth);
|
||||
expect(overflow).toBeLessThanOrEqual(1);
|
||||
}
|
||||
|
||||
await page.evaluate(() => window.localStorage.setItem("jobtracker.themeMode", "dark"));
|
||||
await page.reload();
|
||||
await expect(page.getByLabel("Application answer")).toHaveValue("A reviewed answer that must not be lost.");
|
||||
await expect(page.locator("html")).toHaveAttribute("data-color-scheme", "dark");
|
||||
|
||||
for (const width of [375, 768, 1440]) {
|
||||
await page.setViewportSize({ width, height: 900 });
|
||||
await expect(page.getByRole("heading", { name: title })).toBeVisible();
|
||||
const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth);
|
||||
expect(overflow).toBeLessThanOrEqual(1);
|
||||
}
|
||||
|
||||
await workspaceNav.getByRole("button", { name: "Job Details", exact: true }).click();
|
||||
await expect(page).toHaveURL(new RegExp(`/jobs/${job.id}\\?section=job-details$`));
|
||||
await expect(page.getByText("Ask about platform ownership and the incident response rotation.")).toBeVisible();
|
||||
await expect(page.getByText("<<<APPLICATION_ANSWER_DRAFT>>>")).toHaveCount(0);
|
||||
|
||||
await page.getByRole("button", { name: "Back to applications" }).click();
|
||||
await expect(page).toHaveURL(/\/jobs$/);
|
||||
await expect(page.getByRole("row", { name: new RegExp(`Open ${title}`, "i") })).toBeFocused();
|
||||
|
||||
await page.goto("/jobs/2147483647");
|
||||
await expect(page.getByRole("alert").filter({ hasText: /Not Found|Could not open this application/i })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Back to applications" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("Career Workspace loads from the authenticated application shell", async ({ page }) => {
|
||||
await login(page);
|
||||
await page.goto("/career");
|
||||
|
||||
@@ -3,7 +3,7 @@ import "@testing-library/jest-dom";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
|
||||
import {
|
||||
ApplicationCoverLetterSection, ApplicationCvSection,
|
||||
ApplicationCoverLetterSection, ApplicationCvSection, ApplicationPackageDraftsSection,
|
||||
} from "./components/ApplicationAssets";
|
||||
import { api } from "./api";
|
||||
|
||||
@@ -134,11 +134,13 @@ test("cover letter loads the current text and its history", async () => {
|
||||
test("editing marks the draft dirty and saving sends the new text", async () => {
|
||||
routeGet();
|
||||
mockedApi.put.mockResolvedValue({ data: { ...coverLetter, text: "Dear hiring team" } } as any);
|
||||
const onDirtyChange = jest.fn();
|
||||
|
||||
render(<ApplicationCoverLetterSection jobId={7} />);
|
||||
render(<ApplicationCoverLetterSection jobId={7} onDirtyChange={onDirtyChange} />);
|
||||
fireEvent.change(await screen.findByLabelText("Cover letter"), { target: { value: "Dear hiring team" } });
|
||||
|
||||
expect(screen.getByText("Unsaved changes")).toBeInTheDocument();
|
||||
expect(onDirtyChange).toHaveBeenLastCalledWith(true);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save" }));
|
||||
|
||||
await waitFor(() => expect(mockedApi.put).toHaveBeenCalledWith(
|
||||
@@ -187,3 +189,54 @@ test("a failed load surfaces an error", async () => {
|
||||
|
||||
expect(await screen.findByText(/Could not load this section/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// ---------- Application package drafts ----------
|
||||
|
||||
test("application answer and recruiter drafts save from the dedicated workspace", async () => {
|
||||
mockedApi.put.mockResolvedValue({ data: undefined } as any);
|
||||
const onSaved = jest.fn();
|
||||
|
||||
render(
|
||||
<ApplicationPackageDraftsSection
|
||||
jobId={7}
|
||||
initialApplicationAnswer="Saved answer"
|
||||
initialRecruiterMessage="Saved recruiter note"
|
||||
onSaved={onSaved}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Application answer"), { target: { value: "Edited answer" } });
|
||||
fireEvent.change(screen.getByLabelText("Recruiter message"), { target: { value: "Edited recruiter note" } });
|
||||
expect(screen.getByText("Unsaved changes")).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save application drafts" }));
|
||||
|
||||
await waitFor(() => expect(mockedApi.put).toHaveBeenCalledWith(
|
||||
"/jobapplications/7/application-drafts",
|
||||
{
|
||||
applicationAnswerDraft: "Edited answer",
|
||||
recruiterMessageDraft: "Edited recruiter note",
|
||||
},
|
||||
));
|
||||
expect(onSaved).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("application package drafts can be cleared without deleting ordinary notes", async () => {
|
||||
mockedApi.put.mockResolvedValue({ data: undefined } as any);
|
||||
|
||||
render(
|
||||
<ApplicationPackageDraftsSection
|
||||
jobId={7}
|
||||
initialApplicationAnswer="Saved answer"
|
||||
initialRecruiterMessage="Saved recruiter note"
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Application answer"), { target: { value: "" } });
|
||||
fireEvent.change(screen.getByLabelText("Recruiter message"), { target: { value: "" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save application drafts" }));
|
||||
|
||||
await waitFor(() => expect(mockedApi.put).toHaveBeenCalledWith(
|
||||
"/jobapplications/7/application-drafts",
|
||||
{ applicationAnswerDraft: "", recruiterMessageDraft: "" },
|
||||
));
|
||||
});
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import {
|
||||
extractApplicationAnswerDraft, removeApplicationAnswerDraft, upsertApplicationAnswerDraft,
|
||||
} from "./applicationDrafts";
|
||||
|
||||
test("application answer helpers keep human notes separate", () => {
|
||||
const stored = upsertApplicationAnswerDraft("Human note", " Draft answer ");
|
||||
|
||||
expect(stored).toBe("Human note\n\n<<<APPLICATION_ANSWER_DRAFT>>>\nDraft answer\n<<<END_APPLICATION_ANSWER_DRAFT>>>");
|
||||
expect(extractApplicationAnswerDraft(stored)).toBe("Draft answer");
|
||||
expect(removeApplicationAnswerDraft(stored)).toBe("Human note");
|
||||
});
|
||||
|
||||
test("clearing an answer retains human notes and removes all marker blocks", () => {
|
||||
const duplicated = "Human note\n\n<<<APPLICATION_ANSWER_DRAFT>>>\nFirst\n<<<END_APPLICATION_ANSWER_DRAFT>>>\n\n<<<APPLICATION_ANSWER_DRAFT>>>\nSecond\n<<<END_APPLICATION_ANSWER_DRAFT>>>";
|
||||
|
||||
expect(upsertApplicationAnswerDraft(duplicated, "")).toBe("Human note");
|
||||
});
|
||||
|
||||
test("legacy application answer labels remain readable during migration", () => {
|
||||
const legacy = "Human note\n\nApplication answer draft:\nLegacy answer";
|
||||
|
||||
expect(extractApplicationAnswerDraft(legacy)).toBe("Legacy answer");
|
||||
expect(removeApplicationAnswerDraft(legacy)).toBe("Human note");
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from "react";
|
||||
import "@testing-library/jest-dom";
|
||||
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom";
|
||||
import { createMemoryRouter, RouterProvider, Route, Routes, useLocation } from "react-router-dom";
|
||||
|
||||
import { api } from "./api";
|
||||
import JobTable from "./components/JobTable";
|
||||
@@ -21,13 +21,40 @@ jest.mock("./components/ApplicationIntelligence", () => ({
|
||||
ApplicationTimeline: () => <div>Timeline section</div>,
|
||||
}));
|
||||
jest.mock("./components/ApplicationAssets", () => ({
|
||||
ApplicationCoverLetterSection: () => <div>Cover letter section</div>,
|
||||
ApplicationCoverLetterSection: ({ onDirtyChange }: { onDirtyChange?: (dirty: boolean) => void }) => (
|
||||
<div>
|
||||
Cover letter section
|
||||
<button onClick={() => onDirtyChange?.(true)}>Make cover letter dirty</button>
|
||||
</div>
|
||||
),
|
||||
ApplicationCvSection: () => <div>CV section</div>,
|
||||
ApplicationPackageDraftsSection: () => <div>Application drafts section</div>,
|
||||
}));
|
||||
jest.mock("./components/InterviewPrep", () => ({ ApplicationInterviewPrep: () => <div>Interview section</div> }));
|
||||
|
||||
const mockedApi = api as jest.Mocked<typeof api>;
|
||||
|
||||
// React Router's data router builds a Request for in-memory navigations. JSDOM does not provide
|
||||
// one, and these tests do not run loaders or inspect request bodies, so a small contract stub is
|
||||
// enough to exercise blocker/history behavior.
|
||||
class RouterTestRequest {
|
||||
url: string;
|
||||
method: string;
|
||||
signal?: AbortSignal;
|
||||
headers: Headers;
|
||||
body: unknown;
|
||||
|
||||
constructor(url: string, init: RequestInit = {}) {
|
||||
this.url = url;
|
||||
this.method = init.method ?? "GET";
|
||||
this.signal = init.signal ?? undefined;
|
||||
this.headers = new Headers(init.headers);
|
||||
this.body = init.body;
|
||||
}
|
||||
}
|
||||
|
||||
Object.assign(globalThis, { Request: RouterTestRequest });
|
||||
|
||||
const job = {
|
||||
id: 42,
|
||||
jobTitle: "Backend Developer",
|
||||
@@ -64,6 +91,8 @@ const overview = {
|
||||
descriptionLanguage: "en",
|
||||
tags: [".NET", "SQL"],
|
||||
notes: "Ask about the platform team.",
|
||||
applicationAnswerDraft: "Saved answer",
|
||||
recruiterMessageDraft: "Saved recruiter message",
|
||||
source: "nav",
|
||||
countryCode: "NO",
|
||||
hasJobDescription: true,
|
||||
@@ -88,18 +117,27 @@ function LocationControls() {
|
||||
}
|
||||
|
||||
function renderTable(path = "/jobs") {
|
||||
const router = createMemoryRouter([
|
||||
{
|
||||
path: "*",
|
||||
element: (
|
||||
<>
|
||||
<LocationControls />
|
||||
<Routes>
|
||||
<Route path="/jobs" element={<JobTable refreshToken={0} pageSize={15} onPageSizeChange={() => {}} columns={{ status: true, dateApplied: true, daysSince: true, jobUrl: false }} onColumnsChange={() => {}} />} />
|
||||
<Route path="/jobs/:id" element={<ApplicationWorkspacePage />} />
|
||||
</Routes>
|
||||
</>
|
||||
),
|
||||
},
|
||||
], { initialEntries: [path] });
|
||||
|
||||
return render(
|
||||
<ToastProvider>
|
||||
<I18nProvider>
|
||||
<ConfirmProvider>
|
||||
<PromptProvider>
|
||||
<MemoryRouter initialEntries={[path]}>
|
||||
<LocationControls />
|
||||
<Routes>
|
||||
<Route path="/jobs" element={<JobTable refreshToken={0} pageSize={15} onPageSizeChange={() => {}} columns={{ status: true, dateApplied: true, daysSince: true, jobUrl: false }} onColumnsChange={() => {}} />} />
|
||||
<Route path="/jobs/:id" element={<ApplicationWorkspacePage />} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
<RouterProvider router={router} />
|
||||
</PromptProvider>
|
||||
</ConfirmProvider>
|
||||
</I18nProvider>
|
||||
@@ -136,6 +174,7 @@ test("opens the dedicated workspace from the whole row and preserves list state
|
||||
fireEvent.click(screen.getByRole("button", { name: /back to applications/i }));
|
||||
await waitFor(() => expect(screen.getByTestId("location")).toHaveTextContent("/jobs?q=backend"));
|
||||
expect(await screen.findByRole("textbox", { name: /search/i })).toHaveValue("backend");
|
||||
await waitFor(() => expect(screen.getByRole("row", { name: /open backend developer/i })).toHaveFocus());
|
||||
});
|
||||
|
||||
test("opens a direct workspace URL and returns to applications", async () => {
|
||||
@@ -165,6 +204,22 @@ test("handles a deleted or inaccessible job without rendering a broken workspace
|
||||
expect(screen.getByRole("button", { name: /back to applications/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("warns before section navigation would discard application edits", async () => {
|
||||
renderTable("/jobs/42?section=cover-letter");
|
||||
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Make cover letter dirty" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Match" }));
|
||||
|
||||
expect(await screen.findByRole("dialog", { name: /Unsaved application changes/i })).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Keep editing" }));
|
||||
expect(screen.getByTestId("location")).toHaveTextContent("/jobs/42?section=cover-letter");
|
||||
await waitFor(() => expect(screen.queryByRole("dialog", { name: /Unsaved application changes/i })).not.toBeInTheDocument());
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Match" }));
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Discard and leave" }));
|
||||
await waitFor(() => expect(screen.getByTestId("location")).toHaveTextContent("/jobs/42?section=match"));
|
||||
});
|
||||
|
||||
test("hydrates list filters, sort and page from a shareable URL", async () => {
|
||||
renderTable("/jobs?q=backend&status=Interview&companyId=1&location=Oslo&needsFollowUp=1&readiness=interview&includeDeleted=1&sortBy=company&sortDir=asc&page=2");
|
||||
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
const APPLICATION_ANSWER_START = "<<<APPLICATION_ANSWER_DRAFT>>>";
|
||||
const APPLICATION_ANSWER_END = "<<<END_APPLICATION_ANSWER_DRAFT>>>";
|
||||
|
||||
export function extractApplicationAnswerDraft(notes?: string | null) {
|
||||
const value = (notes ?? "").trim();
|
||||
if (!value) return "";
|
||||
|
||||
const startIndex = value.indexOf(APPLICATION_ANSWER_START);
|
||||
const endIndex = value.indexOf(APPLICATION_ANSWER_END);
|
||||
if (startIndex >= 0 && endIndex > startIndex) {
|
||||
return value.slice(startIndex + APPLICATION_ANSWER_START.length, endIndex).trim();
|
||||
}
|
||||
|
||||
const legacyMatch = value.match(/Application answer draft:\s*\n([\s\S]*)$/i);
|
||||
return legacyMatch?.[1]?.trim() ?? "";
|
||||
}
|
||||
|
||||
export function removeApplicationAnswerDraft(notes?: string | null) {
|
||||
const value = notes ?? "";
|
||||
const markerPattern = new RegExp(`${APPLICATION_ANSWER_START}[\\s\\S]*?${APPLICATION_ANSWER_END}`, "g");
|
||||
const withoutMarkers = value.replace(markerPattern, "").trim();
|
||||
const legacyIndex = withoutMarkers.search(/Application answer draft:\s*\n/i);
|
||||
return (legacyIndex >= 0 ? withoutMarkers.slice(0, legacyIndex) : withoutMarkers).trim();
|
||||
}
|
||||
|
||||
export function upsertApplicationAnswerDraft(notes: string | null | undefined, draft: string) {
|
||||
const humanNotes = removeApplicationAnswerDraft(notes);
|
||||
const answer = draft.trim();
|
||||
const block = answer ? `${APPLICATION_ANSWER_START}\n${answer}\n${APPLICATION_ANSWER_END}` : "";
|
||||
return [humanNotes, block].filter(Boolean).join("\n\n");
|
||||
}
|
||||
@@ -32,6 +32,8 @@ export type WorkspaceOverview = {
|
||||
descriptionLanguage: string | null;
|
||||
tags: string[];
|
||||
notes: string | null;
|
||||
applicationAnswerDraft: string | null;
|
||||
recruiterMessageDraft: string | null;
|
||||
source: string | null;
|
||||
countryCode: string | null;
|
||||
hasJobDescription: boolean;
|
||||
@@ -241,6 +243,8 @@ export const applicationAssetsApi = {
|
||||
api.put<CoverLetter>(`/jobapplications/${jobId}/cover-letter`, { text, source, aiAction }).then((r) => r.data),
|
||||
restoreCoverLetter: (jobId: number, version: number) =>
|
||||
api.post<CoverLetter>(`/jobapplications/${jobId}/cover-letter/versions/${version}/restore`).then((r) => r.data),
|
||||
saveApplicationDrafts: (jobId: number, applicationAnswerDraft: string, recruiterMessageDraft: string) =>
|
||||
api.put(`/jobapplications/${jobId}/application-drafts`, { applicationAnswerDraft, recruiterMessageDraft }).then(() => undefined),
|
||||
};
|
||||
|
||||
// Phase 5.5 — Interview preparation and follow-up. Prep content is the user's; AI suggestions come
|
||||
|
||||
@@ -243,7 +243,7 @@ I would welcome the chance to talk it through.
|
||||
Kind regards,
|
||||
[Your name]`;
|
||||
|
||||
export function ApplicationCoverLetterSection({ jobId }: { jobId: number }) {
|
||||
export function ApplicationCoverLetterSection({ jobId, onDirtyChange }: { jobId: number; onDirtyChange?: (dirty: boolean) => void }) {
|
||||
const { data, error, loading, setData, setError } = useAsset<CoverLetter>(
|
||||
() => applicationAssetsApi.coverLetter(jobId),
|
||||
[jobId],
|
||||
@@ -256,6 +256,11 @@ export function ApplicationCoverLetterSection({ jobId }: { jobId: number }) {
|
||||
const text = draft ?? data?.text ?? "";
|
||||
const dirty = draft !== null && draft !== (data?.text ?? "");
|
||||
|
||||
useEffect(() => {
|
||||
onDirtyChange?.(dirty);
|
||||
return () => onDirtyChange?.(false);
|
||||
}, [dirty, onDirtyChange]);
|
||||
|
||||
const save = async (value: string, source = "manual") => {
|
||||
setBusy(true);
|
||||
try {
|
||||
@@ -367,3 +372,97 @@ export function ApplicationCoverLetterSection({ jobId }: { jobId: number }) {
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- Application answer and recruiter message ----------
|
||||
|
||||
type PackageDrafts = { applicationAnswer: string; recruiterMessage: string };
|
||||
|
||||
export function ApplicationPackageDraftsSection({
|
||||
jobId,
|
||||
initialApplicationAnswer,
|
||||
initialRecruiterMessage,
|
||||
onSaved,
|
||||
onDirtyChange,
|
||||
}: {
|
||||
jobId: number;
|
||||
initialApplicationAnswer: string;
|
||||
initialRecruiterMessage: string;
|
||||
onSaved?: () => void;
|
||||
onDirtyChange?: (dirty: boolean) => void;
|
||||
}) {
|
||||
const initial = { applicationAnswer: initialApplicationAnswer, recruiterMessage: initialRecruiterMessage };
|
||||
const [saved, setSaved] = useState<PackageDrafts>(initial);
|
||||
const [draft, setDraft] = useState<PackageDrafts | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (draft === null) setSaved(initial);
|
||||
// `draft` is deliberately excluded: a parent refresh must never overwrite in-progress edits.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [jobId, initialApplicationAnswer, initialRecruiterMessage]);
|
||||
|
||||
const value = draft ?? saved;
|
||||
const dirty = draft !== null && (
|
||||
draft.applicationAnswer !== saved.applicationAnswer ||
|
||||
draft.recruiterMessage !== saved.recruiterMessage
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
onDirtyChange?.(dirty);
|
||||
return () => onDirtyChange?.(false);
|
||||
}, [dirty, onDirtyChange]);
|
||||
const update = (patch: Partial<PackageDrafts>) => setDraft({ ...value, ...patch });
|
||||
|
||||
const save = async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
await applicationAssetsApi.saveApplicationDrafts(jobId, value.applicationAnswer, value.recruiterMessage);
|
||||
setSaved(value);
|
||||
setDraft(null);
|
||||
setError(null);
|
||||
onSaved?.();
|
||||
} catch (err) {
|
||||
setError(getApiErrorMessage(err, "Could not save the application drafts."));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Shell
|
||||
title="Application answers and recruiter message"
|
||||
subtitle="Keep reusable application-form answers and a recruiter note with this application. Ordinary job notes stay separate."
|
||||
loading={false}
|
||||
error={error}
|
||||
>
|
||||
<Stack spacing={2}>
|
||||
<RichTextField
|
||||
minRows={6}
|
||||
label="Application answer"
|
||||
value={value.applicationAnswer}
|
||||
disabled={busy}
|
||||
onChange={(applicationAnswer) => update({ applicationAnswer })}
|
||||
placeholder="Draft an answer for motivation, suitability, or another application-form question."
|
||||
/>
|
||||
<RichTextField
|
||||
minRows={4}
|
||||
label="Recruiter message"
|
||||
value={value.recruiterMessage}
|
||||
disabled={busy}
|
||||
onChange={(recruiterMessage) => update({ recruiterMessage })}
|
||||
placeholder="Draft a concise message to the recruiter or hiring manager."
|
||||
/>
|
||||
<Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap alignItems="center">
|
||||
<Button variant="contained" disabled={busy || !dirty} onClick={save}>
|
||||
Save application drafts
|
||||
</Button>
|
||||
<Button disabled={busy || !dirty} onClick={() => setDraft(null)}>
|
||||
Discard changes
|
||||
</Button>
|
||||
{dirty && <Chip size="small" color="warning" variant="outlined" label="Unsaved changes" />}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ import { useCompanies } from "../hooks/useCompanies";
|
||||
import TagsInput from "./TagsInput";
|
||||
import { useI18n } from "../i18n/I18nProvider";
|
||||
import { PIPELINE_STATUSES, statusLabel } from "../pipeline";
|
||||
import { removeApplicationAnswerDraft } from "../applicationDrafts";
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
@@ -121,7 +122,7 @@ export default function EditJobDialog({ open, jobId, onClose, onSaved }: Props)
|
||||
setNextAction((j as any).nextAction ?? "");
|
||||
setFollowUpAt((j as any).followUpAt ? toDateInputValue((j as any).followUpAt) : "");
|
||||
setJobUrl(j.jobUrl ?? "");
|
||||
setNotes(j.notes ?? "");
|
||||
setNotes(removeApplicationAnswerDraft(j.notes));
|
||||
setDescription((j as any).description ?? "");
|
||||
setTranslatedDescription((j as any).translatedDescription ?? "");
|
||||
setDescriptionLanguage((j as any).descriptionLanguage ?? "");
|
||||
|
||||
@@ -38,6 +38,7 @@ import { useI18n } from "../i18n/I18nProvider";
|
||||
import { useJobWorkspaceBaseData } from "./job-workspace/useJobWorkspaceBaseData";
|
||||
import { useWorkspaceTabCache } from "./job-workspace/useWorkspaceTabCache";
|
||||
import { useAccountPlan } from "../accountPlan";
|
||||
import { upsertApplicationAnswerDraft } from "../applicationDrafts";
|
||||
|
||||
type GenerationMode = "default" | "concise" | "ats" | "achievement" | "interview";
|
||||
type CoverLetterStyle = "balanced" | "concise" | "formal" | "bold";
|
||||
@@ -85,31 +86,6 @@ function copyLines(items: string[]) {
|
||||
return navigator.clipboard.writeText(items.map((item) => `• ${item}`).join("\n"));
|
||||
}
|
||||
|
||||
const APPLICATION_ANSWER_START = "<<<APPLICATION_ANSWER_DRAFT>>>";
|
||||
const APPLICATION_ANSWER_END = "<<<END_APPLICATION_ANSWER_DRAFT>>>";
|
||||
|
||||
function upsertApplicationAnswerDraft(notes: string | null | undefined, draft: string) {
|
||||
const trimmedNotes = (notes ?? "").trim();
|
||||
const trimmedDraft = draft.trim();
|
||||
const block = trimmedDraft
|
||||
? `${APPLICATION_ANSWER_START}\n${trimmedDraft}\n${APPLICATION_ANSWER_END}`
|
||||
: "";
|
||||
|
||||
if (!trimmedNotes) return block;
|
||||
|
||||
const markerPattern = new RegExp(`${APPLICATION_ANSWER_START}[\\s\\S]*?${APPLICATION_ANSWER_END}`, "g");
|
||||
if (markerPattern.test(trimmedNotes)) {
|
||||
return block ? trimmedNotes.replace(markerPattern, block).trim() : trimmedNotes.replace(markerPattern, "").trim();
|
||||
}
|
||||
|
||||
const legacyPattern = /(?:\n\n)?Application answer draft:\s*\n[\s\S]*$/i;
|
||||
if (legacyPattern.test(trimmedNotes)) {
|
||||
return block ? trimmedNotes.replace(legacyPattern, `\n\n${block}`).trim() : trimmedNotes.replace(legacyPattern, "").trim();
|
||||
}
|
||||
|
||||
return block ? `${trimmedNotes}\n\n${block}` : trimmedNotes;
|
||||
}
|
||||
|
||||
function getWorkspaceStatus(currentValue: string, savedValue: string) {
|
||||
const current = currentValue.trim();
|
||||
const saved = savedValue.trim();
|
||||
|
||||
@@ -175,6 +175,7 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const listRouteRef = useRef(`${location.pathname}${location.search}`);
|
||||
const restoredFocusJobIdRef = useRef<number | null>(null);
|
||||
const [jobs, setJobs] = useState<JobApplication[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(() => queryPage(location.search));
|
||||
@@ -287,7 +288,7 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
|
||||
};
|
||||
|
||||
const openJob = useCallback((jobId: number, path?: string) => {
|
||||
navigate(path ?? `/jobs/${jobId}`, { state: { from: listRouteRef.current } });
|
||||
navigate(path ?? `/jobs/${jobId}`, { state: { from: listRouteRef.current, focusJobId: jobId } });
|
||||
}, [navigate]);
|
||||
|
||||
const params = useMemo(() => ({
|
||||
@@ -338,6 +339,15 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
|
||||
return jobs.filter((job) => needsWorkflowWork(job));
|
||||
}, [jobs, readinessFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
const focusJobId = (location.state as { focusJobId?: unknown } | null)?.focusJobId;
|
||||
if (typeof focusJobId !== "number" || restoredFocusJobIdRef.current === focusJobId || jobsResource.loading) return;
|
||||
const row = document.querySelector<HTMLElement>(`[data-job-row-id="${focusJobId}"]`);
|
||||
if (!row) return;
|
||||
restoredFocusJobIdRef.current = focusJobId;
|
||||
row.focus();
|
||||
}, [filteredJobs, jobsResource.loading, location.state]);
|
||||
|
||||
// Distinguishes "you have zero jobs, period" from "no results match your filters" so the
|
||||
// empty state can actually help a first-time user instead of just saying "nothing here".
|
||||
const noFiltersActive = !debouncedSearch.trim() && statusFilter === "All" && companyFilterId === "All"
|
||||
@@ -640,6 +650,7 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
|
||||
return (
|
||||
<Paper
|
||||
key={job.id}
|
||||
data-job-row-id={job.id}
|
||||
role={mode === "jobs" && !job.isDeleted ? "link" : undefined}
|
||||
tabIndex={mode === "jobs" && !job.isDeleted ? 0 : undefined}
|
||||
aria-label={mode === "jobs" && !job.isDeleted ? `Open ${job.jobTitle} at ${job.company?.name ?? "company"}` : undefined}
|
||||
@@ -780,6 +791,7 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
|
||||
return (
|
||||
<TableRow
|
||||
key={job.id}
|
||||
data-job-row-id={job.id}
|
||||
hover={mode === "jobs" && !job.isDeleted}
|
||||
tabIndex={mode === "jobs" && !job.isDeleted ? 0 : undefined}
|
||||
aria-label={mode === "jobs" && !job.isDeleted ? `Open ${job.jobTitle} at ${job.company?.name ?? "company"}` : undefined}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { api } from "../../api";
|
||||
import { extractApplicationAnswerDraft } from "../../applicationDrafts";
|
||||
import { AttachmentItem, JobApplication } from "../../types";
|
||||
|
||||
type PackageWorkspaceState = {
|
||||
@@ -9,23 +10,6 @@ type PackageWorkspaceState = {
|
||||
recruiterMessage: string;
|
||||
};
|
||||
|
||||
const APPLICATION_ANSWER_START = "<<<APPLICATION_ANSWER_DRAFT>>>";
|
||||
const APPLICATION_ANSWER_END = "<<<END_APPLICATION_ANSWER_DRAFT>>>";
|
||||
|
||||
function extractApplicationAnswerDraft(notes?: string | null) {
|
||||
const value = (notes ?? "").trim();
|
||||
if (!value) return "";
|
||||
|
||||
const startIndex = value.indexOf(APPLICATION_ANSWER_START);
|
||||
const endIndex = value.indexOf(APPLICATION_ANSWER_END);
|
||||
if (startIndex >= 0 && endIndex > startIndex) {
|
||||
return value.slice(startIndex + APPLICATION_ANSWER_START.length, endIndex).trim();
|
||||
}
|
||||
|
||||
const legacyMatch = value.match(/Application answer draft:\s*\n([\s\S]*)$/i);
|
||||
return legacyMatch?.[1]?.trim() ?? "";
|
||||
}
|
||||
|
||||
export function useJobWorkspaceBaseData({
|
||||
open,
|
||||
jobId,
|
||||
@@ -124,5 +108,4 @@ export function useJobWorkspaceBaseData({
|
||||
};
|
||||
}
|
||||
|
||||
export { extractApplicationAnswerDraft };
|
||||
export type { PackageWorkspaceState };
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useLocation, useNavigate, useParams, useSearchParams } from "react-router-dom";
|
||||
import {
|
||||
BlockerFunction, useBeforeUnload, useBlocker, useLocation, useNavigate, useParams, useSearchParams,
|
||||
} from "react-router-dom";
|
||||
|
||||
import {
|
||||
Alert, Box, Button, Chip, Divider, IconButton, List, ListItemButton, ListItemText, Paper,
|
||||
@@ -24,10 +26,11 @@ import {
|
||||
ApplicationAnalysis, ApplicationMatch, ApplicationTimeline,
|
||||
} from "../components/ApplicationIntelligence";
|
||||
import {
|
||||
ApplicationCoverLetterSection, ApplicationCvSection,
|
||||
ApplicationCoverLetterSection, ApplicationCvSection, ApplicationPackageDraftsSection,
|
||||
} from "../components/ApplicationAssets";
|
||||
import { ApplicationInterviewPrep } from "../components/InterviewPrep";
|
||||
import EditJobDialog from "../components/EditJobDialog";
|
||||
import { useConfirm } from "../confirm";
|
||||
import {
|
||||
WORKSPACE_SECTIONS, WorkspaceOverview, WorkspaceSectionKey, applicationWorkspaceApi, workspaceSection,
|
||||
} from "../applicationWorkspace";
|
||||
@@ -60,12 +63,48 @@ export function ApplicationWorkspace({
|
||||
const jobId = jobIdOverride ?? Number(id);
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const { confirm } = useConfirm();
|
||||
const [params, setParams] = useSearchParams();
|
||||
const section = sectionOverride ?? workspaceSection(params.get("section"));
|
||||
|
||||
const [overview, setOverview] = useState<WorkspaceOverview | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const [coverLetterDirty, setCoverLetterDirty] = useState(false);
|
||||
const [packageDraftsDirty, setPackageDraftsDirty] = useState(false);
|
||||
const hasUnsavedChanges = coverLetterDirty || packageDraftsDirty;
|
||||
|
||||
const shouldBlock = useCallback<BlockerFunction>(
|
||||
({ currentLocation, nextLocation }) => hasUnsavedChanges && (
|
||||
currentLocation.pathname !== nextLocation.pathname || currentLocation.search !== nextLocation.search
|
||||
),
|
||||
[hasUnsavedChanges],
|
||||
);
|
||||
const blocker = useBlocker(shouldBlock);
|
||||
|
||||
useBeforeUnload(useCallback((event) => {
|
||||
if (!hasUnsavedChanges) return;
|
||||
event.preventDefault();
|
||||
event.returnValue = "";
|
||||
}, [hasUnsavedChanges]));
|
||||
|
||||
useEffect(() => {
|
||||
if (blocker.state !== "blocked") return;
|
||||
const blockedNavigation = blocker;
|
||||
let active = true;
|
||||
void confirm({
|
||||
title: "Unsaved application changes",
|
||||
message: "Leaving this section will discard changes that have not been saved.",
|
||||
confirmLabel: "Discard and leave",
|
||||
cancelLabel: "Keep editing",
|
||||
destructive: true,
|
||||
}).then((approved) => {
|
||||
if (!active) return;
|
||||
if (approved) blockedNavigation.proceed();
|
||||
else blockedNavigation.reset();
|
||||
});
|
||||
return () => { active = false; };
|
||||
}, [blocker, confirm]);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!Number.isInteger(jobId) || jobId <= 0) {
|
||||
@@ -90,8 +129,13 @@ export function ApplicationWorkspace({
|
||||
else setParams({ section: next }, { replace: true, state: location.state });
|
||||
};
|
||||
const close = onClose ?? (() => {
|
||||
const from = (location.state as { from?: unknown } | null)?.from;
|
||||
navigate(typeof from === "string" && from.startsWith("/") && !from.startsWith("//") ? from : "/jobs", { replace: true });
|
||||
const state = location.state as { from?: unknown; focusJobId?: unknown } | null;
|
||||
const from = state?.from;
|
||||
const focusJobId = typeof state?.focusJobId === "number" ? state.focusJobId : undefined;
|
||||
navigate(
|
||||
typeof from === "string" && from.startsWith("/") && !from.startsWith("//") ? from : "/jobs",
|
||||
{ replace: true, state: focusJobId ? { focusJobId } : undefined },
|
||||
);
|
||||
});
|
||||
|
||||
if (error) {
|
||||
@@ -171,7 +215,14 @@ export function ApplicationWorkspace({
|
||||
{section === "cv" && jobId > 0 && <ApplicationCvSection jobId={jobId} />}
|
||||
{section === "cover-letter" && jobId > 0 && (
|
||||
<>
|
||||
<ApplicationCoverLetterSection jobId={jobId} />
|
||||
<ApplicationCoverLetterSection jobId={jobId} onDirtyChange={setCoverLetterDirty} />
|
||||
<ApplicationPackageDraftsSection
|
||||
jobId={jobId}
|
||||
initialApplicationAnswer={overview?.applicationAnswerDraft ?? ""}
|
||||
initialRecruiterMessage={overview?.recruiterMessageDraft ?? ""}
|
||||
onSaved={load}
|
||||
onDirtyChange={setPackageDraftsDirty}
|
||||
/>
|
||||
{/* Generation stays an explicit user action, below the editor the user owns. */}
|
||||
<Paper sx={{ p: 2, borderRadius: 3 }}><AiWorkspacePanel jobId={jobId} /></Paper>
|
||||
</>
|
||||
|
||||
Reference in New Issue
Block a user