fix(cv): bind letter proposals to current sources and preserve document choices
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { expect, test, type APIRequestContext } from '@playwright/test';
|
||||
import AxeBuilder from '@axe-core/playwright';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { createHash } from 'node:crypto';
|
||||
@@ -35,6 +36,7 @@ test('cover letter native preview/PDF fidelity records four independent physical
|
||||
const email = `letter-fidelity-${Date.now()}@example.test`;
|
||||
try {
|
||||
expect((await admin.post(`${apiUrl}/users`, { headers: adminHeaders, data: { email, password, roles: [] } })).status()).toBe(200);
|
||||
expect((await admin.post(`${apiUrl}/jobapplications/2147483647/ai/history/2147483647/validate-source`, { headers: adminHeaders })).status()).toBe(404);
|
||||
} finally { await admin.dispose(); }
|
||||
const headers = await login(page.request, email);
|
||||
const sourceBefore = await (await page.request.get(`${apiUrl}/career/profile`)).json();
|
||||
@@ -51,7 +53,7 @@ test('cover letter native preview/PDF fidelity records four independent physical
|
||||
: 'Documented reliable workflows and accessible navigation using reviewed source evidence. ';
|
||||
const text = Array.from({ length: 24 }, (_, index) => `B${String(index + 1).padStart(2, '0')} ${sentence.repeat(4)}`)
|
||||
.concat(['- S01 **Reviewed achievement** — æøå.', '- S02 *Verified collaboration* — ÆØÅ.', 'R99 Final reviewed letter content — Åse Østby.']).join('\n\n');
|
||||
const documentSettings = { schemaVersion: 1, senderName: 'R01 Åse Østby', senderTitle: 'P01 Platform engineer',
|
||||
const documentSettings = { schemaVersion: 1, language: language === 'nb' ? 'nb-NO' : 'en', senderName: 'R01 Åse Østby', senderTitle: 'P01 Platform engineer',
|
||||
senderEmail: 'ase@example.test', senderPhone: '+47 12345678', senderLocation: 'Oslo, Norge',
|
||||
recipient: 'R03 Hiring team\nNordlys Synthetic AS\nOslo, Norge', subject: language === 'nb' ? 'R02 Søknad om plattformutvikler' : 'R02 Application for platform engineer',
|
||||
date: '2026-10-25', themeId: 'minimal', accentColor: '#126b55', bodyFont: 'Arial, Helvetica, sans-serif',
|
||||
@@ -71,6 +73,7 @@ test('cover letter native preview/PDF fidelity records four independent physical
|
||||
const preview = await page.request.post(`${letterUrl}/preview`, { headers, data: { text: saved.text, documentSettings: saved.documentSettings } });
|
||||
expect(preview.status()).toBe(200);
|
||||
const rendered = await preview.json();
|
||||
expect(rendered.html).toContain(`<html lang="${documentSettings.language}">`);
|
||||
expect(rendered.html).toContain('class="page letter-paper"');
|
||||
const pdf = await page.request.post(`${letterUrl}/export-pdf?expectedVersion=${saved.revision}`, { headers });
|
||||
expect(pdf.status()).toBe(200);
|
||||
@@ -89,6 +92,7 @@ test('cover letter native preview/PDF fidelity records four independent physical
|
||||
const previewWorkspace = frame.locator('..').locator('..').locator('..');
|
||||
await expect(frame).toBeVisible();
|
||||
await expect.poll(() => frame.evaluate(element => (element as HTMLIFrameElement).contentDocument?.documentElement.dataset.cvPreviewReady)).toBe('true');
|
||||
expect(await frame.evaluate(element => (element as HTMLIFrameElement).contentDocument?.documentElement.lang)).toBe(documentSettings.language);
|
||||
const measured = await frame.evaluate(async element => {
|
||||
const doc = (element as HTMLIFrameElement).contentDocument!;
|
||||
const win = (element as HTMLIFrameElement).contentWindow!;
|
||||
@@ -171,6 +175,7 @@ test('cover letter workspace preserves keyboard drafts and localised controls ac
|
||||
const created = await context.request.post(`${apiUrl}/jobapplications`, { headers, data: { companyId: company.id, jobTitle: 'Responsive reviewed letter', status: 'Saved' } });
|
||||
expect(created.status()).toBe(201);
|
||||
const job = await created.json();
|
||||
expect((await context.request.post(`${apiUrl}/jobapplications/${job.id}/ai/history/2147483647/validate-source`, { headers })).status()).toBe(403);
|
||||
const letterUrl = `${apiUrl}/jobapplications/${job.id}/cover-letter`;
|
||||
const original = await (await context.request.get(letterUrl)).json();
|
||||
const savedResponse = await context.request.put(letterUrl, { headers, data: { text: 'Original reviewed letter — æøå.', source: 'manual', expectedVersion: original.revision } });
|
||||
@@ -199,6 +204,10 @@ test('cover letter workspace preserves keyboard drafts and localised controls ac
|
||||
const workspace = page.getByRole('group', { name: t.workspaceCoverLetter, exact: true });
|
||||
const body = workspace.getByRole('textbox', { name: t.jobDetailsCoverLetter, exact: true });
|
||||
await expect(body).toHaveValue(saved.text);
|
||||
const documentLanguage = workspace.getByRole('combobox', { name: t.letterDocumentLanguage });
|
||||
await expect(documentLanguage).toBeVisible();
|
||||
await documentLanguage.click();
|
||||
await page.getByRole('option', { name: language === 'nb' ? t.languageNorwegianBokmal : t.languageEnglish, exact: true }).click();
|
||||
const draft = `${width} ${language === 'nb' ? 'Ulagret søknadsbrev med gjennomgåtte fakta' : 'Unsaved letter with reviewed facts'} — æøå.`;
|
||||
await body.focus(); await body.press('ControlOrMeta+A'); await body.pressSequentially(draft);
|
||||
await expect(body).toHaveValue(draft);
|
||||
@@ -220,6 +229,15 @@ test('cover letter workspace preserves keyboard drafts and localised controls ac
|
||||
expect(geometry.root).toBeLessThanOrEqual(1); expect(geometry.app).toBeLessThanOrEqual(1);
|
||||
const writePane = workspace.locator('[id$="-write-panel"]');
|
||||
const previewPane = workspace.locator('[id$="-preview-panel"]');
|
||||
const audit = async (state: string) => {
|
||||
await workspace.evaluate(node => node.setAttribute('data-cv-ux-axe', 'letter'));
|
||||
const result = await new AxeBuilder({ page }).include('[data-cv-ux-axe="letter"]').withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa']).analyze();
|
||||
const reportPath = info.outputPath(`letter-${width}-${state}-axe.json`);
|
||||
await fs.writeFile(reportPath, JSON.stringify({ violations: result.violations, incomplete: result.incomplete }, null, 2));
|
||||
await info.attach(`letter-${width}-${state}-axe`, { path: reportPath, contentType: 'application/json' });
|
||||
expect(result.violations).toEqual([]);
|
||||
};
|
||||
if (width === 320 || width === 390 || width === 1440) await audit('write');
|
||||
if (width < 1024) {
|
||||
const tabs = workspace.getByRole('tablist', { name: t.assetsLetterWorkspace, exact: true });
|
||||
const write = tabs.getByRole('tab', { name: t.assetsLetterWrite, exact: true });
|
||||
@@ -228,6 +246,7 @@ test('cover letter workspace preserves keyboard drafts and localised controls ac
|
||||
await write.focus(); await page.keyboard.press('ArrowRight'); await expect(preview).toBeFocused(); await page.keyboard.press('Enter');
|
||||
await expect(preview).toHaveAttribute('aria-selected', 'true');
|
||||
await expect(writePane).not.toBeVisible(); await expect(previewPane).toBeVisible();
|
||||
if (width === 320 || width === 390) await audit('preview');
|
||||
await expect(workspace.getByRole('textbox', { name: t.jobDetailsCoverLetter, exact: true })).toHaveCount(0);
|
||||
for (let step = 0; step < 8; step++) {
|
||||
await page.keyboard.press('Tab');
|
||||
|
||||
@@ -32,5 +32,6 @@ export const aiWorkspaceApi = {
|
||||
api.post<AiInteraction>(`/jobapplications/${jobId}/ai/generate`, body).then((r) => r.data),
|
||||
history: (jobId: number, module?: string) =>
|
||||
api.get<AiInteraction[]>(`/jobapplications/${jobId}/ai/history`, { params: { module } }).then((r) => r.data),
|
||||
validateSource: (jobId: number, id: number) => api.post(`/jobapplications/${jobId}/ai/history/${id}/validate-source`).then(() => undefined),
|
||||
remove: (jobId: number, id: number) => api.delete(`/jobapplications/${jobId}/ai/history/${id}`),
|
||||
};
|
||||
|
||||
@@ -359,7 +359,7 @@ test("AI cover letter suggestions use the linked CV and require explicit apply",
|
||||
));
|
||||
expect(screen.getByRole("textbox", { name: "Cover letter" })).toHaveValue("Dear team");
|
||||
fireEvent.click(await screen.findByRole("button", { name: /Apply to editor/i }));
|
||||
expect(screen.getByRole("textbox", { name: "Cover letter" })).toHaveValue("A tailored, truthful suggestion.");
|
||||
await waitFor(() => expect(screen.getByRole("textbox", { name: "Cover letter" })).toHaveValue("A tailored, truthful suggestion."));
|
||||
|
||||
mockedApi.put.mockResolvedValue({ data: { ...coverLetter, text: "A tailored, truthful suggestion." } } as any);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save" }));
|
||||
@@ -369,6 +369,77 @@ test("AI cover letter suggestions use the linked CV and require explicit apply",
|
||||
));
|
||||
});
|
||||
|
||||
test("letter AI revalidates its recorded source before applying and refuses a cross-tab source conflict", async () => {
|
||||
routeGet();
|
||||
mockedApi.post.mockImplementation(((url: string) => url.endsWith('/generate')
|
||||
? Promise.resolve({ data: { id: 91, result: { text: 'Old source proposal' } } })
|
||||
: Promise.reject({ response: { status: 409, data: { code: 'cover_letter_source_changed' } } })) as any);
|
||||
render(<ApplicationCoverLetterSection jobId={7} />);
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Generate' }));
|
||||
fireEvent.click(await screen.findByRole('button', { name: /Apply to editor/i }));
|
||||
await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith('/jobapplications/7/ai/history/91/validate-source'));
|
||||
expect(await screen.findByText(copy('coverAiSourceChanged'))).toBeInTheDocument();
|
||||
expect(screen.getByRole('textbox', { name: 'Cover letter' })).toHaveValue('Dear team');
|
||||
expect(screen.queryByRole('button', { name: /Apply to editor/i })).not.toBeInTheDocument();
|
||||
expect(mockedApi.put).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("letter AI language follows the saved document and updates the same local language draft", async () => {
|
||||
routeGet({ coverLetter: { ...coverLetter, documentSettings: { ...letterSettings, language: 'nb-NO' } } });
|
||||
render(<ApplicationCoverLetterSection jobId={7} />);
|
||||
expect(await screen.findByRole('combobox', { name: 'Letter language' })).toHaveTextContent('Norsk bokmål');
|
||||
await waitFor(() => expect(screen.getByRole('combobox', { name: 'Document language' })).toHaveTextContent('Norsk bokmål'));
|
||||
fireEvent.mouseDown(screen.getByRole('combobox', { name: 'Document language' }));
|
||||
fireEvent.click(await screen.findByRole('option', { name: 'English' }));
|
||||
expect(screen.getByRole('combobox', { name: 'Letter language' })).toHaveTextContent('English');
|
||||
expect(screen.getByRole('textbox', { name: 'Cover letter' })).toHaveValue('Dear team');
|
||||
expect(mockedApi.put).not.toHaveBeenCalled();
|
||||
mockedApi.put.mockResolvedValue({ data: { ...coverLetter, revision: 8, documentSettings: { ...letterSettings, language: 'en' } } } as any);
|
||||
fireEvent.click(section('workspaceCoverLetter').getByRole('button', { name: 'Save' }));
|
||||
await waitFor(() => expect(mockedApi.put).toHaveBeenCalledWith('/jobapplications/7/cover-letter', expect.objectContaining({ documentSettings: { ...letterSettings, language: 'en' }, expectedVersion: 7 })));
|
||||
});
|
||||
|
||||
test.each(['edit', 'cancel', 'job change'])("letter AI ignores a late source validation after local %s", async action => {
|
||||
routeGet();
|
||||
const validation = deferred<any>();
|
||||
mockedApi.post.mockImplementation(((url: string) => url.endsWith('/generate')
|
||||
? Promise.resolve({ data: { id: 92, result: { text: 'Previously reviewed proposal' } } })
|
||||
: validation.promise) as any);
|
||||
const view = render(<ApplicationCoverLetterSection jobId={7} />);
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Generate' }));
|
||||
fireEvent.click(await screen.findByRole('button', { name: /Apply to editor/i }));
|
||||
await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith('/jobapplications/7/ai/history/92/validate-source'));
|
||||
if (action === 'edit') fireEvent.change(screen.getByRole('textbox', { name: 'Cover letter' }), { target: { value: 'New local draft' } });
|
||||
else if (action === 'cancel') fireEvent.click(screen.getByRole('button', { name: 'Cancel' }));
|
||||
else {
|
||||
routeGet({ coverLetter: { ...coverLetter, text: 'Different job draft' } });
|
||||
view.rerender(withProviders(<ApplicationCoverLetterSection jobId={8} />));
|
||||
await screen.findByDisplayValue('Different job draft');
|
||||
}
|
||||
await act(async () => { validation.resolve({ status: 204 }); await validation.promise; });
|
||||
expect(screen.getByRole('textbox', { name: 'Cover letter' })).toHaveValue(action === 'edit' ? 'New local draft' : action === 'cancel' ? 'Dear team' : 'Different job draft');
|
||||
expect(mockedApi.put).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("a held letter Save prevents an in-flight AI validation from replacing the submitted snapshot", async () => {
|
||||
routeGet();
|
||||
const validation = deferred<any>(); const save = deferred<any>();
|
||||
mockedApi.post.mockImplementation(((url: string) => url.endsWith('/generate')
|
||||
? Promise.resolve({ data: { id: 94, result: { text: 'Validated proposal' } } }) : validation.promise) as any);
|
||||
mockedApi.put.mockReturnValue(save.promise);
|
||||
render(<ApplicationCoverLetterSection jobId={7} />);
|
||||
fireEvent.change(await screen.findByRole('textbox', { name: 'Cover letter' }), { target: { value: 'Snapshot being saved' } });
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Generate' }));
|
||||
fireEvent.click(await screen.findByRole('button', { name: /Apply to editor/i }));
|
||||
await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith('/jobapplications/7/ai/history/94/validate-source'));
|
||||
fireEvent.click(section('workspaceCoverLetter').getByRole('button', { name: 'Save' }));
|
||||
expect(mockedApi.put).toHaveBeenCalledWith('/jobapplications/7/cover-letter', expect.objectContaining({ text: 'Snapshot being saved', expectedVersion: 7 }));
|
||||
await act(async () => { validation.resolve({ status: 204 }); await validation.promise; });
|
||||
expect(screen.getByRole('textbox', { name: 'Cover letter' })).toHaveValue('Snapshot being saved');
|
||||
await act(async () => { save.resolve({ data: { ...coverLetter, text: 'Snapshot being saved', revision: 8 } }); await save.promise; });
|
||||
expect(screen.getByRole('textbox', { name: 'Cover letter' })).toHaveValue('Snapshot being saved');
|
||||
});
|
||||
|
||||
test("[PRES-W06] rejecting an AI cover letter preserves the saved draft without a write", async () => {
|
||||
routeGet();
|
||||
mockedApi.post.mockResolvedValue({ data: { result: { text: "Unaccepted letter" } } } as never);
|
||||
@@ -601,14 +672,15 @@ test("held AI generation locks its controls and preserves captured action attrib
|
||||
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" } } }); });
|
||||
await act(async () => { generation.resolve({ data: { id: 93, 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 }));
|
||||
await waitFor(() => expect(screen.getByRole("textbox", { name: "Cover letter" })).toHaveValue("Captured generated suggestion"));
|
||||
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", expectedVersion: 7 }));
|
||||
expect(mockedApi.post).toHaveBeenCalledTimes(1);
|
||||
expect(mockedApi.post).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
test("capture, detail and download failures keep owned frozen history and recover only through their original explicit actions", async () => {
|
||||
|
||||
@@ -263,6 +263,7 @@ export type CoverLetter = {
|
||||
|
||||
export type CoverLetterDocumentSettings = {
|
||||
schemaVersion: 1;
|
||||
language?: "en" | "nb-NO";
|
||||
senderName: string; senderTitle: string; senderEmail: string; senderPhone: string; senderLocation: string;
|
||||
recipient: string; subject: string; date: string;
|
||||
themeId: string; accentColor: string; bodyFont: string;
|
||||
|
||||
@@ -666,6 +666,8 @@ export function ApplicationCoverLetterSection({ jobId, onDirtyChange }: { jobId:
|
||||
|
||||
<CoverLetterAiAssistant
|
||||
jobId={jobId}
|
||||
documentLanguage={settings?.language}
|
||||
onDocumentLanguageChange={settings ? next => setSettingsDraft({ ...settings, language: next }) : undefined}
|
||||
toolbarHost={aiToolbarHost}
|
||||
currentText={text}
|
||||
disabled={busy || confirming || loading || !data}
|
||||
@@ -737,7 +739,7 @@ const COVER_LETTER_ACTIONS = [
|
||||
{ key: "tailor", labelKey: "coverActionTailor" },
|
||||
] as const;
|
||||
|
||||
function CoverLetterAiAssistant({ jobId, currentText, disabled, onApply, toolbarHost }: { jobId: number; currentText: string; disabled: boolean; onApply: (text: string, action: string) => void; toolbarHost: HTMLDivElement | null }) {
|
||||
function CoverLetterAiAssistant({ jobId, currentText, disabled, onApply, toolbarHost, documentLanguage, onDocumentLanguageChange }: { jobId: number; currentText: string; disabled: boolean; onApply: (text: string, action: string) => void; toolbarHost: HTMLDivElement | null; documentLanguage?: "en" | "nb-NO"; onDocumentLanguageChange?: (language: "en" | "nb-NO") => void }) {
|
||||
const labelId = useId();
|
||||
const { canUseAi } = useAccountPlan();
|
||||
const { language: uiLanguage, t } = useI18n();
|
||||
@@ -746,11 +748,14 @@ function CoverLetterAiAssistant({ jobId, currentText, disabled, onApply, toolbar
|
||||
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<{ text: string; action: string } | null>(null);
|
||||
const [suggestion, setSuggestion] = useState<{ id: number; text: string; action: string } | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<AssetError | null>(null);
|
||||
const busyRef = useRef(false);
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
const disabledRef = useRef(disabled);
|
||||
disabledRef.current = disabled;
|
||||
useEffect(() => { if (documentLanguage) setLanguage(documentLanguage); }, [documentLanguage]);
|
||||
|
||||
const requestSequence = useRef(0);
|
||||
useEffect(() => {
|
||||
@@ -779,11 +784,39 @@ function CoverLetterAiAssistant({ jobId, currentText, disabled, onApply, toolbar
|
||||
});
|
||||
if (!isCurrent() || request !== requestSequence.current) return;
|
||||
const text = result.result.text?.trim() ?? "";
|
||||
setSuggestion(text ? { text, action: requestedAction } : null);
|
||||
setSuggestion(text ? { id: result.id, text, action: requestedAction } : null);
|
||||
} catch (err) {
|
||||
if (isCurrent() && request === requestSequence.current) {
|
||||
const sourceRequired = (err as { response?: { data?: unknown } })?.response?.data === "cover_letter_source_required";
|
||||
setError(sourceRequired ? { kind: "error", message: "", messageKey: "coverAiSourceRequired", retryable: false } : assetError(err, "coverAiGenerateFailed"));
|
||||
const sourceChanged = (err as { response?: { data?: { code?: string } } })?.response?.data?.code === "cover_letter_source_changed";
|
||||
setError(sourceRequired || sourceChanged ? { kind: "error", message: "", messageKey: sourceChanged ? "coverAiSourceChanged" : "coverAiSourceRequired", retryable: false } : assetError(err, "coverAiGenerateFailed"));
|
||||
}
|
||||
} finally {
|
||||
if (request === requestSequence.current) { busyRef.current = false; setBusy(false); }
|
||||
}
|
||||
};
|
||||
|
||||
const applySuggestion = async () => {
|
||||
if (!suggestion || busyRef.current || disabledRef.current || loadingCv) return;
|
||||
if (!Number.isSafeInteger(suggestion.id) || suggestion.id <= 0) {
|
||||
setSuggestion(null);
|
||||
setError({ kind: "error", message: "", messageKey: "coverAiSourceChanged", retryable: false });
|
||||
return;
|
||||
}
|
||||
const request = ++requestSequence.current;
|
||||
busyRef.current = true;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await aiWorkspaceApi.validateSource(jobId, suggestion.id);
|
||||
if (!isCurrent() || request !== requestSequence.current || disabledRef.current) return;
|
||||
onApply(suggestion.text, suggestion.action);
|
||||
setSuggestion(null);
|
||||
} catch (err) {
|
||||
if (isCurrent() && request === requestSequence.current) {
|
||||
setSuggestion(null);
|
||||
const sourceChanged = (err as { response?: { data?: { code?: string } } })?.response?.data?.code === "cover_letter_source_changed";
|
||||
setError(sourceChanged ? { kind: "error", message: "", messageKey: "coverAiSourceChanged", retryable: false } : assetError(err, "coverAiGenerateFailed"));
|
||||
}
|
||||
} finally {
|
||||
if (request === requestSequence.current) { busyRef.current = false; setBusy(false); }
|
||||
@@ -835,7 +868,7 @@ function CoverLetterAiAssistant({ jobId, currentText, disabled, onApply, toolbar
|
||||
</FormControl>
|
||||
<FormControl size="small" sx={{ minWidth: 190 }}>
|
||||
<InputLabel id={`${labelId}-coverAiDocumentLanguage`}>{t("coverAiDocumentLanguage")}</InputLabel>
|
||||
<Select disabled={busy || disabled || loadingCv} 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) => { const next = event.target.value as "en" | "nb-NO"; setLanguage(next); onDocumentLanguageChange?.(next); }}>
|
||||
<MenuItem value="en">{t("languageEnglish")}</MenuItem>
|
||||
<MenuItem value="nb-NO">{t("languageNorwegianBokmal")}</MenuItem>
|
||||
</Select>
|
||||
@@ -875,7 +908,7 @@ function CoverLetterAiAssistant({ jobId, currentText, disabled, onApply, toolbar
|
||||
<TextField label={t("coverAiSuggestion")} fullWidth multiline minRows={3} value={suggestion.text} disabled={busy || disabled}
|
||||
onChange={event => setSuggestion({ ...suggestion, text: event.target.value })} />
|
||||
<Stack direction="row" spacing={1} sx={{ mt: 2 }}>
|
||||
<Button size="small" variant="contained" disabled={busy || disabled || loadingCv || !suggestion.text.trim()} onClick={() => { if (!busyRef.current && !disabled && !loadingCv) { onApply(suggestion.text, suggestion.action); setSuggestion(null); } }}>{t("coverAiApply")}</Button>
|
||||
<Button size="small" variant="contained" disabled={busy || disabled || loadingCv || !suggestion.text.trim()} onClick={() => void applySuggestion()}>{t("coverAiApply")}</Button>
|
||||
<Button size="small" disabled={busy || disabled || loadingCv} onClick={() => setSuggestion(null)}>{t("coverAiReject")}</Button>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useEffect, useId, useRef, useState } from "react";
|
||||
import { Alert, Box, Button, Dialog, DialogActions, DialogContent, DialogTitle, MenuItem, Stack, TextField, Typography } from "@mui/material";
|
||||
import { Alert, Box, Button, Checkbox, Dialog, DialogActions, DialogContent, DialogTitle, FormControlLabel, MenuItem, Stack, TextField, Typography } from "@mui/material";
|
||||
import { api } from "../api";
|
||||
import { applicationAssetsApi, CoverLetterDocumentSettings } from "../applicationWorkspace";
|
||||
import { cvBuilderApi, CvTheme, CvVariantSummary, getCvPageMetrics, getCvPreviewFitZoom } from "../cvBuilder";
|
||||
@@ -25,6 +25,11 @@ export function CoverLetterDocumentFields({ settings, disabled, onChange }: Prop
|
||||
<Button disabled={disabled} onClick={() => setCopy("contact")}>{t("letterCopyContact")}</Button>
|
||||
<Button disabled={disabled} onClick={() => setCopy("design")}>{t("letterMatchDesign")}</Button>
|
||||
</Stack>
|
||||
<TextField select size="small" label={t("letterDocumentLanguage")} value={settings.language ?? "en"} disabled={disabled}
|
||||
onChange={event => onChange({ ...settings, language: event.target.value as "en" | "nb-NO" })}>
|
||||
<MenuItem value="en">{t("languageEnglish")}</MenuItem>
|
||||
<MenuItem value="nb-NO">{t("languageNorwegianBokmal")}</MenuItem>
|
||||
</TextField>
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "minmax(0, 1fr)", sm: "repeat(2, minmax(0, 1fr))" }, gap: 1.5 }}>
|
||||
{detailFields.map(key => <TextField key={key} label={t(fieldLabels[key])} size="small" fullWidth multiline={key === "recipient"} value={settings[key]} disabled={disabled}
|
||||
slotProps={{ htmlInput: { maxLength: key === "recipient" ? 2000 : key === "subject" ? 500 : key === "date" || key === "senderPhone" ? 100 : 300 } }}
|
||||
@@ -44,6 +49,7 @@ function CopyLetterSnapshot({ kind, settings, onClose, onApply }: { kind: "conta
|
||||
const [failed, setFailed] = useState(false);
|
||||
const [attempt, setAttempt] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedContact, setSelectedContact] = useState<readonly typeof contactFields[number][]>(contactFields.filter(key => key !== "senderLocation"));
|
||||
useEffect(() => {
|
||||
let current = true;
|
||||
setLoading(true); setFailed(false);
|
||||
@@ -78,25 +84,37 @@ function CopyLetterSnapshot({ kind, settings, onClose, onApply }: { kind: "conta
|
||||
// The dialog captures the original local draft until the user explicitly applies or cancels.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selected, kind, attempt]);
|
||||
const apply = () => {
|
||||
if (!snapshot) return;
|
||||
if (kind === "design") { onApply(snapshot); return; }
|
||||
const next = { ...settings };
|
||||
const chosen = selectedContact.filter(key => snapshot[key].trim());
|
||||
for (const key of chosen) next[key] = snapshot[key];
|
||||
if (chosen.length) next.sourceCvId = snapshot.sourceCvId;
|
||||
onApply(next);
|
||||
};
|
||||
return <Dialog open onClose={onClose} fullWidth maxWidth="sm" aria-labelledby={`${id}-title`} aria-describedby={`${id}-help`}>
|
||||
<DialogTitle id={`${id}-title`}>{t(kind === "contact" ? "letterCopyContact" : "letterMatchDesign")}</DialogTitle>
|
||||
<DialogContent dividers>
|
||||
<Stack spacing={2}>
|
||||
<Typography id={`${id}-help`} variant="body2">{t("letterCopyHelp")}</Typography>
|
||||
{kind === "contact" && <Typography variant="body2">{t("letterCopyContactHelp")}</Typography>}
|
||||
<TextField select fullWidth size="small" label={t("letterSourceCv")} value={selected} disabled={loading} onChange={event => setSelected(Number(event.target.value))}>
|
||||
{variants.map(variant => <MenuItem key={variant.id} value={variant.id}>{variant.name}</MenuItem>)}
|
||||
</TextField>
|
||||
{!loading && !failed && variants.length === 0 && <Typography variant="body2">{t("assetsNoCvVariants")}</Typography>}
|
||||
{failed && <Alert severity="error" action={<Button onClick={() => setAttempt(value => value + 1)}>{t("retry")}</Button>}>{t("letterCopyFailed")}</Alert>}
|
||||
{snapshot && <Box role="region" aria-label={t(kind === "contact" ? "letterContactSnapshot" : "letterDesignSnapshot")} sx={{ overflowWrap: "anywhere" }}>
|
||||
{kind === "contact" ? contactFields.map(key => <Typography key={key} variant="body2">{t(fieldLabels[key])}: {snapshot[key]}</Typography>) : <>
|
||||
{kind === "contact" ? <Stack>{contactFields.map(key => <FormControlLabel key={key} label={`${t(fieldLabels[key])}: ${snapshot[key]}`}
|
||||
control={<Checkbox size="small" disabled={!snapshot[key].trim()} checked={selectedContact.includes(key) && !!snapshot[key].trim()}
|
||||
onChange={(_, checked) => setSelectedContact(current => checked ? [...current, key] : current.filter(field => field !== key))} />} />)}</Stack> : <>
|
||||
<Typography variant="body2">{snapshot.themeId} · {snapshot.accentColor} · {snapshot.bodyFont}</Typography>
|
||||
<Typography variant="body2">{snapshot.baseFontSizePt} pt · {snapshot.lineHeight} · {snapshot.pageMarginMm} mm · {snapshot.pageSize}</Typography>
|
||||
</>}
|
||||
</Box>}
|
||||
</Stack>
|
||||
</DialogContent>
|
||||
<DialogActions><Button autoFocus onClick={onClose}>{t("cancel")}</Button><Button variant="contained" disabled={loading || failed || !snapshot} onClick={() => snapshot && onApply(snapshot)}>{t("letterCopyApply")}</Button></DialogActions>
|
||||
<DialogActions><Button autoFocus onClick={onClose}>{t("cancel")}</Button><Button variant="contained" disabled={loading || failed || !snapshot} onClick={apply}>{t("letterCopyApply")}</Button></DialogActions>
|
||||
</Dialog>;
|
||||
}
|
||||
|
||||
|
||||
@@ -12,11 +12,11 @@ const mockedApi = api as jest.Mocked<typeof api>;
|
||||
const settings: CoverLetterDocumentSettings = {
|
||||
schemaVersion: 1, senderName: "Saved sender", senderTitle: "Saved title", senderEmail: "saved@example.test", senderPhone: "123", senderLocation: "Oslo",
|
||||
recipient: "Preserve recipient", subject: "Preserve subject", date: "2026-10-25", themeId: "modern", accentColor: "#2563eb", bodyFont: "Arial, Helvetica, sans-serif",
|
||||
baseFontSizePt: 10, lineHeight: 1.42, pageMarginMm: 16, pageSize: "a4",
|
||||
baseFontSizePt: 10, lineHeight: 1.42, pageMarginMm: 16, pageSize: "a4", language: "nb-NO",
|
||||
};
|
||||
const letter = { revision: 7, text: "Preserve letter body", currentVersion: 2, aiSuggestionCount: 0, documentSettings: settings,
|
||||
versions: [{ version: 1, source: "manual", aiAction: null, length: 20, createdAtUtc: "2026-10-25T10:00:00Z", isCurrent: false }] };
|
||||
const variant = { id: 3, name: "Chosen CV", settings: { themeId: "modern", headline: null, accentColor: null, bodyFont: null, baseFontSizePt: null, lineHeight: null, pageMarginMm: null, pageSize: null, density: "compact" } };
|
||||
const variant = { id: 3, name: "Chosen CV", settings: { themeId: "modern", headline: null, accentColor: null, bodyFont: null, baseFontSizePt: null, lineHeight: null, pageMarginMm: null, pageSize: null, density: "compact", language: "en" } };
|
||||
const wrap = (ui: React.ReactNode) => <I18nProvider><ConfirmProvider>{ui}</ConfirmProvider></I18nProvider>;
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
@@ -52,6 +52,8 @@ test("contact copy is a reviewed canonical snapshot; cancel is inert and apply p
|
||||
expect(snapshot).toHaveTextContent("Canonical name");
|
||||
expect(snapshot).toHaveTextContent("Canonical title");
|
||||
expect(snapshot).not.toHaveTextContent("private");
|
||||
for (const name of [/Sender name/, /Professional title/, /Email/, /Phone/]) expect(within(snapshot).getByRole("checkbox", { name })).toBeChecked();
|
||||
expect(within(snapshot).getByRole("checkbox", { name: /Location/ })).not.toBeChecked();
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||||
await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
|
||||
@@ -60,11 +62,52 @@ test("contact copy is a reviewed canonical snapshot; cancel is inert and apply p
|
||||
await selectSource();
|
||||
await screen.findByRole("region", { name: "Contact snapshot" });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Use this copy" }));
|
||||
expect(onChange).toHaveBeenCalledWith({ ...settings, senderName: "Canonical name", senderTitle: "Canonical title", senderEmail: "canonical@example.test", senderPhone: "+47 555", senderLocation: "Bergen", sourceCvId: 3 });
|
||||
expect(onChange).toHaveBeenCalledWith({ ...settings, senderName: "Canonical name", senderTitle: "Canonical title", senderEmail: "canonical@example.test", senderPhone: "+47 555", sourceCvId: 3 });
|
||||
expect(mockedApi.get).not.toHaveBeenCalledWith("/auth/me");
|
||||
expect(mockedApi.put).not.toHaveBeenCalled(); expect(mockedApi.post).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("contact location copies only after selection and unselected contact values preserve destination fields", async () => {
|
||||
const onChange = jest.fn();
|
||||
render(wrap(<CoverLetterDocumentFields settings={settings} disabled={false} onChange={onChange} />));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Copy contact from CV" }));
|
||||
await selectSource();
|
||||
const snapshot = within(await screen.findByRole("region", { name: "Contact snapshot" }));
|
||||
for (const name of [/Sender name/, /Professional title/, /Email/, /Phone/]) fireEvent.click(snapshot.getByRole("checkbox", { name }));
|
||||
fireEvent.click(snapshot.getByRole("checkbox", { name: /Location/ }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Use this copy" }));
|
||||
expect(onChange).toHaveBeenCalledWith({ ...settings, senderLocation: "Bergen", sourceCvId: 3 });
|
||||
expect(mockedApi.put).not.toHaveBeenCalled(); expect(mockedApi.post).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("blank source contacts and choosing no fields never clear destination contacts", async () => {
|
||||
const originalGet = mockedApi.get.getMockImplementation()!;
|
||||
mockedApi.get.mockImplementation(((url: string) => url === "/career/profile" ? Promise.resolve({ data: { profile: { contact: { fullName: "Canonical name", email: "", phone: " " } } } }) : originalGet(url)) as any);
|
||||
const onChange = jest.fn();
|
||||
render(wrap(<CoverLetterDocumentFields settings={settings} disabled={false} onChange={onChange} />));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Copy contact from CV" }));
|
||||
await selectSource();
|
||||
const snapshot = within(await screen.findByRole("region", { name: "Contact snapshot" }));
|
||||
expect(snapshot.getByRole("checkbox", { name: /Email/ })).toBeDisabled();
|
||||
expect(snapshot.getByRole("checkbox", { name: /Phone/ })).toBeDisabled();
|
||||
fireEvent.click(snapshot.getByRole("checkbox", { name: /Sender name/ }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Use this copy" }));
|
||||
expect(onChange).toHaveBeenCalledWith(settings);
|
||||
});
|
||||
|
||||
test("letter language is an explicit document draft and saves independently of interface and CV language", async () => {
|
||||
mockedApi.put.mockResolvedValue({ data: { ...letter, revision: 8, documentSettings: { ...settings, language: "en" } } } as any);
|
||||
render(wrap(<ApplicationCoverLetterSection jobId={7} />));
|
||||
const language = await screen.findByRole("combobox", { name: "Letter language" });
|
||||
expect(language).toHaveTextContent(/Norsk bokmål/i);
|
||||
fireEvent.mouseDown(language);
|
||||
fireEvent.click(await screen.findByRole("option", { name: "English" }));
|
||||
expect(mockedApi.put).not.toHaveBeenCalled();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save" }));
|
||||
await waitFor(() => expect(mockedApi.put).toHaveBeenCalledWith("/jobapplications/7/cover-letter", expect.objectContaining({ documentSettings: { ...settings, language: "en" }, expectedVersion: 7 })));
|
||||
expect(screen.getByRole("textbox", { name: "Cover letter" })).toHaveValue(letter.text);
|
||||
});
|
||||
|
||||
test("design copy resolves null overrides through actual theme defaults and effective CV density", async () => {
|
||||
const onChange = jest.fn();
|
||||
render(wrap(<CoverLetterDocumentFields settings={settings} disabled={false} onChange={onChange} />));
|
||||
@@ -109,7 +152,7 @@ test("failed copy reads are retryable and cannot apply or save a partial snapsho
|
||||
});
|
||||
|
||||
test("restoring a historical letter restores saved details with text after explicit dirty confirmation", async () => {
|
||||
mockedApi.post.mockResolvedValue({ data: { ...letter, revision: 8, text: "Historical body", documentSettings: { ...settings, subject: "Historical subject", senderName: "Historical sender" } } } as any);
|
||||
mockedApi.post.mockResolvedValue({ data: { ...letter, revision: 8, text: "Historical body", documentSettings: { ...settings, subject: "Historical subject", senderName: "Historical sender", language: "en" } } } as any);
|
||||
render(wrap(<ApplicationCoverLetterSection jobId={7} />));
|
||||
fireEvent.change(await screen.findByRole("textbox", { name: "Subject" }), { target: { value: "Local unsaved subject" } });
|
||||
expect(screen.queryByRole("region", { name: "Letter text preview" })).not.toBeInTheDocument();
|
||||
@@ -118,6 +161,7 @@ test("restoring a historical letter restores saved details with text after expli
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "Discard and restore" }));
|
||||
await waitFor(() => expect(screen.getByRole("textbox", { name: "Subject" })).toHaveValue("Historical subject"));
|
||||
expect(screen.getByRole("textbox", { name: "Sender name" })).toHaveValue("Historical sender");
|
||||
expect(screen.getByRole("combobox", { name: "Letter language" })).toHaveTextContent("English");
|
||||
expect(screen.getByRole("textbox", { name: "Cover letter" })).toHaveValue("Historical body");
|
||||
expect(mockedApi.post).toHaveBeenCalledWith("/jobapplications/7/cover-letter/versions/1/restore", { expectedVersion: 7 });
|
||||
expect(mockedApi.put).not.toHaveBeenCalled();
|
||||
|
||||
@@ -716,6 +716,9 @@ export const translations = {
|
||||
letterRecipient: "Recipient",
|
||||
letterSubject: "Subject",
|
||||
letterDate: "Date",
|
||||
letterDocumentLanguage: "Letter language",
|
||||
coverAiSourceChanged: "The linked CV, Career Profile or job changed. Generate a new suggestion before applying.",
|
||||
letterCopyContactHelp: "Choose the contact fields to copy. Location is optional and starts unselected. Blank or unselected fields keep your existing letter details.",
|
||||
letterCopyContact: "Copy contact from CV",
|
||||
letterMatchDesign: "Match CV design",
|
||||
letterCopyHelp: "Review this snapshot before applying. Your letter text and recipient stay unchanged; future profile or CV edits do not update this copy.",
|
||||
@@ -3638,6 +3641,9 @@ export const translations = {
|
||||
letterRecipient: "Mottaker",
|
||||
letterSubject: "Emne",
|
||||
letterDate: "Dato",
|
||||
letterDocumentLanguage: "Språk i brevet",
|
||||
coverAiSourceChanged: "Den tilknyttede CV-en, karriereprofilen eller stillingen er endret. Generer et nytt forslag før du bruker det.",
|
||||
letterCopyContactHelp: "Velg kontaktfeltene du vil kopiere. Sted er valgfritt og er ikke valgt som standard. Tomme eller ikke valgte felt beholder de eksisterende opplysningene i brevet.",
|
||||
letterCopyContact: "Kopier kontakt fra CV",
|
||||
letterMatchDesign: "Bruk CV-design",
|
||||
letterCopyHelp: "Se gjennom kopien før du bruker den. Brevteksten og mottakeren beholdes; senere profil- eller CV-endringer oppdaterer ikke denne kopien.",
|
||||
|
||||
Reference in New Issue
Block a user