fix(ui): preserve application drafts and recovery states

This commit is contained in:
cesnimda
2026-10-13 23:27:16 +02:00
parent 627955afa9
commit 4c3254c472
19 changed files with 5388 additions and 118 deletions
+382 -5
View File
@@ -1,14 +1,17 @@
import React from "react";
import "@testing-library/jest-dom";
import { fireEvent, render as rtlRender, screen, waitFor } from "@testing-library/react";
import { act, fireEvent, render as rtlRender, screen, waitFor, within } from "@testing-library/react";
import {
ApplicationCoverLetterSection, ApplicationCvSection, ApplicationPackageDraftsSection, SubmittedPackagesSection,
} from "./components/ApplicationAssets";
import { api } from "./api";
import { I18nProvider } from "./i18n/I18nProvider";
import { ConfirmProvider } from "./confirm";
import { translations, type UiLanguage } from "./i18n/translations";
const render = (ui: React.ReactElement) => rtlRender(<I18nProvider>{ui}</I18nProvider>);
const withProviders = (ui: React.ReactElement) => <I18nProvider><ConfirmProvider>{ui}</ConfirmProvider></I18nProvider>;
const render = (ui: React.ReactElement) => rtlRender(withProviders(ui));
jest.mock("./api", () => ({
api: {
@@ -17,7 +20,7 @@ jest.mock("./api", () => ({
post: jest.fn(),
interceptors: { request: { use: jest.fn() }, response: { use: jest.fn() } },
},
getApiErrorMessage: (_e: any, fallback?: string) => fallback || "Request failed.",
getApiErrorMessage: (_e: unknown, fallback = "Request failed.") => fallback,
}));
const mockedApi = api as jest.Mocked<typeof api>;
@@ -69,10 +72,37 @@ function routeGet(overrides: Record<string, any> = {}) {
beforeEach(() => {
window.localStorage.removeItem("uiLanguage");
document.documentElement.lang = "en";
jest.clearAllMocks();
mockedApi.get.mockReset();
mockedApi.put.mockReset();
mockedApi.post.mockReset();
});
afterEach(() => window.localStorage.removeItem("uiLanguage"));
afterEach(() => {
window.localStorage.removeItem("uiLanguage");
jest.restoreAllMocks();
});
function copy(key: string, params: Record<string, string | number> = {}, language: UiLanguage = document.documentElement.lang === "nb-NO" ? "nb" : "en") {
const template = (translations[language] as Record<string, string>)[key] ?? key;
return template.replace(/\{(\w+)\}/g, (_, name) => String(params[name] ?? `{${name}}`));
}
function section(key: string) {
return within(screen.getByRole("region", { name: copy(key) }));
}
function changeLanguage(language: UiLanguage) {
act(() => { window.dispatchEvent(new StorageEvent("storage", { key: "uiLanguage", newValue: language })); });
}
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason: unknown) => void;
const promise = new Promise<T>((yes, no) => { resolve = yes; reject = no; });
return { promise, resolve, reject };
}
test("document package history is read-only and separates captured notes from answers", async () => {
const summary = { id: 91, version: 1, cvVariantId: 3, cvVariantName: "Original CV", cvVariantVersion: 2, cvThemeId: "nordic", hasCv: false, hasCoverLetter: true, createdAtUtc: "2026-07-19T10:00:00Z", attachments: [] };
@@ -302,7 +332,7 @@ test("a failed load surfaces an error", async () => {
render(<ApplicationCoverLetterSection jobId={7} />);
expect(await screen.findByText(/Could not load this section/i)).toBeInTheDocument();
expect(await section("workspaceCoverLetter").findByText(/Could not load this section/i)).toBeInTheDocument();
});
// ---------- Application package drafts ----------
@@ -355,3 +385,350 @@ test("application package drafts can be cleared without deleting ordinary notes"
{ applicationAnswerDraft: "", recruiterMessageDraft: "" },
));
});
// ---------- Phase 7 retained-owner recovery ----------
test("failed cover-letter saving retains editable dirty text and original Save recovery across a live locale change", async () => {
routeGet();
const text = "Retained letter draft — æøå";
mockedApi.put.mockRejectedValueOnce({ response: { status: 503 } })
.mockResolvedValueOnce({ data: { ...coverLetter, text } } as any);
const onDirtyChange = jest.fn();
render(<ApplicationCoverLetterSection jobId={7} onDirtyChange={onDirtyChange} />);
fireEvent.change(await screen.findByRole("textbox", { name: copy("jobDetailsCoverLetter") }), { target: { value: text } });
fireEvent.click(section("workspaceCoverLetter").getByRole("button", { name: copy("save") }));
expect(await section("workspaceCoverLetter").findByRole("alert")).toHaveTextContent(copy("assetsCoverSaveFailed"));
expect(screen.getByRole("textbox", { name: copy("jobDetailsCoverLetter") })).toHaveValue(text);
expect(screen.getByRole("textbox", { name: copy("jobDetailsCoverLetter") })).toBeEnabled();
expect(section("workspaceCoverLetter").getByRole("button", { name: copy("save") })).toBeEnabled();
expect(section("workspaceCoverLetter").getByRole("button", { name: copy("assetsDiscardChanges") })).toBeEnabled();
expect(section("workspaceCoverLetter").queryByRole("button", { name: copy("retry") })).not.toBeInTheDocument();
expect(onDirtyChange).toHaveBeenLastCalledWith(true);
const reads = mockedApi.get.mock.calls.length;
changeLanguage("nb");
expect(section("workspaceCoverLetter").getByRole("alert")).toHaveTextContent(copy("assetsCoverSaveFailed"));
expect(screen.getByRole("textbox", { name: copy("jobDetailsCoverLetter") })).toHaveValue(text);
expect(mockedApi.get).toHaveBeenCalledTimes(reads);
fireEvent.click(section("workspaceCoverLetter").getByRole("button", { name: copy("save") }));
await waitFor(() => expect(mockedApi.put).toHaveBeenCalledTimes(2));
const payload = { text, source: "manual", aiAction: undefined };
expect(mockedApi.put).toHaveBeenNthCalledWith(1, "/jobapplications/7/cover-letter", payload);
expect(mockedApi.put).toHaveBeenNthCalledWith(2, "/jobapplications/7/cover-letter", payload);
await waitFor(() => expect(onDirtyChange).toHaveBeenLastCalledWith(false));
expect(screen.getByRole("textbox", { name: copy("jobDetailsCoverLetter") })).toHaveValue(text);
expect(mockedApi.post).not.toHaveBeenCalled();
});
test("failed application-answer saving keeps both dirty drafts editable and retries only the original save payload", async () => {
mockedApi.put.mockRejectedValueOnce({ response: { status: 503 } }).mockResolvedValueOnce({ status: 204 } as any);
const onDirtyChange = jest.fn();
const onSaved = jest.fn();
render(<ApplicationPackageDraftsSection jobId={7} initialApplicationAnswer="Saved answer" initialRecruiterMessage="Saved message" onDirtyChange={onDirtyChange} onSaved={onSaved} />);
fireEvent.change(screen.getByLabelText(copy("assetsApplicationAnswer")), { target: { value: "Retained answer" } });
fireEvent.change(screen.getByLabelText(copy("assetsRecruiterMessage")), { target: { value: "Retained recruiter draft" } });
fireEvent.click(screen.getByRole("button", { name: copy("assetsSaveDrafts") }));
expect(await section("assetsApplicationDrafts").findByRole("alert")).toHaveTextContent(copy("assetsDraftsSaveFailed"));
expect(screen.getByLabelText(copy("assetsApplicationAnswer"))).toHaveValue("Retained answer");
expect(screen.getByLabelText(copy("assetsRecruiterMessage"))).toHaveValue("Retained recruiter draft");
expect(screen.getByLabelText(copy("assetsApplicationAnswer"))).toBeEnabled();
expect(screen.getByLabelText(copy("assetsRecruiterMessage"))).toBeEnabled();
expect(onDirtyChange).toHaveBeenLastCalledWith(true);
expect(onSaved).not.toHaveBeenCalled();
changeLanguage("nb");
expect(section("assetsApplicationDrafts").getByRole("alert")).toHaveTextContent(copy("assetsDraftsSaveFailed"));
expect(mockedApi.get).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole("button", { name: copy("assetsSaveDrafts") }));
await waitFor(() => expect(onSaved).toHaveBeenCalledTimes(1));
const payload = { applicationAnswerDraft: "Retained answer", recruiterMessageDraft: "Retained recruiter draft" };
expect(mockedApi.put).toHaveBeenNthCalledWith(1, "/jobapplications/7/application-drafts", payload);
expect(mockedApi.put).toHaveBeenNthCalledWith(2, "/jobapplications/7/application-drafts", payload);
expect(onDirtyChange).toHaveBeenLastCalledWith(false);
expect(mockedApi.post).not.toHaveBeenCalled();
});
test("failed CV attachment leaves the current association and selector available for the original explicit action", async () => {
routeGet();
mockedApi.put.mockRejectedValueOnce({ response: { status: 503 } })
.mockResolvedValueOnce({ data: { ...cv, attachedVariantId: 5, attachedVariantName: "Generalist CV" } } as any);
render(<ApplicationCvSection jobId={7} includePackages={false} />);
const select = () => screen.getByRole("combobox", { name: copy("assetsAttachedCv") });
fireEvent.mouseDown(await screen.findByRole("combobox", { name: /Attached CV variant/i }));
fireEvent.click(await screen.findByRole("option", { name: /Generalist CV/ }));
expect(await section("workspaceCv").findByRole("alert")).toHaveTextContent(copy("assetsAttachCvFailed"));
expect(select()).toHaveTextContent("Backend CV");
expect(select()).not.toHaveAttribute("aria-disabled", "true");
expect(section("workspaceCv").getByRole("link", { name: /Edit, preview and export/i })).toHaveAttribute("href", "/career/builder/3");
expect(section("workspaceCv").queryByRole("button", { name: copy("retry") })).not.toBeInTheDocument();
fireEvent.mouseDown(select());
fireEvent.click(await screen.findByRole("option", { name: /Generalist CV/ }));
await waitFor(() => expect(mockedApi.put).toHaveBeenCalledTimes(2));
expect(mockedApi.put).toHaveBeenNthCalledWith(1, "/jobapplications/7/cv", { variantId: 5 });
expect(mockedApi.put).toHaveBeenNthCalledWith(2, "/jobapplications/7/cv", { variantId: 5 });
await waitFor(() => expect(section("workspaceCv").getByRole("link", { name: /Edit, preview and export/i })).toHaveAttribute("href", "/career/builder/5"));
expect(mockedApi.post).not.toHaveBeenCalled();
});
test("dirty version restore cancels without a write and confirmed held restore appends history exactly once", async () => {
routeGet();
const write = deferred<any>();
mockedApi.post.mockImplementation(() => write.promise);
const onDirtyChange = jest.fn();
render(<ApplicationCoverLetterSection jobId={7} onDirtyChange={onDirtyChange} />);
fireEvent.change(await screen.findByRole("textbox", { name: "Cover letter" }), { target: { value: "Unsaved text before restore" } });
fireEvent.click(screen.getByRole("button", { name: /Restore version 1/i }));
expect(mockedApi.post).not.toHaveBeenCalled();
const dialog = await screen.findByRole("dialog", { name: copy("assetsRestoreDiscardTitle") });
fireEvent.click(within(dialog).getByRole("button", { name: copy("cancel") }));
await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
expect(screen.getByRole("textbox", { name: "Cover letter" })).toHaveValue("Unsaved text before restore");
expect(onDirtyChange).toHaveBeenLastCalledWith(true);
expect(mockedApi.post).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole("button", { name: /Restore version 1/i }));
const confirmed = await screen.findByRole("dialog", { name: copy("assetsRestoreDiscardTitle") });
const confirm = within(confirmed).getByRole("button", { name: copy("assetsRestoreConfirm") });
act(() => { fireEvent.click(confirm); fireEvent.click(confirm); });
await waitFor(() => expect(mockedApi.post).toHaveBeenCalledTimes(1));
expect(mockedApi.post).toHaveBeenCalledWith("/jobapplications/7/cover-letter/versions/1/restore");
await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
expect(screen.getByRole("button", { name: /Restore version 1/i })).toBeDisabled();
await act(async () => { write.resolve({ data: {
...coverLetter, text: "Restored old text", currentVersion: 3,
versions: [{ version: 3, source: "restore", aiAction: null, length: 17, createdAtUtc: "2026-07-20T10:00:00Z", isCurrent: true }, ...coverLetter.versions.map((version) => ({ ...version, isCurrent: false }))],
} }); });
expect(await screen.findByDisplayValue("Restored old text")).toBeInTheDocument();
expect(screen.getByText("v3")).toBeInTheDocument();
expect(screen.getByText("v2")).toBeInTheDocument();
expect(screen.getByText("v1")).toBeInTheDocument();
expect(onDirtyChange).toHaveBeenLastCalledWith(false);
expect(mockedApi.post).toHaveBeenCalledTimes(1);
expect(mockedApi.put).not.toHaveBeenCalled();
});
test("held letter Save submits one snapshot and prevents an AI Apply from replacing its pending draft", async () => {
routeGet();
mockedApi.post.mockResolvedValue({ data: { result: { text: "Unaccepted AI suggestion" } } } as any);
const write = deferred<any>();
mockedApi.put.mockImplementation(() => write.promise);
render(<ApplicationCoverLetterSection jobId={7} />);
fireEvent.click(await screen.findByRole("button", { name: "Generate" }));
const apply = await screen.findByRole("button", { name: /Apply to editor/i });
fireEvent.change(screen.getByRole("textbox", { name: "Cover letter" }), { target: { value: "Explicit manual save snapshot" } });
const save = section("workspaceCoverLetter").getByRole("button", { name: "Save" });
act(() => { fireEvent.click(save); fireEvent.click(save); });
expect(mockedApi.put).toHaveBeenCalledTimes(1);
expect(mockedApi.put).toHaveBeenCalledWith("/jobapplications/7/cover-letter", { text: "Explicit manual save snapshot", source: "manual", aiAction: undefined });
expect(apply).toBeDisabled();
fireEvent.click(apply);
expect(screen.getByRole("textbox", { name: "Cover letter" })).toHaveValue("Explicit manual save snapshot");
await act(async () => { write.resolve({ data: { ...coverLetter, text: "Explicit manual save snapshot" } }); });
expect(screen.getByRole("textbox", { name: "Cover letter" })).toHaveValue("Explicit manual save snapshot");
expect(mockedApi.put).toHaveBeenCalledTimes(1);
});
test("held AI generation locks its controls and preserves captured action attribution after a later action selection", async () => {
routeGet();
const generation = deferred<any>();
mockedApi.post.mockImplementation(() => generation.promise);
render(<ApplicationCoverLetterSection jobId={7} />);
await screen.findByText(/application's full job advert and analysis/i);
fireEvent.mouseDown(screen.getByRole("combobox", { name: "Document language" }));
fireEvent.click(await screen.findByRole("option", { name: /Norsk bokmål/i }));
fireEvent.change(screen.getByRole("textbox", { name: "Additional instructions" }), { target: { value: "Use only recorded context" } });
const generate = screen.getByRole("button", { name: "Generate" });
act(() => { fireEvent.click(generate); fireEvent.click(generate); });
expect(mockedApi.post).toHaveBeenCalledTimes(1);
expect(mockedApi.post).toHaveBeenCalledWith("/jobapplications/7/ai/generate", expect.objectContaining({ module: "cover-letter", action: "generate", mode: "professional", targetLanguage: "nb-NO", currentText: "Dear team", extraContext: "Use only recorded context" }));
for (const name of ["Action", "Tone", "Document language"]) expect(screen.getByRole("combobox", { name })).toHaveAttribute("aria-disabled", "true");
expect(screen.getByRole("textbox", { name: "Additional instructions" })).toBeDisabled();
await act(async () => { generation.resolve({ data: { result: { text: "Captured generated suggestion" } } }); });
fireEvent.mouseDown(screen.getByRole("combobox", { name: "Action" }));
fireEvent.click(await screen.findByRole("option", { name: "Improve" }));
fireEvent.click(screen.getByRole("button", { name: /Apply to editor/i }));
mockedApi.put.mockResolvedValue({ data: { ...coverLetter, text: "Captured generated suggestion" } } as any);
fireEvent.click(section("workspaceCoverLetter").getByRole("button", { name: "Save" }));
await waitFor(() => expect(mockedApi.put).toHaveBeenCalledWith("/jobapplications/7/cover-letter", { text: "Captured generated suggestion", source: "ai", aiAction: "generate" }));
expect(mockedApi.post).toHaveBeenCalledTimes(1);
});
test("capture, detail and download failures keep owned frozen history and recover only through their original explicit actions", async () => {
const attachment = { id: 17, fileName: "frozen-letter.txt", fileSize: 1024, contentType: "text/plain", sha256: "fixture-hash" };
const summary = { id: 91, version: 1, cvVariantId: 3, cvVariantName: "Original CV", cvVariantVersion: 2, cvThemeId: "nordic", hasCv: true, hasCoverLetter: true, createdAtUtc: "2026-07-19T10:00:00Z", attachments: [attachment] };
const created = { ...summary, id: 92, version: 2, cvVariantName: "Backend CV", cvVariantVersion: 4, attachments: [] };
const detail = { summary, renderedCvHtml: "<html><body>Frozen CV fixture</body></html>", coverLetterText: "Frozen original letter", applicationMaterialJson: "{}" };
const blob = new Blob(["Frozen file fixture"], { type: "text/plain" });
let detailReads = 0;
let downloads = 0;
mockedApi.get.mockImplementation((url: string) => {
if (url === "/jobapplications/7/submitted-packages/91") return ++detailReads === 1 ? Promise.reject({ response: { status: 503 } }) : Promise.resolve({ data: detail } as any);
if (url === "/jobapplications/7/submitted-packages/91/attachments/17") return ++downloads === 1 ? Promise.reject({ response: { status: 503 } }) : Promise.resolve({ data: blob } as any);
if (url.endsWith("/submitted-packages")) return Promise.resolve({ data: [summary] } as any);
if (url.endsWith("/tailoring")) return Promise.resolve({ data: tailoring } as any);
return Promise.resolve({ data: cv } as any);
});
mockedApi.post.mockRejectedValueOnce({ response: { status: 503 } }).mockResolvedValueOnce({ data: created } as any);
const originalCreate = Object.getOwnPropertyDescriptor(URL, "createObjectURL");
const originalRevoke = Object.getOwnPropertyDescriptor(URL, "revokeObjectURL");
const createUrl = jest.fn(() => "blob:owned-frozen-fixture");
const revokeUrl = jest.fn();
Object.defineProperty(URL, "createObjectURL", { configurable: true, value: createUrl });
Object.defineProperty(URL, "revokeObjectURL", { configurable: true, value: revokeUrl });
const downloadedNames: string[] = [];
jest.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(function (this: HTMLAnchorElement) { downloadedNames.push(this.download); });
try {
render(<ApplicationCvSection jobId={7} />);
fireEvent.click(await screen.findByRole("button", { name: /Freeze current package/i }));
expect(await section("assetsSubmittedPackages").findByRole("alert")).toHaveTextContent(copy("assetsPackageCaptureFailed"));
expect(screen.getByText("Submission package v1")).toBeInTheDocument();
expect(section("workspaceCv").getByRole("link", { name: /Edit, preview and export/i })).toHaveAttribute("href", "/career/builder/3");
expect(section("assetsSubmittedPackages").queryByRole("button", { name: copy("retry") })).not.toBeInTheDocument();
expect(mockedApi.post).toHaveBeenCalledTimes(1);
fireEvent.click(section("assetsSubmittedPackages").getByRole("button", { name: /Freeze current package/i }));
await screen.findByText("Submission package v2");
const packageRow = () => screen.getByText("Submission package v1").closest<HTMLElement>(".MuiPaper-root")!;
fireEvent.click(within(packageRow()).getByRole("button", { name: "View preserved package" }));
expect(await section("assetsSubmittedPackages").findByRole("alert")).toHaveTextContent(copy("assetsPackageLoadFailed"));
expect(screen.getByText("Submission package v1")).toBeInTheDocument();
expect(screen.getByText("Submission package v2")).toBeInTheDocument();
expect(detailReads).toBe(1);
expect(mockedApi.post).toHaveBeenCalledTimes(2);
fireEvent.click(within(packageRow()).getByRole("button", { name: "View preserved package" }));
const preserved = await screen.findByRole("dialog");
expect(preserved).toHaveTextContent("Frozen original letter");
expect(detailReads).toBe(2);
fireEvent.click(within(preserved).getByRole("button", { name: "Close" }));
await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
fireEvent.click(within(packageRow()).getByRole("button", { name: /frozen-letter\.txt/ }));
expect(await section("assetsSubmittedPackages").findByRole("alert")).toHaveTextContent(copy("assetsPackageDownloadFailed"));
expect(screen.getByText("Original CV · v2")).toBeInTheDocument();
expect(section("workspaceCv").getByRole("link", { name: /Edit, preview and export/i })).toHaveAttribute("href", "/career/builder/3");
expect(downloads).toBe(1);
fireEvent.click(within(packageRow()).getByRole("button", { name: /frozen-letter\.txt/ }));
await waitFor(() => expect(createUrl).toHaveBeenCalledWith(blob));
expect(downloads).toBe(2);
expect(downloadedNames).toEqual(["frozen-letter.txt"]);
expect(revokeUrl).toHaveBeenCalledWith("blob:owned-frozen-fixture");
expect(mockedApi.post.mock.calls).toEqual([["/jobapplications/7/submitted-packages"], ["/jobapplications/7/submitted-packages"]]);
expect(mockedApi.put).not.toHaveBeenCalled();
} finally {
if (originalCreate) Object.defineProperty(URL, "createObjectURL", originalCreate); else Reflect.deleteProperty(URL, "createObjectURL");
if (originalRevoke) Object.defineProperty(URL, "revokeObjectURL", originalRevoke); else Reflect.deleteProperty(URL, "revokeObjectURL");
}
});
test("all five asset read owners expose GET-only recovery without false empty history or locale refetch", async () => {
mockedApi.get.mockRejectedValue({ response: { status: 503 } });
render(<><ApplicationCvSection jobId={7} includePackages={false} /><SubmittedPackagesSection jobId={7} allowCapture={false} /><ApplicationCoverLetterSection jobId={7} /></>);
const owners = ["workspaceCv", "assetsTailoring", "assetsSubmittedPackages", "workspaceCoverLetter", "coverAiTitle"];
await waitFor(() => expect(mockedApi.get).toHaveBeenCalledTimes(5));
for (const key of owners) expect(await section(key).findByRole("alert")).toHaveTextContent(copy("assetsLoadFailed"));
expect(screen.queryByText(copy("assetsNoVersions"))).not.toBeInTheDocument();
expect(screen.queryByText(copy("assetsNoSubmittedPackages"))).not.toBeInTheDocument();
expect(screen.queryByText(copy("coverAiSelectCv"))).not.toBeInTheDocument();
changeLanguage("nb");
for (const key of owners) expect(section(key).getByRole("alert")).toHaveTextContent(copy("assetsLoadFailed"));
expect(mockedApi.get).toHaveBeenCalledTimes(5);
routeGet();
const endpoints = ["cv", "tailoring", "submitted-packages", "cover-letter", "cv"];
for (let index = 0; index < owners.length; index++) {
fireEvent.click(section(owners[index]).getByRole("button", { name: copy("retry") }));
await waitFor(() => expect(mockedApi.get).toHaveBeenCalledTimes(6 + index));
expect(mockedApi.get).toHaveBeenNthCalledWith(6 + index, `/jobapplications/7/${endpoints[index]}`);
await waitFor(() => expect(section(owners[index]).queryByRole("button", { name: copy("retry") })).not.toBeInTheDocument());
}
expect(screen.getByRole("textbox", { name: copy("jobDetailsCoverLetter") })).toHaveValue("Dear team");
expect(screen.getByText("v2")).toBeInTheDocument();
expect(mockedApi.put).not.toHaveBeenCalled();
expect(mockedApi.post).not.toHaveBeenCalled();
});
test("keyed job changes stop a late CV copy from attaching and never reuse another job's saved-copy recovery", async () => {
const jobBCv = {
...cv,
attachedVariantId: 9,
attachedVariantName: "Job B CV",
attachedVersion: 6,
availableVariants: [{ ...cv.availableVariants[0], id: 9, name: "Job B CV", version: 6, jobApplicationId: 8 }],
};
mockedApi.get.mockImplementation((url: string) => Promise.resolve({
data: url.endsWith("/tailoring") ? tailoring : url === "/jobapplications/8/cv" ? jobBCv : cv,
} as any));
const oldCopy = deferred<any>();
mockedApi.post.mockReturnValueOnce(oldCopy.promise);
const mounted = render(<ApplicationCvSection key={7} jobId={7} includePackages={false} />);
const initialLocation = window.location.href;
fireEvent.click(await screen.findByRole("button", { name: copy("assetsDuplicateForJob") }));
expect(mockedApi.post).toHaveBeenCalledWith("/cv/variants/3/duplicate", {
name: copy("assetsTailoredCopyName", { name: "Backend CV" }),
});
mounted.rerender(withProviders(<ApplicationCvSection key={8} jobId={8} includePackages={false} />));
await waitFor(() => expect(screen.getByRole("combobox", { name: copy("assetsAttachedCv") })).toHaveTextContent("Job B CV"));
await act(async () => { oldCopy.resolve({ data: { id: 83 } }); await oldCopy.promise; });
expect(mockedApi.put).not.toHaveBeenCalled();
expect(window.location.href).toBe(initialLocation);
expect(section("workspaceCv").getByRole("link", { name: /Edit, preview and export/i })).toHaveAttribute("href", "/career/builder/9");
expect(screen.getByRole("button", { name: copy("assetsDuplicateForJob") })).toBeEnabled();
expect(screen.queryByRole("button", { name: copy("assetsAttachCreatedCopy") })).not.toBeInTheDocument();
mounted.rerender(withProviders(<ApplicationCvSection key={7} jobId={7} includePackages={false} />));
mockedApi.post.mockResolvedValueOnce({ data: { id: 84 } } as any);
mockedApi.put.mockRejectedValueOnce({ response: { status: 503 } });
fireEvent.click(await screen.findByRole("button", { name: copy("assetsDuplicateForJob") }));
await waitFor(() => expect(screen.getByRole("button", { name: copy("assetsAttachCreatedCopy") })).toBeEnabled());
expect(await section("workspaceCv").findByText(copy("assetsAttachCvFailed"))).toBeInTheDocument();
expect(mockedApi.put).toHaveBeenCalledTimes(1);
expect(mockedApi.put).toHaveBeenCalledWith("/jobapplications/7/cv", { variantId: 84 });
mounted.rerender(withProviders(<ApplicationCvSection key={8} jobId={8} includePackages={false} />));
await waitFor(() => expect(screen.getByRole("combobox", { name: copy("assetsAttachedCv") })).toHaveTextContent("Job B CV"));
expect(screen.queryByRole("button", { name: copy("assetsAttachCreatedCopy") })).not.toBeInTheDocument();
expect(screen.queryByText(copy("assetsCopyCreatedHelp"))).not.toBeInTheDocument();
const jobBCopy = deferred<any>();
mockedApi.post.mockReturnValueOnce(jobBCopy.promise);
fireEvent.click(screen.getByRole("button", { name: copy("assetsDuplicateForJob") }));
expect(mockedApi.post).toHaveBeenCalledTimes(3);
expect(mockedApi.post).toHaveBeenLastCalledWith("/cv/variants/9/duplicate", {
name: copy("assetsTailoredCopyName", { name: "Job B CV" }),
});
expect(mockedApi.put).toHaveBeenCalledTimes(1);
expect(section("workspaceCv").getByRole("link", { name: /Edit, preview and export/i })).toHaveAttribute("href", "/career/builder/9");
expect(window.location.href).toBe(initialLocation);
mounted.unmount();
});
test("keyed job changes keep job B's answer drafts and callbacks independent of job A's late save", async () => {
const saveA = deferred<any>();
mockedApi.put.mockReturnValueOnce(saveA.promise);
const onSavedA = jest.fn();
const onSavedB = jest.fn();
const onDirtyB = jest.fn();
const mounted = render(<ApplicationPackageDraftsSection key={7} jobId={7}
initialApplicationAnswer="Saved A answer" initialRecruiterMessage="Saved A message" onSaved={onSavedA} />);
fireEvent.change(screen.getByRole("textbox", { name: copy("assetsApplicationAnswer") }), { target: { value: "Pending A answer" } });
fireEvent.change(screen.getByRole("textbox", { name: copy("assetsRecruiterMessage") }), { target: { value: "Pending A message" } });
fireEvent.click(screen.getByRole("button", { name: copy("assetsSaveDrafts") }));
expect(mockedApi.put).toHaveBeenCalledWith("/jobapplications/7/application-drafts", {
applicationAnswerDraft: "Pending A answer", recruiterMessageDraft: "Pending A message",
});
mounted.rerender(withProviders(<ApplicationPackageDraftsSection key={8} jobId={8}
initialApplicationAnswer="Saved B answer" initialRecruiterMessage="Saved B message" onSaved={onSavedB} onDirtyChange={onDirtyB} />));
fireEvent.change(screen.getByRole("textbox", { name: copy("assetsApplicationAnswer") }), { target: { value: "Unsaved B answer" } });
fireEvent.change(screen.getByRole("textbox", { name: copy("assetsRecruiterMessage") }), { target: { value: "Unsaved B message" } });
await act(async () => { saveA.resolve({ data: undefined }); await saveA.promise; });
expect(screen.getByRole("textbox", { name: copy("assetsApplicationAnswer") })).toHaveValue("Unsaved B answer");
expect(screen.getByRole("textbox", { name: copy("assetsRecruiterMessage") })).toHaveValue("Unsaved B message");
expect(screen.getByRole("button", { name: copy("assetsSaveDrafts") })).toBeEnabled();
expect(onDirtyB).toHaveBeenLastCalledWith(true);
expect(onSavedA).not.toHaveBeenCalled();
expect(onSavedB).not.toHaveBeenCalled();
expect(mockedApi.put).toHaveBeenCalledTimes(1);
mockedApi.put.mockResolvedValueOnce({ data: undefined } as any);
fireEvent.click(screen.getByRole("button", { name: copy("assetsSaveDrafts") }));
await waitFor(() => expect(onSavedB).toHaveBeenCalledTimes(1));
expect(mockedApi.put).toHaveBeenLastCalledWith("/jobapplications/8/application-drafts", {
applicationAnswerDraft: "Unsaved B answer", recruiterMessageDraft: "Unsaved B message",
});
expect(onSavedA).not.toHaveBeenCalled();
});
@@ -1,6 +1,6 @@
import React from "react";
import "@testing-library/jest-dom";
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { createMemoryRouter, RouterProvider, Route, Routes, useLocation } from "react-router-dom";
import { api } from "./api";
@@ -27,7 +27,7 @@ jest.mock("./components/ApplicationAssets", () => ({
</div>
),
ApplicationCvSection: () => <div>CV section</div>,
ApplicationPackageDraftsSection: ({ onDirtyChange }: { onDirtyChange?: (dirty: boolean) => void }) => <div>Application drafts section<button onClick={() => onDirtyChange?.(true)}>Make answers dirty</button></div>,
ApplicationPackageDraftsSection: ({ jobId, initialApplicationAnswer, initialRecruiterMessage, onDirtyChange }: { jobId: number; initialApplicationAnswer: string; initialRecruiterMessage: string; onDirtyChange?: (dirty: boolean) => void }) => <div>Application drafts section<output data-testid="answer-props">{jobId}|{initialApplicationAnswer}|{initialRecruiterMessage}</output><button onClick={() => onDirtyChange?.(true)}>Make answers dirty</button></div>,
SubmittedPackagesSection: () => <div>Frozen packages section</div>,
}));
jest.mock("./components/InterviewPrep", () => ({ ApplicationInterviewPrep: () => <div>Interview section</div> }));
@@ -136,7 +136,7 @@ function renderTable(path = "/jobs") {
},
], { initialEntries: [path] });
return render(
const mounted = render(
<ToastProvider>
<I18nProvider>
<ConfirmProvider>
@@ -147,6 +147,7 @@ function renderTable(path = "/jobs") {
</I18nProvider>
</ToastProvider>,
);
return { ...mounted, router };
}
beforeEach(() => {
@@ -383,3 +384,30 @@ test("legacy correspondence and notes reveal distinct domain content", async ()
expect(screen.queryByText("Application drafts section")).not.toBeInTheDocument();
expect(screen.getByTestId("location")).toHaveTextContent("followMode=waiting-update");
});
test("holds answers until their aggregate matches the route and ignores a late previous-job aggregate", async () => {
let resolveB!: (value: any) => void;
let resolveC!: (value: any) => void;
const heldB = new Promise<any>((resolve) => { resolveB = resolve; });
const heldC = new Promise<any>((resolve) => { resolveC = resolve; });
const originalGet = mockedApi.get.getMockImplementation()!;
mockedApi.get.mockImplementation((url: string, ...rest: any[]) => {
if (url === "/jobapplications/43/workspace") return heldB;
if (url === "/jobapplications/44/workspace") return heldC;
return originalGet(url, ...rest);
});
const { router } = renderTable("/jobs/42?section=application&sub=answers");
expect(await screen.findByTestId("answer-props")).toHaveTextContent("42|Saved answer|Saved recruiter message");
await act(async () => { await router.navigate("/jobs/43?section=application&sub=answers"); });
await waitFor(() => expect(mockedApi.get).toHaveBeenCalledWith("/jobapplications/43/workspace"));
expect(screen.queryByTestId("answer-props")).not.toBeInTheDocument();
await act(async () => { await router.navigate("/jobs/44?section=application&sub=answers"); });
expect(screen.queryByTestId("answer-props")).not.toBeInTheDocument();
await act(async () => { resolveC({ data: { ...overview, id: 44, jobTitle: "Job C", applicationAnswerDraft: "C answer", recruiterMessageDraft: "C message" } }); await heldC; });
expect(await screen.findByTestId("answer-props")).toHaveTextContent("44|C answer|C message");
await act(async () => { resolveB({ data: { ...overview, id: 43, jobTitle: "Late Job B", applicationAnswerDraft: "B answer", recruiterMessageDraft: "B message" } }); await heldB; });
expect(screen.getByTestId("answer-props")).toHaveTextContent("44|C answer|C message");
expect(screen.getByRole("heading", { name: "Job C" })).toBeInTheDocument();
expect(mockedApi.post).not.toHaveBeenCalled();
expect(mockedApi.put).not.toHaveBeenCalled();
});
@@ -19,6 +19,9 @@ import { cvBuilderApi } from "../cvBuilder";
import { aiWorkspaceApi } from "../aiWorkspace";
import { useAccountPlan } from "../accountPlan";
import { useI18n } from "../i18n/I18nProvider";
import type { TranslationKey } from "../i18n/translations";
import type { ViewResourceError } from "../hooks/useViewResource";
import { useConfirm } from "../confirm";
import { PageSection } from "../layout/PageLayout";
import ViewStateNotice from "./ViewStateNotice";
import { extractApplicationAnswerDraft, removeApplicationAnswerDraft } from "../applicationDrafts";
@@ -30,52 +33,75 @@ import { extractApplicationAnswerDraft, removeApplicationAnswerDraft } from "../
// application uses and links out. The cover letter is the one thing genuinely owned here, because it
// is application-specific by nature. docs/architecture/overview.md.
type AssetError = ViewResourceError & { messageKey: TranslationKey; retry?: () => Promise<void> };
function assetError(error: unknown, messageKey: TranslationKey, retryable = false): AssetError {
const status = (error as { response?: { status?: number } })?.response?.status;
const unauthorized = status === 401 || status === 403;
return { kind: unauthorized ? "unauthorized" : "error", message: getApiErrorMessage(error, ""), messageKey, retryable: retryable && !unauthorized, status };
}
function useAsset<T>(load: () => Promise<T>, deps: React.DependencyList) {
const { t } = useI18n();
const [data, setData] = useState<T | null>(null);
const [error, setError] = useState<string | null>(null);
const [error, setError] = useState<AssetError | null>(null);
const [loading, setLoading] = useState(true);
const request = useRef(0);
const pending = useRef(false);
const active = useRef(true);
// eslint-disable-next-line react-hooks/exhaustive-deps
const run = useCallback(load, deps);
const reload = useCallback(() => {
let cancelled = false;
const reload = useCallback(async () => {
if (pending.current) return;
pending.current = true;
const current = ++request.current;
setLoading(true);
run()
.then((r) => {
if (!cancelled) {
setData(r);
setError(null);
}
})
.catch((err) => {
if (!cancelled) setError(getApiErrorMessage(err, t("assetsLoadFailed")));
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [run, t]);
try {
const next = await run();
if (current !== request.current) return;
setData(next);
setError(null);
} catch (err) {
if (current === request.current) setError(assetError(err, "assetsLoadFailed", true));
} finally {
if (current === request.current) { pending.current = false; setLoading(false); }
}
}, [run]);
useEffect(() => reload(), [reload]);
useEffect(() => {
active.current = true;
setData(null);
setError(null);
pending.current = false;
void reload();
return () => { active.current = false; request.current += 1; pending.current = false; };
}, [reload]);
return { data, error, loading, setData, setError, reload };
return { data, error, loading, setData, setError, reload, isCurrent: () => active.current };
}
function Shell({ title, subtitle, loading, error, children }: {
function Shell({ title, subtitle, loading, error, available = true, onRetry, contentRef, children }: {
title: string;
subtitle?: string;
loading: boolean;
error: string | null;
error: AssetError | null;
available?: boolean;
onRetry?: () => void | Promise<void>;
contentRef?: React.RefObject<HTMLDivElement | null>;
children: React.ReactNode;
}) {
const { t } = useI18n();
const localRef = useRef<HTMLDivElement>(null);
const bodyRef = contentRef ?? localRef;
const retry = error?.retry ?? onRetry;
return (
<PageSection surface title={title} description={subtitle} level="h3">
<ViewStateNotice title={title} loading={loading} error={error ? { kind: "error", message: error, retryable: false } : null} compact />
{!loading && !error && children}
<Box ref={bodyRef} role="group" aria-label={title} tabIndex={-1} sx={{ minWidth: 0 }}>
<ViewStateNotice title={title} loading={loading} error={error ? { ...error, message: error.message || t(error.messageKey) } : null}
onRetry={retry ? () => { bodyRef.current?.focus(); return retry(); } : undefined} compact />
{available && error?.kind !== "unauthorized" && children}
</Box>
</PageSection>
);
}
@@ -84,20 +110,29 @@ function Shell({ title, subtitle, loading, error, children }: {
export function ApplicationCvSection({ jobId, includePackages = true }: { jobId: number; includePackages?: boolean }) {
const { t } = useI18n();
const { data, error, loading, setData, setError } = useAsset<ApplicationCv>(
const { data, error, loading, setData, setError, reload, isCurrent } = useAsset<ApplicationCv>(
() => applicationAssetsApi.cv(jobId),
[jobId],
);
const [busy, setBusy] = useState(false);
const busyRef = useRef(false);
const contentRef = useRef<HTMLDivElement>(null);
const [createdCopy, setCreatedCopy] = useState<number | null>(null);
const attach = async (variantId: number | null) => {
if (busyRef.current || loading || !data) return;
busyRef.current = true;
contentRef.current?.focus();
setBusy(true);
try {
setData(await applicationAssetsApi.attachVariant(jobId, variantId));
const next = await applicationAssetsApi.attachVariant(jobId, variantId);
if (!isCurrent()) return;
setData(next);
setError(null);
} catch (err) {
setError(getApiErrorMessage(err, t("assetsAttachCvFailed")));
if (isCurrent()) setError(assetError(err, "assetsAttachCvFailed"));
} finally {
busyRef.current = false;
setBusy(false);
}
};
@@ -105,15 +140,24 @@ export function ApplicationCvSection({ jobId, includePackages = true }: { jobId:
const attached = data?.attachedVariantId ?? "";
const duplicateForJob = async () => {
if (!data?.attachedVariantId) return;
if (busyRef.current || loading || !data?.attachedVariantId) return;
busyRef.current = true;
contentRef.current?.focus();
setBusy(true);
let copyId = createdCopy;
try {
const copy = await cvBuilderApi.duplicate(data.attachedVariantId, t("assetsTailoredCopyName", { name: data.attachedVariantName || "CV" }));
setData(await applicationAssetsApi.attachVariant(jobId, copy.id));
window.location.assign(`/career/builder/${copy.id}`);
copyId ??= (await cvBuilderApi.duplicate(data.attachedVariantId, t("assetsTailoredCopyName", { name: data.attachedVariantName || "CV" }))).id;
if (!isCurrent()) return;
setCreatedCopy(copyId);
const next = await applicationAssetsApi.attachVariant(jobId, copyId);
if (!isCurrent()) return;
setData(next);
setCreatedCopy(null);
window.location.assign(`/career/builder/${copyId}`);
} catch (err) {
setError(getApiErrorMessage(err, t("assetsDuplicateCvFailed")));
if (isCurrent()) setError(assetError(err, copyId ? "assetsAttachCvFailed" : "assetsDuplicateCvFailed"));
} finally {
busyRef.current = false;
setBusy(false);
}
};
@@ -123,8 +167,11 @@ export function ApplicationCvSection({ jobId, includePackages = true }: { jobId:
<Shell
title={t("workspaceCv")}
subtitle={t("assetsCvSubtitle")}
loading={loading}
loading={loading || busy}
error={error}
available={data !== null}
onRetry={reload}
contentRef={contentRef}
>
<Stack spacing={2}>
{(data?.availableVariants.length ?? 0) === 0 ? (
@@ -138,7 +185,7 @@ export function ApplicationCvSection({ jobId, includePackages = true }: { jobId:
fullWidth
label={t("assetsAttachedCv")}
value={attached}
disabled={busy}
disabled={busy || loading}
onChange={(e) => attach(e.target.value === "" ? null : Number(e.target.value))}
helperText={t("assetsAttachedCvHelp")}
>
@@ -172,8 +219,8 @@ export function ApplicationCvSection({ jobId, includePackages = true }: { jobId:
>
{t("assetsEditPreviewExport")}
</Button>
<Button size="small" variant="contained" disabled={busy} onClick={() => void duplicateForJob()}>
{t("assetsDuplicateForJob")}
<Button size="small" variant="contained" disabled={busy || loading} onClick={() => void duplicateForJob()}>
{t(createdCopy ? "assetsAttachCreatedCopy" : "assetsDuplicateForJob")}
</Button>
</Stack>
</Stack>
@@ -189,6 +236,7 @@ export function ApplicationCvSection({ jobId, includePackages = true }: { jobId:
{t("assetsLegacyCvText")}
</Alert>
)}
{createdCopy && <Alert severity="info">{t("assetsCopyCreatedHelp")}</Alert>}
</Stack>
</Shell>
@@ -202,49 +250,73 @@ export function SubmittedPackagesSection({ jobId, allowCapture = true }: { jobId
const titleId = useId();
const viewTrigger = useRef<HTMLButtonElement | null>(null);
const { language, t } = useI18n();
const { data, error, loading, setData, setError } = useAsset<SubmittedPackage[]>(
const { data, error, loading, setData, setError, reload, isCurrent } = useAsset<SubmittedPackage[]>(
() => applicationAssetsApi.submittedPackages(jobId),
[jobId],
);
const [busy, setBusy] = useState(false);
const [detail, setDetail] = useState<SubmittedPackageDetail | null>(null);
const [detailBusy, setDetailBusy] = useState(false);
const [downloadBusy, setDownloadBusy] = useState(false);
const operation = useRef(false);
const contentRef = useRef<HTMLDivElement>(null);
const pending = busy || detailBusy || downloadBusy || loading;
const capture = async () => {
if (operation.current || loading || !data) return;
operation.current = true;
contentRef.current?.focus();
setBusy(true);
try {
const created = await applicationAssetsApi.captureSubmittedPackage(jobId);
if (!isCurrent()) return;
setData([created, ...(data ?? [])]);
setError(null);
} catch (err) {
setError(getApiErrorMessage(err, t("assetsPackageCaptureFailed")));
if (isCurrent()) setError(assetError(err, "assetsPackageCaptureFailed"));
} finally {
operation.current = false;
setBusy(false);
}
};
const download = async (item: SubmittedPackage, attachment: SubmittedPackage["attachments"][number]) => {
if (operation.current || loading || !data) return;
operation.current = true;
contentRef.current?.focus();
setDownloadBusy(true);
try {
const blob = await applicationAssetsApi.downloadSubmittedAttachment(jobId, item.id, attachment.id);
if (!isCurrent()) return;
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = attachment.fileName;
anchor.click();
URL.revokeObjectURL(url);
setError(null);
} catch (err) {
setError(getApiErrorMessage(err, t("assetsPackageDownloadFailed")));
if (isCurrent()) setError({ ...assetError(err, "assetsPackageDownloadFailed", true), retry: () => download(item, attachment) });
} finally {
operation.current = false;
setDownloadBusy(false);
}
};
const view = async (item: SubmittedPackage) => {
if (operation.current || loading || !data) return;
operation.current = true;
contentRef.current?.focus();
setDetailBusy(true);
try {
setDetail(await applicationAssetsApi.submittedPackage(jobId, item.id));
const next = await applicationAssetsApi.submittedPackage(jobId, item.id);
if (!isCurrent()) return;
setDetail(next);
setError(null);
} catch (err) {
setError(getApiErrorMessage(err, t("assetsPackageLoadFailed")));
if (isCurrent()) setError({ ...assetError(err, "assetsPackageLoadFailed", true), retry: () => view(item) });
} finally {
operation.current = false;
setDetailBusy(false);
}
};
@@ -255,9 +327,9 @@ export function SubmittedPackagesSection({ jobId, allowCapture = true }: { jobId
})();
return (
<Shell title={t("assetsSubmittedPackages")} subtitle={t("assetsSubmittedPackagesSubtitle")} loading={loading} error={error}>
<Shell title={t("assetsSubmittedPackages")} subtitle={t("assetsSubmittedPackagesSubtitle")} loading={pending} error={error} available={data !== null} onRetry={reload} contentRef={contentRef}>
<Stack spacing={1.5}>
{allowCapture && <Button variant="contained" startIcon={<ArchiveOutlinedIcon />} disabled={busy} onClick={() => void capture()} sx={{ alignSelf: "flex-start" }}>
{allowCapture && <Button variant="contained" startIcon={<ArchiveOutlinedIcon />} disabled={pending} onClick={() => void capture()} sx={{ alignSelf: "flex-start" }}>
{busy ? t("assetsPackageCapturing") : t("assetsPackageCapture")}
</Button>}
{(data?.length ?? 0) === 0 ? (
@@ -277,11 +349,11 @@ export function SubmittedPackagesSection({ jobId, allowCapture = true }: { jobId
<Chip size="small" variant="outlined" color={item.hasCoverLetter ? "success" : "default"} label={item.hasCoverLetter ? t("workspaceCoverLetter") : t("assetsPackageNoCoverLetter")} />
</Stack>
</Stack>
<Button size="small" variant="outlined" disabled={detailBusy} onClick={(event) => { viewTrigger.current = event.currentTarget; void view(item); }} sx={{ alignSelf: "flex-start" }}>
<Button size="small" variant="outlined" disabled={pending} onClick={(event) => { viewTrigger.current = event.currentTarget; void view(item); }} sx={{ alignSelf: "flex-start" }}>
{t("assetsPackageView")}
</Button>
{item.attachments.map((attachment) => (
<Button key={attachment.id} size="small" variant="text" startIcon={<DownloadOutlinedIcon />} onClick={() => void download(item, attachment)} sx={{ alignSelf: "flex-start", maxWidth: "100%", overflowWrap: "anywhere", textAlign: "left" }}>
<Button key={attachment.id} size="small" variant="text" disabled={pending} startIcon={<DownloadOutlinedIcon />} onClick={() => void download(item, attachment)} sx={{ alignSelf: "flex-start", maxWidth: "100%", overflowWrap: "anywhere", textAlign: "left" }}>
{attachment.fileName} · {(attachment.fileSize / 1024).toFixed(1)} KB
</Button>
))}
@@ -293,25 +365,25 @@ export function SubmittedPackagesSection({ jobId, allowCapture = true }: { jobId
<DialogTitle id={titleId}>
{detail ? t("assetsSubmittedPackageVersion", { version: detail.summary.version }) : t("assetsSubmittedPackages")}
</DialogTitle>
<DialogContent dividers>
<DialogContent dividers role="region" tabIndex={0} aria-labelledby={titleId}>
<Alert severity="info" sx={{ mb: 2 }}>{t("workspaceFrozenHelp")}</Alert>
<Stack spacing={2.5}>
{detail?.renderedCvHtml ? (
<Box>
<Typography variant="subtitle2" gutterBottom>{t("workspaceCv")}</Typography>
<Typography component="h3" variant="subtitle2" gutterBottom>{t("workspaceCv")}</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1, overflowWrap: "anywhere" }}>{detail.summary.cvVariantName} · {t("assetsThemeVersion", { theme: detail.summary.cvThemeId ?? "—", version: detail.summary.cvVariantVersion ?? "—" })}</Typography>
<Box component="iframe" title={t("assetsPackageCvPreview")} srcDoc={detail.renderedCvHtml} sandbox="" sx={{ width: "100%", height: { xs: 520, md: 760 }, border: 1, borderColor: "divider", bgcolor: "white" }} />
</Box>
) : <Alert severity="info">{t("assetsPackageNoCv")}</Alert>}
<Box>
<Typography variant="subtitle2" gutterBottom>{t("workspaceCoverLetter")}</Typography>
<Typography component="h3" variant="subtitle2" gutterBottom>{t("workspaceCoverLetter")}</Typography>
<Typography variant="body2" sx={{ whiteSpace: "pre-wrap", overflowWrap: "anywhere" }}>
{detail?.coverLetterText || t("assetsPackageNoCoverLetter")}
</Typography>
</Box>
{material && ["TailoredCvText", "RecruiterMessageDraft"].map((key) => typeof material[key] === "string" && material[key] ? (
<Box key={key}>
<Typography variant="subtitle2">{t(`assetsPackage${key}` as "assetsPackageTailoredCvText")}</Typography>
<Typography component="h3" variant="subtitle2">{t(`assetsPackage${key}` as "assetsPackageTailoredCvText")}</Typography>
<Typography variant="body2" sx={{ whiteSpace: "pre-wrap", overflowWrap: "anywhere" }}>{String(material[key])}</Typography>
</Box>
) : null)}
@@ -319,7 +391,7 @@ export function SubmittedPackagesSection({ jobId, allowCapture = true }: { jobId
[t("workspaceNotes"), removeApplicationAnswerDraft(material.Notes)],
[t("assetsApplicationAnswer"), extractApplicationAnswerDraft(material.Notes)],
].map(([label, content]) => content ? <Box key={label}>
<Typography variant="subtitle2">{label}</Typography>
<Typography component="h3" variant="subtitle2">{label}</Typography>
<Typography variant="body2" sx={{ whiteSpace: "pre-wrap", overflowWrap: "anywhere" }}>{content}</Typography>
</Box> : null)}
</Stack>
@@ -334,7 +406,7 @@ export function SubmittedPackagesSection({ jobId, allowCapture = true }: { jobId
export function ApplicationTailoringSection({ jobId }: { jobId: number }) {
const { t } = useI18n();
const { data, error, loading } = useAsset<TailoringPlan>(
const { data, error, loading, reload } = useAsset<TailoringPlan>(
() => applicationAssetsApi.tailoring(jobId),
[jobId],
);
@@ -345,6 +417,8 @@ export function ApplicationTailoringSection({ jobId }: { jobId: number }) {
subtitle={t("assetsTailoringSubtitle")}
loading={loading}
error={error}
available={data !== null}
onRetry={reload}
>
<Stack spacing={2}>
{data && !data.hasCareerProfile && (
@@ -413,13 +487,17 @@ Med vennlig hilsen
export function ApplicationCoverLetterSection({ jobId, onDirtyChange }: { jobId: number; onDirtyChange?: (dirty: boolean) => void }) {
const { language, t } = useI18n();
const { data, error, loading, setData, setError } = useAsset<CoverLetter>(
const { confirm } = useConfirm();
const { data, error, loading, setData, setError, reload, isCurrent } = useAsset<CoverLetter>(
() => applicationAssetsApi.coverLetter(jobId),
[jobId],
);
const [draft, setDraft] = useState<string | null>(null);
const [draftAiAction, setDraftAiAction] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [confirming, setConfirming] = useState(false);
const busyRef = useRef(false);
const contentRef = useRef<HTMLDivElement>(null);
// The textarea is only seeded from the server until the user starts typing, so a reload never
// clobbers unsaved edits.
@@ -432,30 +510,53 @@ export function ApplicationCoverLetterSection({ jobId, onDirtyChange }: { jobId:
}, [dirty, onDirtyChange]);
const save = async (value: string, source = "manual", aiAction?: string) => {
if (busyRef.current || loading || !data) return;
busyRef.current = true;
contentRef.current?.focus();
setBusy(true);
try {
setData(await applicationAssetsApi.saveCoverLetter(jobId, value, source, aiAction));
const next = await applicationAssetsApi.saveCoverLetter(jobId, value, source, aiAction);
if (!isCurrent()) return;
setData(next);
setDraft(null);
setDraftAiAction(null);
setError(null);
} catch (err) {
setError(getApiErrorMessage(err, t("assetsCoverSaveFailed")));
if (isCurrent()) setError(assetError(err, "assetsCoverSaveFailed"));
} finally {
busyRef.current = false;
setBusy(false);
}
};
const restore = async (version: number) => {
setBusy(true);
if (busyRef.current || loading || !data) return;
busyRef.current = true;
try {
setData(await applicationAssetsApi.restoreCoverLetter(jobId, version));
if (dirty) {
setConfirming(true);
const accepted = await confirm({
title: t("assetsRestoreDiscardTitle"), message: t("assetsRestoreDiscardMessage"),
confirmLabel: t("assetsRestoreConfirm"), cancelLabel: t("cancel"), destructive: true,
});
setConfirming(false);
if (!accepted) return;
}
if (!isCurrent()) return;
contentRef.current?.focus();
setBusy(true);
const next = await applicationAssetsApi.restoreCoverLetter(jobId, version);
if (!isCurrent()) return;
setData(next);
setDraft(null);
setDraftAiAction(null);
setError(null);
} catch (err) {
setError(getApiErrorMessage(err, t("assetsCoverRestoreFailed")));
if (isCurrent()) setError(assetError(err, "assetsCoverRestoreFailed"));
} finally {
busyRef.current = false;
setBusy(false);
setConfirming(false);
}
};
@@ -464,28 +565,31 @@ export function ApplicationCoverLetterSection({ jobId, onDirtyChange }: { jobId:
<Shell
title={t("workspaceCoverLetter")}
subtitle={t("assetsCoverSubtitle")}
loading={loading}
loading={loading || busy}
error={error}
available={data !== null}
onRetry={reload}
contentRef={contentRef}
>
<Stack spacing={2}>
<RichTextField
minRows={12}
label={t("jobDetailsCoverLetter")}
value={text}
disabled={busy}
disabled={busy || confirming || loading}
onChange={setDraft}
placeholder={t("assetsCoverPlaceholder")}
/>
<Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap>
<Button variant="contained" disabled={busy || !dirty} onClick={() => save(text, draftAiAction ? "ai" : "manual", draftAiAction ?? undefined)}>
<Button variant="contained" disabled={busy || confirming || loading || !dirty} onClick={() => save(text, draftAiAction ? "ai" : "manual", draftAiAction ?? undefined)}>
{t("save")}
</Button>
<Button disabled={busy || !dirty} onClick={() => { setDraft(null); setDraftAiAction(null); }}>
<Button disabled={busy || confirming || loading || !dirty} onClick={() => { setDraft(null); setDraftAiAction(null); }}>
{t("assetsDiscardChanges")}
</Button>
<Button
disabled={busy || text.trim().length > 0}
disabled={busy || confirming || loading || text.trim().length > 0}
onClick={() => setDraft(COVER_LETTER_TEMPLATES[language])}
>
{t("assetsStartTemplate")}
@@ -500,10 +604,11 @@ export function ApplicationCoverLetterSection({ jobId, onDirtyChange }: { jobId:
<CoverLetterAiAssistant
jobId={jobId}
currentText={text}
onApply={(value, aiAction) => { setDraft(value); setDraftAiAction(aiAction); }}
disabled={busy || confirming || loading || !data}
onApply={(value, aiAction) => { if (!busyRef.current && !loading && data) { setDraft(value); setDraftAiAction(aiAction); } }}
/>
<Shell title={t("assetsVersionHistory")} loading={loading} error={null}>
<Shell title={t("assetsVersionHistory")} loading={loading} error={null} available={data !== null}>
{(data?.versions.length ?? 0) === 0 ? (
<Typography variant="body2" color="text.secondary">
{t("assetsNoVersions")}
@@ -529,11 +634,11 @@ export function ApplicationCoverLetterSection({ jobId, onDirtyChange }: { jobId:
</Typography>
</Box>
{!v.isCurrent && (
<Tooltip title={t("assetsRestoreVersion")}>
<Tooltip describeChild title={t("assetsRestoreVersion")}>
<span>
<IconButton
size="small"
disabled={busy}
disabled={busy || loading}
aria-label={t("assetsRestoreVersionNumber", { version: v.version })}
onClick={() => restore(v.version)}
>
@@ -563,20 +668,25 @@ const COVER_LETTER_ACTIONS = [
{ key: "tailor", labelKey: "coverActionTailor" },
] as const;
function CoverLetterAiAssistant({ jobId, currentText, onApply }: { jobId: number; currentText: string; onApply: (text: string, action: string) => void }) {
function CoverLetterAiAssistant({ jobId, currentText, disabled, onApply }: { jobId: number; currentText: string; disabled: boolean; onApply: (text: string, action: string) => void }) {
const labelId = useId();
const { canUseAi } = useAccountPlan();
const { language: uiLanguage, t } = useI18n();
const { data: cv, loading: loadingCv } = useAsset<ApplicationCv>(() => applicationAssetsApi.cv(jobId), [jobId]);
const { data: cv, error: cvError, loading: loadingCv, reload, isCurrent } = useAsset<ApplicationCv>(() => applicationAssetsApi.cv(jobId), [jobId]);
const [action, setAction] = useState("generate");
const [mode, setMode] = useState("professional");
const [language, setLanguage] = useState<"en" | "nb-NO">(() => uiLanguage === "nb" ? "nb-NO" : "en");
const [instructions, setInstructions] = useState("");
const [suggestion, setSuggestion] = useState("");
const [suggestion, setSuggestion] = useState<{ text: string; action: string } | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [error, setError] = useState<AssetError | null>(null);
const busyRef = useRef(false);
const contentRef = useRef<HTMLDivElement>(null);
const generate = async () => {
if (busyRef.current || disabled || loadingCv || !canUseAi || !cv?.attachedVariantId) return;
busyRef.current = true;
contentRef.current?.focus();
setBusy(true);
setError(null);
try {
@@ -588,10 +698,13 @@ function CoverLetterAiAssistant({ jobId, currentText, onApply }: { jobId: number
currentText: currentText.trim() || undefined,
extraContext: instructions.trim() || undefined,
});
setSuggestion(result.result.text?.trim() ?? "");
if (!isCurrent()) return;
const text = result.result.text?.trim() ?? "";
setSuggestion(text ? { text, action } : null);
} catch (err) {
setError(getApiErrorMessage(err, t("coverAiGenerateFailed")));
if (isCurrent()) setError(assetError(err, "coverAiGenerateFailed"));
} finally {
busyRef.current = false;
setBusy(false);
}
};
@@ -601,8 +714,11 @@ function CoverLetterAiAssistant({ jobId, currentText, onApply }: { jobId: number
<Shell
title={t("coverAiTitle")}
subtitle={t("coverAiSubtitle")}
loading={loadingCv}
error={null}
loading={loadingCv || busy}
error={cvError}
available={cv !== null}
onRetry={reload}
contentRef={contentRef}
>
<Stack spacing={2}>
{!hasCv ? (
@@ -615,13 +731,13 @@ function CoverLetterAiAssistant({ jobId, currentText, onApply }: { jobId: number
<Stack direction={{ xs: "column", sm: "row" }} spacing={1.5}>
<FormControl size="small" sx={{ minWidth: 180 }}>
<InputLabel id={`${labelId}-coverAiAction`}>{t("coverAiAction")}</InputLabel>
<Select labelId={`${labelId}-coverAiAction`} label={t("coverAiAction")} value={action} onChange={(event) => setAction(event.target.value)}>
<Select disabled={busy || disabled || loadingCv} labelId={`${labelId}-coverAiAction`} label={t("coverAiAction")} value={action} onChange={(event) => setAction(event.target.value)}>
{COVER_LETTER_ACTIONS.map((item) => <MenuItem key={item.key} value={item.key}>{t(item.labelKey)}</MenuItem>)}
</Select>
</FormControl>
<FormControl size="small" sx={{ minWidth: 160 }}>
<InputLabel id={`${labelId}-coverAiTone`}>{t("coverAiTone")}</InputLabel>
<Select labelId={`${labelId}-coverAiTone`} label={t("coverAiTone")} value={mode} onChange={(event) => setMode(event.target.value)}>
<Select disabled={busy || disabled || loadingCv} labelId={`${labelId}-coverAiTone`} label={t("coverAiTone")} value={mode} onChange={(event) => setMode(event.target.value)}>
{[
["professional", "coverToneProfessional"], ["friendly", "coverToneFriendly"], ["short", "coverToneShort"],
["detailed", "coverToneDetailed"], ["modern", "coverToneModern"], ["traditional", "coverToneTraditional"],
@@ -630,7 +746,7 @@ function CoverLetterAiAssistant({ jobId, currentText, onApply }: { jobId: number
</FormControl>
<FormControl size="small" sx={{ minWidth: 190 }}>
<InputLabel id={`${labelId}-coverAiDocumentLanguage`}>{t("coverAiDocumentLanguage")}</InputLabel>
<Select labelId={`${labelId}-coverAiDocumentLanguage`} label={t("coverAiDocumentLanguage")} value={language} onChange={(event) => setLanguage(event.target.value as "en" | "nb-NO")}>
<Select disabled={busy || disabled || loadingCv} labelId={`${labelId}-coverAiDocumentLanguage`} label={t("coverAiDocumentLanguage")} value={language} onChange={(event) => setLanguage(event.target.value as "en" | "nb-NO")}>
<MenuItem value="en">{t("languageEnglish")}</MenuItem>
<MenuItem value="nb-NO">{t("languageNorwegianBokmal")}</MenuItem>
</Select>
@@ -640,16 +756,17 @@ function CoverLetterAiAssistant({ jobId, currentText, onApply }: { jobId: number
label={t("coverAiAdditionalInstructions")}
placeholder={t("coverAiInstructionsPlaceholder")}
value={instructions}
disabled={busy || disabled || loadingCv}
onChange={(event) => setInstructions(event.target.value)}
multiline
minRows={2}
fullWidth
/>
{error && <Alert severity="error" action={<Button color="inherit" size="small" onClick={() => void generate()}>{t("retry")}</Button>}>{error}</Alert>}
{error && <Alert severity="error" action={<Button color="inherit" size="small" disabled={busy || disabled || loadingCv} onClick={() => void generate()}>{t("retry")}</Button>}>{error.message || t(error.messageKey)}</Alert>}
<Button
variant="contained"
startIcon={<AutoFixHighIcon />}
disabled={!canUseAi || !hasCv || busy}
disabled={!canUseAi || !hasCv || busy || disabled || loadingCv}
onClick={() => void generate()}
sx={{ alignSelf: "flex-start" }}
>
@@ -666,10 +783,10 @@ function CoverLetterAiAssistant({ jobId, currentText, onApply }: { jobId: number
</Paper>
<Paper variant="outlined" sx={{ p: 2, minWidth: 0, borderColor: "primary.main" }}>
<Typography variant="overline" color="primary">{t("coverAiSuggestion")}</Typography>
<Typography variant="body2" sx={{ whiteSpace: "pre-wrap", overflowWrap: "anywhere" }}>{suggestion}</Typography>
<Typography variant="body2" sx={{ whiteSpace: "pre-wrap", overflowWrap: "anywhere" }}>{suggestion.text}</Typography>
<Stack direction="row" spacing={1} sx={{ mt: 2 }}>
<Button size="small" variant="contained" onClick={() => { onApply(suggestion, action); setSuggestion(""); }}>{t("coverAiApply")}</Button>
<Button size="small" onClick={() => setSuggestion("")}>{t("coverAiReject")}</Button>
<Button size="small" variant="contained" disabled={busy || disabled || loadingCv} onClick={() => { if (!busyRef.current && !disabled && !loadingCv) { onApply(suggestion.text, suggestion.action); setSuggestion(null); } }}>{t("coverAiApply")}</Button>
<Button size="small" disabled={busy || disabled || loadingCv} onClick={() => setSuggestion(null)}>{t("coverAiReject")}</Button>
</Stack>
</Paper>
</Box>
@@ -701,7 +818,11 @@ export function ApplicationPackageDraftsSection({
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);
const [error, setError] = useState<AssetError | null>(null);
const busyRef = useRef(false);
const contentRef = useRef<HTMLDivElement>(null);
const active = useRef(true);
useEffect(() => { active.current = true; return () => { active.current = false; }; }, []);
useEffect(() => {
if (draft === null) setSaved(initial);
@@ -722,16 +843,21 @@ export function ApplicationPackageDraftsSection({
const update = (patch: Partial<PackageDrafts>) => setDraft({ ...value, ...patch });
const save = async () => {
if (busyRef.current) return;
busyRef.current = true;
contentRef.current?.focus();
setBusy(true);
try {
await applicationAssetsApi.saveApplicationDrafts(jobId, value.applicationAnswer, value.recruiterMessage);
if (!active.current) return;
setSaved(value);
setDraft(null);
setError(null);
onSaved?.();
} catch (err) {
setError(getApiErrorMessage(err, t("assetsDraftsSaveFailed")));
if (active.current) setError(assetError(err, "assetsDraftsSaveFailed"));
} finally {
busyRef.current = false;
setBusy(false);
}
};
@@ -740,8 +866,9 @@ export function ApplicationPackageDraftsSection({
<Shell
title={t("assetsApplicationDrafts")}
subtitle={t("assetsApplicationDraftsSubtitle")}
loading={false}
loading={busy}
error={error}
contentRef={contentRef}
>
<Stack spacing={2}>
<RichTextField
@@ -47,7 +47,7 @@ export default function RichTextField({
};
const btn = (title: string, icon: React.ReactNode, before: string, after: string, ph: string) => (
<Tooltip title={title}>
<Tooltip title={title} slotProps={{ popper: { sx: { "&[data-popper-reference-hidden]": { visibility: "hidden" } } } }}>
<IconButton size="small" disabled={disabled} aria-label={title} onMouseDown={(e) => e.preventDefault()} onClick={() => apply(before, after, ph)}>
{icon}
</IconButton>
+10
View File
@@ -508,6 +508,8 @@ export const translations = {
assetsPublic: "public",
assetsEditPreviewExport: "Edit, preview and export",
assetsDuplicateForJob: "Duplicate for this job",
assetsAttachCreatedCopy: "Attach created copy",
assetsCopyCreatedHelp: "The copy is saved in your CV library. Attach it here to continue editing.",
assetsNoCvAttached: "No CV is attached to this application yet.",
assetsLegacyCvText: "This application also contains legacy tailored CV text. An attached CV variant takes precedence.",
assetsTailoring: "Tailoring",
@@ -958,6 +960,9 @@ export const translations = {
assetsCharacterCount: "{count} characters",
assetsRestoreVersion: "Restore this version",
assetsRestoreVersionNumber: "Restore version {version}",
assetsRestoreDiscardTitle: "Restore saved version?",
assetsRestoreDiscardMessage: "Your unsaved edits will be discarded and replaced with the selected saved version.",
assetsRestoreConfirm: "Discard and restore",
coverAiAction: "Action",
coverAiTone: "Tone",
coverActionGenerate: "Generate",
@@ -3157,6 +3162,8 @@ export const translations = {
assetsPublic: "offentlig",
assetsEditPreviewExport: "Rediger, forhåndsvis og eksporter",
assetsDuplicateForJob: "Dupliser for denne jobben",
assetsAttachCreatedCopy: "Koble til opprettet kopi",
assetsCopyCreatedHelp: "Kopien er lagret i CV-biblioteket. Koble den til her for å fortsette redigeringen.",
assetsNoCvAttached: "Ingen CV er tilknyttet søknaden ennå.",
assetsLegacyCvText: "Søknaden inneholder også eldre tilpasset CV-tekst. En tilknyttet CV-variant har prioritet.",
assetsTailoring: "Tilpasning",
@@ -3607,6 +3614,9 @@ export const translations = {
assetsCharacterCount: "{count} tegn",
assetsRestoreVersion: "Gjenopprett denne versjonen",
assetsRestoreVersionNumber: "Gjenopprett versjon {version}",
assetsRestoreDiscardTitle: "Gjenopprette lagret versjon?",
assetsRestoreDiscardMessage: "Endringene du ikke har lagret, blir forkastet og erstattet med den valgte versjonen.",
assetsRestoreConfirm: "Forkast og gjenopprett",
coverAiAction: "Handling",
coverAiTone: "Tone",
coverActionGenerate: "Generer",
@@ -82,7 +82,9 @@ export function ApplicationWorkspace({
const secondaryId = useId();
const panelRef = useRef<HTMLDivElement>(null);
const [overview, setOverview] = useState<WorkspaceOverview | null>(null);
const [loadedOverview, setOverview] = useState<WorkspaceOverview | null>(null);
const overview = loadedOverview?.id === jobId ? loadedOverview : null;
const overviewRequest = useRef(0);
const [error, setError] = useState<ViewResourceError | null>(null);
const [loading, setLoading] = useState(true);
const [editOpen, setEditOpen] = useState(false);
@@ -123,6 +125,7 @@ export function ApplicationWorkspace({
}, [blocker, confirm, t]);
const load = useCallback(async () => {
const request = ++overviewRequest.current;
if (!Number.isInteger(jobId) || jobId <= 0) {
setOverview(null);
setError({ kind: "error", message: t("workspaceInvalidLink"), retryable: false });
@@ -132,17 +135,20 @@ export function ApplicationWorkspace({
try {
setError(null);
setLoading(true);
setOverview(await applicationWorkspaceApi.overview(jobId));
const next = await applicationWorkspaceApi.overview(jobId);
if (request === overviewRequest.current) setOverview(next);
} catch (err) {
if (request !== overviewRequest.current) return;
const status = (err as { response?: { status?: number } })?.response?.status;
setError({ kind: "error", message: getApiErrorMessage(err, t("workspaceLoadFailed")), retryable: !status || status >= 500, status });
} finally {
setLoading(false);
if (request === overviewRequest.current) setLoading(false);
}
}, [jobId, t]);
useEffect(() => {
load();
void load();
return () => { overviewRequest.current += 1; };
}, [load]);
useEffect(() => {
@@ -253,16 +259,17 @@ export function ApplicationWorkspace({
{subsections.map((sub) => <Tab key={sub.key} value={sub.key} id={`${secondaryId}-${sub.key}`} aria-controls={`${secondaryId}-panel`} label={sub.label} />)}
</Tabs>
<Box role="tabpanel" id={`${secondaryId}-panel`} aria-labelledby={`${secondaryId}-${subsection}`} tabIndex={0} sx={{ display: "grid", gap: 2, minWidth: 0 }}>
{section === "application" && subsection === "cv" && jobId > 0 && <ApplicationCvSection jobId={jobId} includePackages={false} />}
{section === "application" && subsection === "letter" && jobId > 0 && <ApplicationCoverLetterSection jobId={jobId} onDirtyChange={setCoverLetterDirty} />}
{section === "application" && subsection === "answers" && jobId > 0 && <ApplicationPackageDraftsSection
{section === "application" && subsection === "cv" && jobId > 0 && <ApplicationCvSection key={jobId} jobId={jobId} includePackages={false} />}
{section === "application" && subsection === "letter" && jobId > 0 && <ApplicationCoverLetterSection key={jobId} jobId={jobId} onDirtyChange={setCoverLetterDirty} />}
{section === "application" && subsection === "answers" && overview && jobId > 0 && <ApplicationPackageDraftsSection
key={jobId}
jobId={jobId}
initialApplicationAnswer={overview?.applicationAnswerDraft ?? ""}
initialRecruiterMessage={overview?.recruiterMessageDraft ?? ""}
onSaved={load}
onDirtyChange={setPackageDraftsDirty}
/>}
{section === "application" && subsection === "submission" && jobId > 0 && <SubmittedPackagesSection jobId={jobId} />}
{section === "application" && subsection === "submission" && jobId > 0 && <SubmittedPackagesSection key={jobId} jobId={jobId} />}
{section === "timeline" && subsection === "activity" && jobId > 0 && <ApplicationTimeline jobId={jobId} />}
{section === "timeline" && subsection === "correspondence" && jobId > 0 && <PageSection surface title={t("workspaceCommunication")} level="h3"><Correspondence jobId={jobId} jobContext={{ companyName: overview?.company, jobTitle: overview?.jobTitle }} /></PageSection>}
</Box>
@@ -272,7 +279,7 @@ export function ApplicationWorkspace({
<Button onClick={() => go("application", "cv")}>{t("workspaceCv")}</Button>
<Button onClick={() => go("application", "letter")}>{t("workspaceCoverLetter")}</Button>
</>}><Attachments jobId={jobId} /></PageSection>
<SubmittedPackagesSection jobId={jobId} allowCapture={false} />
<SubmittedPackagesSection key={jobId} jobId={jobId} allowCapture={false} />
</>}
{section === "tasks" && jobId > 0 && <>
<PageSection surface title={t("workspaceNotes")} actions={<Button startIcon={<EditOutlinedIcon />} onClick={() => setEditOpen(true)}>{t("workspaceEdit")}</Button>}>