feat(cv): redesign document editing with revision-safe letters and writing tools
This commit is contained in:
@@ -62,6 +62,12 @@ const coverLetter = {
|
||||
aiSuggestionCount: 1,
|
||||
};
|
||||
|
||||
const letterSettings = {
|
||||
schemaVersion: 1, senderName: "Recorded sender", senderTitle: "Engineer", senderEmail: "sender@example.test", senderPhone: "", senderLocation: "Oslo",
|
||||
recipient: "Hiring team", subject: "Original subject", date: "2026-10-25", themeId: "modern", accentColor: "#2563eb", bodyFont: "Arial, Helvetica, sans-serif",
|
||||
baseFontSizePt: 10, lineHeight: 1.42, pageMarginMm: 16, pageSize: "a4",
|
||||
};
|
||||
|
||||
function routeGet(overrides: Record<string, any> = {}) {
|
||||
mockedApi.get.mockImplementation((url: string) => {
|
||||
if (url.endsWith("/tailoring")) return Promise.resolve({ data: overrides.tailoring ?? tailoring } as any);
|
||||
@@ -283,6 +289,43 @@ test("the empty cover-letter template and rich-text controls follow Bokmål UI l
|
||||
expect(screen.getByRole("button", { name: "Lenke" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("AI letter results are discarded when the source changes during generation", async () => {
|
||||
routeGet();
|
||||
let complete!: (value: unknown) => void;
|
||||
mockedApi.post.mockImplementation(() => new Promise(resolve => { complete = resolve; }) as never);
|
||||
render(<ApplicationCoverLetterSection jobId={7} />);
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Generate" }));
|
||||
fireEvent.change(screen.getByRole("textbox", { name: "Cover letter" }), { target: { value: "Newer local facts" } });
|
||||
await act(async () => complete({ data: { result: { text: "Outdated proposal" } } }));
|
||||
expect(screen.queryByRole("button", { name: /Apply to editor/i })).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("textbox", { name: "Cover letter" })).toHaveValue("Newer local facts");
|
||||
});
|
||||
|
||||
test.each([['Improve writing', 'improve'], ['Suggest content', 'generate'], ['Grammar check', 'grammar'], ['Shorter', 'shorten']])(
|
||||
'letter %s uses the existing reviewed action', async (label, action) => {
|
||||
routeGet();
|
||||
mockedApi.post.mockResolvedValue({ data: { result: { text: 'Reviewed candidate wording' } } } as never);
|
||||
render(<ApplicationCoverLetterSection jobId={7} />);
|
||||
fireEvent.click(await screen.findByRole('button', { name: label }));
|
||||
await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith('/jobapplications/7/ai/generate', expect.objectContaining({ action })));
|
||||
expect(await screen.findByDisplayValue('Reviewed candidate wording')).toBeInTheDocument();
|
||||
expect(screen.getByRole('textbox', { name: 'Cover letter' })).toHaveValue('Dear team');
|
||||
expect(mockedApi.put).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
test('cancelling letter generation ignores its late response', async () => {
|
||||
routeGet();
|
||||
const pending = deferred<any>();
|
||||
mockedApi.post.mockImplementation(() => pending.promise);
|
||||
render(<ApplicationCoverLetterSection jobId={7} />);
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Suggest content' }));
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Cancel' }));
|
||||
await act(async () => pending.resolve({ data: { result: { text: 'Cancelled response' } } }));
|
||||
expect(screen.queryByDisplayValue('Cancelled response')).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('textbox', { name: 'Cover letter' })).toHaveValue('Dear team');
|
||||
});
|
||||
|
||||
test("AI cover letter suggestions use the linked CV and require explicit apply", async () => {
|
||||
routeGet();
|
||||
mockedApi.post.mockResolvedValue({
|
||||
@@ -535,7 +578,7 @@ test("held letter Save submits one snapshot and prevents an AI Apply from replac
|
||||
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, expectedVersion: 7 });
|
||||
expect(apply).toBeDisabled();
|
||||
expect(apply).not.toBeInTheDocument();
|
||||
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" } }); });
|
||||
@@ -779,3 +822,166 @@ test.each([409, 0])("cover-letter rejection (%s) retains draft and source revisi
|
||||
expect(mockedApi.get).toHaveBeenCalledTimes(reads);
|
||||
if (status === 409) expect(section("workspaceCoverLetter").getByRole("alert")).toHaveTextContent(copy("revisionConflict"));
|
||||
});
|
||||
|
||||
test("letter text preview shows unsaved formatting, hides the inactive narrow pane and never saves on switching", async () => {
|
||||
routeGet();
|
||||
render(<ApplicationCoverLetterSection jobId={7} />);
|
||||
const editor = await screen.findByRole("textbox", { name: copy("jobDetailsCoverLetter") });
|
||||
fireEvent.change(editor, { target: { value: "Dear **team**, *hello*\n- First achievement\n[Portfolio](https://example.com)" } });
|
||||
fireEvent.click(screen.getByRole("tab", { name: copy("assetsLetterPreview") }));
|
||||
const preview = screen.getByRole("region", { name: copy("assetsLetterTextPreview") });
|
||||
expect(within(preview).getByText("team").tagName).toBe("STRONG");
|
||||
expect(within(preview).getByText("hello").tagName).toBe("EM");
|
||||
expect(within(preview).getByRole("listitem")).toHaveTextContent("First achievement");
|
||||
expect(within(preview).getByRole("link", { name: "Portfolio" })).toHaveAttribute("rel", "noopener noreferrer");
|
||||
expect(screen.queryByRole("textbox", { name: copy("jobDetailsCoverLetter") })).not.toBeInTheDocument();
|
||||
expect(editor).not.toBeVisible();
|
||||
expect(screen.getByText(copy("assetsLetterTextPreviewHelp"))).toBeVisible();
|
||||
fireEvent.click(screen.getByRole("tab", { name: copy("assetsLetterWrite") }));
|
||||
expect(screen.getByRole("textbox", { name: copy("jobDetailsCoverLetter") })).toBe(editor);
|
||||
expect(screen.queryByRole("region", { name: copy("assetsLetterTextPreview") })).not.toBeInTheDocument();
|
||||
expect(mockedApi.put).not.toHaveBeenCalled();
|
||||
expect(mockedApi.post).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("letter text preview renders unsafe Markdown as inert text", async () => {
|
||||
routeGet();
|
||||
render(<ApplicationCoverLetterSection jobId={7} />);
|
||||
fireEvent.change(await screen.findByRole("textbox", { name: copy("jobDetailsCoverLetter") }), { target: { value: '<script>alert(1)</script>\n<img src=x onerror=alert(1)>\n[Run](javascript:alert(1))\n[Data](data:text/html,bad)' } });
|
||||
fireEvent.click(screen.getByRole("tab", { name: copy("assetsLetterPreview") }));
|
||||
const preview = screen.getByRole("region", { name: copy("assetsLetterTextPreview") });
|
||||
expect(preview.querySelector("script, img, iframe, a")).toBeNull();
|
||||
expect(preview).toHaveTextContent("<script>alert(1)</script>");
|
||||
expect(preview).toHaveTextContent("[Run](javascript:alert(1))");
|
||||
});
|
||||
|
||||
test("letter preview retains the draft after cancelled restore and failed save", async () => {
|
||||
routeGet();
|
||||
mockedApi.put.mockRejectedValue({ response: { status: 409 } });
|
||||
render(<ApplicationCoverLetterSection jobId={7} />);
|
||||
fireEvent.change(await screen.findByRole("textbox", { name: copy("jobDetailsCoverLetter") }), { target: { value: "Retained **draft** body" } });
|
||||
fireEvent.click(screen.getByRole("tab", { name: copy("assetsLetterPreview") }));
|
||||
fireEvent.click(screen.getByRole("button", { name: /Restore version 1/i }));
|
||||
fireEvent.click(within(await screen.findByRole("dialog")).getByRole("button", { name: copy("cancel") }));
|
||||
await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
|
||||
expect(screen.getByRole("region", { name: copy("assetsLetterTextPreview") })).toHaveTextContent("Retained draft body");
|
||||
expect(mockedApi.post).not.toHaveBeenCalled();
|
||||
fireEvent.click(section("workspaceCoverLetter").getByRole("button", { name: copy("save") }));
|
||||
await section("workspaceCoverLetter").findByRole("alert");
|
||||
expect(screen.getByRole("region", { name: copy("assetsLetterTextPreview") })).toHaveTextContent("Retained draft body");
|
||||
fireEvent.click(screen.getByRole("tab", { name: copy("assetsLetterWrite") }));
|
||||
expect(screen.getByRole("textbox", { name: copy("jobDetailsCoverLetter") })).toHaveValue("Retained **draft** body");
|
||||
expect(mockedApi.put).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("keyed letter job changes discard the old preview and ignore its late save", async () => {
|
||||
routeGet();
|
||||
const saveA = deferred<any>();
|
||||
mockedApi.put.mockReturnValueOnce(saveA.promise);
|
||||
const mounted = render(<ApplicationCoverLetterSection key={7} jobId={7} />);
|
||||
fireEvent.change(await screen.findByRole("textbox", { name: copy("jobDetailsCoverLetter") }), { target: { value: "Private job A draft" } });
|
||||
fireEvent.click(screen.getByRole("tab", { name: copy("assetsLetterPreview") }));
|
||||
fireEvent.click(section("workspaceCoverLetter").getByRole("button", { name: copy("save") }));
|
||||
routeGet({ coverLetter: { ...coverLetter, text: "Job B saved letter" } });
|
||||
mounted.rerender(withProviders(<ApplicationCoverLetterSection key={8} jobId={8} />));
|
||||
fireEvent.change(await screen.findByRole("textbox", { name: copy("jobDetailsCoverLetter") }), { target: { value: "Private job B draft" } });
|
||||
fireEvent.click(screen.getByRole("tab", { name: copy("assetsLetterPreview") }));
|
||||
await act(async () => { saveA.resolve({ data: { ...coverLetter, text: "Private job A draft" } }); await saveA.promise; });
|
||||
const preview = screen.getByRole("region", { name: copy("assetsLetterTextPreview") });
|
||||
expect(preview).toHaveTextContent("Private job B draft");
|
||||
expect(preview).not.toHaveTextContent("Private job A draft");
|
||||
expect(mockedApi.put).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("wide letter workspace keeps one editor beside its live preview", async () => {
|
||||
const previous = Object.getOwnPropertyDescriptor(window, "matchMedia");
|
||||
Object.defineProperty(window, "matchMedia", { configurable: true, value: (query: string) => ({
|
||||
matches: query.includes("min-width:900px"), media: query, onchange: null,
|
||||
addListener: jest.fn(), removeListener: jest.fn(), addEventListener: jest.fn(), removeEventListener: jest.fn(), dispatchEvent: jest.fn(),
|
||||
}) });
|
||||
try {
|
||||
routeGet();
|
||||
render(<ApplicationCoverLetterSection jobId={7} />);
|
||||
const editor = await screen.findByRole("textbox", { name: copy("jobDetailsCoverLetter") });
|
||||
fireEvent.change(editor, { target: { value: "Live desktop **draft**" } });
|
||||
expect(screen.getByRole("region", { name: copy("assetsLetterTextPreview") })).toHaveTextContent("Live desktop draft");
|
||||
expect(editor).toBeVisible();
|
||||
expect(screen.getAllByRole("textbox", { name: copy("jobDetailsCoverLetter") })).toHaveLength(1);
|
||||
expect(screen.queryByRole("tablist", { name: copy("assetsLetterWorkspace") })).not.toBeInTheDocument();
|
||||
expect(mockedApi.put).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
if (previous) Object.defineProperty(window, "matchMedia", previous); else Reflect.deleteProperty(window, "matchMedia");
|
||||
}
|
||||
});
|
||||
|
||||
test("letter preview controls follow Bokmål without translating or saving the local draft", async () => {
|
||||
routeGet();
|
||||
render(<ApplicationCoverLetterSection jobId={7} />);
|
||||
fireEvent.change(await screen.findByRole("textbox", { name: copy("jobDetailsCoverLetter") }), { target: { value: "Keep this **original** prose" } });
|
||||
changeLanguage("nb");
|
||||
fireEvent.click(screen.getByRole("tab", { name: copy("assetsLetterPreview") }));
|
||||
expect(screen.getByRole("region", { name: copy("assetsLetterTextPreview") })).toHaveTextContent("Keep this original prose");
|
||||
expect(screen.getByText(copy("assetsLetterTextPreviewHelp"))).toBeVisible();
|
||||
fireEvent.click(screen.getByRole("tab", { name: copy("assetsLetterWrite") }));
|
||||
expect(screen.getByRole("textbox", { name: copy("jobDetailsCoverLetter") })).toHaveValue("Keep this **original** prose");
|
||||
expect(mockedApi.put).not.toHaveBeenCalled();
|
||||
expect(mockedApi.post).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("document settings are dirty local drafts until explicit Save, and Discard restores saved details", async () => {
|
||||
routeGet({ coverLetter: { ...coverLetter, documentSettings: letterSettings } });
|
||||
mockedApi.put.mockResolvedValue({ data: { ...coverLetter, revision: 8, documentSettings: { ...letterSettings, subject: "New subject" } } } as any);
|
||||
render(<ApplicationCoverLetterSection jobId={7} />);
|
||||
fireEvent.change(await screen.findByRole("textbox", { name: copy("letterSubject") }), { target: { value: "New subject" } });
|
||||
expect(screen.getByText(copy("assetsUnsavedChanges"))).toBeInTheDocument();
|
||||
expect(mockedApi.put).not.toHaveBeenCalled();
|
||||
fireEvent.click(section("workspaceCoverLetter").getByRole("button", { name: copy("save") }));
|
||||
await waitFor(() => expect(mockedApi.put).toHaveBeenCalledWith("/jobapplications/7/cover-letter", expect.objectContaining({ text: "Dear team", expectedVersion: 7, documentSettings: { ...letterSettings, subject: "New subject" } })));
|
||||
await waitFor(() => expect(screen.queryByText(copy("assetsUnsavedChanges"))).not.toBeInTheDocument());
|
||||
fireEvent.change(screen.getByRole("textbox", { name: copy("letterSubject") }), { target: { value: "Discard me" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: copy("assetsDiscardChanges") }));
|
||||
expect(screen.getByRole("textbox", { name: copy("letterSubject") })).toHaveValue("New subject");
|
||||
});
|
||||
|
||||
test("letter PDF awaits durable settings and blocks duplicate exports; failed save never exports", async () => {
|
||||
routeGet({ coverLetter: { ...coverLetter, documentSettings: letterSettings } });
|
||||
const write = deferred<any>();
|
||||
mockedApi.put.mockReturnValueOnce(write.promise);
|
||||
render(<ApplicationCoverLetterSection jobId={7} />);
|
||||
fireEvent.change(await screen.findByRole("textbox", { name: copy("letterSubject") }), { target: { value: "Keep draft" } });
|
||||
const exportButton = screen.getByRole("button", { name: copy("letterExportPdf") });
|
||||
act(() => { fireEvent.click(exportButton); fireEvent.click(exportButton); });
|
||||
expect(mockedApi.put).toHaveBeenCalledTimes(1);
|
||||
expect(mockedApi.post).not.toHaveBeenCalled();
|
||||
await act(async () => { write.reject({ response: { status: 409 } }); });
|
||||
expect(await section("workspaceCoverLetter").findByRole("alert")).toHaveTextContent(copy("revisionConflict"));
|
||||
expect(screen.getByRole("textbox", { name: copy("letterSubject") })).toHaveValue("Keep draft");
|
||||
expect(mockedApi.post).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("letter document preview ignores stale responses, keeps last good HTML on failure and retries without saving", async () => {
|
||||
routeGet({ coverLetter: { ...coverLetter, documentSettings: letterSettings } });
|
||||
const oldPreview = deferred<any>();
|
||||
mockedApi.post.mockReturnValueOnce(oldPreview.promise)
|
||||
.mockResolvedValueOnce({ data: { html: "<html><body>Current document</body></html>", themeId: "modern", suggestedFileName: "letter.pdf" } } as any)
|
||||
.mockRejectedValueOnce({ response: { status: 503 } })
|
||||
.mockResolvedValueOnce({ data: { html: "<html><body>Retried document</body></html>", themeId: "modern", suggestedFileName: "letter.pdf" } } as any);
|
||||
render(<ApplicationCoverLetterSection jobId={7} />);
|
||||
await screen.findByRole("textbox", { name: copy("letterSubject") });
|
||||
fireEvent.click(screen.getByRole("tab", { name: copy("assetsLetterPreview") }));
|
||||
await waitFor(() => expect(mockedApi.post).toHaveBeenCalledTimes(1));
|
||||
fireEvent.click(screen.getByRole("tab", { name: copy("assetsLetterWrite") }));
|
||||
fireEvent.change(screen.getByRole("textbox", { name: copy("letterSubject") }), { target: { value: "Current subject" } });
|
||||
fireEvent.click(screen.getByRole("tab", { name: copy("assetsLetterPreview") }));
|
||||
await waitFor(() => expect(screen.getByTitle(copy("letterDocumentPreview"))).toHaveAttribute("srcdoc", expect.stringContaining("Current document")));
|
||||
await act(async () => { oldPreview.resolve({ data: { html: "<html><body>Stale document</body></html>" } }); await oldPreview.promise; });
|
||||
expect(screen.getByTitle(copy("letterDocumentPreview"))).not.toHaveAttribute("srcdoc", expect.stringContaining("Stale document"));
|
||||
fireEvent.click(screen.getByRole("tab", { name: copy("assetsLetterWrite") }));
|
||||
fireEvent.change(screen.getByRole("textbox", { name: copy("letterSubject") }), { target: { value: "Next subject" } });
|
||||
fireEvent.click(screen.getByRole("tab", { name: copy("assetsLetterPreview") }));
|
||||
await screen.findByText(copy("letterPreviewFailed"));
|
||||
expect(screen.getByTitle(copy("letterDocumentPreview"))).toHaveAttribute("srcdoc", expect.stringContaining("Current document"));
|
||||
fireEvent.click(screen.getByRole("button", { name: copy("retry") }));
|
||||
await waitFor(() => expect(screen.getByTitle(copy("letterDocumentPreview"))).toHaveAttribute("srcdoc", expect.stringContaining("Retried document")));
|
||||
expect(mockedApi.put).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user