feat(ui): redesign job workspace sections

This commit is contained in:
cesnimda
2026-10-07 01:58:31 +02:00
parent acd1d94684
commit ac6d78289d
123 changed files with 2842 additions and 205 deletions
@@ -40,7 +40,7 @@ test('[PRES-W01 W02 W03 J05 E08 E09] native Job header/Overview retains actions,
});
await page.goto(`/jobs/${id}`);
await expect(page.getByRole('heading', { name: title, level: 2 })).toBeVisible();
await expect(page.getByRole('tablist', { name: translations.en.workspaceSections }).getByRole('tab')).toHaveCount(5);
await expect(page.getByRole('tablist', { name: translations.en.workspaceSections }).getByRole('tab')).toHaveCount(7);
await expect(page.getByRole('link', { name: translations.en.workspaceOpenAdvert })).toHaveAttribute('href', jobData.jobUrl);
await expect(page.getByRole('link', { name: translations.en.workspaceOpenAdvert })).toHaveAttribute('rel', 'noopener noreferrer');
expect(writes).toEqual([]);
@@ -0,0 +1,175 @@
import { expect, test, type APIRequestContext, type Page, type TestInfo } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
import { translations } from '../src/i18n/translations';
const apiUrl = 'http://localhost:5302/api';
const password = 'E2ePassword123!';
async function login(api: APIRequestContext, email: string) {
expect((await api.post(`${apiUrl}/auth/login`, { data: { email, password } })).status()).toBe(200);
const me = await (await api.get(`${apiUrl}/auth/me`)).json();
return { 'X-CSRF-TOKEN': (await api.storageState()).cookies.find(cookie => cookie.name === 'XSRF-TOKEN')!.value, 'X-Session-Context': me.sessionContext as string };
}
test('[PRES-W01 W08 M01 E08] Job section loading and partial read failures stay explicit and recover locally', async ({ page }, info) => {
const headers = await login(page.request, 'e2e@example.test');
const company = await page.request.post(`${apiUrl}/companies`, { headers, data: { name: 'Section state fixture' } });
expect(company.status()).toBe(201);
const created = await page.request.post(`${apiUrl}/jobapplications`, { headers, data: { companyId: (await company.json()).id, jobTitle: 'Section state fixture', status: 'Saved', notes: 'Retained personal notes' } });
expect(created.status()).toBe(201); const id = (await created.json()).id;
await page.setViewportSize({ width: 390, height: 844 });
let release: (() => void) | undefined; let fail = true;
const hold = new Promise<void>(resolve => { release = resolve; });
await page.route(`${apiUrl}/attachments/${id}`, async route => {
await hold;
return fail ? route.fulfill({ status: 503, json: { message: 'Temporary attachment read failure' } }) : route.continue();
});
await page.goto(`/jobs/${id}?section=documents`);
await expect(page.getByRole('region', { name: translations.en.workspaceCurrentDocuments })).toBeVisible();
await expect(page.getByRole('progressbar').first()).toBeVisible();
await capture(page, info, 'sections-documents-loading'); release!();
await expect(page.getByRole('alert').filter({ hasText: 'Temporary attachment read failure' })).toBeVisible();
await expect(page.getByText(translations.en.attachmentsEmpty, { exact: true })).toHaveCount(0);
await capture(page, info, 'sections-documents-error'); fail = false;
await page.getByRole('button', { name: 'Retry', exact: true }).click();
await expect(page.getByText(translations.en.attachmentsEmpty, { exact: true })).toBeVisible();
await capture(page, info, 'sections-documents-recovered');
await page.route(`${apiUrl}/correspondence/${id}`, route => route.fulfill({ status: 503, json: { message: 'Temporary correspondence read failure' } }));
await page.goto(`/jobs/${id}?section=communication&followMode=waiting-update`);
await expect(page.getByRole('alert').filter({ hasText: 'Temporary correspondence read failure' })).toBeVisible();
await expect(page.getByText(translations.en.correspondenceNoMessages, { exact: true })).toHaveCount(0);
await expect(page.getByRole('textbox', { name: translations.en.correspondenceLogNoteOrMessage })).toBeVisible();
await capture(page, info, 'sections-correspondence-error');
await page.goto(`/jobs/${id}?section=tasks`);
await expect(page.getByRole('region', { name: translations.en.workspaceNotes, exact: true })).toContainText('Retained personal notes');
await capture(page, info, 'sections-sparse-tasks');
await page.goto(`/jobs/${id}?section=interview`);
await expect(page.getByText(translations.en.interviewPrepEarly, { exact: true })).toBeVisible();
await capture(page, info, 'sections-sparse-interview');
});
async function capture(page: Page, info: TestInfo, name: string) {
await page.evaluate(() => document.fonts.ready);
const path = info.outputPath(`${name}.png`);
await page.screenshot({ path, fullPage: false, animations: 'disabled' });
await info.attach(name, { path, contentType: 'image/png' });
if (name.endsWith('en-light') && page.viewportSize()?.width === 1440 && ['sections-documents-', 'sections-correspondence-', 'sections-notes-tasks-', 'sections-interview-'].some(prefix => name.startsWith(prefix))) {
const scan = await new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa']).analyze();
await info.attach(`${name}-axe`, { body: JSON.stringify({ violations: scan.violations, incomplete: scan.incomplete }), contentType: 'application/json' });
expect(scan.violations).toEqual([]);
}
}
for (const language of ['en', 'nb'] as const) for (const mode of ['light', 'dark'] as const) {
test(`[PRES-W01–W11 E08 E09] native Job sections retain drafts, frozen documents, tasks and history (${language}, ${mode})`, async ({ page, playwright }, info) => {
test.setTimeout(240_000);
const t = translations[language];
const admin = await playwright.request.newContext();
const adminHeaders = await login(admin, 'e2e@example.test');
const email = `job-sections-${language}-${mode}-${Date.now()}@example.test`;
expect((await admin.post(`${apiUrl}/users`, { headers: adminHeaders, data: { email, password, roles: [] } })).status()).toBe(200);
await admin.dispose();
const headers = await login(page.request, email);
await page.route('**/*', route => ['localhost', '127.0.0.1'].includes(new URL(route.request().url()).hostname) || ['blob:', 'data:', 'about:'].includes(new URL(route.request().url()).protocol) ? route.continue() : route.abort());
await page.addInitScript(({ language, mode }) => { localStorage.setItem('uiLanguage', language); localStorage.setItem('jobtracker.themeMode', mode); }, { language, mode });
expect((await page.request.put(`${apiUrl}/auth/preferences/language`, { headers, data: { language: language === 'nb' ? 'nb-NO' : 'en' } })).status()).toBe(204);
expect((await page.request.put(`${apiUrl}/career/profile`, { headers, data: { profile: { version: '1', contact: { fullName: 'Åse Eksempel', headline: 'Recorded engineer' }, skills: ['C#', 'SQL'] } } })).status()).toBe(200);
const company = await page.request.post(`${apiUrl}/companies`, { headers, data: { name: 'Nordlys samfunnskritiske plattformer og inkluderende digitale tjenester æøå' } });
expect(company.status()).toBe(201); const companyId = (await company.json()).id;
const title = 'Senior utvikler og rådgiver for langsiktig plattformutvikling';
const created = await page.request.post(`${apiUrl}/jobapplications`, { headers, data: { companyId, jobTitle: title, status: 'Interview', description: 'Build C# and SQL services.', notes: 'Human notes — æøå', deadline: '2026-12-14T12:00:00Z', followUpAt: '2026-12-10T12:00:00Z' } });
expect(created.status()).toBe(201); const id = (await created.json()).id;
const jobUrl = `${apiUrl}/jobapplications/${id}`;
const cv = await page.request.post(`${apiUrl}/cv/variants`, { headers, data: { name: 'Application CV — Åse', jobApplicationId: id } });
expect(cv.status()).toBe(200); const variant = await cv.json();
expect((await page.request.put(`${jobUrl}/cv`, { headers, data: { variantId: variant.id } })).status()).toBe(200);
expect((await page.request.put(`${jobUrl}/cover-letter`, { headers, data: { text: 'Reviewed letter — æøå', source: 'manual' } })).status()).toBe(200);
expect((await page.request.put(`${jobUrl}/application-drafts`, { headers, data: { applicationAnswerDraft: 'Reviewed answer — æøå', recruiterMessageDraft: 'Message draft; never sent' } })).status()).toBe(204);
expect((await page.request.post(`${jobUrl}/checklist`, { headers, data: { title: 'Review employer questions — æøå', category: 'custom' } })).status()).toBe(200);
expect((await page.request.post(`${jobUrl}/interview-prep`, { headers, data: { title: 'Ask about sustainable delivery', category: 'question', content: 'My recorded question', source: 'manual' } })).status()).toBe(200);
expect((await page.request.post(`${apiUrl}/correspondence`, { headers, data: { jobApplicationId: id, from: 'Company', subject: 'Interview invitation — æøå', content: 'Recorded invitation; no inferred company facts', channel: 'Email' } })).ok()).toBe(true);
expect((await page.request.post(`${apiUrl}/attachments`, { headers, multipart: { jobId: String(id), files: { name: 'Employment evidence — æøå.txt', mimeType: 'text/plain', buffer: Buffer.from('Recorded evidence — æøå') } } })).ok()).toBe(true);
await page.setViewportSize({ width: 1440, height: 900 });
const entry = async (section: string, sub?: string) => {
await page.goto(`/jobs/${id}?section=${section}${sub ? `&sub=${sub}` : ''}&followMode=waiting-update`);
await expect(page.getByRole('heading', { name: title, exact: true })).toBeVisible();
await expect(page.getByRole('tablist', { name: t.workspaceSections }).getByRole('tab')).toHaveCount(7);
expect(new URL(page.url()).searchParams.get('followMode')).toBe('waiting-update');
await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1)).toBe(true);
};
await entry('overview'); await capture(page, info, `sections-overview-${language}-${mode}`);
await entry('analysis'); await expect(page.getByRole('heading', { name: t.workspaceCvMatch, exact: true })).toBeVisible();
await capture(page, info, `sections-match-${language}-${mode}`);
await entry('cv'); await expect(page.getByRole('combobox', { name: new RegExp(t.assetsAttachedCv) })).toBeVisible();
await expect(page.getByRole('link', { name: t.assetsEditPreviewExport })).toHaveAttribute('href', `/career/builder/${variant.id}`);
await expect(page.getByRole('button', { name: t.assetsPackageCapture, exact: true })).toHaveCount(0);
await capture(page, info, `sections-current-cv-${language}-${mode}`);
await entry('cover-letter'); await expect(page.getByRole('textbox', { name: t.jobDetailsCoverLetter, exact: true })).toHaveValue('Reviewed letter — æøå');
await capture(page, info, `sections-letter-${language}-${mode}`);
await entry('application', 'answers');
const answer = page.getByRole('textbox', { name: t.assetsApplicationAnswer, exact: true });
await expect(answer).toHaveValue('Reviewed answer — æøå');
await answer.fill('Edited answer, pending explicit save — æøå');
await page.getByRole('tab', { name: t.workspaceSubmission, exact: true }).click();
await expect(page.getByRole('dialog', { name: t.workspaceUnsavedTitle })).toBeVisible();
await page.getByRole('button', { name: t.workspaceKeepEditing, exact: true }).click();
await expect(answer).toHaveValue('Edited answer, pending explicit save — æøå');
const save = page.waitForResponse(r => r.url() === `${jobUrl}/application-drafts` && r.request().method() === 'PUT');
await page.getByRole('button', { name: t.assetsSaveDrafts, exact: true }).click(); expect((await save).status()).toBe(204);
await page.getByRole('tab', { name: t.workspaceSubmission, exact: true }).click();
const freeze = page.waitForResponse(r => r.url() === `${jobUrl}/submitted-packages` && r.request().method() === 'POST');
await page.getByRole('button', { name: t.assetsPackageCapture, exact: true }).click(); expect((await freeze).status()).toBe(200);
await expect(page.getByText(t.assetsSubmittedPackageVersion.replace('{version}', '1'), { exact: true })).toBeVisible();
await capture(page, info, `sections-submission-${language}-${mode}`);
await page.getByRole('button', { name: t.assetsPackageView, exact: true }).click();
await expect(page.getByRole('dialog').getByText(t.workspaceFrozenHelp, { exact: true })).toBeVisible();
await expect(page.getByRole('dialog').getByText('Reviewed letter — æøå', { exact: true })).toBeVisible();
await capture(page, info, `sections-frozen-${language}-${mode}`);
await page.keyboard.press('Escape'); await expect(page.getByRole('button', { name: t.assetsPackageView, exact: true })).toBeFocused();
await entry('documents'); await expect(page.getByRole('switch', { name: `${t.attachmentsAiUse}: Employment evidence — æøå.txt` })).toBeVisible();
await expect(page.getByRole('columnheader', { name: t.attachmentsUploaded, exact: true })).toBeVisible();
await expect(page.getByRole('button', { name: t.assetsPackageCapture, exact: true })).toHaveCount(0);
await capture(page, info, `sections-documents-${language}-${mode}`);
await entry('timeline'); await expect(page.getByRole('heading', { name: t.intelligenceTimeline, exact: true })).toBeVisible();
await expect(page.locator('time').first()).toBeVisible(); await capture(page, info, `sections-timeline-${language}-${mode}`);
await entry('communication'); await expect(page.getByText('Interview invitation — æøå', { exact: true })).toBeVisible();
await capture(page, info, `sections-correspondence-${language}-${mode}`);
await entry('notes');
await expect(page.getByRole('region', { name: t.workspaceNotes, exact: true })).toContainText('Human notes — æøå');
await expect(page.getByRole('region', { name: t.workspaceNotes, exact: true })).not.toContainText('Edited answer');
const complete = page.waitForResponse(r => r.url().includes('/checklist/') && r.request().method() === 'PATCH');
const task = page.getByRole('checkbox', { name: 'Review employer questions — æøå', exact: true });
await task.click(); expect((await complete).status()).toBe(200);
await expect(task).toBeChecked(); // Existing owner re-reads after the server write; no optimistic tick.
await capture(page, info, `sections-notes-tasks-${language}-${mode}`);
const notes = page.getByRole('region', { name: t.workspaceNotes, exact: true });
await notes.getByRole('button', { name: t.workspaceEdit, exact: true }).click();
await page.getByRole('dialog').getByRole('textbox', { name: t.editJobNotes, exact: true }).fill('Edited human notes — æøå');
const notesSave = page.waitForResponse(r => r.url() === jobUrl && r.request().method() === 'PUT');
await page.getByRole('dialog').getByRole('button', { name: t.save, exact: true }).click(); expect((await notesSave).status()).toBe(204);
await expect(notes).toContainText('Edited human notes — æøå');
expect((await (await page.request.get(`${jobUrl}/workspace`)).json()).applicationAnswerDraft).toBe('Edited answer, pending explicit save — æøå');
await entry('interview'); await expect(page.getByText('Ask about sustainable delivery', { exact: true })).toBeVisible();
await capture(page, info, `sections-interview-${language}-${mode}`);
for (const [width, height, section] of [[768, 1024, 'timeline'], [390, 844, 'communication'], [320, 844, 'documents'], [320, 844, 'application']] as const) {
await page.setViewportSize({ width, height }); await entry(section, section === 'application' ? 'submission' : undefined);
const selected = page.getByRole('tablist', { name: t.workspaceSections }).locator('[aria-selected=true]'); await selected.focus();
await expect(selected).toBeFocused();
await capture(page, info, `sections-${section}-${width}-${language}-${mode}`);
if (section === 'documents') {
const files = page.getByRole('region', { name: t.attachmentsTitle.replace('{count}', '1'), exact: true });
await files.focus(); await page.keyboard.press('ArrowRight');
await expect.poll(() => files.evaluate(node => node.scrollLeft)).toBeGreaterThan(0);
await capture(page, info, `sections-document-table-${width}-${language}-${mode}`);
}
}
const scan = await new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa']).analyze();
await info.attach(`sections-axe-${language}-${mode}`, { body: JSON.stringify({ violations: scan.violations, incomplete: scan.incomplete }), contentType: 'application/json' });
expect(scan.violations).toEqual([]);
const sparse = await page.request.post(`${apiUrl}/jobapplications`, { headers, data: { companyId, jobTitle: 'Sparse sections', status: 'Saved' } });
expect(sparse.status()).toBe(201);
await page.goto(`/jobs/${(await sparse.json()).id}?section=documents`);
await expect(page.getByText(t.attachmentsEmpty, { exact: true })).toBeVisible();
await expect(page.getByText(t.assetsNoSubmittedPackages, { exact: true })).toBeVisible();
await capture(page, info, `sections-sparse-${language}-${mode}`);
await info.attach(`sections-native-invariants-${language}-${mode}`, { body: JSON.stringify({ packageCapture: true, dirtyAnswersKeptAndExplicitlySaved: true, currentVsFrozenSeparated: true, personalNotesSeparate: true, checklistWriteSucceeded: true, legacyCvLetterCommunicationNotes: true, followModePreserved: true }), contentType: 'application/json' });
});
}
@@ -133,7 +133,7 @@ for (const [mode, language, width] of [["light", "en", 1440], ["dark", "nb", 144
await page.getByRole("combobox", { name: new RegExp(t.coverAiAction) }).scrollIntoViewIfNeeded();
await capture(page, info, `cover-controls-${suffix}`);
await page.goto(`/jobs/${owner.jobId}?section=overview`);
await page.getByRole("button", { name: t.workspaceDocuments, exact: true }).click();
await page.getByRole("tab", { name: t.workspaceDocuments, exact: true }).click();
const purpose = page.getByRole("combobox", { name: new RegExp(t.attachmentsPurpose) });
await purpose.focus();
await purpose.press("Enter");
@@ -95,6 +95,7 @@ test("[PRES-W01 PRES-W05 PRES-W06 PRES-W07 PRES-W08] legacy workspace preserves
const [attachment] = await (await page.request.get(`${apiUrl}/attachments/${job.id}`)).json();
await page.goto(`/applications/${job.id}?section=cv`);
await expect(page).toHaveURL(new RegExp(`/jobs/${job.id}\\?section=cv$`));
await page.getByRole("tab", { name: "Submission", exact: true }).click();
const captured = page.waitForResponse(response => response.url() === `${jobUrl}/submitted-packages` && response.request().method() === 'POST');
await page.getByRole("button", { name: "Freeze current package", exact: true }).click();
const captureResponse = await captured;
@@ -168,11 +169,10 @@ for (const language of ["en", "nb"] as const) {
await expect(page.getByRole("heading", { name: "Readiness workspace", exact: true })).toBeVisible();
expect(new URL(page.url()).searchParams.get("followMode")).toBe("waiting-update");
const section = expectedSections[tab];
if (["communication", "documents", "checklist", "timeline"].includes(section)) {
const names = language === "en" ? { communication: "Communication", documents: "Documents", checklist: "Next actions and checklist", timeline: "Activity history" } : { communication: "Kommunikasjon", documents: "Dokumenter", checklist: "Neste handlinger og sjekkliste", timeline: "Aktivitetshistorikk" };
await expect(page.getByRole("button", { name: names[section as keyof typeof names], exact: true })).toHaveAttribute("aria-expanded", "true");
}
await expect(page.getByRole("tabpanel")).toBeVisible();
const names = language === "en" ? { communication: "Timeline", documents: "Documents", checklist: "Notes & tasks", timeline: "Timeline", cv: "Application", match: "Match", analysis: "Match", overview: "Overview", interview: "Interview Prep" } : { communication: "Tidslinje", documents: "Dokumenter", checklist: "Notater og oppgaver", timeline: "Tidslinje", cv: "Søknad", match: "Samsvar", analysis: "Samsvar", overview: "Oversikt", interview: "Intervjuforberedelse" };
await expect(page.getByRole("tablist", { name: language === "en" ? "Workspace sections" : "Deler av arbeidsområdet" }).getByRole("tab", { name: names[section as keyof typeof names], exact: true })).toHaveAttribute("aria-selected", "true");
if (section === "communication") await expect(page.getByRole("tabpanel", { name: language === "en" ? "Communication" : "Kommunikasjon", exact: true })).toBeVisible();
await expect(page.getByRole("tabpanel").first()).toBeVisible();
}
await page.goto(`/jobs/${job.id}?section=checklist&followMode=waiting-update`);
const move = page.getByRole("button", { name: language === "en" ? /^Move up:/ : /^Flytt opp:/ }).first();
@@ -191,13 +191,13 @@ for (const language of ["en", "nb"] as const) {
const overviewTab = page.getByRole("tab", { name: language === "en" ? "Overview" : "Oversikt", exact: true });
await overviewTab.focus();
await page.keyboard.press("ArrowRight");
const analysisTab = page.getByRole("tab", { name: language === "en" ? "Analysis" : "Analyse", exact: true });
const analysisTab = page.getByRole("tab", { name: language === "en" ? "Match" : "Samsvar", exact: true });
await expect(analysisTab).toBeFocused();
await page.keyboard.press("Enter");
await expect(analysisTab).toHaveAttribute("aria-selected", "true");
expect(new URL(page.url()).searchParams.get("followMode")).toBe("waiting-update");
await page.goBack();
await expect(overviewTab).toHaveAttribute("aria-selected", "true");
await expect(page.getByRole("tab", { name: language === "en" ? "Documents" : "Dokumenter", exact: true })).toHaveAttribute("aria-selected", "true");
expect(new URL(page.url()).searchParams.get("section")).toBe("documents");
await page.goForward();
await expect(analysisTab).toHaveAttribute("aria-selected", "true");
@@ -256,7 +256,7 @@ test("[PRES-E07 PRES-W01 PRES-J04] keyboard command navigation and legacy worksp
await expect(page.getByRole("heading", { name: "Command engineer", exact: true })).toBeVisible();
await page.goto(`/applications/${job.id}?section=match`);
await expect(page).toHaveURL(new RegExp(`/jobs/${job.id}\\?section=match$`));
await expect(page.getByRole("tab", { name: "Analysis", exact: true })).toHaveAttribute("aria-selected", "true");
await expect(page.getByRole("tab", { name: "Match", exact: true })).toHaveAttribute("aria-selected", "true");
await page.keyboard.press("Control+k");
await page.getByRole("dialog").getByText("Add new job", { exact: true }).click();
await expect(page.getByRole("dialog")).toBeVisible();
+4 -3
View File
@@ -260,10 +260,11 @@ test("the dedicated application workspace survives deep links, long data and uns
await expect(page).toHaveURL(new RegExp(`/jobs/${job.id}$`));
const workspaceNav = page.getByRole("tablist", { name: "Workspace sections" });
await workspaceNav.getByRole("tab", { name: "Cover Letter", exact: true }).click();
await expect(page).toHaveURL(new RegExp(`/jobs/${job.id}\\?section=cover-letter$`));
await workspaceNav.getByRole("tab", { name: "Application", exact: true }).click();
await page.getByRole("tab", { name: "Answers & message draft", exact: true }).click();
await expect(page).toHaveURL(new RegExp(`/jobs/${job.id}\\?section=application&sub=answers$`));
await page.getByLabel("Application answer", { exact: true }).fill("A reviewed answer that must not be lost.");
await workspaceNav.getByRole("tab", { name: "Analysis", exact: true }).click();
await workspaceNav.getByRole("tab", { name: "Match", exact: true }).click();
await expect(page.getByRole("dialog", { name: "Unsaved application changes" })).toBeVisible();
await page.getByRole("button", { name: "Keep editing" }).click();
await expect(page.getByLabel("Application answer", { exact: true })).toHaveValue("A reviewed answer that must not be lost.");