fix(ui): preserve application drafts and recovery states

This commit is contained in:
cesnimda
2026-10-13 23:27:16 +02:00
parent 627955afa9
commit 4c3254c472
19 changed files with 5388 additions and 118 deletions
@@ -0,0 +1,559 @@
import AxeBuilder from '@axe-core/playwright';
import { createHash } from 'node:crypto';
import { readFile } from 'node:fs/promises';
import { expect, test as base, type APIRequestContext, type APIResponse, type Locator, type Page, type Response, type TestInfo } from '@playwright/test';
import { translations } from '../src/i18n/translations';
import { emptyStructuredCv } from '../src/profileCv';
import type { CvVariant, CvVariantSummary } from '../src/cvBuilder';
import type { ApplicationCv, CoverLetter, SubmittedPackage, SubmittedPackageDetail, WorkspaceOverview } from '../src/applicationWorkspace';
import { removeApplicationAnswerDraft } from '../src/applicationDrafts';
const apiUrl = 'http://localhost:5302/api';
const password = 'E2ePassword123!';
type Owner = { id: string; headers: Record<string, string> };
type Sample = { language: 'en' | 'nb'; theme: 'light' | 'dark'; width: number; height: number };
type Subsection = 'cv' | 'letter' | 'answers' | 'submission';
type Job = { id: number; jobTitle: string; notes: string | null };
type RequestRecord = { method: string; path: string; body: unknown };
type Session = { owner: Owner; admin: APIRequestContext; administrator: Owner; requests: RequestRecord[] };
const desktop: Sample = { language: 'en', theme: 'light', width: 1440, height: 900 };
const boundary = 'Actual owner HTTP fixtures use disposable isolated SQLite and existing application/CV/attachment APIs. Free accounts disable AI; the serial runtime disables model, delivery and alert workers. No provider/model/generation/send endpoint, raw SQL, schema or production write. Controlled precommit HTTP failures and held requests qualify scoped presentation/recovery, not network-uncertain idempotency or production reliability. Request metadata includes only fixture application/CV/file paths and bodies; no auth/CSRF/session headers. Existing seven workspace sections, CV renderer, immutable packages, one-job CV association and append-only cover history are preserved. Existing P2-D01 remains a separately executed ordinary preservation case; this file supplements that corpus. Duplicate-for-job remains two writes; created-copy/failed-attach is explicitly qualified, with no automatic second duplicate. Current AttachmentsTable and its separate ID0-download debt are outside this slice.';
function jobUrl(id: number) { return `${apiUrl}/jobapplications/${id}`; }
function interpolate(template: string, params: Record<string, string | number>) { return template.replace(/\{(\w+)\}/g, (_, key: string) => String(params[key] ?? `{${key}}`)); }
function hold() { let release!: () => void; const pending = new Promise<void>(resolve => { release = resolve; }); return { pending, release }; }
async function json<T>(response: APIResponse | Response, status = 200): Promise<T> { expect(response.status(), response.url()).toBe(status); return response.json(); }
function response(page: Page, method: string, url: string) { return page.waitForResponse(value => value.url() === url && value.request().method() === method); }
async function keyboard(page: Page, control: Locator) { await control.focus(); await expect(control).toBeFocused(); await page.keyboard.press('Enter'); }
function count(session: Session, method: string, path: string) { return session.requests.filter(value => value.method === method && value.path === path); }
function mutations(session: Session) { return session.requests.filter(value => ['POST', 'PUT', 'PATCH', 'DELETE'].includes(value.method)); }
function assetReads(session: Session, id: number) {
const start = `/api/jobapplications/${id}/`;
return session.requests.filter(value => value.method === 'GET' && value.path.startsWith(start) &&
['cv', 'tailoring', 'cover-letter', 'submitted-packages'].includes(value.path.slice(start.length).split('/')[0]));
}
function section(page: Page, title: string) { return page.getByRole('region', { name: title, exact: true }); }
function childFocus(page: Page, title: string) { return page.getByRole('group', { name: title, exact: true }); }
function letter(page: Page, language: Sample['language'] = 'en') { return page.getByRole('textbox', { name: translations[language].jobDetailsCoverLetter, exact: true }); }
function attachedCv(page: Page, language: Sample['language'] = 'en') {
const label = translations[language].assetsAttachedCv.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
// MUI names its Select from the field label and the current selected value.
return page.getByRole('combobox', { name: new RegExp(`^${label}(?:\\s|$)`) });
}
function drafts(page: Page, language: Sample['language'] = 'en') { return {
answer: page.getByRole('textbox', { name: translations[language].assetsApplicationAnswer, exact: true }),
message: page.getByRole('textbox', { name: translations[language].assetsRecruiterMessage, exact: true }),
}; }
async function login(api: APIRequestContext, email: string): Promise<Owner> {
expect((await api.post(`${apiUrl}/auth/login`, { data: { email, password } })).status()).toBe(200);
const me = await json<{ id: string; sessionContext: string }>(await api.get(`${apiUrl}/auth/me`));
const csrf = (await api.storageState()).cookies.find(cookie => cookie.name === 'XSRF-TOKEN'); expect(csrf).toBeDefined();
return { id: me.id, headers: { 'X-CSRF-TOKEN': csrf!.value, 'X-Session-Context': me.sessionContext } };
}
async function account(admin: APIRequestContext, owner: Owner) {
const email = `p706-forms-${Date.now()}-${Math.random().toString(36).slice(2)}@example.test`;
expect((await admin.post(`${apiUrl}/users`, { headers: owner.headers, data: { email, password, roles: [] } })).status()).toBe(200);
return email;
}
const test = base.extend<{ session: Session }>({
session: async ({ page, context, playwright }, provide, info) => {
expect(process.env.PLAYWRIGHT_REUSE_SERVERS).not.toBe('true');
expect(info.config.metadata.isolatedDataRoot).toEqual(expect.any(String));
const admin = await playwright.request.newContext(); const requests: RequestRecord[] = []; const blocked: string[] = [];
page.on('request', request => {
const url = new URL(request.url());
if (!/^\/api\/(?:jobapplications(?:\/|$)|cv\/variants(?:\/|$)|attachments(?:\/|$))/.test(url.pathname)) return;
requests.push({ method: request.method(), path: url.pathname, body: request.postData() ? request.postDataJSON() : null });
});
try {
const administrator = await login(admin, 'e2e@example.test');
const owner = await login(page.request, await account(admin, administrator));
expect((await json<{ entitlements: { ai: boolean } }>(await page.request.get(`${apiUrl}/auth/me`))).entitlements.ai).toBe(false);
await context.route('**/*', route => {
const url = new URL(route.request().url());
if (['localhost', '127.0.0.1'].includes(url.hostname) || ['blob:', 'data:', 'about:'].includes(url.protocol)) return route.continue();
blocked.push(url.origin + url.pathname); return route.abort();
});
await provide({ owner, admin, administrator, requests });
} finally {
await info.attach('application-forms-boundary', { body: JSON.stringify({ boundary, requests, blockedExternalRequests: blocked }), contentType: 'application/json' });
await admin.dispose();
}
},
});
function profile(headline = 'Original reviewed career evidence') {
const empty = emptyStructuredCv();
return { ...empty, contact: { ...empty.contact, fullName: 'Åse Recorded Application Identity', headline },
summary: ['Canonical owner-reviewed evidence — æøå'], skills: ['C#', 'SQL'] };
}
async function source(api: APIRequestContext, owner: Owner, headline?: string) {
return json(await api.put(`${apiUrl}/career/profile`, { headers: owner.headers, data: { profile: profile(headline), cvText: 'Exact owner-reviewed source — æøå' } }));
}
async function createJob(api: APIRequestContext, owner: Owner, title = 'Recorded application material — æøå') {
const company = await json<{ id: number }>(await api.post(`${apiUrl}/companies`, { headers: owner.headers, data: { name: 'Recorded application company — æøå', source: 'User' } }), 201);
return json<Job>(await api.post(`${apiUrl}/jobapplications`, { headers: owner.headers, data: {
companyId: company.id, jobTitle: title, status: 'Saved', notes: 'Human notes remain separate — æøå', description: 'Recorded C# and SQL role; fixture facts only.',
} }), 201);
}
async function variant(api: APIRequestContext, owner: Owner, name: string) {
return json<CvVariant>(await api.post(`${apiUrl}/cv/variants`, { headers: owner.headers, data: {
name, settings: { themeId: 'minimal', language: 'nb-NO', showPhoto: false, showPageNumbers: false, showIcons: true, sections: [], overrides: {}, customSections: [] },
} }));
}
async function saveLetter(api: APIRequestContext, owner: Owner, id: number, text: string) {
return json<CoverLetter>(await api.put(`${jobUrl(id)}/cover-letter`, { headers: owner.headers, data: { text, source: 'manual' } }));
}
async function saveDrafts(api: APIRequestContext, owner: Owner, id: number, answer: string, message: string) {
expect((await api.put(`${jobUrl(id)}/application-drafts`, { headers: owner.headers, data: { applicationAnswerDraft: answer, recruiterMessageDraft: message } })).status()).toBe(204);
}
async function upload(api: APIRequestContext, owner: Owner, id: number) {
const bytes = Buffer.from('Exact archived application evidence — æøå\nOriginal bytes retained.\n', 'utf8');
const name = 'Recorded application evidence — æøå.txt';
expect((await api.post(`${apiUrl}/attachments`, { headers: owner.headers, multipart: { jobId: String(id), files: { name, mimeType: 'text/plain', buffer: bytes } } })).ok()).toBe(true);
const [attachment] = await json<{ id: number; fileName: string }[]>(await api.get(`${apiUrl}/attachments/${id}`));
expect(attachment.id).toBeGreaterThan(0); return { attachment, bytes, name };
}
async function fixture(page: Page, owner: Owner, long = false) {
await source(page.request, owner);
const job = await createJob(page.request, owner, long ? 'Langsiktig søknad om samfunnskritisk plattformutvikling med mange samarbeidspartnere og særnorske bokstaver Æ Ø Å' : undefined);
const cv = await variant(page.request, owner, long ? 'CV med lang dokumenttittel for samfunnskritisk plattformutvikling og inkluderende arbeidsprosesser Æ Ø Å' : 'Recorded attached application CV');
await json<ApplicationCv>(await page.request.put(`${jobUrl(job.id)}/cv`, { headers: owner.headers, data: { variantId: cv.id } }));
const originalLetter = long ? ('Reviewed long cover letter — æøå. Existing storage remains plain markdown.\n\n').repeat(55).trim() : 'Original reviewed cover letter — æøå';
const answer = long ? ('Recorded motivation and application answer — æøå.\n').repeat(24).trim() : 'Original application answer — æøå';
const message = long ? ('Reviewed recruiter message draft; never sent — æøå.\n').repeat(14).trim() : 'Original recruiter message; never sent';
await saveLetter(page.request, owner, job.id, originalLetter);
await saveDrafts(page.request, owner, job.id, answer, message);
return { job, cv, originalLetter, answer, message };
}
async function frozenFixture(page: Page, owner: Owner, long = false) {
const seeded = await fixture(page, owner, long); const file = await upload(page.request, owner, seeded.job.id);
const preserved = await json<SubmittedPackage>(await page.request.post(`${jobUrl(seeded.job.id)}/submitted-packages`, { headers: owner.headers }));
const detail = await json<SubmittedPackageDetail>(await page.request.get(`${jobUrl(seeded.job.id)}/submitted-packages/${preserved.id}`));
return { ...seeded, ...file, preserved, detail };
}
async function enter(page: Page, owner: Owner, job: Job, sub: Subsection, sample: Sample = desktop) {
expect((await page.request.put(`${apiUrl}/auth/preferences/language`, { headers: owner.headers, data: { language: sample.language === 'nb' ? 'nb-NO' : 'en' } })).status()).toBe(204);
await page.addInitScript(({ language, theme }) => {
if (localStorage.getItem('uiLanguage') === null) localStorage.setItem('uiLanguage', language);
if (localStorage.getItem('jobtracker.themeMode') === null) localStorage.setItem('jobtracker.themeMode', theme);
}, sample);
await page.setViewportSize({ width: sample.width, height: sample.height }); await page.emulateMedia({ reducedMotion: 'reduce' });
await page.goto(`/jobs/${job.id}?section=application&sub=${sub}&source=phase7-forms&followMode=waiting-update#materials`);
const stored = await page.evaluate(() => ({ language: localStorage.getItem('uiLanguage'), theme: localStorage.getItem('jobtracker.themeMode') }));
if (stored.language !== sample.language || stored.theme !== sample.theme) {
await page.evaluate(({ language, theme }) => { localStorage.setItem('uiLanguage', language); localStorage.setItem('jobtracker.themeMode', theme); }, sample); await page.reload();
}
const t = translations[sample.language];
await expect(page.locator('html')).toHaveAttribute('lang', sample.language === 'nb' ? 'nb-NO' : 'en');
await expect(page.locator('html')).toHaveAttribute('data-color-scheme', sample.theme);
await expect(page.getByRole('heading', { level: 1 })).toHaveCount(1);
await expect(page.getByRole('heading', { level: 2, name: job.jobTitle, exact: true })).toBeVisible();
await expect(page.getByRole('tablist', { name: t.workspaceSections, exact: true }).getByRole('tab')).toHaveCount(7);
await expect(page.getByRole('tablist', { name: t.workspaceApplicationSections, exact: true }).getByRole('tab')).toHaveCount(4);
expect(new URL(page.url()).searchParams.get('followMode')).toBe('waiting-update');
}
async function selectCv(page: Page, value: CvVariant | null, language: Sample['language'] = 'en') {
await keyboard(page, attachedCv(page, language));
const option = value ? page.getByRole('option').filter({ hasText: value.name }) : page.getByRole('option', { name: translations[language].assetsNone, exact: true });
await keyboard(page, option);
}
test('[P7-06 PRES-W01 W04 W10] same-route job navigation waits for owned answers and preserves both jobs on save', async ({ page, session }, info) => {
const first = await fixture(page, session.owner);
const original = await json<{ companyId: number }>(await page.request.get(jobUrl(first.job.id)));
const second = await json<Job>(await page.request.post(`${apiUrl}/jobapplications`, { headers: session.owner.headers, data: {
companyId: original.companyId, jobTitle: 'Second independent application draft owner', status: 'Saved', notes: 'Independent second-job notes',
} }), 201);
const secondAnswer = 'Owned second-job answer — æøå';
const secondMessage = 'Owned second-job recruiter message; never sent';
await saveDrafts(page.request, session.owner, second.id, secondAnswer, secondMessage);
await enter(page, session.owner, first.job, 'answers');
await expect(drafts(page).answer).toHaveValue(first.answer);
const origin = await page.evaluate(() => performance.timeOrigin);
const held = hold(); const reached = hold();
const workspaceUrl = `${jobUrl(second.id)}/workspace`;
await page.route(workspaceUrl, async route => { reached.release(); await held.pending; await route.continue(); });
try {
await keyboard(page, page.getByRole('button', { name: new RegExp(translations.en.quickSearch) }));
const commands = page.getByRole('dialog', { name: translations.en.quickSearch });
await commands.getByRole('textbox', { name: translations.en.quickSearch }).fill(second.jobTitle);
await keyboard(page, commands.getByRole('button').filter({ hasText: second.jobTitle }));
await reached.pending;
await expect(page).toHaveURL(new RegExp(`/jobs/${second.id}$`));
expect(await page.evaluate(() => performance.timeOrigin)).toBe(origin);
await keyboard(page, page.getByRole('tab', { name: translations.en.workspaceApplication, exact: true }));
await keyboard(page, page.getByRole('tab', { name: translations.en.workspaceAnswers, exact: true }));
await expect(drafts(page).answer).toHaveCount(0);
await expect(drafts(page).message).toHaveCount(0);
expect(count(session, 'PUT', `/api/jobapplications/${second.id}/application-drafts`)).toHaveLength(0);
await capture(page, info, desktop, 'held-second-job-aggregate-no-foreign-drafts');
const loaded = response(page, 'GET', workspaceUrl); held.release(); await loaded;
await expect(drafts(page).answer).toHaveValue(secondAnswer);
await expect(drafts(page).message).toHaveValue(secondMessage);
const reviewed = 'Edited second-job answer only — æøå';
await drafts(page).answer.fill(reviewed);
const saved = response(page, 'PUT', `${jobUrl(second.id)}/application-drafts`);
await keyboard(page, page.getByRole('button', { name: translations.en.assetsSaveDrafts, exact: true }));
expect((await saved).status()).toBe(204);
await expect(drafts(page).message).toHaveValue(secondMessage);
const persistedSecond = await json<WorkspaceOverview>(await page.request.get(workspaceUrl));
expect(persistedSecond.applicationAnswerDraft).toBe(reviewed);
expect(persistedSecond.recruiterMessageDraft).toBe(secondMessage);
const persistedFirst = await json<WorkspaceOverview>(await page.request.get(`${jobUrl(first.job.id)}/workspace`));
expect(persistedFirst.applicationAnswerDraft).toBe(first.answer);
expect(persistedFirst.recruiterMessageDraft).toBe(first.message);
expect(count(session, 'PUT', `/api/jobapplications/${second.id}/application-drafts`)).toEqual([{ method: 'PUT', path: `/api/jobapplications/${second.id}/application-drafts`, body: { applicationAnswerDraft: reviewed, recruiterMessageDraft: secondMessage } }]);
await capture(page, info, desktop, 'second-job-answer-saved-first-job-preserved');
} finally { held.release(); await page.unroute(workspaceUrl); }
});
async function capture(page: Page, info: TestInfo, sample: Sample, state: string) {
await page.evaluate(async () => { await document.fonts.ready; await new Promise<void>(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))); });
const geometry = await page.evaluate(() => ({ width: innerWidth, height: innerHeight, rootOverflow: Math.max(0, document.documentElement.scrollWidth - document.documentElement.clientWidth),
focused: { tag: document.activeElement?.tagName, role: document.activeElement?.getAttribute('role'), label: document.activeElement?.getAttribute('aria-label') } }));
const axe = await new AxeBuilder({ page }).options({ rules: { 'target-size': { enabled: true } } }).analyze();
const name = `p706-forms-${sample.language}-${sample.theme}-${sample.width}-${state}`;
await info.attach(`${name}-audit`, { body: JSON.stringify({ sample, state, geometry, boundary, ariaSnapshot: await page.locator('body').ariaSnapshot(),
axe: { scope: 'All default-enabled rules plus target-size, no exclusions', violations: axe.violations, incomplete: axe.incomplete, passes: axe.passes.map(rule => ({ id: rule.id, nodes: rule.nodes.length })) },
qualifications: ['Finite Chromium states, not all combinations or physical devices.', 'All incomplete vectors retained for contextual triage; not human screen-reader, touch/software keyboard or real browser zoom acceptance.',
'Full-page screenshots do not establish real viewport focus occlusion or every-pixel contrast.', 'Print iframe/canvas may scroll internally; outer app root reflow measured separately. Frozen HTML unchanged, not a substitute for existing full fidelity corpus.'],
}), contentType: 'application/json' });
await info.attach(name, { body: await page.screenshot({ fullPage: true, animations: 'disabled' }), contentType: 'image/png' });
expect(geometry.rootOverflow).toBe(0); expect(axe.violations).toEqual([]);
}
test('[P7-06 forms] held CV read Retry preserves its request and actual attach/replace/detach remain owner scoped', async ({ page, session, playwright }, info) => {
test.setTimeout(120_000); await source(page.request, session.owner);
const job = await createJob(page.request, session.owner); const first = await variant(page.request, session.owner, 'First reusable CV'); const second = await variant(page.request, session.owner, 'Second reusable CV');
const gate = hold(); let reads = 0;
await page.route(`${jobUrl(job.id)}/cv`, async route => {
if (route.request().method() !== 'GET') return route.continue();
reads += 1; if (reads === 1) { await gate.pending; await route.fulfill({ status: 503, json: {} }); } else return route.continue();
});
try {
await enter(page, session.owner, job, 'cv'); await expect(section(page, translations.en.workspaceCv).getByRole('status')).toContainText(translations.en.loading);
await capture(page, info, desktop, 'held-cv-read'); gate.release();
await expect(section(page, translations.en.workspaceCv).getByRole('alert')).toContainText(translations.en.assetsLoadFailed);
await keyboard(page, section(page, translations.en.workspaceCv).getByRole('button', { name: translations.en.retry, exact: true }));
await expect(childFocus(page, translations.en.workspaceCv)).toBeFocused(); await expect.poll(() => reads).toBe(2);
await expect(attachedCv(page)).toBeVisible();
for (const current of [first, second, null]) {
const written = response(page, 'PUT', `${jobUrl(job.id)}/cv`); await selectCv(page, current); expect((await written).status()).toBe(200);
await expect(attachedCv(page)).toBeEnabled();
expect((await json<ApplicationCv>(await page.request.get(`${jobUrl(job.id)}/cv`))).attachedVariantId).toBe(current?.id ?? null);
}
expect(count(session, 'PUT', `/api/jobapplications/${job.id}/cv`).map(value => value.body)).toEqual([{ variantId: first.id }, { variantId: second.id }, { variantId: null }]);
const available = await json<CvVariantSummary[]>(await page.request.get(`${apiUrl}/cv/variants`));
expect(available.map(value => value.id).sort((a, b) => a - b)).toEqual([first.id, second.id].sort((a, b) => a - b));
expect(available.every(value => value.jobApplicationId === null)).toBe(true);
const foreign = await playwright.request.newContext();
try {
const other = await login(foreign, await account(session.admin, session.administrator));
const foreignVariant = await variant(foreign, other, 'Foreign private variant');
expect((await page.request.put(`${jobUrl(job.id)}/cv`, { headers: session.owner.headers, data: { variantId: foreignVariant.id } })).status()).toBe(404);
expect((await page.request.put(`${jobUrl(job.id)}/cv`, { headers: session.owner.headers, data: { variantId: 2147483647 } })).status()).toBe(404);
for (const suffix of ['cv', 'cover-letter', 'submitted-packages']) expect((await foreign.get(`${jobUrl(job.id)}/${suffix}`)).status()).toBe(404);
expect((await foreign.put(`${jobUrl(job.id)}/cv`, { headers: other.headers, data: { variantId: null } })).status()).toBe(404);
expect((await json<ApplicationCv>(await page.request.get(`${jobUrl(job.id)}/cv`))).availableVariants.some(value => value.id === foreignVariant.id)).toBe(false);
} finally { await foreign.dispose(); }
await capture(page, info, desktop, 'cv-detached-preserved');
} finally { gate.release(); }
});
test('[P7-06 forms] long letter failed held Save retains snapshot and dirty restore Cancel writes nothing before additive explicit restore', async ({ page, session }, info) => {
test.setTimeout(120_000); const seeded = await fixture(page, session.owner); const url = `${jobUrl(seeded.job.id)}/cover-letter`;
await saveLetter(page.request, session.owner, seeded.job.id, 'Second reviewed letter — æøå');
const before = await json<CoverLetter>(await page.request.get(url));
await enter(page, session.owner, seeded.job, 'letter');
const draft = ' ' + ('Long manually reviewed application letter — æøå. Preserve every sentence and markdown **evidence**.\n\n').repeat(60) + ' ';
await letter(page).fill(draft); const gate = hold(); let saves = 0;
await page.route(url, async route => {
if (route.request().method() !== 'PUT') return route.continue();
saves += 1; if (saves === 1) { await gate.pending; await route.fulfill({ status: 503, json: {} }); } else return route.continue();
});
try {
const body = section(page, translations.en.workspaceCoverLetter);
await keyboard(page, body.getByRole('button', { name: translations.en.save, exact: true })); await expect.poll(() => saves).toBe(1);
await expect(letter(page)).toBeDisabled(); await expect(childFocus(page, translations.en.workspaceCoverLetter)).toBeFocused();
await expect(body.getByRole('button', { name: translations.en.assetsDiscardChanges, exact: true })).toBeDisabled();
expect(count(session, 'PUT', `/api/jobapplications/${seeded.job.id}/cover-letter`).map(value => value.body)).toEqual([{ text: draft, source: 'manual' }]);
await capture(page, info, desktop, 'held-letter-save'); gate.release();
await expect(body.getByRole('alert')).toContainText(translations.en.assetsCoverSaveFailed); await expect(letter(page)).toHaveValue(draft);
expect(await json<CoverLetter>(await page.request.get(url))).toEqual(before); expect(saves).toBe(1);
const saved = response(page, 'PUT', url); await keyboard(page, body.getByRole('button', { name: translations.en.save, exact: true }));
const current = await json<CoverLetter>(await saved); expect(current.text).toBe(draft.trim()); expect(current.currentVersion).toBe(3);
expect(current.versions.map(value => [value.version, value.source, value.aiAction])).toEqual([[3, 'manual', null], [2, 'manual', null], [1, 'manual', null]]);
await expect(letter(page)).toHaveValue(draft.trim()); await letter(page).fill('Unsaved changes must survive Cancel — æøå');
const restore = page.getByRole('button', { name: interpolate(translations.en.assetsRestoreVersionNumber, { version: 1 }), exact: true });
await keyboard(page, restore);
const dialog = page.getByRole('dialog', { name: translations.en.assetsRestoreDiscardTitle, exact: true });
await expect(dialog).toContainText(translations.en.assetsRestoreDiscardMessage);
await keyboard(page, dialog.getByRole('button', { name: translations.en.cancel, exact: true }));
await expect(dialog).toHaveCount(0); await expect(restore).toBeFocused(); await expect(letter(page)).toHaveValue('Unsaved changes must survive Cancel — æøå');
expect(count(session, 'POST', `/api/jobapplications/${seeded.job.id}/cover-letter/versions/1/restore`)).toHaveLength(0);
expect(await json<CoverLetter>(await page.request.get(url))).toEqual(current);
await keyboard(page, restore); const restored = response(page, 'POST', `${url}/versions/1/restore`);
await keyboard(page, dialog.getByRole('button', { name: translations.en.assetsRestoreConfirm, exact: true }));
const readback = await json<CoverLetter>(await restored); expect(readback.text).toBe(seeded.originalLetter); expect(readback.currentVersion).toBe(4);
expect(readback.versions.map(value => [value.version, value.source, value.aiAction])).toEqual([[4, 'restore', null], [3, 'manual', null], [2, 'manual', null], [1, 'manual', null]]);
await expect(letter(page)).toHaveValue(seeded.originalLetter); await capture(page, info, desktop, 'restored-additive-letter-history');
} finally { gate.release(); }
});
test('[P7-06 forms] failed held Answers save preserves both drafts and ordinary Notes through explicit recovery and reload', async ({ page, session }, info) => {
test.setTimeout(90_000); const seeded = await fixture(page, session.owner); const url = `${jobUrl(seeded.job.id)}/application-drafts`;
const original = await json<Job>(await page.request.get(jobUrl(seeded.job.id)));
await enter(page, session.owner, seeded.job, 'answers'); const fields = drafts(page); const answer = 'Reviewed answer with **specific evidence** — æøå\nSecond line.'; const message = 'Reviewed recruiter note — æøå; draft only, never sent.';
await fields.answer.fill(answer); await fields.message.fill(message);
const gate = hold(); let writes = 0;
await page.route(url, async route => { writes += 1; if (writes === 1) { await gate.pending; await route.fulfill({ status: 503, json: {} }); } else return route.continue(); });
try {
const body = section(page, translations.en.assetsApplicationDrafts);
await keyboard(page, body.getByRole('button', { name: translations.en.assetsSaveDrafts, exact: true }));
await expect.poll(() => writes).toBe(1); await expect(fields.answer).toBeDisabled(); await expect(fields.message).toBeDisabled(); await expect(childFocus(page, translations.en.assetsApplicationDrafts)).toBeFocused();
expect(count(session, 'PUT', `/api/jobapplications/${seeded.job.id}/application-drafts`).map(value => value.body)).toEqual([{ applicationAnswerDraft: answer, recruiterMessageDraft: message }]);
await capture(page, info, desktop, 'held-two-draft-save'); gate.release();
await expect(body.getByRole('alert')).toContainText(translations.en.assetsDraftsSaveFailed); await expect(fields.answer).toHaveValue(answer); await expect(fields.message).toHaveValue(message);
expect((await json<WorkspaceOverview>(await page.request.get(`${jobUrl(seeded.job.id)}/workspace`))).applicationAnswerDraft).toBe(seeded.answer); expect(writes).toBe(1);
const saved = response(page, 'PUT', url); await keyboard(page, body.getByRole('button', { name: translations.en.assetsSaveDrafts, exact: true })); expect((await saved).status()).toBe(204);
await expect(body.getByRole('button', { name: translations.en.assetsSaveDrafts, exact: true })).toBeDisabled(); await page.reload();
await expect(drafts(page).answer).toHaveValue(answer); await expect(drafts(page).message).toHaveValue(message);
const readback = await json<WorkspaceOverview>(await page.request.get(`${jobUrl(seeded.job.id)}/workspace`));
expect(readback.applicationAnswerDraft).toBe(answer); expect(readback.recruiterMessageDraft).toBe(message);
const stored = await json<Job>(await page.request.get(jobUrl(seeded.job.id))); expect(removeApplicationAnswerDraft(stored.notes)).toBe(removeApplicationAnswerDraft(original.notes));
await keyboard(page, page.getByRole('tablist', { name: translations.en.workspaceSections, exact: true }).getByRole('tab', { name: translations.en.workspaceTasks, exact: true }));
await expect(section(page, translations.en.workspaceNotes)).toContainText('Human notes remain separate — æøå'); await expect(section(page, translations.en.workspaceNotes)).not.toContainText(answer);
expect(count(session, 'PUT', `/api/jobapplications/${seeded.job.id}/application-drafts`)).toHaveLength(2);
await capture(page, info, desktop, 'drafts-saved-personal-notes-preserved');
} finally { gate.release(); }
});
test('[P7-06 forms] actual native capture freezes CV and exact archive bytes after current sources change without renderer mutation', async ({ page, session }, info) => {
test.setTimeout(120_000); const seeded = await fixture(page, session.owner); const file = await upload(page.request, session.owner, seeded.job.id);
await enter(page, session.owner, seeded.job, 'submission'); const captured = response(page, 'POST', `${jobUrl(seeded.job.id)}/submitted-packages`);
await keyboard(page, section(page, translations.en.assetsSubmittedPackages).getByRole('button', { name: translations.en.assetsPackageCapture, exact: true }));
const summary = await json<SubmittedPackage>(await captured); expect(summary.version).toBe(1); expect(summary.attachments).toHaveLength(1);
const preservedUrl = `${jobUrl(seeded.job.id)}/submitted-packages/${summary.id}`; const before = await json<SubmittedPackageDetail>(await page.request.get(preservedUrl));
expect(before.renderedCvHtml).toContain('Original reviewed career evidence'); expect(before.coverLetterText).toBe(seeded.originalLetter);
expect(summary.cvVariantId).toBe(seeded.cv.id); expect(summary.cvVariantVersion).toBe(seeded.cv.version);
expect(summary.attachments[0].sha256).toBe(createHash('sha256').update(file.bytes).digest('hex'));
await saveLetter(page.request, session.owner, seeded.job.id, 'Later current letter — æøå');
await saveDrafts(page.request, session.owner, seeded.job.id, 'Later answer', 'Later recruiter draft');
const cv = await json<CvVariant>(await page.request.get(`${apiUrl}/cv/variants/${seeded.cv.id}`));
const changedCv = await json<CvVariant>(await page.request.put(`${apiUrl}/cv/variants/${cv.id}`, { headers: session.owner.headers, data: { name: 'Later current CV', settings: { ...cv.settings, headline: 'Later CV-only headline' }, expectedVersion: cv.version, source: 'manual' } }));
await source(page.request, session.owner, 'Later canonical headline');
expect((await page.request.delete(`${apiUrl}/attachments/${file.attachment.id}`, { headers: session.owner.headers })).status()).toBe(204);
expect(await json<SubmittedPackageDetail>(await page.request.get(preservedUrl))).toEqual(before);
const archiveUrl = `${preservedUrl}/attachments/${summary.attachments[0].id}`; const archived = await page.request.get(archiveUrl); expect(archived.status()).toBe(200); expect(await archived.body()).toEqual(file.bytes);
await page.reload(); const view = section(page, translations.en.assetsSubmittedPackages).getByRole('button', { name: translations.en.assetsPackageView, exact: true }); await keyboard(page, view);
const dialog = page.getByRole('dialog', { name: interpolate(translations.en.assetsSubmittedPackageVersion, { version: 1 }), exact: true });
await expect(dialog).toContainText(seeded.originalLetter); await expect(dialog).toContainText(seeded.answer); await expect(dialog).not.toContainText('Later current letter');
const scrollRegion = dialog.getByRole('region', { name: interpolate(translations.en.assetsSubmittedPackageVersion, { version: 1 }), exact: true });
await scrollRegion.focus(); await expect(scrollRegion).toBeFocused();
const beforeScroll = await scrollRegion.evaluate(node => node.scrollTop); await page.keyboard.press('PageDown');
await expect.poll(() => scrollRegion.evaluate(node => node.scrollTop)).toBeGreaterThan(beforeScroll);
await page.keyboard.press('Home'); await expect.poll(() => scrollRegion.evaluate(node => node.scrollTop)).toBe(0);
const iframe = dialog.getByTitle(translations.en.assetsPackageCvPreview, { exact: true });
await expect(iframe).toHaveAttribute('sandbox', ''); await expect(iframe).toHaveAttribute('srcdoc', before.renderedCvHtml!);
expect(await iframe.evaluate(node => getComputedStyle(node).backgroundColor)).toBe('rgb(255, 255, 255)');
await capture(page, info, desktop, 'frozen-original-white-print-preview');
await page.keyboard.press('Escape'); await expect(view).toBeFocused();
const download = page.waitForEvent('download'); await keyboard(page, section(page, translations.en.assetsSubmittedPackages).getByRole('button').filter({ hasText: file.name }));
const result = await download; expect(result.suggestedFilename()).toBe(file.name); const path = await result.path(); expect(path).not.toBeNull(); expect(await readFile(path!)).toEqual(file.bytes);
expect(count(session, 'POST', `/api/jobapplications/${seeded.job.id}/submitted-packages`)).toHaveLength(1);
expect(await json<SubmittedPackageDetail>(await page.request.get(preservedUrl))).toEqual(before);
await info.attach('application-frozen-bytes', { body: JSON.stringify({ boundary, packageId: summary.id, recordedFileSha256: summary.attachments[0].sha256, downloadSha256: createHash('sha256').update(file.bytes).digest('hex'),
preservedCvVersion: summary.cvVariantVersion, currentCvVersion: changedCv.version, qualification: 'Exact scoped real-HTTP current-source changes and archive-byte checks; existing ordinary P2-D01/full renderer fidelity runs remain required separately.' }), contentType: 'application/json' });
});
test('[P7-06 forms] failed preserved detail and download retain history and Retry only the original owned read', async ({ page, session }, info) => {
test.setTimeout(120_000); const seeded = await frozenFixture(page, session.owner); const detailUrl = `${jobUrl(seeded.job.id)}/submitted-packages/${seeded.preserved.id}`;
const downloadUrl = `${detailUrl}/attachments/${seeded.preserved.attachments[0].id}`;
await enter(page, session.owner, seeded.job, 'submission'); const body = section(page, translations.en.assetsSubmittedPackages); await expect(body.getByRole('button', { name: translations.en.assetsPackageView, exact: true })).toBeVisible();
const gate = hold(); let details = 0; let downloads = 0;
await page.route(detailUrl, async route => { details += 1; if (details === 1) await route.fulfill({ status: 503, json: {} }); else { await gate.pending; await route.continue(); } });
await page.route(downloadUrl, async route => { downloads += 1; if (downloads === 1) await route.fulfill({ status: 503, json: {} }); else return route.continue(); });
try {
await keyboard(page, body.getByRole('button', { name: translations.en.assetsPackageView, exact: true }));
await expect(body.getByRole('alert')).toContainText(translations.en.assetsPackageLoadFailed); await expect(body.getByText(interpolate(translations.en.assetsSubmittedPackageVersion, { version: 1 }), { exact: true })).toBeVisible();
await expect(body.getByRole('button', { name: translations.en.assetsPackageView, exact: true })).toBeVisible(); expect(mutations(session)).toEqual([]);
const baseline = assetReads(session, seeded.job.id).length;
await keyboard(page, body.getByRole('button', { name: translations.en.retry, exact: true })); await expect.poll(() => details).toBe(2);
await expect(childFocus(page, translations.en.assetsSubmittedPackages)).toBeFocused(); await capture(page, info, desktop, 'held-original-package-detail-retry'); gate.release();
const dialog = page.getByRole('dialog'); await expect(dialog).toContainText(seeded.originalLetter); await page.keyboard.press('Escape');
await expect(body.getByRole('button', { name: translations.en.assetsPackageView, exact: true })).toBeFocused();
expect(assetReads(session, seeded.job.id)).toHaveLength(baseline + 1);
await keyboard(page, body.getByRole('button').filter({ hasText: seeded.name }));
await expect(body.getByRole('alert')).toContainText(translations.en.assetsPackageDownloadFailed); await expect(body.getByText(interpolate(translations.en.assetsSubmittedPackageVersion, { version: 1 }), { exact: true })).toBeVisible();
const download = page.waitForEvent('download'); await keyboard(page, body.getByRole('button', { name: translations.en.retry, exact: true }));
const result = await download; const path = await result.path(); expect(path).not.toBeNull(); expect(await readFile(path!)).toEqual(seeded.bytes);
expect(details).toBe(2); expect(downloads).toBe(2); expect(mutations(session)).toEqual([]);
expect(await json<SubmittedPackageDetail>(await page.request.get(detailUrl))).toEqual(seeded.detail);
await capture(page, info, desktop, 'original-archive-retry-history-retained');
} finally { gate.release(); }
});
test('[P7-06 forms] live EN/Bokmal retains dirty letter and both answer drafts without asset GET or writes', async ({ page, session }, info) => {
test.setTimeout(90_000); const seeded = await fixture(page, session.owner); await enter(page, session.owner, seeded.job, 'letter');
await expect(letter(page)).toHaveValue(seeded.originalLetter); await letter(page).fill('Dirty owner text remains the same across UI locale — æøå');
let reads = assetReads(session, seeded.job.id).length;
const firstLocale = response(page, 'PUT', `${apiUrl}/auth/preferences/language`); await keyboard(page, page.locator('header').first().getByRole('button', { name: 'Norsk', exact: true }));
const firstLocaleResponse = await firstLocale;
await info.attach('application-locale-first-http', { body: JSON.stringify({ status: firstLocaleResponse.status(), body: await firstLocaleResponse.text() }), contentType: 'application/json' });
expect(firstLocaleResponse.status()).toBe(204);
await expect(page.locator('html')).toHaveAttribute('lang', 'nb-NO'); await expect(letter(page, 'nb')).toHaveValue('Dirty owner text remains the same across UI locale — æøå');
expect(assetReads(session, seeded.job.id)).toHaveLength(reads); expect(mutations(session)).toEqual([]);
await capture(page, info, { ...desktop, language: 'nb' }, 'dirty-letter-live-locale');
await keyboard(page, section(page, translations.nb.workspaceCoverLetter).getByRole('button', { name: translations.nb.assetsDiscardChanges, exact: true }));
await keyboard(page, page.getByRole('tablist', { name: translations.nb.workspaceApplicationSections, exact: true }).getByRole('tab', { name: translations.nb.workspaceAnswers, exact: true }));
const fields = drafts(page, 'nb'); await expect(fields.answer).toHaveValue(seeded.answer);
await fields.answer.fill('Ulagret søknadssvar — æøå'); await fields.message.fill('Ulagret melding; ikke sendt — æøå'); reads = assetReads(session, seeded.job.id).length;
const secondLocale = response(page, 'PUT', `${apiUrl}/auth/preferences/language`); await keyboard(page, page.locator('header').first().getByRole('button', { name: 'English', exact: true }));
const secondLocaleResponse = await secondLocale;
await info.attach('application-locale-second-http', { body: JSON.stringify({ status: secondLocaleResponse.status(), body: await secondLocaleResponse.text() }), contentType: 'application/json' });
expect(secondLocaleResponse.status()).toBe(204);
await expect(page.locator('html')).toHaveAttribute('lang', 'en'); await expect(drafts(page).answer).toHaveValue('Ulagret søknadssvar — æøå'); await expect(drafts(page).message).toHaveValue('Ulagret melding; ikke sendt — æøå');
expect(assetReads(session, seeded.job.id)).toHaveLength(reads); expect(mutations(session)).toEqual([]);
await capture(page, info, desktop, 'dirty-two-drafts-live-locale');
await info.attach('application-locale-contract', { body: JSON.stringify({ boundary, assetGetDelta: 0, writes: 0, qualification: 'Assets only: existing workspace aggregate may independently reload on locale. Persisted document/draft language is not rewritten by UI language; no model calls.' }), contentType: 'application/json' });
});
test('[P7-06 forms] real duplicate then failed attach preserves created copy and original without automatic duplicate retry', async ({ page, session }, info) => {
test.setTimeout(90_000); const seeded = await fixture(page, session.owner);
const original = await json<CvVariant>(await page.request.get(`${apiUrl}/cv/variants/${seeded.cv.id}`));
await enter(page, session.owner, seeded.job, 'cv'); const attachUrl = `${jobUrl(seeded.job.id)}/cv`; let attaches = 0;
await page.route(attachUrl, async route => { if (route.request().method() !== 'PUT') return route.continue(); attaches += 1; return attaches === 1 ? route.fulfill({ status: 503, json: {} }) : route.continue(); });
const copyResponse = response(page, 'POST', `${apiUrl}/cv/variants/${seeded.cv.id}/duplicate`);
await keyboard(page, section(page, translations.en.workspaceCv).getByRole('button', { name: translations.en.assetsDuplicateForJob, exact: true }));
const copy = await json<CvVariant>(await copyResponse);
await expect(section(page, translations.en.workspaceCv).getByRole('alert').filter({ hasText: translations.en.assetsAttachCvFailed })).toBeVisible();
await expect(section(page, translations.en.workspaceCv).getByText(translations.en.assetsCopyCreatedHelp, { exact: true })).toBeVisible();
await expect(attachedCv(page)).toContainText(seeded.cv.name);
// DuplicateAsync preserves JobApplicationId before the second attach write. The
// existing read projects the newest associated variant, even while this UI
// still holds its pre-write selection. Explicit attach normalises cardinality.
expect((await json<ApplicationCv>(await page.request.get(attachUrl))).attachedVariantId).toBe(copy.id);
expect(await json<CvVariant>(await page.request.get(`${apiUrl}/cv/variants/${seeded.cv.id}`))).toEqual(original);
const partial = await json<CvVariantSummary[]>(await page.request.get(`${apiUrl}/cv/variants`));
expect(partial.map(value => value.id).sort((a, b) => a - b)).toEqual([seeded.cv.id, copy.id].sort((a, b) => a - b));
expect(partial.filter(value => value.jobApplicationId === seeded.job.id).map(value => value.id).sort((a, b) => a - b)).toEqual([seeded.cv.id, copy.id].sort((a, b) => a - b));
await expect(section(page, translations.en.workspaceCv).getByRole('button', { name: translations.en.retry, exact: true })).toHaveCount(0);
await capture(page, info, desktop, 'created-copy-failed-attach-preserved');
expect(count(session, 'POST', `/api/cv/variants/${seeded.cv.id}/duplicate`)).toHaveLength(1); expect(attaches).toBe(1);
const recovery = response(page, 'PUT', attachUrl);
const builderRead = response(page, 'GET', `${apiUrl}/cv/variants/${copy.id}`);
await keyboard(page, section(page, translations.en.workspaceCv).getByRole('button', { name: translations.en.assetsAttachCreatedCopy, exact: true }));
expect((await recovery).status()).toBe(200);
await expect(page).toHaveURL(new RegExp(`/career/builder/${copy.id}$`));
expect(await json<CvVariant>(await builderRead)).toMatchObject({ id: copy.id, name: copy.name });
expect((await json<ApplicationCv>(await page.request.get(attachUrl))).attachedVariantId).toBe(copy.id);
expect((await json<CvVariantSummary[]>(await page.request.get(`${apiUrl}/cv/variants`))).filter(value => value.jobApplicationId === seeded.job.id).map(value => value.id)).toEqual([copy.id]);
expect(count(session, 'POST', `/api/cv/variants/${seeded.cv.id}/duplicate`)).toHaveLength(1); expect(attaches).toBe(2);
await info.attach('application-duplicate-partial-write', { body: JSON.stringify({ boundary, originalId: seeded.cv.id, createdCopyId: copy.id, duplicatePostCount: 1, attachAttempts: 2,
recovery: 'Explicit Attach created copy reuses its exact ID, normalises association cardinality and opens its existing builder; no second duplicate.',
qualification: 'Existing duplicate retains JobApplicationId on both original and copy before explicit attach; newest-copy read projection can differ from stale UI. Two nontransactional HTTP writes remain, without rollback/idempotency/version guarantee.' }), contentType: 'application/json' });
});
test('[P7-06 forms] eight finite bilingual theme widths keep long CV, letter, drafts and frozen preview usable with keyboard', async ({ page, session }, info) => {
test.setTimeout(240_000); const seeded = await frozenFixture(page, session.owner, true);
const samples: (Sample & { sub: Subsection })[] = [
{ language: 'en', theme: 'light', width: 320, height: 740, sub: 'answers' }, { language: 'nb', theme: 'dark', width: 360, height: 844, sub: 'letter' },
{ language: 'en', theme: 'dark', width: 390, height: 844, sub: 'submission' }, { language: 'nb', theme: 'light', width: 768, height: 1024, sub: 'cv' },
{ language: 'en', theme: 'dark', width: 1024, height: 768, sub: 'answers' }, { language: 'nb', theme: 'light', width: 1280, height: 320, sub: 'letter' },
{ language: 'en', theme: 'light', width: 1440, height: 900, sub: 'submission' }, { language: 'nb', theme: 'dark', width: 1920, height: 1080, sub: 'cv' },
];
for (const sample of samples) {
await enter(page, session.owner, seeded.job, sample.sub, sample); const t = translations[sample.language];
let letterHint: { anchor: Locator; hint: Locator; id: string } | null = null;
const selected = page.getByRole('tablist', { name: t.workspaceApplicationSections, exact: true }).locator('[aria-selected=true]'); await selected.focus(); await expect(selected).toBeFocused();
if (sample.sub === 'letter') {
await expect(letter(page, sample.language)).toHaveValue(seeded.originalLetter); await letter(page, sample.language).focus(); await expect(letter(page, sample.language)).toBeFocused();
for (const title of [t.richTextBold, t.richTextItalic, t.richTextUnderline, t.richTextLink]) { const button = section(page, t.workspaceCoverLetter).getByRole('button', { name: title, exact: true }); await button.focus(); await expect(button).toBeFocused(); }
const anchor = section(page, t.workspaceCoverLetter).getByRole('button', { name: t.richTextLink, exact: true });
await expect(anchor).toHaveAttribute('aria-label', t.richTextLink);
const visibleHint = page.getByRole('tooltip', { name: t.richTextLink, exact: true }); await expect(visibleHint).toBeVisible();
// Existing MUI Tooltip names the child; describeChild is false, so there
// is no aria-describedby contract to invent for this toolbar button.
const id = await visibleHint.getAttribute('id'); expect(id).not.toBeNull();
const hint = page.locator(`[id="${id}"]`); await expect(hint).toHaveAttribute('role', 'tooltip'); await expect(hint).toBeVisible();
letterHint = { anchor, hint, id: id! };
} else if (sample.sub === 'answers') {
const fields = drafts(page, sample.language); await expect(fields.answer).toHaveValue(seeded.answer); await expect(fields.message).toHaveValue(seeded.message);
await fields.answer.focus(); await expect(fields.answer).toBeFocused(); await fields.message.focus(); await expect(fields.message).toBeFocused();
} else if (sample.sub === 'cv') {
const control = attachedCv(page, sample.language); await expect(control).toContainText(seeded.cv.name); await control.focus(); await expect(control).toBeFocused();
await expect(page.getByRole('link', { name: t.assetsEditPreviewExport, exact: true })).toHaveAttribute('href', `/career/builder/${seeded.cv.id}`);
} else {
const view = section(page, t.assetsSubmittedPackages).getByRole('button', { name: t.assetsPackageView, exact: true }); await keyboard(page, view);
const dialog = page.getByRole('dialog', { name: interpolate(t.assetsSubmittedPackageVersion, { version: 1 }), exact: true });
await expect(dialog).toContainText(seeded.originalLetter); const iframe = dialog.getByTitle(t.assetsPackageCvPreview, { exact: true });
await expect(iframe).toHaveAttribute('srcdoc', seeded.detail.renderedCvHtml!); await expect(iframe).toHaveAttribute('sandbox', '');
expect(await iframe.evaluate(node => getComputedStyle(node).backgroundColor)).toBe('rgb(255, 255, 255)');
const close = dialog.getByRole('button', { name: t.close, exact: true }); await close.focus(); await expect(close).toBeFocused();
}
await page.evaluate(() => scrollTo(0, 0));
if (letterHint) {
await expect(letterHint.anchor).toBeFocused();
const geometry = await letterHint.anchor.evaluate(node => {
const rect = node.getBoundingClientRect();
return { top: rect.top, bottom: rect.bottom, left: rect.left, right: rect.right, viewportWidth: innerWidth, viewportHeight: innerHeight,
whollyOutsideViewport: rect.bottom <= 0 || rect.top >= innerHeight || rect.right <= 0 || rect.left >= innerWidth };
});
await expect(letterHint.hint).toBeAttached();
if (geometry.whollyOutsideViewport) await expect(letterHint.hint).toBeHidden();
else await expect(letterHint.hint).toBeVisible();
await info.attach(`application-letter-hint-${sample.language}-${sample.width}`, { body: JSON.stringify({ matchingTooltipId: letterHint.id, anchorAccessibleName: t.richTextLink, geometry,
expectation: geometry.whollyOutsideViewport ? 'Focused anchor outside viewport: matching hint hidden' : 'Focused anchor intersects viewport: matching hint visible' }), contentType: 'application/json' });
}
await capture(page, info, sample, `long-${sample.sub}`);
if (sample.sub === 'submission') { await page.keyboard.press('Escape'); await expect(section(page, t.assetsSubmittedPackages).getByRole('button', { name: t.assetsPackageView, exact: true })).toBeFocused(); }
}
expect(mutations(session)).toEqual([]);
});
test('[P7-06 forms] controlled language HTTP503 preserves local dirty drafts and Retry only repeats the language request', async ({ page, session }, info) => {
test.setTimeout(90_000);
const seeded = await fixture(page, session.owner); await enter(page, session.owner, seeded.job, 'letter');
await expect(letter(page)).toHaveValue(seeded.originalLetter);
const persistedBefore = await json<{ uiLanguage: string }>(await page.request.get(`${apiUrl}/auth/me`)); expect(persistedBefore.uiLanguage).toBe('en');
const preferenceUrl = `${apiUrl}/auth/preferences/language`;
const preferenceAttempts: { method: string; body: unknown }[] = [];
await page.route(preferenceUrl, async route => {
if (route.request().method() !== 'PUT') return route.continue();
preferenceAttempts.push({ method: route.request().method(), body: route.request().postDataJSON() });
return route.fulfill({ status: 503, json: {} });
});
const dirtyLetter = 'Controlled-failure draft; retain my exact unsaved letter — æøå';
await letter(page).fill(dirtyLetter); let reads = assetReads(session, seeded.job.id).length;
const firstResponse = response(page, 'PUT', preferenceUrl);
await keyboard(page, page.locator('header').first().getByRole('button', { name: 'Norsk', exact: true }));
expect((await firstResponse).status()).toBe(503); await expect(page.locator('html')).toHaveAttribute('lang', 'nb-NO');
await expect(letter(page, 'nb')).toHaveValue(dirtyLetter);
const nbFeedback = page.getByRole('alert').filter({ hasText: translations.nb.settingsLanguageSaveFailed }); await expect(nbFeedback).toBeVisible();
const firstRetry = response(page, 'PUT', preferenceUrl);
await keyboard(page, nbFeedback.getByRole('button', { name: translations.nb.retry, exact: true })); expect((await firstRetry).status()).toBe(503);
await expect(nbFeedback).toBeVisible(); await expect(letter(page, 'nb')).toHaveValue(dirtyLetter);
expect(preferenceAttempts).toEqual([{ method: 'PUT', body: { language: 'nb-NO' } }, { method: 'PUT', body: { language: 'nb-NO' } }]);
expect(assetReads(session, seeded.job.id)).toHaveLength(reads); expect(mutations(session)).toEqual([]);
await capture(page, info, { ...desktop, language: 'nb' }, 'controlled-language503-dirty-letter');
await keyboard(page, section(page, translations.nb.workspaceCoverLetter).getByRole('button', { name: translations.nb.assetsDiscardChanges, exact: true }));
await keyboard(page, page.getByRole('tablist', { name: translations.nb.workspaceApplicationSections, exact: true }).getByRole('tab', { name: translations.nb.workspaceAnswers, exact: true }));
await expect(drafts(page, 'nb').answer).toHaveValue(seeded.answer);
const dirtyAnswer = 'Ulagret søknadssvar under kontrollert språkfeil — æøå'; const dirtyMessage = 'Ulagret melding; ikke sendt under språkfeilen — æøå';
await drafts(page, 'nb').answer.fill(dirtyAnswer); await drafts(page, 'nb').message.fill(dirtyMessage); reads = assetReads(session, seeded.job.id).length;
const secondResponse = response(page, 'PUT', preferenceUrl);
await keyboard(page, page.locator('header').first().getByRole('button', { name: 'English', exact: true }));
expect((await secondResponse).status()).toBe(503); await expect(page.locator('html')).toHaveAttribute('lang', 'en');
await expect(drafts(page).answer).toHaveValue(dirtyAnswer); await expect(drafts(page).message).toHaveValue(dirtyMessage);
const enFeedback = page.getByRole('alert').filter({ hasText: translations.en.settingsLanguageSaveFailed }); await expect(enFeedback).toBeVisible();
const secondRetry = response(page, 'PUT', preferenceUrl);
await keyboard(page, enFeedback.getByRole('button', { name: translations.en.retry, exact: true })); expect((await secondRetry).status()).toBe(503);
await expect(enFeedback).toBeVisible(); await expect(drafts(page).answer).toHaveValue(dirtyAnswer); await expect(drafts(page).message).toHaveValue(dirtyMessage);
expect(preferenceAttempts).toEqual([{ method: 'PUT', body: { language: 'nb-NO' } }, { method: 'PUT', body: { language: 'nb-NO' } }, { method: 'PUT', body: { language: 'en' } }, { method: 'PUT', body: { language: 'en' } }]);
expect(assetReads(session, seeded.job.id)).toHaveLength(reads); expect(mutations(session)).toEqual([]);
const persistedAfter = await json<{ uiLanguage: string }>(await page.request.get(`${apiUrl}/auth/me`)); expect(persistedAfter.uiLanguage).toBe(persistedBefore.uiLanguage);
expect((await json<CoverLetter>(await page.request.get(`${jobUrl(seeded.job.id)}/cover-letter`))).text).toBe(seeded.originalLetter);
const workspace = await json<WorkspaceOverview>(await page.request.get(`${jobUrl(seeded.job.id)}/workspace`)); expect(workspace.applicationAnswerDraft).toBe(seeded.answer); expect(workspace.recruiterMessageDraft).toBe(seeded.message);
await capture(page, info, desktop, 'controlled-language503-dirty-answer-drafts');
await info.attach('application-controlled-language503-boundary', { body: JSON.stringify({ boundary, controlledResponseStatus: 503, preferenceAttempts, persistedBefore: persistedBefore.uiLanguage, persistedAfter: persistedAfter.uiLanguage,
qualification: 'Page-only language PUT is deliberately fulfilled503 before server execution. Proves local presentation, retained drafts and exact Retry request without asset GET/job writes; it does not prove account preference persistence or close RD11. The separate ordinary real-server locale case keeps its strict204 assertions and observed500 failures.' }), contentType: 'application/json' });
});
+382 -5
View File
@@ -1,14 +1,17 @@
import React from "react";
import "@testing-library/jest-dom";
import { fireEvent, render as rtlRender, screen, waitFor } from "@testing-library/react";
import { act, fireEvent, render as rtlRender, screen, waitFor, within } from "@testing-library/react";
import {
ApplicationCoverLetterSection, ApplicationCvSection, ApplicationPackageDraftsSection, SubmittedPackagesSection,
} from "./components/ApplicationAssets";
import { api } from "./api";
import { I18nProvider } from "./i18n/I18nProvider";
import { ConfirmProvider } from "./confirm";
import { translations, type UiLanguage } from "./i18n/translations";
const render = (ui: React.ReactElement) => rtlRender(<I18nProvider>{ui}</I18nProvider>);
const withProviders = (ui: React.ReactElement) => <I18nProvider><ConfirmProvider>{ui}</ConfirmProvider></I18nProvider>;
const render = (ui: React.ReactElement) => rtlRender(withProviders(ui));
jest.mock("./api", () => ({
api: {
@@ -17,7 +20,7 @@ jest.mock("./api", () => ({
post: jest.fn(),
interceptors: { request: { use: jest.fn() }, response: { use: jest.fn() } },
},
getApiErrorMessage: (_e: any, fallback?: string) => fallback || "Request failed.",
getApiErrorMessage: (_e: unknown, fallback = "Request failed.") => fallback,
}));
const mockedApi = api as jest.Mocked<typeof api>;
@@ -69,10 +72,37 @@ function routeGet(overrides: Record<string, any> = {}) {
beforeEach(() => {
window.localStorage.removeItem("uiLanguage");
document.documentElement.lang = "en";
jest.clearAllMocks();
mockedApi.get.mockReset();
mockedApi.put.mockReset();
mockedApi.post.mockReset();
});
afterEach(() => window.localStorage.removeItem("uiLanguage"));
afterEach(() => {
window.localStorage.removeItem("uiLanguage");
jest.restoreAllMocks();
});
function copy(key: string, params: Record<string, string | number> = {}, language: UiLanguage = document.documentElement.lang === "nb-NO" ? "nb" : "en") {
const template = (translations[language] as Record<string, string>)[key] ?? key;
return template.replace(/\{(\w+)\}/g, (_, name) => String(params[name] ?? `{${name}}`));
}
function section(key: string) {
return within(screen.getByRole("region", { name: copy(key) }));
}
function changeLanguage(language: UiLanguage) {
act(() => { window.dispatchEvent(new StorageEvent("storage", { key: "uiLanguage", newValue: language })); });
}
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason: unknown) => void;
const promise = new Promise<T>((yes, no) => { resolve = yes; reject = no; });
return { promise, resolve, reject };
}
test("document package history is read-only and separates captured notes from answers", async () => {
const summary = { id: 91, version: 1, cvVariantId: 3, cvVariantName: "Original CV", cvVariantVersion: 2, cvThemeId: "nordic", hasCv: false, hasCoverLetter: true, createdAtUtc: "2026-07-19T10:00:00Z", attachments: [] };
@@ -302,7 +332,7 @@ test("a failed load surfaces an error", async () => {
render(<ApplicationCoverLetterSection jobId={7} />);
expect(await screen.findByText(/Could not load this section/i)).toBeInTheDocument();
expect(await section("workspaceCoverLetter").findByText(/Could not load this section/i)).toBeInTheDocument();
});
// ---------- Application package drafts ----------
@@ -355,3 +385,350 @@ test("application package drafts can be cleared without deleting ordinary notes"
{ applicationAnswerDraft: "", recruiterMessageDraft: "" },
));
});
// ---------- Phase 7 retained-owner recovery ----------
test("failed cover-letter saving retains editable dirty text and original Save recovery across a live locale change", async () => {
routeGet();
const text = "Retained letter draft — æøå";
mockedApi.put.mockRejectedValueOnce({ response: { status: 503 } })
.mockResolvedValueOnce({ data: { ...coverLetter, text } } as any);
const onDirtyChange = jest.fn();
render(<ApplicationCoverLetterSection jobId={7} onDirtyChange={onDirtyChange} />);
fireEvent.change(await screen.findByRole("textbox", { name: copy("jobDetailsCoverLetter") }), { target: { value: text } });
fireEvent.click(section("workspaceCoverLetter").getByRole("button", { name: copy("save") }));
expect(await section("workspaceCoverLetter").findByRole("alert")).toHaveTextContent(copy("assetsCoverSaveFailed"));
expect(screen.getByRole("textbox", { name: copy("jobDetailsCoverLetter") })).toHaveValue(text);
expect(screen.getByRole("textbox", { name: copy("jobDetailsCoverLetter") })).toBeEnabled();
expect(section("workspaceCoverLetter").getByRole("button", { name: copy("save") })).toBeEnabled();
expect(section("workspaceCoverLetter").getByRole("button", { name: copy("assetsDiscardChanges") })).toBeEnabled();
expect(section("workspaceCoverLetter").queryByRole("button", { name: copy("retry") })).not.toBeInTheDocument();
expect(onDirtyChange).toHaveBeenLastCalledWith(true);
const reads = mockedApi.get.mock.calls.length;
changeLanguage("nb");
expect(section("workspaceCoverLetter").getByRole("alert")).toHaveTextContent(copy("assetsCoverSaveFailed"));
expect(screen.getByRole("textbox", { name: copy("jobDetailsCoverLetter") })).toHaveValue(text);
expect(mockedApi.get).toHaveBeenCalledTimes(reads);
fireEvent.click(section("workspaceCoverLetter").getByRole("button", { name: copy("save") }));
await waitFor(() => expect(mockedApi.put).toHaveBeenCalledTimes(2));
const payload = { text, source: "manual", aiAction: undefined };
expect(mockedApi.put).toHaveBeenNthCalledWith(1, "/jobapplications/7/cover-letter", payload);
expect(mockedApi.put).toHaveBeenNthCalledWith(2, "/jobapplications/7/cover-letter", payload);
await waitFor(() => expect(onDirtyChange).toHaveBeenLastCalledWith(false));
expect(screen.getByRole("textbox", { name: copy("jobDetailsCoverLetter") })).toHaveValue(text);
expect(mockedApi.post).not.toHaveBeenCalled();
});
test("failed application-answer saving keeps both dirty drafts editable and retries only the original save payload", async () => {
mockedApi.put.mockRejectedValueOnce({ response: { status: 503 } }).mockResolvedValueOnce({ status: 204 } as any);
const onDirtyChange = jest.fn();
const onSaved = jest.fn();
render(<ApplicationPackageDraftsSection jobId={7} initialApplicationAnswer="Saved answer" initialRecruiterMessage="Saved message" onDirtyChange={onDirtyChange} onSaved={onSaved} />);
fireEvent.change(screen.getByLabelText(copy("assetsApplicationAnswer")), { target: { value: "Retained answer" } });
fireEvent.change(screen.getByLabelText(copy("assetsRecruiterMessage")), { target: { value: "Retained recruiter draft" } });
fireEvent.click(screen.getByRole("button", { name: copy("assetsSaveDrafts") }));
expect(await section("assetsApplicationDrafts").findByRole("alert")).toHaveTextContent(copy("assetsDraftsSaveFailed"));
expect(screen.getByLabelText(copy("assetsApplicationAnswer"))).toHaveValue("Retained answer");
expect(screen.getByLabelText(copy("assetsRecruiterMessage"))).toHaveValue("Retained recruiter draft");
expect(screen.getByLabelText(copy("assetsApplicationAnswer"))).toBeEnabled();
expect(screen.getByLabelText(copy("assetsRecruiterMessage"))).toBeEnabled();
expect(onDirtyChange).toHaveBeenLastCalledWith(true);
expect(onSaved).not.toHaveBeenCalled();
changeLanguage("nb");
expect(section("assetsApplicationDrafts").getByRole("alert")).toHaveTextContent(copy("assetsDraftsSaveFailed"));
expect(mockedApi.get).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole("button", { name: copy("assetsSaveDrafts") }));
await waitFor(() => expect(onSaved).toHaveBeenCalledTimes(1));
const payload = { applicationAnswerDraft: "Retained answer", recruiterMessageDraft: "Retained recruiter draft" };
expect(mockedApi.put).toHaveBeenNthCalledWith(1, "/jobapplications/7/application-drafts", payload);
expect(mockedApi.put).toHaveBeenNthCalledWith(2, "/jobapplications/7/application-drafts", payload);
expect(onDirtyChange).toHaveBeenLastCalledWith(false);
expect(mockedApi.post).not.toHaveBeenCalled();
});
test("failed CV attachment leaves the current association and selector available for the original explicit action", async () => {
routeGet();
mockedApi.put.mockRejectedValueOnce({ response: { status: 503 } })
.mockResolvedValueOnce({ data: { ...cv, attachedVariantId: 5, attachedVariantName: "Generalist CV" } } as any);
render(<ApplicationCvSection jobId={7} includePackages={false} />);
const select = () => screen.getByRole("combobox", { name: copy("assetsAttachedCv") });
fireEvent.mouseDown(await screen.findByRole("combobox", { name: /Attached CV variant/i }));
fireEvent.click(await screen.findByRole("option", { name: /Generalist CV/ }));
expect(await section("workspaceCv").findByRole("alert")).toHaveTextContent(copy("assetsAttachCvFailed"));
expect(select()).toHaveTextContent("Backend CV");
expect(select()).not.toHaveAttribute("aria-disabled", "true");
expect(section("workspaceCv").getByRole("link", { name: /Edit, preview and export/i })).toHaveAttribute("href", "/career/builder/3");
expect(section("workspaceCv").queryByRole("button", { name: copy("retry") })).not.toBeInTheDocument();
fireEvent.mouseDown(select());
fireEvent.click(await screen.findByRole("option", { name: /Generalist CV/ }));
await waitFor(() => expect(mockedApi.put).toHaveBeenCalledTimes(2));
expect(mockedApi.put).toHaveBeenNthCalledWith(1, "/jobapplications/7/cv", { variantId: 5 });
expect(mockedApi.put).toHaveBeenNthCalledWith(2, "/jobapplications/7/cv", { variantId: 5 });
await waitFor(() => expect(section("workspaceCv").getByRole("link", { name: /Edit, preview and export/i })).toHaveAttribute("href", "/career/builder/5"));
expect(mockedApi.post).not.toHaveBeenCalled();
});
test("dirty version restore cancels without a write and confirmed held restore appends history exactly once", async () => {
routeGet();
const write = deferred<any>();
mockedApi.post.mockImplementation(() => write.promise);
const onDirtyChange = jest.fn();
render(<ApplicationCoverLetterSection jobId={7} onDirtyChange={onDirtyChange} />);
fireEvent.change(await screen.findByRole("textbox", { name: "Cover letter" }), { target: { value: "Unsaved text before restore" } });
fireEvent.click(screen.getByRole("button", { name: /Restore version 1/i }));
expect(mockedApi.post).not.toHaveBeenCalled();
const dialog = await screen.findByRole("dialog", { name: copy("assetsRestoreDiscardTitle") });
fireEvent.click(within(dialog).getByRole("button", { name: copy("cancel") }));
await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
expect(screen.getByRole("textbox", { name: "Cover letter" })).toHaveValue("Unsaved text before restore");
expect(onDirtyChange).toHaveBeenLastCalledWith(true);
expect(mockedApi.post).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole("button", { name: /Restore version 1/i }));
const confirmed = await screen.findByRole("dialog", { name: copy("assetsRestoreDiscardTitle") });
const confirm = within(confirmed).getByRole("button", { name: copy("assetsRestoreConfirm") });
act(() => { fireEvent.click(confirm); fireEvent.click(confirm); });
await waitFor(() => expect(mockedApi.post).toHaveBeenCalledTimes(1));
expect(mockedApi.post).toHaveBeenCalledWith("/jobapplications/7/cover-letter/versions/1/restore");
await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
expect(screen.getByRole("button", { name: /Restore version 1/i })).toBeDisabled();
await act(async () => { write.resolve({ data: {
...coverLetter, text: "Restored old text", currentVersion: 3,
versions: [{ version: 3, source: "restore", aiAction: null, length: 17, createdAtUtc: "2026-07-20T10:00:00Z", isCurrent: true }, ...coverLetter.versions.map((version) => ({ ...version, isCurrent: false }))],
} }); });
expect(await screen.findByDisplayValue("Restored old text")).toBeInTheDocument();
expect(screen.getByText("v3")).toBeInTheDocument();
expect(screen.getByText("v2")).toBeInTheDocument();
expect(screen.getByText("v1")).toBeInTheDocument();
expect(onDirtyChange).toHaveBeenLastCalledWith(false);
expect(mockedApi.post).toHaveBeenCalledTimes(1);
expect(mockedApi.put).not.toHaveBeenCalled();
});
test("held letter Save submits one snapshot and prevents an AI Apply from replacing its pending draft", async () => {
routeGet();
mockedApi.post.mockResolvedValue({ data: { result: { text: "Unaccepted AI suggestion" } } } as any);
const write = deferred<any>();
mockedApi.put.mockImplementation(() => write.promise);
render(<ApplicationCoverLetterSection jobId={7} />);
fireEvent.click(await screen.findByRole("button", { name: "Generate" }));
const apply = await screen.findByRole("button", { name: /Apply to editor/i });
fireEvent.change(screen.getByRole("textbox", { name: "Cover letter" }), { target: { value: "Explicit manual save snapshot" } });
const save = section("workspaceCoverLetter").getByRole("button", { name: "Save" });
act(() => { fireEvent.click(save); fireEvent.click(save); });
expect(mockedApi.put).toHaveBeenCalledTimes(1);
expect(mockedApi.put).toHaveBeenCalledWith("/jobapplications/7/cover-letter", { text: "Explicit manual save snapshot", source: "manual", aiAction: undefined });
expect(apply).toBeDisabled();
fireEvent.click(apply);
expect(screen.getByRole("textbox", { name: "Cover letter" })).toHaveValue("Explicit manual save snapshot");
await act(async () => { write.resolve({ data: { ...coverLetter, text: "Explicit manual save snapshot" } }); });
expect(screen.getByRole("textbox", { name: "Cover letter" })).toHaveValue("Explicit manual save snapshot");
expect(mockedApi.put).toHaveBeenCalledTimes(1);
});
test("held AI generation locks its controls and preserves captured action attribution after a later action selection", async () => {
routeGet();
const generation = deferred<any>();
mockedApi.post.mockImplementation(() => generation.promise);
render(<ApplicationCoverLetterSection jobId={7} />);
await screen.findByText(/application's full job advert and analysis/i);
fireEvent.mouseDown(screen.getByRole("combobox", { name: "Document language" }));
fireEvent.click(await screen.findByRole("option", { name: /Norsk bokmål/i }));
fireEvent.change(screen.getByRole("textbox", { name: "Additional instructions" }), { target: { value: "Use only recorded context" } });
const generate = screen.getByRole("button", { name: "Generate" });
act(() => { fireEvent.click(generate); fireEvent.click(generate); });
expect(mockedApi.post).toHaveBeenCalledTimes(1);
expect(mockedApi.post).toHaveBeenCalledWith("/jobapplications/7/ai/generate", expect.objectContaining({ module: "cover-letter", action: "generate", mode: "professional", targetLanguage: "nb-NO", currentText: "Dear team", extraContext: "Use only recorded context" }));
for (const name of ["Action", "Tone", "Document language"]) expect(screen.getByRole("combobox", { name })).toHaveAttribute("aria-disabled", "true");
expect(screen.getByRole("textbox", { name: "Additional instructions" })).toBeDisabled();
await act(async () => { generation.resolve({ data: { result: { text: "Captured generated suggestion" } } }); });
fireEvent.mouseDown(screen.getByRole("combobox", { name: "Action" }));
fireEvent.click(await screen.findByRole("option", { name: "Improve" }));
fireEvent.click(screen.getByRole("button", { name: /Apply to editor/i }));
mockedApi.put.mockResolvedValue({ data: { ...coverLetter, text: "Captured generated suggestion" } } as any);
fireEvent.click(section("workspaceCoverLetter").getByRole("button", { name: "Save" }));
await waitFor(() => expect(mockedApi.put).toHaveBeenCalledWith("/jobapplications/7/cover-letter", { text: "Captured generated suggestion", source: "ai", aiAction: "generate" }));
expect(mockedApi.post).toHaveBeenCalledTimes(1);
});
test("capture, detail and download failures keep owned frozen history and recover only through their original explicit actions", async () => {
const attachment = { id: 17, fileName: "frozen-letter.txt", fileSize: 1024, contentType: "text/plain", sha256: "fixture-hash" };
const summary = { id: 91, version: 1, cvVariantId: 3, cvVariantName: "Original CV", cvVariantVersion: 2, cvThemeId: "nordic", hasCv: true, hasCoverLetter: true, createdAtUtc: "2026-07-19T10:00:00Z", attachments: [attachment] };
const created = { ...summary, id: 92, version: 2, cvVariantName: "Backend CV", cvVariantVersion: 4, attachments: [] };
const detail = { summary, renderedCvHtml: "<html><body>Frozen CV fixture</body></html>", coverLetterText: "Frozen original letter", applicationMaterialJson: "{}" };
const blob = new Blob(["Frozen file fixture"], { type: "text/plain" });
let detailReads = 0;
let downloads = 0;
mockedApi.get.mockImplementation((url: string) => {
if (url === "/jobapplications/7/submitted-packages/91") return ++detailReads === 1 ? Promise.reject({ response: { status: 503 } }) : Promise.resolve({ data: detail } as any);
if (url === "/jobapplications/7/submitted-packages/91/attachments/17") return ++downloads === 1 ? Promise.reject({ response: { status: 503 } }) : Promise.resolve({ data: blob } as any);
if (url.endsWith("/submitted-packages")) return Promise.resolve({ data: [summary] } as any);
if (url.endsWith("/tailoring")) return Promise.resolve({ data: tailoring } as any);
return Promise.resolve({ data: cv } as any);
});
mockedApi.post.mockRejectedValueOnce({ response: { status: 503 } }).mockResolvedValueOnce({ data: created } as any);
const originalCreate = Object.getOwnPropertyDescriptor(URL, "createObjectURL");
const originalRevoke = Object.getOwnPropertyDescriptor(URL, "revokeObjectURL");
const createUrl = jest.fn(() => "blob:owned-frozen-fixture");
const revokeUrl = jest.fn();
Object.defineProperty(URL, "createObjectURL", { configurable: true, value: createUrl });
Object.defineProperty(URL, "revokeObjectURL", { configurable: true, value: revokeUrl });
const downloadedNames: string[] = [];
jest.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(function (this: HTMLAnchorElement) { downloadedNames.push(this.download); });
try {
render(<ApplicationCvSection jobId={7} />);
fireEvent.click(await screen.findByRole("button", { name: /Freeze current package/i }));
expect(await section("assetsSubmittedPackages").findByRole("alert")).toHaveTextContent(copy("assetsPackageCaptureFailed"));
expect(screen.getByText("Submission package v1")).toBeInTheDocument();
expect(section("workspaceCv").getByRole("link", { name: /Edit, preview and export/i })).toHaveAttribute("href", "/career/builder/3");
expect(section("assetsSubmittedPackages").queryByRole("button", { name: copy("retry") })).not.toBeInTheDocument();
expect(mockedApi.post).toHaveBeenCalledTimes(1);
fireEvent.click(section("assetsSubmittedPackages").getByRole("button", { name: /Freeze current package/i }));
await screen.findByText("Submission package v2");
const packageRow = () => screen.getByText("Submission package v1").closest<HTMLElement>(".MuiPaper-root")!;
fireEvent.click(within(packageRow()).getByRole("button", { name: "View preserved package" }));
expect(await section("assetsSubmittedPackages").findByRole("alert")).toHaveTextContent(copy("assetsPackageLoadFailed"));
expect(screen.getByText("Submission package v1")).toBeInTheDocument();
expect(screen.getByText("Submission package v2")).toBeInTheDocument();
expect(detailReads).toBe(1);
expect(mockedApi.post).toHaveBeenCalledTimes(2);
fireEvent.click(within(packageRow()).getByRole("button", { name: "View preserved package" }));
const preserved = await screen.findByRole("dialog");
expect(preserved).toHaveTextContent("Frozen original letter");
expect(detailReads).toBe(2);
fireEvent.click(within(preserved).getByRole("button", { name: "Close" }));
await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
fireEvent.click(within(packageRow()).getByRole("button", { name: /frozen-letter\.txt/ }));
expect(await section("assetsSubmittedPackages").findByRole("alert")).toHaveTextContent(copy("assetsPackageDownloadFailed"));
expect(screen.getByText("Original CV · v2")).toBeInTheDocument();
expect(section("workspaceCv").getByRole("link", { name: /Edit, preview and export/i })).toHaveAttribute("href", "/career/builder/3");
expect(downloads).toBe(1);
fireEvent.click(within(packageRow()).getByRole("button", { name: /frozen-letter\.txt/ }));
await waitFor(() => expect(createUrl).toHaveBeenCalledWith(blob));
expect(downloads).toBe(2);
expect(downloadedNames).toEqual(["frozen-letter.txt"]);
expect(revokeUrl).toHaveBeenCalledWith("blob:owned-frozen-fixture");
expect(mockedApi.post.mock.calls).toEqual([["/jobapplications/7/submitted-packages"], ["/jobapplications/7/submitted-packages"]]);
expect(mockedApi.put).not.toHaveBeenCalled();
} finally {
if (originalCreate) Object.defineProperty(URL, "createObjectURL", originalCreate); else Reflect.deleteProperty(URL, "createObjectURL");
if (originalRevoke) Object.defineProperty(URL, "revokeObjectURL", originalRevoke); else Reflect.deleteProperty(URL, "revokeObjectURL");
}
});
test("all five asset read owners expose GET-only recovery without false empty history or locale refetch", async () => {
mockedApi.get.mockRejectedValue({ response: { status: 503 } });
render(<><ApplicationCvSection jobId={7} includePackages={false} /><SubmittedPackagesSection jobId={7} allowCapture={false} /><ApplicationCoverLetterSection jobId={7} /></>);
const owners = ["workspaceCv", "assetsTailoring", "assetsSubmittedPackages", "workspaceCoverLetter", "coverAiTitle"];
await waitFor(() => expect(mockedApi.get).toHaveBeenCalledTimes(5));
for (const key of owners) expect(await section(key).findByRole("alert")).toHaveTextContent(copy("assetsLoadFailed"));
expect(screen.queryByText(copy("assetsNoVersions"))).not.toBeInTheDocument();
expect(screen.queryByText(copy("assetsNoSubmittedPackages"))).not.toBeInTheDocument();
expect(screen.queryByText(copy("coverAiSelectCv"))).not.toBeInTheDocument();
changeLanguage("nb");
for (const key of owners) expect(section(key).getByRole("alert")).toHaveTextContent(copy("assetsLoadFailed"));
expect(mockedApi.get).toHaveBeenCalledTimes(5);
routeGet();
const endpoints = ["cv", "tailoring", "submitted-packages", "cover-letter", "cv"];
for (let index = 0; index < owners.length; index++) {
fireEvent.click(section(owners[index]).getByRole("button", { name: copy("retry") }));
await waitFor(() => expect(mockedApi.get).toHaveBeenCalledTimes(6 + index));
expect(mockedApi.get).toHaveBeenNthCalledWith(6 + index, `/jobapplications/7/${endpoints[index]}`);
await waitFor(() => expect(section(owners[index]).queryByRole("button", { name: copy("retry") })).not.toBeInTheDocument());
}
expect(screen.getByRole("textbox", { name: copy("jobDetailsCoverLetter") })).toHaveValue("Dear team");
expect(screen.getByText("v2")).toBeInTheDocument();
expect(mockedApi.put).not.toHaveBeenCalled();
expect(mockedApi.post).not.toHaveBeenCalled();
});
test("keyed job changes stop a late CV copy from attaching and never reuse another job's saved-copy recovery", async () => {
const jobBCv = {
...cv,
attachedVariantId: 9,
attachedVariantName: "Job B CV",
attachedVersion: 6,
availableVariants: [{ ...cv.availableVariants[0], id: 9, name: "Job B CV", version: 6, jobApplicationId: 8 }],
};
mockedApi.get.mockImplementation((url: string) => Promise.resolve({
data: url.endsWith("/tailoring") ? tailoring : url === "/jobapplications/8/cv" ? jobBCv : cv,
} as any));
const oldCopy = deferred<any>();
mockedApi.post.mockReturnValueOnce(oldCopy.promise);
const mounted = render(<ApplicationCvSection key={7} jobId={7} includePackages={false} />);
const initialLocation = window.location.href;
fireEvent.click(await screen.findByRole("button", { name: copy("assetsDuplicateForJob") }));
expect(mockedApi.post).toHaveBeenCalledWith("/cv/variants/3/duplicate", {
name: copy("assetsTailoredCopyName", { name: "Backend CV" }),
});
mounted.rerender(withProviders(<ApplicationCvSection key={8} jobId={8} includePackages={false} />));
await waitFor(() => expect(screen.getByRole("combobox", { name: copy("assetsAttachedCv") })).toHaveTextContent("Job B CV"));
await act(async () => { oldCopy.resolve({ data: { id: 83 } }); await oldCopy.promise; });
expect(mockedApi.put).not.toHaveBeenCalled();
expect(window.location.href).toBe(initialLocation);
expect(section("workspaceCv").getByRole("link", { name: /Edit, preview and export/i })).toHaveAttribute("href", "/career/builder/9");
expect(screen.getByRole("button", { name: copy("assetsDuplicateForJob") })).toBeEnabled();
expect(screen.queryByRole("button", { name: copy("assetsAttachCreatedCopy") })).not.toBeInTheDocument();
mounted.rerender(withProviders(<ApplicationCvSection key={7} jobId={7} includePackages={false} />));
mockedApi.post.mockResolvedValueOnce({ data: { id: 84 } } as any);
mockedApi.put.mockRejectedValueOnce({ response: { status: 503 } });
fireEvent.click(await screen.findByRole("button", { name: copy("assetsDuplicateForJob") }));
await waitFor(() => expect(screen.getByRole("button", { name: copy("assetsAttachCreatedCopy") })).toBeEnabled());
expect(await section("workspaceCv").findByText(copy("assetsAttachCvFailed"))).toBeInTheDocument();
expect(mockedApi.put).toHaveBeenCalledTimes(1);
expect(mockedApi.put).toHaveBeenCalledWith("/jobapplications/7/cv", { variantId: 84 });
mounted.rerender(withProviders(<ApplicationCvSection key={8} jobId={8} includePackages={false} />));
await waitFor(() => expect(screen.getByRole("combobox", { name: copy("assetsAttachedCv") })).toHaveTextContent("Job B CV"));
expect(screen.queryByRole("button", { name: copy("assetsAttachCreatedCopy") })).not.toBeInTheDocument();
expect(screen.queryByText(copy("assetsCopyCreatedHelp"))).not.toBeInTheDocument();
const jobBCopy = deferred<any>();
mockedApi.post.mockReturnValueOnce(jobBCopy.promise);
fireEvent.click(screen.getByRole("button", { name: copy("assetsDuplicateForJob") }));
expect(mockedApi.post).toHaveBeenCalledTimes(3);
expect(mockedApi.post).toHaveBeenLastCalledWith("/cv/variants/9/duplicate", {
name: copy("assetsTailoredCopyName", { name: "Job B CV" }),
});
expect(mockedApi.put).toHaveBeenCalledTimes(1);
expect(section("workspaceCv").getByRole("link", { name: /Edit, preview and export/i })).toHaveAttribute("href", "/career/builder/9");
expect(window.location.href).toBe(initialLocation);
mounted.unmount();
});
test("keyed job changes keep job B's answer drafts and callbacks independent of job A's late save", async () => {
const saveA = deferred<any>();
mockedApi.put.mockReturnValueOnce(saveA.promise);
const onSavedA = jest.fn();
const onSavedB = jest.fn();
const onDirtyB = jest.fn();
const mounted = render(<ApplicationPackageDraftsSection key={7} jobId={7}
initialApplicationAnswer="Saved A answer" initialRecruiterMessage="Saved A message" onSaved={onSavedA} />);
fireEvent.change(screen.getByRole("textbox", { name: copy("assetsApplicationAnswer") }), { target: { value: "Pending A answer" } });
fireEvent.change(screen.getByRole("textbox", { name: copy("assetsRecruiterMessage") }), { target: { value: "Pending A message" } });
fireEvent.click(screen.getByRole("button", { name: copy("assetsSaveDrafts") }));
expect(mockedApi.put).toHaveBeenCalledWith("/jobapplications/7/application-drafts", {
applicationAnswerDraft: "Pending A answer", recruiterMessageDraft: "Pending A message",
});
mounted.rerender(withProviders(<ApplicationPackageDraftsSection key={8} jobId={8}
initialApplicationAnswer="Saved B answer" initialRecruiterMessage="Saved B message" onSaved={onSavedB} onDirtyChange={onDirtyB} />));
fireEvent.change(screen.getByRole("textbox", { name: copy("assetsApplicationAnswer") }), { target: { value: "Unsaved B answer" } });
fireEvent.change(screen.getByRole("textbox", { name: copy("assetsRecruiterMessage") }), { target: { value: "Unsaved B message" } });
await act(async () => { saveA.resolve({ data: undefined }); await saveA.promise; });
expect(screen.getByRole("textbox", { name: copy("assetsApplicationAnswer") })).toHaveValue("Unsaved B answer");
expect(screen.getByRole("textbox", { name: copy("assetsRecruiterMessage") })).toHaveValue("Unsaved B message");
expect(screen.getByRole("button", { name: copy("assetsSaveDrafts") })).toBeEnabled();
expect(onDirtyB).toHaveBeenLastCalledWith(true);
expect(onSavedA).not.toHaveBeenCalled();
expect(onSavedB).not.toHaveBeenCalled();
expect(mockedApi.put).toHaveBeenCalledTimes(1);
mockedApi.put.mockResolvedValueOnce({ data: undefined } as any);
fireEvent.click(screen.getByRole("button", { name: copy("assetsSaveDrafts") }));
await waitFor(() => expect(onSavedB).toHaveBeenCalledTimes(1));
expect(mockedApi.put).toHaveBeenLastCalledWith("/jobapplications/8/application-drafts", {
applicationAnswerDraft: "Unsaved B answer", recruiterMessageDraft: "Unsaved B message",
});
expect(onSavedA).not.toHaveBeenCalled();
});
@@ -1,6 +1,6 @@
import React from "react";
import "@testing-library/jest-dom";
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { createMemoryRouter, RouterProvider, Route, Routes, useLocation } from "react-router-dom";
import { api } from "./api";
@@ -27,7 +27,7 @@ jest.mock("./components/ApplicationAssets", () => ({
</div>
),
ApplicationCvSection: () => <div>CV section</div>,
ApplicationPackageDraftsSection: ({ onDirtyChange }: { onDirtyChange?: (dirty: boolean) => void }) => <div>Application drafts section<button onClick={() => onDirtyChange?.(true)}>Make answers dirty</button></div>,
ApplicationPackageDraftsSection: ({ jobId, initialApplicationAnswer, initialRecruiterMessage, onDirtyChange }: { jobId: number; initialApplicationAnswer: string; initialRecruiterMessage: string; onDirtyChange?: (dirty: boolean) => void }) => <div>Application drafts section<output data-testid="answer-props">{jobId}|{initialApplicationAnswer}|{initialRecruiterMessage}</output><button onClick={() => onDirtyChange?.(true)}>Make answers dirty</button></div>,
SubmittedPackagesSection: () => <div>Frozen packages section</div>,
}));
jest.mock("./components/InterviewPrep", () => ({ ApplicationInterviewPrep: () => <div>Interview section</div> }));
@@ -136,7 +136,7 @@ function renderTable(path = "/jobs") {
},
], { initialEntries: [path] });
return render(
const mounted = render(
<ToastProvider>
<I18nProvider>
<ConfirmProvider>
@@ -147,6 +147,7 @@ function renderTable(path = "/jobs") {
</I18nProvider>
</ToastProvider>,
);
return { ...mounted, router };
}
beforeEach(() => {
@@ -383,3 +384,30 @@ test("legacy correspondence and notes reveal distinct domain content", async ()
expect(screen.queryByText("Application drafts section")).not.toBeInTheDocument();
expect(screen.getByTestId("location")).toHaveTextContent("followMode=waiting-update");
});
test("holds answers until their aggregate matches the route and ignores a late previous-job aggregate", async () => {
let resolveB!: (value: any) => void;
let resolveC!: (value: any) => void;
const heldB = new Promise<any>((resolve) => { resolveB = resolve; });
const heldC = new Promise<any>((resolve) => { resolveC = resolve; });
const originalGet = mockedApi.get.getMockImplementation()!;
mockedApi.get.mockImplementation((url: string, ...rest: any[]) => {
if (url === "/jobapplications/43/workspace") return heldB;
if (url === "/jobapplications/44/workspace") return heldC;
return originalGet(url, ...rest);
});
const { router } = renderTable("/jobs/42?section=application&sub=answers");
expect(await screen.findByTestId("answer-props")).toHaveTextContent("42|Saved answer|Saved recruiter message");
await act(async () => { await router.navigate("/jobs/43?section=application&sub=answers"); });
await waitFor(() => expect(mockedApi.get).toHaveBeenCalledWith("/jobapplications/43/workspace"));
expect(screen.queryByTestId("answer-props")).not.toBeInTheDocument();
await act(async () => { await router.navigate("/jobs/44?section=application&sub=answers"); });
expect(screen.queryByTestId("answer-props")).not.toBeInTheDocument();
await act(async () => { resolveC({ data: { ...overview, id: 44, jobTitle: "Job C", applicationAnswerDraft: "C answer", recruiterMessageDraft: "C message" } }); await heldC; });
expect(await screen.findByTestId("answer-props")).toHaveTextContent("44|C answer|C message");
await act(async () => { resolveB({ data: { ...overview, id: 43, jobTitle: "Late Job B", applicationAnswerDraft: "B answer", recruiterMessageDraft: "B message" } }); await heldB; });
expect(screen.getByTestId("answer-props")).toHaveTextContent("44|C answer|C message");
expect(screen.getByRole("heading", { name: "Job C" })).toBeInTheDocument();
expect(mockedApi.post).not.toHaveBeenCalled();
expect(mockedApi.put).not.toHaveBeenCalled();
});
@@ -19,6 +19,9 @@ import { cvBuilderApi } from "../cvBuilder";
import { aiWorkspaceApi } from "../aiWorkspace";
import { useAccountPlan } from "../accountPlan";
import { useI18n } from "../i18n/I18nProvider";
import type { TranslationKey } from "../i18n/translations";
import type { ViewResourceError } from "../hooks/useViewResource";
import { useConfirm } from "../confirm";
import { PageSection } from "../layout/PageLayout";
import ViewStateNotice from "./ViewStateNotice";
import { extractApplicationAnswerDraft, removeApplicationAnswerDraft } from "../applicationDrafts";
@@ -30,52 +33,75 @@ import { extractApplicationAnswerDraft, removeApplicationAnswerDraft } from "../
// application uses and links out. The cover letter is the one thing genuinely owned here, because it
// is application-specific by nature. docs/architecture/overview.md.
type AssetError = ViewResourceError & { messageKey: TranslationKey; retry?: () => Promise<void> };
function assetError(error: unknown, messageKey: TranslationKey, retryable = false): AssetError {
const status = (error as { response?: { status?: number } })?.response?.status;
const unauthorized = status === 401 || status === 403;
return { kind: unauthorized ? "unauthorized" : "error", message: getApiErrorMessage(error, ""), messageKey, retryable: retryable && !unauthorized, status };
}
function useAsset<T>(load: () => Promise<T>, deps: React.DependencyList) {
const { t } = useI18n();
const [data, setData] = useState<T | null>(null);
const [error, setError] = useState<string | null>(null);
const [error, setError] = useState<AssetError | null>(null);
const [loading, setLoading] = useState(true);
const request = useRef(0);
const pending = useRef(false);
const active = useRef(true);
// eslint-disable-next-line react-hooks/exhaustive-deps
const run = useCallback(load, deps);
const reload = useCallback(() => {
let cancelled = false;
const reload = useCallback(async () => {
if (pending.current) return;
pending.current = true;
const current = ++request.current;
setLoading(true);
run()
.then((r) => {
if (!cancelled) {
setData(r);
setError(null);
}
})
.catch((err) => {
if (!cancelled) setError(getApiErrorMessage(err, t("assetsLoadFailed")));
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [run, t]);
try {
const next = await run();
if (current !== request.current) return;
setData(next);
setError(null);
} catch (err) {
if (current === request.current) setError(assetError(err, "assetsLoadFailed", true));
} finally {
if (current === request.current) { pending.current = false; setLoading(false); }
}
}, [run]);
useEffect(() => reload(), [reload]);
useEffect(() => {
active.current = true;
setData(null);
setError(null);
pending.current = false;
void reload();
return () => { active.current = false; request.current += 1; pending.current = false; };
}, [reload]);
return { data, error, loading, setData, setError, reload };
return { data, error, loading, setData, setError, reload, isCurrent: () => active.current };
}
function Shell({ title, subtitle, loading, error, children }: {
function Shell({ title, subtitle, loading, error, available = true, onRetry, contentRef, children }: {
title: string;
subtitle?: string;
loading: boolean;
error: string | null;
error: AssetError | null;
available?: boolean;
onRetry?: () => void | Promise<void>;
contentRef?: React.RefObject<HTMLDivElement | null>;
children: React.ReactNode;
}) {
const { t } = useI18n();
const localRef = useRef<HTMLDivElement>(null);
const bodyRef = contentRef ?? localRef;
const retry = error?.retry ?? onRetry;
return (
<PageSection surface title={title} description={subtitle} level="h3">
<ViewStateNotice title={title} loading={loading} error={error ? { kind: "error", message: error, retryable: false } : null} compact />
{!loading && !error && children}
<Box ref={bodyRef} role="group" aria-label={title} tabIndex={-1} sx={{ minWidth: 0 }}>
<ViewStateNotice title={title} loading={loading} error={error ? { ...error, message: error.message || t(error.messageKey) } : null}
onRetry={retry ? () => { bodyRef.current?.focus(); return retry(); } : undefined} compact />
{available && error?.kind !== "unauthorized" && children}
</Box>
</PageSection>
);
}
@@ -84,20 +110,29 @@ function Shell({ title, subtitle, loading, error, children }: {
export function ApplicationCvSection({ jobId, includePackages = true }: { jobId: number; includePackages?: boolean }) {
const { t } = useI18n();
const { data, error, loading, setData, setError } = useAsset<ApplicationCv>(
const { data, error, loading, setData, setError, reload, isCurrent } = useAsset<ApplicationCv>(
() => applicationAssetsApi.cv(jobId),
[jobId],
);
const [busy, setBusy] = useState(false);
const busyRef = useRef(false);
const contentRef = useRef<HTMLDivElement>(null);
const [createdCopy, setCreatedCopy] = useState<number | null>(null);
const attach = async (variantId: number | null) => {
if (busyRef.current || loading || !data) return;
busyRef.current = true;
contentRef.current?.focus();
setBusy(true);
try {
setData(await applicationAssetsApi.attachVariant(jobId, variantId));
const next = await applicationAssetsApi.attachVariant(jobId, variantId);
if (!isCurrent()) return;
setData(next);
setError(null);
} catch (err) {
setError(getApiErrorMessage(err, t("assetsAttachCvFailed")));
if (isCurrent()) setError(assetError(err, "assetsAttachCvFailed"));
} finally {
busyRef.current = false;
setBusy(false);
}
};
@@ -105,15 +140,24 @@ export function ApplicationCvSection({ jobId, includePackages = true }: { jobId:
const attached = data?.attachedVariantId ?? "";
const duplicateForJob = async () => {
if (!data?.attachedVariantId) return;
if (busyRef.current || loading || !data?.attachedVariantId) return;
busyRef.current = true;
contentRef.current?.focus();
setBusy(true);
let copyId = createdCopy;
try {
const copy = await cvBuilderApi.duplicate(data.attachedVariantId, t("assetsTailoredCopyName", { name: data.attachedVariantName || "CV" }));
setData(await applicationAssetsApi.attachVariant(jobId, copy.id));
window.location.assign(`/career/builder/${copy.id}`);
copyId ??= (await cvBuilderApi.duplicate(data.attachedVariantId, t("assetsTailoredCopyName", { name: data.attachedVariantName || "CV" }))).id;
if (!isCurrent()) return;
setCreatedCopy(copyId);
const next = await applicationAssetsApi.attachVariant(jobId, copyId);
if (!isCurrent()) return;
setData(next);
setCreatedCopy(null);
window.location.assign(`/career/builder/${copyId}`);
} catch (err) {
setError(getApiErrorMessage(err, t("assetsDuplicateCvFailed")));
if (isCurrent()) setError(assetError(err, copyId ? "assetsAttachCvFailed" : "assetsDuplicateCvFailed"));
} finally {
busyRef.current = false;
setBusy(false);
}
};
@@ -123,8 +167,11 @@ export function ApplicationCvSection({ jobId, includePackages = true }: { jobId:
<Shell
title={t("workspaceCv")}
subtitle={t("assetsCvSubtitle")}
loading={loading}
loading={loading || busy}
error={error}
available={data !== null}
onRetry={reload}
contentRef={contentRef}
>
<Stack spacing={2}>
{(data?.availableVariants.length ?? 0) === 0 ? (
@@ -138,7 +185,7 @@ export function ApplicationCvSection({ jobId, includePackages = true }: { jobId:
fullWidth
label={t("assetsAttachedCv")}
value={attached}
disabled={busy}
disabled={busy || loading}
onChange={(e) => attach(e.target.value === "" ? null : Number(e.target.value))}
helperText={t("assetsAttachedCvHelp")}
>
@@ -172,8 +219,8 @@ export function ApplicationCvSection({ jobId, includePackages = true }: { jobId:
>
{t("assetsEditPreviewExport")}
</Button>
<Button size="small" variant="contained" disabled={busy} onClick={() => void duplicateForJob()}>
{t("assetsDuplicateForJob")}
<Button size="small" variant="contained" disabled={busy || loading} onClick={() => void duplicateForJob()}>
{t(createdCopy ? "assetsAttachCreatedCopy" : "assetsDuplicateForJob")}
</Button>
</Stack>
</Stack>
@@ -189,6 +236,7 @@ export function ApplicationCvSection({ jobId, includePackages = true }: { jobId:
{t("assetsLegacyCvText")}
</Alert>
)}
{createdCopy && <Alert severity="info">{t("assetsCopyCreatedHelp")}</Alert>}
</Stack>
</Shell>
@@ -202,49 +250,73 @@ export function SubmittedPackagesSection({ jobId, allowCapture = true }: { jobId
const titleId = useId();
const viewTrigger = useRef<HTMLButtonElement | null>(null);
const { language, t } = useI18n();
const { data, error, loading, setData, setError } = useAsset<SubmittedPackage[]>(
const { data, error, loading, setData, setError, reload, isCurrent } = useAsset<SubmittedPackage[]>(
() => applicationAssetsApi.submittedPackages(jobId),
[jobId],
);
const [busy, setBusy] = useState(false);
const [detail, setDetail] = useState<SubmittedPackageDetail | null>(null);
const [detailBusy, setDetailBusy] = useState(false);
const [downloadBusy, setDownloadBusy] = useState(false);
const operation = useRef(false);
const contentRef = useRef<HTMLDivElement>(null);
const pending = busy || detailBusy || downloadBusy || loading;
const capture = async () => {
if (operation.current || loading || !data) return;
operation.current = true;
contentRef.current?.focus();
setBusy(true);
try {
const created = await applicationAssetsApi.captureSubmittedPackage(jobId);
if (!isCurrent()) return;
setData([created, ...(data ?? [])]);
setError(null);
} catch (err) {
setError(getApiErrorMessage(err, t("assetsPackageCaptureFailed")));
if (isCurrent()) setError(assetError(err, "assetsPackageCaptureFailed"));
} finally {
operation.current = false;
setBusy(false);
}
};
const download = async (item: SubmittedPackage, attachment: SubmittedPackage["attachments"][number]) => {
if (operation.current || loading || !data) return;
operation.current = true;
contentRef.current?.focus();
setDownloadBusy(true);
try {
const blob = await applicationAssetsApi.downloadSubmittedAttachment(jobId, item.id, attachment.id);
if (!isCurrent()) return;
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = attachment.fileName;
anchor.click();
URL.revokeObjectURL(url);
setError(null);
} catch (err) {
setError(getApiErrorMessage(err, t("assetsPackageDownloadFailed")));
if (isCurrent()) setError({ ...assetError(err, "assetsPackageDownloadFailed", true), retry: () => download(item, attachment) });
} finally {
operation.current = false;
setDownloadBusy(false);
}
};
const view = async (item: SubmittedPackage) => {
if (operation.current || loading || !data) return;
operation.current = true;
contentRef.current?.focus();
setDetailBusy(true);
try {
setDetail(await applicationAssetsApi.submittedPackage(jobId, item.id));
const next = await applicationAssetsApi.submittedPackage(jobId, item.id);
if (!isCurrent()) return;
setDetail(next);
setError(null);
} catch (err) {
setError(getApiErrorMessage(err, t("assetsPackageLoadFailed")));
if (isCurrent()) setError({ ...assetError(err, "assetsPackageLoadFailed", true), retry: () => view(item) });
} finally {
operation.current = false;
setDetailBusy(false);
}
};
@@ -255,9 +327,9 @@ export function SubmittedPackagesSection({ jobId, allowCapture = true }: { jobId
})();
return (
<Shell title={t("assetsSubmittedPackages")} subtitle={t("assetsSubmittedPackagesSubtitle")} loading={loading} error={error}>
<Shell title={t("assetsSubmittedPackages")} subtitle={t("assetsSubmittedPackagesSubtitle")} loading={pending} error={error} available={data !== null} onRetry={reload} contentRef={contentRef}>
<Stack spacing={1.5}>
{allowCapture && <Button variant="contained" startIcon={<ArchiveOutlinedIcon />} disabled={busy} onClick={() => void capture()} sx={{ alignSelf: "flex-start" }}>
{allowCapture && <Button variant="contained" startIcon={<ArchiveOutlinedIcon />} disabled={pending} onClick={() => void capture()} sx={{ alignSelf: "flex-start" }}>
{busy ? t("assetsPackageCapturing") : t("assetsPackageCapture")}
</Button>}
{(data?.length ?? 0) === 0 ? (
@@ -277,11 +349,11 @@ export function SubmittedPackagesSection({ jobId, allowCapture = true }: { jobId
<Chip size="small" variant="outlined" color={item.hasCoverLetter ? "success" : "default"} label={item.hasCoverLetter ? t("workspaceCoverLetter") : t("assetsPackageNoCoverLetter")} />
</Stack>
</Stack>
<Button size="small" variant="outlined" disabled={detailBusy} onClick={(event) => { viewTrigger.current = event.currentTarget; void view(item); }} sx={{ alignSelf: "flex-start" }}>
<Button size="small" variant="outlined" disabled={pending} onClick={(event) => { viewTrigger.current = event.currentTarget; void view(item); }} sx={{ alignSelf: "flex-start" }}>
{t("assetsPackageView")}
</Button>
{item.attachments.map((attachment) => (
<Button key={attachment.id} size="small" variant="text" startIcon={<DownloadOutlinedIcon />} onClick={() => void download(item, attachment)} sx={{ alignSelf: "flex-start", maxWidth: "100%", overflowWrap: "anywhere", textAlign: "left" }}>
<Button key={attachment.id} size="small" variant="text" disabled={pending} startIcon={<DownloadOutlinedIcon />} onClick={() => void download(item, attachment)} sx={{ alignSelf: "flex-start", maxWidth: "100%", overflowWrap: "anywhere", textAlign: "left" }}>
{attachment.fileName} · {(attachment.fileSize / 1024).toFixed(1)} KB
</Button>
))}
@@ -293,25 +365,25 @@ export function SubmittedPackagesSection({ jobId, allowCapture = true }: { jobId
<DialogTitle id={titleId}>
{detail ? t("assetsSubmittedPackageVersion", { version: detail.summary.version }) : t("assetsSubmittedPackages")}
</DialogTitle>
<DialogContent dividers>
<DialogContent dividers role="region" tabIndex={0} aria-labelledby={titleId}>
<Alert severity="info" sx={{ mb: 2 }}>{t("workspaceFrozenHelp")}</Alert>
<Stack spacing={2.5}>
{detail?.renderedCvHtml ? (
<Box>
<Typography variant="subtitle2" gutterBottom>{t("workspaceCv")}</Typography>
<Typography component="h3" variant="subtitle2" gutterBottom>{t("workspaceCv")}</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1, overflowWrap: "anywhere" }}>{detail.summary.cvVariantName} · {t("assetsThemeVersion", { theme: detail.summary.cvThemeId ?? "—", version: detail.summary.cvVariantVersion ?? "—" })}</Typography>
<Box component="iframe" title={t("assetsPackageCvPreview")} srcDoc={detail.renderedCvHtml} sandbox="" sx={{ width: "100%", height: { xs: 520, md: 760 }, border: 1, borderColor: "divider", bgcolor: "white" }} />
</Box>
) : <Alert severity="info">{t("assetsPackageNoCv")}</Alert>}
<Box>
<Typography variant="subtitle2" gutterBottom>{t("workspaceCoverLetter")}</Typography>
<Typography component="h3" variant="subtitle2" gutterBottom>{t("workspaceCoverLetter")}</Typography>
<Typography variant="body2" sx={{ whiteSpace: "pre-wrap", overflowWrap: "anywhere" }}>
{detail?.coverLetterText || t("assetsPackageNoCoverLetter")}
</Typography>
</Box>
{material && ["TailoredCvText", "RecruiterMessageDraft"].map((key) => typeof material[key] === "string" && material[key] ? (
<Box key={key}>
<Typography variant="subtitle2">{t(`assetsPackage${key}` as "assetsPackageTailoredCvText")}</Typography>
<Typography component="h3" variant="subtitle2">{t(`assetsPackage${key}` as "assetsPackageTailoredCvText")}</Typography>
<Typography variant="body2" sx={{ whiteSpace: "pre-wrap", overflowWrap: "anywhere" }}>{String(material[key])}</Typography>
</Box>
) : null)}
@@ -319,7 +391,7 @@ export function SubmittedPackagesSection({ jobId, allowCapture = true }: { jobId
[t("workspaceNotes"), removeApplicationAnswerDraft(material.Notes)],
[t("assetsApplicationAnswer"), extractApplicationAnswerDraft(material.Notes)],
].map(([label, content]) => content ? <Box key={label}>
<Typography variant="subtitle2">{label}</Typography>
<Typography component="h3" variant="subtitle2">{label}</Typography>
<Typography variant="body2" sx={{ whiteSpace: "pre-wrap", overflowWrap: "anywhere" }}>{content}</Typography>
</Box> : null)}
</Stack>
@@ -334,7 +406,7 @@ export function SubmittedPackagesSection({ jobId, allowCapture = true }: { jobId
export function ApplicationTailoringSection({ jobId }: { jobId: number }) {
const { t } = useI18n();
const { data, error, loading } = useAsset<TailoringPlan>(
const { data, error, loading, reload } = useAsset<TailoringPlan>(
() => applicationAssetsApi.tailoring(jobId),
[jobId],
);
@@ -345,6 +417,8 @@ export function ApplicationTailoringSection({ jobId }: { jobId: number }) {
subtitle={t("assetsTailoringSubtitle")}
loading={loading}
error={error}
available={data !== null}
onRetry={reload}
>
<Stack spacing={2}>
{data && !data.hasCareerProfile && (
@@ -413,13 +487,17 @@ Med vennlig hilsen
export function ApplicationCoverLetterSection({ jobId, onDirtyChange }: { jobId: number; onDirtyChange?: (dirty: boolean) => void }) {
const { language, t } = useI18n();
const { data, error, loading, setData, setError } = useAsset<CoverLetter>(
const { confirm } = useConfirm();
const { data, error, loading, setData, setError, reload, isCurrent } = useAsset<CoverLetter>(
() => applicationAssetsApi.coverLetter(jobId),
[jobId],
);
const [draft, setDraft] = useState<string | null>(null);
const [draftAiAction, setDraftAiAction] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [confirming, setConfirming] = useState(false);
const busyRef = useRef(false);
const contentRef = useRef<HTMLDivElement>(null);
// The textarea is only seeded from the server until the user starts typing, so a reload never
// clobbers unsaved edits.
@@ -432,30 +510,53 @@ export function ApplicationCoverLetterSection({ jobId, onDirtyChange }: { jobId:
}, [dirty, onDirtyChange]);
const save = async (value: string, source = "manual", aiAction?: string) => {
if (busyRef.current || loading || !data) return;
busyRef.current = true;
contentRef.current?.focus();
setBusy(true);
try {
setData(await applicationAssetsApi.saveCoverLetter(jobId, value, source, aiAction));
const next = await applicationAssetsApi.saveCoverLetter(jobId, value, source, aiAction);
if (!isCurrent()) return;
setData(next);
setDraft(null);
setDraftAiAction(null);
setError(null);
} catch (err) {
setError(getApiErrorMessage(err, t("assetsCoverSaveFailed")));
if (isCurrent()) setError(assetError(err, "assetsCoverSaveFailed"));
} finally {
busyRef.current = false;
setBusy(false);
}
};
const restore = async (version: number) => {
setBusy(true);
if (busyRef.current || loading || !data) return;
busyRef.current = true;
try {
setData(await applicationAssetsApi.restoreCoverLetter(jobId, version));
if (dirty) {
setConfirming(true);
const accepted = await confirm({
title: t("assetsRestoreDiscardTitle"), message: t("assetsRestoreDiscardMessage"),
confirmLabel: t("assetsRestoreConfirm"), cancelLabel: t("cancel"), destructive: true,
});
setConfirming(false);
if (!accepted) return;
}
if (!isCurrent()) return;
contentRef.current?.focus();
setBusy(true);
const next = await applicationAssetsApi.restoreCoverLetter(jobId, version);
if (!isCurrent()) return;
setData(next);
setDraft(null);
setDraftAiAction(null);
setError(null);
} catch (err) {
setError(getApiErrorMessage(err, t("assetsCoverRestoreFailed")));
if (isCurrent()) setError(assetError(err, "assetsCoverRestoreFailed"));
} finally {
busyRef.current = false;
setBusy(false);
setConfirming(false);
}
};
@@ -464,28 +565,31 @@ export function ApplicationCoverLetterSection({ jobId, onDirtyChange }: { jobId:
<Shell
title={t("workspaceCoverLetter")}
subtitle={t("assetsCoverSubtitle")}
loading={loading}
loading={loading || busy}
error={error}
available={data !== null}
onRetry={reload}
contentRef={contentRef}
>
<Stack spacing={2}>
<RichTextField
minRows={12}
label={t("jobDetailsCoverLetter")}
value={text}
disabled={busy}
disabled={busy || confirming || loading}
onChange={setDraft}
placeholder={t("assetsCoverPlaceholder")}
/>
<Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap>
<Button variant="contained" disabled={busy || !dirty} onClick={() => save(text, draftAiAction ? "ai" : "manual", draftAiAction ?? undefined)}>
<Button variant="contained" disabled={busy || confirming || loading || !dirty} onClick={() => save(text, draftAiAction ? "ai" : "manual", draftAiAction ?? undefined)}>
{t("save")}
</Button>
<Button disabled={busy || !dirty} onClick={() => { setDraft(null); setDraftAiAction(null); }}>
<Button disabled={busy || confirming || loading || !dirty} onClick={() => { setDraft(null); setDraftAiAction(null); }}>
{t("assetsDiscardChanges")}
</Button>
<Button
disabled={busy || text.trim().length > 0}
disabled={busy || confirming || loading || text.trim().length > 0}
onClick={() => setDraft(COVER_LETTER_TEMPLATES[language])}
>
{t("assetsStartTemplate")}
@@ -500,10 +604,11 @@ export function ApplicationCoverLetterSection({ jobId, onDirtyChange }: { jobId:
<CoverLetterAiAssistant
jobId={jobId}
currentText={text}
onApply={(value, aiAction) => { setDraft(value); setDraftAiAction(aiAction); }}
disabled={busy || confirming || loading || !data}
onApply={(value, aiAction) => { if (!busyRef.current && !loading && data) { setDraft(value); setDraftAiAction(aiAction); } }}
/>
<Shell title={t("assetsVersionHistory")} loading={loading} error={null}>
<Shell title={t("assetsVersionHistory")} loading={loading} error={null} available={data !== null}>
{(data?.versions.length ?? 0) === 0 ? (
<Typography variant="body2" color="text.secondary">
{t("assetsNoVersions")}
@@ -529,11 +634,11 @@ export function ApplicationCoverLetterSection({ jobId, onDirtyChange }: { jobId:
</Typography>
</Box>
{!v.isCurrent && (
<Tooltip title={t("assetsRestoreVersion")}>
<Tooltip describeChild title={t("assetsRestoreVersion")}>
<span>
<IconButton
size="small"
disabled={busy}
disabled={busy || loading}
aria-label={t("assetsRestoreVersionNumber", { version: v.version })}
onClick={() => restore(v.version)}
>
@@ -563,20 +668,25 @@ const COVER_LETTER_ACTIONS = [
{ key: "tailor", labelKey: "coverActionTailor" },
] as const;
function CoverLetterAiAssistant({ jobId, currentText, onApply }: { jobId: number; currentText: string; onApply: (text: string, action: string) => void }) {
function CoverLetterAiAssistant({ jobId, currentText, disabled, onApply }: { jobId: number; currentText: string; disabled: boolean; onApply: (text: string, action: string) => void }) {
const labelId = useId();
const { canUseAi } = useAccountPlan();
const { language: uiLanguage, t } = useI18n();
const { data: cv, loading: loadingCv } = useAsset<ApplicationCv>(() => applicationAssetsApi.cv(jobId), [jobId]);
const { data: cv, error: cvError, loading: loadingCv, reload, isCurrent } = useAsset<ApplicationCv>(() => applicationAssetsApi.cv(jobId), [jobId]);
const [action, setAction] = useState("generate");
const [mode, setMode] = useState("professional");
const [language, setLanguage] = useState<"en" | "nb-NO">(() => uiLanguage === "nb" ? "nb-NO" : "en");
const [instructions, setInstructions] = useState("");
const [suggestion, setSuggestion] = useState("");
const [suggestion, setSuggestion] = useState<{ text: string; action: string } | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [error, setError] = useState<AssetError | null>(null);
const busyRef = useRef(false);
const contentRef = useRef<HTMLDivElement>(null);
const generate = async () => {
if (busyRef.current || disabled || loadingCv || !canUseAi || !cv?.attachedVariantId) return;
busyRef.current = true;
contentRef.current?.focus();
setBusy(true);
setError(null);
try {
@@ -588,10 +698,13 @@ function CoverLetterAiAssistant({ jobId, currentText, onApply }: { jobId: number
currentText: currentText.trim() || undefined,
extraContext: instructions.trim() || undefined,
});
setSuggestion(result.result.text?.trim() ?? "");
if (!isCurrent()) return;
const text = result.result.text?.trim() ?? "";
setSuggestion(text ? { text, action } : null);
} catch (err) {
setError(getApiErrorMessage(err, t("coverAiGenerateFailed")));
if (isCurrent()) setError(assetError(err, "coverAiGenerateFailed"));
} finally {
busyRef.current = false;
setBusy(false);
}
};
@@ -601,8 +714,11 @@ function CoverLetterAiAssistant({ jobId, currentText, onApply }: { jobId: number
<Shell
title={t("coverAiTitle")}
subtitle={t("coverAiSubtitle")}
loading={loadingCv}
error={null}
loading={loadingCv || busy}
error={cvError}
available={cv !== null}
onRetry={reload}
contentRef={contentRef}
>
<Stack spacing={2}>
{!hasCv ? (
@@ -615,13 +731,13 @@ function CoverLetterAiAssistant({ jobId, currentText, onApply }: { jobId: number
<Stack direction={{ xs: "column", sm: "row" }} spacing={1.5}>
<FormControl size="small" sx={{ minWidth: 180 }}>
<InputLabel id={`${labelId}-coverAiAction`}>{t("coverAiAction")}</InputLabel>
<Select labelId={`${labelId}-coverAiAction`} label={t("coverAiAction")} value={action} onChange={(event) => setAction(event.target.value)}>
<Select disabled={busy || disabled || loadingCv} labelId={`${labelId}-coverAiAction`} label={t("coverAiAction")} value={action} onChange={(event) => setAction(event.target.value)}>
{COVER_LETTER_ACTIONS.map((item) => <MenuItem key={item.key} value={item.key}>{t(item.labelKey)}</MenuItem>)}
</Select>
</FormControl>
<FormControl size="small" sx={{ minWidth: 160 }}>
<InputLabel id={`${labelId}-coverAiTone`}>{t("coverAiTone")}</InputLabel>
<Select labelId={`${labelId}-coverAiTone`} label={t("coverAiTone")} value={mode} onChange={(event) => setMode(event.target.value)}>
<Select disabled={busy || disabled || loadingCv} labelId={`${labelId}-coverAiTone`} label={t("coverAiTone")} value={mode} onChange={(event) => setMode(event.target.value)}>
{[
["professional", "coverToneProfessional"], ["friendly", "coverToneFriendly"], ["short", "coverToneShort"],
["detailed", "coverToneDetailed"], ["modern", "coverToneModern"], ["traditional", "coverToneTraditional"],
@@ -630,7 +746,7 @@ function CoverLetterAiAssistant({ jobId, currentText, onApply }: { jobId: number
</FormControl>
<FormControl size="small" sx={{ minWidth: 190 }}>
<InputLabel id={`${labelId}-coverAiDocumentLanguage`}>{t("coverAiDocumentLanguage")}</InputLabel>
<Select labelId={`${labelId}-coverAiDocumentLanguage`} label={t("coverAiDocumentLanguage")} value={language} onChange={(event) => setLanguage(event.target.value as "en" | "nb-NO")}>
<Select disabled={busy || disabled || loadingCv} labelId={`${labelId}-coverAiDocumentLanguage`} label={t("coverAiDocumentLanguage")} value={language} onChange={(event) => setLanguage(event.target.value as "en" | "nb-NO")}>
<MenuItem value="en">{t("languageEnglish")}</MenuItem>
<MenuItem value="nb-NO">{t("languageNorwegianBokmal")}</MenuItem>
</Select>
@@ -640,16 +756,17 @@ function CoverLetterAiAssistant({ jobId, currentText, onApply }: { jobId: number
label={t("coverAiAdditionalInstructions")}
placeholder={t("coverAiInstructionsPlaceholder")}
value={instructions}
disabled={busy || disabled || loadingCv}
onChange={(event) => setInstructions(event.target.value)}
multiline
minRows={2}
fullWidth
/>
{error && <Alert severity="error" action={<Button color="inherit" size="small" onClick={() => void generate()}>{t("retry")}</Button>}>{error}</Alert>}
{error && <Alert severity="error" action={<Button color="inherit" size="small" disabled={busy || disabled || loadingCv} onClick={() => void generate()}>{t("retry")}</Button>}>{error.message || t(error.messageKey)}</Alert>}
<Button
variant="contained"
startIcon={<AutoFixHighIcon />}
disabled={!canUseAi || !hasCv || busy}
disabled={!canUseAi || !hasCv || busy || disabled || loadingCv}
onClick={() => void generate()}
sx={{ alignSelf: "flex-start" }}
>
@@ -666,10 +783,10 @@ function CoverLetterAiAssistant({ jobId, currentText, onApply }: { jobId: number
</Paper>
<Paper variant="outlined" sx={{ p: 2, minWidth: 0, borderColor: "primary.main" }}>
<Typography variant="overline" color="primary">{t("coverAiSuggestion")}</Typography>
<Typography variant="body2" sx={{ whiteSpace: "pre-wrap", overflowWrap: "anywhere" }}>{suggestion}</Typography>
<Typography variant="body2" sx={{ whiteSpace: "pre-wrap", overflowWrap: "anywhere" }}>{suggestion.text}</Typography>
<Stack direction="row" spacing={1} sx={{ mt: 2 }}>
<Button size="small" variant="contained" onClick={() => { onApply(suggestion, action); setSuggestion(""); }}>{t("coverAiApply")}</Button>
<Button size="small" onClick={() => setSuggestion("")}>{t("coverAiReject")}</Button>
<Button size="small" variant="contained" disabled={busy || disabled || loadingCv} onClick={() => { if (!busyRef.current && !disabled && !loadingCv) { onApply(suggestion.text, suggestion.action); setSuggestion(null); } }}>{t("coverAiApply")}</Button>
<Button size="small" disabled={busy || disabled || loadingCv} onClick={() => setSuggestion(null)}>{t("coverAiReject")}</Button>
</Stack>
</Paper>
</Box>
@@ -701,7 +818,11 @@ export function ApplicationPackageDraftsSection({
const [saved, setSaved] = useState<PackageDrafts>(initial);
const [draft, setDraft] = useState<PackageDrafts | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [error, setError] = useState<AssetError | null>(null);
const busyRef = useRef(false);
const contentRef = useRef<HTMLDivElement>(null);
const active = useRef(true);
useEffect(() => { active.current = true; return () => { active.current = false; }; }, []);
useEffect(() => {
if (draft === null) setSaved(initial);
@@ -722,16 +843,21 @@ export function ApplicationPackageDraftsSection({
const update = (patch: Partial<PackageDrafts>) => setDraft({ ...value, ...patch });
const save = async () => {
if (busyRef.current) return;
busyRef.current = true;
contentRef.current?.focus();
setBusy(true);
try {
await applicationAssetsApi.saveApplicationDrafts(jobId, value.applicationAnswer, value.recruiterMessage);
if (!active.current) return;
setSaved(value);
setDraft(null);
setError(null);
onSaved?.();
} catch (err) {
setError(getApiErrorMessage(err, t("assetsDraftsSaveFailed")));
if (active.current) setError(assetError(err, "assetsDraftsSaveFailed"));
} finally {
busyRef.current = false;
setBusy(false);
}
};
@@ -740,8 +866,9 @@ export function ApplicationPackageDraftsSection({
<Shell
title={t("assetsApplicationDrafts")}
subtitle={t("assetsApplicationDraftsSubtitle")}
loading={false}
loading={busy}
error={error}
contentRef={contentRef}
>
<Stack spacing={2}>
<RichTextField
@@ -47,7 +47,7 @@ export default function RichTextField({
};
const btn = (title: string, icon: React.ReactNode, before: string, after: string, ph: string) => (
<Tooltip title={title}>
<Tooltip title={title} slotProps={{ popper: { sx: { "&[data-popper-reference-hidden]": { visibility: "hidden" } } } }}>
<IconButton size="small" disabled={disabled} aria-label={title} onMouseDown={(e) => e.preventDefault()} onClick={() => apply(before, after, ph)}>
{icon}
</IconButton>
+10
View File
@@ -508,6 +508,8 @@ export const translations = {
assetsPublic: "public",
assetsEditPreviewExport: "Edit, preview and export",
assetsDuplicateForJob: "Duplicate for this job",
assetsAttachCreatedCopy: "Attach created copy",
assetsCopyCreatedHelp: "The copy is saved in your CV library. Attach it here to continue editing.",
assetsNoCvAttached: "No CV is attached to this application yet.",
assetsLegacyCvText: "This application also contains legacy tailored CV text. An attached CV variant takes precedence.",
assetsTailoring: "Tailoring",
@@ -958,6 +960,9 @@ export const translations = {
assetsCharacterCount: "{count} characters",
assetsRestoreVersion: "Restore this version",
assetsRestoreVersionNumber: "Restore version {version}",
assetsRestoreDiscardTitle: "Restore saved version?",
assetsRestoreDiscardMessage: "Your unsaved edits will be discarded and replaced with the selected saved version.",
assetsRestoreConfirm: "Discard and restore",
coverAiAction: "Action",
coverAiTone: "Tone",
coverActionGenerate: "Generate",
@@ -3157,6 +3162,8 @@ export const translations = {
assetsPublic: "offentlig",
assetsEditPreviewExport: "Rediger, forhåndsvis og eksporter",
assetsDuplicateForJob: "Dupliser for denne jobben",
assetsAttachCreatedCopy: "Koble til opprettet kopi",
assetsCopyCreatedHelp: "Kopien er lagret i CV-biblioteket. Koble den til her for å fortsette redigeringen.",
assetsNoCvAttached: "Ingen CV er tilknyttet søknaden ennå.",
assetsLegacyCvText: "Søknaden inneholder også eldre tilpasset CV-tekst. En tilknyttet CV-variant har prioritet.",
assetsTailoring: "Tilpasning",
@@ -3607,6 +3614,9 @@ export const translations = {
assetsCharacterCount: "{count} tegn",
assetsRestoreVersion: "Gjenopprett denne versjonen",
assetsRestoreVersionNumber: "Gjenopprett versjon {version}",
assetsRestoreDiscardTitle: "Gjenopprette lagret versjon?",
assetsRestoreDiscardMessage: "Endringene du ikke har lagret, blir forkastet og erstattet med den valgte versjonen.",
assetsRestoreConfirm: "Forkast og gjenopprett",
coverAiAction: "Handling",
coverAiTone: "Tone",
coverActionGenerate: "Generer",
@@ -82,7 +82,9 @@ export function ApplicationWorkspace({
const secondaryId = useId();
const panelRef = useRef<HTMLDivElement>(null);
const [overview, setOverview] = useState<WorkspaceOverview | null>(null);
const [loadedOverview, setOverview] = useState<WorkspaceOverview | null>(null);
const overview = loadedOverview?.id === jobId ? loadedOverview : null;
const overviewRequest = useRef(0);
const [error, setError] = useState<ViewResourceError | null>(null);
const [loading, setLoading] = useState(true);
const [editOpen, setEditOpen] = useState(false);
@@ -123,6 +125,7 @@ export function ApplicationWorkspace({
}, [blocker, confirm, t]);
const load = useCallback(async () => {
const request = ++overviewRequest.current;
if (!Number.isInteger(jobId) || jobId <= 0) {
setOverview(null);
setError({ kind: "error", message: t("workspaceInvalidLink"), retryable: false });
@@ -132,17 +135,20 @@ export function ApplicationWorkspace({
try {
setError(null);
setLoading(true);
setOverview(await applicationWorkspaceApi.overview(jobId));
const next = await applicationWorkspaceApi.overview(jobId);
if (request === overviewRequest.current) setOverview(next);
} catch (err) {
if (request !== overviewRequest.current) return;
const status = (err as { response?: { status?: number } })?.response?.status;
setError({ kind: "error", message: getApiErrorMessage(err, t("workspaceLoadFailed")), retryable: !status || status >= 500, status });
} finally {
setLoading(false);
if (request === overviewRequest.current) setLoading(false);
}
}, [jobId, t]);
useEffect(() => {
load();
void load();
return () => { overviewRequest.current += 1; };
}, [load]);
useEffect(() => {
@@ -253,16 +259,17 @@ export function ApplicationWorkspace({
{subsections.map((sub) => <Tab key={sub.key} value={sub.key} id={`${secondaryId}-${sub.key}`} aria-controls={`${secondaryId}-panel`} label={sub.label} />)}
</Tabs>
<Box role="tabpanel" id={`${secondaryId}-panel`} aria-labelledby={`${secondaryId}-${subsection}`} tabIndex={0} sx={{ display: "grid", gap: 2, minWidth: 0 }}>
{section === "application" && subsection === "cv" && jobId > 0 && <ApplicationCvSection jobId={jobId} includePackages={false} />}
{section === "application" && subsection === "letter" && jobId > 0 && <ApplicationCoverLetterSection jobId={jobId} onDirtyChange={setCoverLetterDirty} />}
{section === "application" && subsection === "answers" && jobId > 0 && <ApplicationPackageDraftsSection
{section === "application" && subsection === "cv" && jobId > 0 && <ApplicationCvSection key={jobId} jobId={jobId} includePackages={false} />}
{section === "application" && subsection === "letter" && jobId > 0 && <ApplicationCoverLetterSection key={jobId} jobId={jobId} onDirtyChange={setCoverLetterDirty} />}
{section === "application" && subsection === "answers" && overview && jobId > 0 && <ApplicationPackageDraftsSection
key={jobId}
jobId={jobId}
initialApplicationAnswer={overview?.applicationAnswerDraft ?? ""}
initialRecruiterMessage={overview?.recruiterMessageDraft ?? ""}
onSaved={load}
onDirtyChange={setPackageDraftsDirty}
/>}
{section === "application" && subsection === "submission" && jobId > 0 && <SubmittedPackagesSection jobId={jobId} />}
{section === "application" && subsection === "submission" && jobId > 0 && <SubmittedPackagesSection key={jobId} jobId={jobId} />}
{section === "timeline" && subsection === "activity" && jobId > 0 && <ApplicationTimeline jobId={jobId} />}
{section === "timeline" && subsection === "correspondence" && jobId > 0 && <PageSection surface title={t("workspaceCommunication")} level="h3"><Correspondence jobId={jobId} jobContext={{ companyName: overview?.company, jobTitle: overview?.jobTitle }} /></PageSection>}
</Box>
@@ -272,7 +279,7 @@ export function ApplicationWorkspace({
<Button onClick={() => go("application", "cv")}>{t("workspaceCv")}</Button>
<Button onClick={() => go("application", "letter")}>{t("workspaceCoverLetter")}</Button>
</>}><Attachments jobId={jobId} /></PageSection>
<SubmittedPackagesSection jobId={jobId} allowCapture={false} />
<SubmittedPackagesSection key={jobId} jobId={jobId} allowCapture={false} />
</>}
{section === "tasks" && jobId > 0 && <>
<PageSection surface title={t("workspaceNotes")} actions={<Button startIcon={<EditOutlinedIcon />} onClick={() => setEditOpen(true)}>{t("workspaceEdit")}</Button>}>