fix(ui): preserve application drafts and recovery states
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>}>
|
||||
|
||||
Reference in New Issue
Block a user