feat(documents): add owner-scoped document workspace

This commit is contained in:
cesnimda
2026-10-16 18:22:19 +02:00
parent 8e4db121ae
commit 5a67ec3d3d
19 changed files with 4932 additions and 9 deletions
+132
View File
@@ -0,0 +1,132 @@
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 } from '../src/cvBuilder';
import type { CoverLetter, SubmittedPackage, SubmittedPackageDetail } from '../src/applicationWorkspace';
const apiUrl = 'http://localhost:5302/api';
const password = 'E2ePassword123!';
const privateMarker = 'PRIVATE_DOCUMENT_BODY_NOT_INDEX_METADATA';
type Owner = { id: string; headers: Record<string, string> };
type Job = { id: number; companyId: number; jobTitle: string };
type Email = { id: string; subject: string; bodyText: string; clientRequestId: string; revision: number };
type Kind = 'cv' | 'cover-letter' | 'application-material' | 'tailored-cv' | 'attachment' | 'submitted-package' | 'email-draft';
type Item = { id: string; resourceId: string; type: Kind; name: string; state: string; visibility: string | null; jobApplicationId: number | null; jobTitle: string | null; companyName: string | null; jobIsDeleted: boolean | null; recordedAt: string | null; dateKind: string; version: number | null; historyCount: number | null; attachmentCount: number | null; cvVariantId: number | null; cvVariantName: string | null; cvVariantVersion: number | null; fileType: string | null; fileSize: number | null; purpose: string | null; useForAi: boolean | null; provider: string | null; sourceHref: string; downloadHref: string | null; publicHref: string | null; hasCurrentContent: boolean | null };
type Documents = { items: Item[]; page: number; pageSize: number; total: number; totalPages: number };
type Sample = { language: 'en' | 'nb'; theme: 'light' | 'dark'; width: number; height: number };
type RequestRecord = { method: string; path: string; query: Record<string, string>; body: unknown };
type Session = { owner: Owner; administrator: Owner; admin: APIRequestContext; requests: RequestRecord[] };
const desktop: Sample = { language: 'en', theme: 'light', width: 1440, height: 900 };
const itemFields = ['id', 'resourceId', 'type', 'name', 'state', 'visibility', 'jobApplicationId', 'jobTitle', 'companyName', 'jobIsDeleted', 'recordedAt', 'dateKind', 'version', 'historyCount', 'attachmentCount', 'cvVariantId', 'cvVariantName', 'cvVariantVersion', 'fileType', 'fileSize', 'purpose', 'useForAi', 'provider', 'sourceHref', 'downloadHref', 'publicHref', 'hasCurrentContent'].sort();
const typeKeys: Record<Kind, string> = { cv: 'documentsTypeCv', 'cover-letter': 'documentsTypeLetter', 'application-material': 'documentsTypeMaterial', 'tailored-cv': 'documentsTypeTailoredCv', attachment: 'documentsTypeAttachment', 'submitted-package': 'documentsTypePackage', 'email-draft': 'documentsTypeEmailDraft' };
const boundary = 'Actual Program/local Identity cookie sessions, CSRF-protected existing source writes and owner Documents GET use disposable isolated SQLite. Setup creates seven genuine existing resource kinds; an explicit setup submitted-package capture invokes the established CV renderer once to create its immutable snapshot. Documents listing itself must issue no source-body/render/preview/PDF/export/AI/provider/send request or mutation. Existing source editors own their own reads, errors, dirty guards and saves after deliberate navigation. Attachment download is an explicit authenticated existing GET; controlled precommit503 qualifies read recovery, not uncertain delivery. Frozen package metadata/archive bytes are independently read after current CV/upload deletion. No provider grant/send/sync/import/model, raw SQL/schema, storage relocation, production/main/deployment or Phase8 action. Free accounts and disabled workers remain mandatory; external/model/delivery browser requests are blocked and recorded without credentials/CSRF/session headers. Exact27-field metadata and finite all-enabled axe/target-size vectors retain every incomplete node. UI evidence does not establish internal SQL/service-call counts, live MariaDB, human screen reader, physical touch/software keyboard, real zoom, production fonts/photos, provider qualification or production readiness. Correspondence opens its existing context without exact-ID Resume; Submission opens history without automatically selecting a package; CV PDF stays the existing explicit export operation.';
function copy(key: string, params: Record<string, string | number> = {}, language: Sample['language'] = 'en') { return ((translations[language] as Record<string, string>)[key] ?? key).replace(/\{(\w+)\}/g, (_, name: string) => String(params[name] ?? `{${name}}`)); }
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 waitResponse(page: Page, method: string, path: string) { return page.waitForResponse(response => response.request().method() === method && new URL(response.url()).pathname === path); }
function count(session: Session, method: string, path: string) { return session.requests.filter(value => value.method === method && value.path === path); }
function writes(session: Session) { return session.requests.filter(value => value.method !== 'GET'); }
function indexPath(page: Page) { const url = new URL(page.url()); return url.pathname + url.search + url.hash; }
function results(page: Page, language: Sample['language'] = 'en') { return page.getByRole('region', { name: copy('documentsYourDocuments', {}, language), exact: true }); }
function rows(page: Page, language: Sample['language'] = 'en') { return results(page, language).locator('tbody tr, article'); }
function row(page: Page, item: Pick<Item, 'name' | 'type'>, language: Sample['language'] = 'en') { const type = copy(typeKeys[item.type], {}, language).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); return rows(page, language).filter({ has: page.getByText(item.name, { exact: true }) }).filter({ has: page.getByText(new RegExp(`^${type}(?:\\s*·|$)`)) }); }
function search(page: Page, language: Sample['language'] = 'en') { return page.getByRole('textbox', { name: copy('documentsSearch', {}, language), exact: true }); }
async function keyboard(page: Page, control: Locator) { await expect(control).toBeVisible(); await expect(control).toBeEnabled(); await control.focus(); await expect(control).toBeFocused(); await page.keyboard.press('Enter'); }
async function select(page: Page, label: string, option: string, language: Sample['language'] = 'en') { await keyboard(page, page.getByRole('combobox', { name: copy(label, {}, language), exact: true })); await keyboard(page, page.getByRole('option', { name: copy(option, {}, language), exact: true })); }
async function goPage(page: Page, number: number, language: Sample['language'] = 'en') { await keyboard(page, page.getByRole('navigation', { name: copy('documentsPages', {}, language), exact: true }).getByRole('button', { name: copy('documentsGoToPage', { page: number }, language), exact: true })); }
function metadata(data: Documents) { expect(Object.keys(data).sort()).toEqual(['items', 'page', 'pageSize', 'total', 'totalPages'].sort()); for (const item of data.items) expect(Object.keys(item).sort()).toEqual(itemFields); expect(JSON.stringify(data)).not.toContain(privateMarker); return data; }
async function readIndex(api: APIRequestContext, params: Record<string, string | number> = {}) { return metadata(await json<Documents>(await api.get(`${apiUrl}/documents`, { params: { page: 1, pageSize: 25, ...params } }))); }
function noImplicitSourceWork(session: Session) { expect(writes(session)).toEqual([]); expect(session.requests.filter(value => /\/api\/(?:attachments\/download|email\/drafts|email\/send|gmail|ai|career\/profile|cv\/variants\/\d+|jobapplications\/\d+\/)/.test(value.path))).toEqual([]); }
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 token = (await api.storageState()).cookies.find(cookie => cookie.name === 'XSRF-TOKEN'); expect(token).toBeDefined(); return { id: me.id, headers: { 'X-CSRF-TOKEN': token!.value, 'X-Session-Context': me.sessionContext } }; }
async function account(admin: APIRequestContext, owner: Owner) { const email = `p707-ui-${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\/(?:documents|attachments|cv|career|email|gmail|ai|correspondence|jobapplications)(?:\/|$)/.test(url.pathname)) requests.push({ method: request.method(), path: url.pathname, query: Object.fromEntries(url.searchParams), body: request.postData() ? request.postDataJSON() : null }); });
await context.route('**/*', route => { const url = new URL(route.request().url()); if (/^\/api\/(?:email\/send|gmail|ai)(?:\/|$)/.test(url.pathname) || !['localhost', '127.0.0.1'].includes(url.hostname) && !['blob:', 'data:', 'about:'].includes(url.protocol)) { blocked.push(url.origin + url.pathname); return route.abort(); } return route.continue(); });
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 provide({ owner, administrator, admin, requests }); expect(requests.filter(value => /\/api\/(?:email\/send|gmail|ai)(?:\/|$)/.test(value.path))).toEqual([]); }
finally { await info.attach('documents-ui-boundary', { body: JSON.stringify({ boundary, requests, blockedExternalOrModelDeliveryRequests: blocked }), contentType: 'application/json' }); await admin.dispose(); }
},
});
async function job(api: APIRequestContext, owner: Owner, title = 'Recorded document role — æøå', company?: number) { const companyId = company ?? (await json<{ id: number }>(await api.post(`${apiUrl}/companies`, { headers: owner.headers, data: { name: 'Recorded Documents company — æøå' } }), 201)).id; return json<Job>(await api.post(`${apiUrl}/jobapplications`, { headers: owner.headers, data: { companyId, jobTitle: title, status: 'Saved', notes: 'Ordinary notes stay separate.', description: 'Recorded platform engineering vacancy.' } }), 201); }
async function cv(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 email(api: APIRequestContext, owner: Owner, parent: Job, subject: string) { return json<Email>(await api.post(`${apiUrl}/email/drafts`, { headers: owner.headers, data: { jobApplicationId: parent.id, provider: 'gmail', to: 'private-document-recipient@example.test', subject, bodyText: privateMarker, threadId: 'private-document-thread' } }), 201); }
async function upload(api: APIRequestContext, owner: Owner, parent: Job, name = 'Recorded Documents attachment — æøå.txt') {
const bytes = Buffer.from(`${privateMarker}\nExact owned file bytes — æøå\n`, 'utf8'); expect((await api.post(`${apiUrl}/attachments`, { headers: owner.headers, multipart: { jobId: String(parent.id), files: { name, mimeType: 'text/plain', buffer: bytes } } })).ok()).toBe(true); const attachment = (await json<{ id: number; fileName: string }[]>(await api.get(`${apiUrl}/attachments/${parent.id}`))).find(value => value.fileName === name)!; expect(attachment.id).toBeGreaterThan(0); expect((await api.patch(`${apiUrl}/attachments/${attachment.id}`, { headers: owner.headers, data: { useForAi: false } })).status()).toBe(204); return { attachment, bytes, name };
}
async function seven(page: Page, owner: Owner, long = false) {
const empty = emptyStructuredCv(); await json(await page.request.put(`${apiUrl}/career/profile`, { headers: owner.headers, data: { profile: { ...empty, contact: { ...empty.contact, fullName: 'Åse Recorded Documents', headline: 'Owner-reviewed engineering evidence' }, summary: [privateMarker], skills: ['C#', 'SQL'] }, cvText: privateMarker } }));
const parent = await job(page.request, owner, long ? 'Langvarig plattformutvikling for samfunnskritiske tjenester og internasjonale samarbeidspartnere Æ Ø Å' : undefined); const variant = await cv(page.request, owner, long ? 'CV med lang dokumenttittel om samfunnskritisk plattformutvikling og inkluderende arbeidsprosesser Æ Ø Å' : 'Recorded current and captured CV — æøå'); await json(await page.request.put(`${apiUrl}/jobapplications/${parent.id}/cv`, { headers: owner.headers, data: { variantId: variant.id } })); const published = await json<CvVariant>(await page.request.put(`${apiUrl}/cv/variants/${variant.id}/public`, { headers: owner.headers, data: { isPublic: true } }));
const letter = `${privateMarker}\nOriginal reviewed cover letter — æøå`; await json(await page.request.put(`${apiUrl}/jobapplications/${parent.id}/cover-letter`, { headers: owner.headers, data: { text: letter, source: 'manual' } })); expect((await page.request.put(`${apiUrl}/jobapplications/${parent.id}/application-drafts`, { headers: owner.headers, data: { applicationAnswerDraft: `${privateMarker} answer`, recruiterMessageDraft: `${privateMarker} recruiter draft` } })).status()).toBe(204); expect((await page.request.put(`${apiUrl}/jobapplications/${parent.id}/tailored-cv`, { headers: owner.headers, data: { tailoredCvText: `${privateMarker} manually tailored CV` } })).status()).toBe(204);
const file = await upload(page.request, owner, parent, long ? 'Dokumentasjon av samfunnskritiske tjenester og inkluderende arbeidsprosesser med lange dokumentnavn Æ Ø Å.txt' : undefined); const draft = await email(page.request, owner, parent, long ? 'Lagret e-postutkast om samfunnskritiske tjenester og internasjonalt samarbeid Æ Ø Å' : 'Recorded saved email draft — æøå'); const preserved = await json<SubmittedPackage>(await page.request.post(`${apiUrl}/jobapplications/${parent.id}/submitted-packages`, { headers: owner.headers })); const items = (await readIndex(page.request)).items; expect(items.map(value => value.type).sort()).toEqual(Object.keys(typeKeys).sort()); return { parent, variant: published, letter, draft, preserved, file, items };
}
async function enter(page: Page, owner: Owner, sample: Sample = desktop, path = '/documents?source=phase7-documents&keep=%C3%A6%26x&followMode=waiting-update#library') {
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(path);
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(); }
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, name: copy('documentsTitle', {}, sample.language), exact: true })).toBeVisible(); await expect(results(page, sample.language)).toBeVisible();
}
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'), name: document.activeElement?.getAttribute('aria-label') }, overflowElements: [...document.querySelectorAll('body *')].filter(node => node.getClientRects().length && getComputedStyle(node).visibility !== 'hidden' && !node.closest('[hidden],[aria-hidden="true"]')).map(node => { const rect = node.getBoundingClientRect(); return { tag: node.tagName, text: node.textContent?.slice(0, 80), left: rect.left, right: rect.right, width: rect.width }; }).filter(node => node.width > 0 && (node.left < -1 || node.right > innerWidth + 1)).slice(0, 20) })); const axe = await new AxeBuilder({ page }).options({ rules: { 'target-size': { enabled: true } } }).analyze(); const name = `p707-documents-${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', testEngine: axe.testEngine, testEnvironment: axe.testEnvironment, toolOptions: axe.toolOptions, violations: axe.violations, incomplete: axe.incomplete, passes: axe.passes.map(rule => ({ id: rule.id, nodes: rule.nodes.length })) }, qualifications: ['Finite local Chromium states; not the full width/language/theme/state cross product.', 'All incomplete nodes retained for triage; no human AT, physical device/software keyboard, real zoom, precise screenshot contrast or production font/photo acceptance.', 'Full-page PNG can be resized and sticky elements captured out of viewport; focus viewport images are separate.', 'Actual MariaDB, model/provider/delivery and production release remain unverified.'] }), 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-07 Documents] seven owned metadata kinds retain honest source actions and frozen provenance after current source deletion', async ({ page, session }, info) => {
test.setTimeout(180_000); const seeded = await seven(page, session.owner); await enter(page, session.owner); await expect(rows(page)).toHaveCount(7); noImplicitSourceWork(session); expect(count(session, 'GET', '/api/jobapplications/choices')).toHaveLength(0);
for (const item of seeded.items) { const body = row(page, item); await expect(body).toBeVisible(); await expect(body.getByText(copy(item.state === 'frozen' ? 'documentsFrozen' : item.state === 'draft' ? 'documentsDraft' : 'documentsCurrent'), { exact: true })).toBeVisible(); const action = item.type === 'email-draft' ? 'documentsOpenCorrespondence' : item.type === 'submitted-package' ? 'documentsOpenSubmissionHistory' : 'documentsOpenSource'; await expect(body.getByRole('link', { name: copy(action), exact: true })).toHaveAttribute('href', item.sourceHref); }
const currentCv = seeded.items.find(value => value.type === 'cv')!; await expect(row(page, currentCv).getByRole('link', { name: copy('documentsOpenPublicCv'), exact: true })).toHaveAttribute('href', `/cv/${seeded.variant.publicSlug}`); await expect(page.getByRole('button', { name: copy('documentsDownloadAttachment'), exact: true })).toHaveCount(1); await expect(results(page)).not.toContainText(privateMarker); await expect(row(page, seeded.items.find(value => value.type === 'application-material')!).getByText(copy('documentsDateUnknown'), { exact: true })).toBeVisible(); await expect(row(page, seeded.items.find(value => value.type === 'attachment')!)).toContainText(copy('documentsUploadedLocal'));
const frozen = seeded.items.find(value => value.type === 'submitted-package')!; await expect(row(page, frozen)).toContainText(copy('documentsCapturedCv', { name: seeded.preserved.cvVariantName!, version: seeded.preserved.cvVariantVersion! })); await capture(page, info, desktop, 'seven-honest-metadata-kinds');
expect((await page.request.delete(`${apiUrl}/cv/variants/${seeded.variant.id}`, { headers: session.owner.headers })).status()).toBe(204); expect((await page.request.delete(`${apiUrl}/attachments/${seeded.file.attachment.id}`, { headers: session.owner.headers })).status()).toBe(204); const refreshed = waitResponse(page, 'GET', '/api/documents'); await keyboard(page, page.getByRole('button', { name: copy('documentsRefresh'), exact: true })); const after = metadata(await json<Documents>(await refreshed)); expect(after.items.find(value => value.id === frozen.id)).toEqual(frozen); await expect(rows(page)).toHaveCount(5); await expect(row(page, currentCv)).toHaveCount(0); await expect(row(page, frozen)).toContainText(copy('documentsFrozen'));
const detail = await json<SubmittedPackageDetail>(await page.request.get(`${apiUrl}/jobapplications/${seeded.parent.id}/submitted-packages/${seeded.preserved.id}`)); expect(detail.summary.cvVariantName).toBe(seeded.preserved.cvVariantName); const archive = await page.request.get(`${apiUrl}/jobapplications/${seeded.parent.id}/submitted-packages/${seeded.preserved.id}/attachments/${seeded.preserved.attachments[0].id}`); expect(archive.status()).toBe(200); expect(await archive.body()).toEqual(seeded.file.bytes); noImplicitSourceWork(session); await info.attach('documents-frozen-metadata-readback', { body: JSON.stringify({ frozenBefore: frozen, frozenAfter: after.items.find(value => value.id === frozen.id), archivedSha256: createHash('sha256').update(await archive.body()).digest('hex'), qualification: 'Explicit setup capture; index does not render or recapture. Full immutable corpus/P2-D01 remain separately required.' }), contentType: 'application/json' });
});
test('[P7-07 Documents] server pages filter history owned job selection and reload preserve unknown query and hash', async ({ page, session }, info) => {
test.setTimeout(180_000); const parent = await job(page.request, session.owner, 'Indexed primary role'); const other = await job(page.request, session.owner, 'Independent secondary role', parent.companyId); for (let index = 0; index < 26; index++) await email(page.request, session.owner, parent, `Indexed draft ${String(index).padStart(2, '0')} — æøå`); for (let index = 0; index < 2; index++) await email(page.request, session.owner, other, `Other draft ${index}`);
await enter(page, session.owner); await expect(rows(page)).toHaveCount(25); expect(count(session, 'GET', '/api/jobapplications/choices')).toHaveLength(0); await goPage(page, 2); await expect(rows(page)).toHaveCount(3); expect(new URL(page.url()).searchParams.get('page')).toBe('2'); const before = indexPath(page); await select(page, 'documentsType', 'documentsTypeEmailDraft'); await expect(rows(page)).toHaveCount(25); expect(new URL(page.url()).searchParams.get('page') || '1').toBe('1'); await page.goBack(); await expect(rows(page)).toHaveCount(3); expect(indexPath(page)).toBe(before);
await select(page, 'documentsType', 'documentsTypeEmailDraft'); await select(page, 'documentsState', 'documentsDraft'); await select(page, 'documentsSort', 'documentsSortNameAsc'); await search(page).fill('Indexed'); await expect(rows(page)).toHaveCount(25); const choice = page.getByRole('combobox', { name: copy('documentsJobFilter'), exact: true }); await choice.fill(parent.jobTitle); const selectedOption = page.getByRole('option').filter({ hasText: parent.jobTitle }); await expect(selectedOption).toBeVisible(); const selectedOptionId = await selectedOption.getAttribute('id'); expect(selectedOptionId).not.toBeNull(); await choice.press('ArrowDown'); await expect(choice).toBeFocused(); await expect(choice).toHaveAttribute('aria-activedescendant', selectedOptionId!); await choice.press('Enter'); await expect.poll(() => new URL(page.url()).searchParams.get('jobApplicationId')).toBe(String(parent.id)); await goPage(page, 2); await expect(rows(page)).toHaveCount(1); const retained = indexPath(page); expect(new URL(page.url()).searchParams.get('keep')).toBe('æ&x'); expect(new URL(page.url()).searchParams.get('followMode')).toBe('waiting-update'); expect(new URL(page.url()).hash).toBe('#library');
await page.reload(); await expect(rows(page)).toHaveCount(1); expect(indexPath(page)).toBe(retained); await expect(search(page)).toHaveValue('Indexed'); await expect(page.getByRole('combobox', { name: copy('documentsType'), exact: true })).toContainText(copy('documentsTypeEmailDraft')); expect(count(session, 'GET', '/api/documents').at(-1)?.query).toMatchObject({ page: '2', pageSize: '25', q: 'Indexed', type: 'email-draft', state: 'draft', sort: 'name-asc', jobApplicationId: String(parent.id) }); noImplicitSourceWork(session); await capture(page, info, desktop, 'filtered-server-page-reloaded');
});
test('[P7-07 Documents] initial503 Retry and incompatible held query expose no stale actions or false empty state', async ({ page, session }, info) => {
test.setTimeout(120_000); const parent = await job(page.request, session.owner); const first = await email(page.request, session.owner, parent, 'Initial owned metadata'); const latest = await email(page.request, session.owner, parent, 'Latest owned metadata'); let attempts = 0; const gate = hold(); const reached = hold(); const completed = hold(); let heldMetadata: Documents | null = null;
await page.route(`${apiUrl}/documents*`, async route => { const query = new URL(route.request().url()).searchParams.get('q'); attempts += 1; if (attempts === 1) return route.fulfill({ status: 503, json: {} }); if (query === 'Held old query') { try { const actual = await route.fetch(); heldMetadata = metadata(await json<Documents>(actual)); reached.release(); await gate.pending; await route.fulfill({ response: actual }); } finally { completed.release(); } return; } return route.fulfill({ response: await route.fetch() }); });
try { await enter(page, session.owner); const error = page.getByRole('alert').filter({ hasText: copy('documentsLoadFailed') }); await expect(error).toBeVisible(); await expect(page.getByText(copy('documentsEmpty'), { exact: true })).toHaveCount(0); await expect(page.getByText(copy('documentsNoMatches'), { exact: true })).toHaveCount(0); await keyboard(page, error.getByRole('button', { name: copy('retry'), exact: true })); await expect(rows(page)).toHaveCount(2); expect(attempts).toBe(2);
await search(page).fill('Held old query'); await reached.pending; await expect(rows(page)).toHaveCount(0); await expect(page.getByRole('link', { name: copy('documentsOpenCorrespondence'), exact: true })).toHaveCount(0); await expect(page.getByText(copy('documentsNoMatches'), { exact: true })).toHaveCount(0); const cancelled = page.waitForEvent('requestfailed', { predicate: request => request.method() === 'GET' && new URL(request.url()).pathname === '/api/documents' && new URL(request.url()).searchParams.get('q') === 'Held old query' }); await search(page).fill('Latest owned'); const failedRead = await cancelled; expect(failedRead.failure()?.errorText).toContain('ERR_ABORTED'); await expect(rows(page)).toHaveCount(1); await expect(row(page, { name: latest.subject, type: 'email-draft' })).toBeVisible(); gate.release(); await completed.pending; await expect(row(page, { name: first.subject, type: 'email-draft' })).toHaveCount(0); noImplicitSourceWork(session); await capture(page, info, desktop, 'latest-query-after-get-recovery'); await expect(row(page, { name: latest.subject, type: 'email-draft' })).toBeVisible(); await expect(page.getByText(copy('documentsNoMatches'), { exact: true })).toHaveCount(0); await info.attach('documents-cancelled-obsolete-read', { body: JSON.stringify({ heldServerMetadata: heldMetadata, browserFailure: failedRead.failure(), qualification: 'The actual held server response was retained, but the superseded browser GET was aborted. Handler completion and latest rendered rows are asserted; no browser response is claimed for the cancelled read.' }), contentType: 'application/json' });
} finally { gate.release(); await page.unroute(`${apiUrl}/documents*`); }
});
test('[P7-07 Documents] stale CV opens the existing missing-source error and Back plus explicit Refresh removes its metadata', async ({ page, session }, info) => {
test.setTimeout(120_000); const variant = await cv(page.request, session.owner, 'Removed current CV — æøå'); await enter(page, session.owner); const item = (await readIndex(page.request)).items[0]; await expect(row(page, item)).toBeVisible(); const from = indexPath(page); noImplicitSourceWork(session); expect((await page.request.delete(`${apiUrl}/cv/variants/${variant.id}`, { headers: session.owner.headers })).status()).toBe(204);
const missing = waitResponse(page, 'GET', `/api/cv/variants/${variant.id}`); await keyboard(page, row(page, item).getByRole('link', { name: copy('documentsOpenSource'), exact: true })); expect((await missing).status()).toBe(404); await expect(page).toHaveURL(new RegExp(`/career/builder/${variant.id}$`)); const sourceError = page.getByRole('main').getByRole('alert'); await expect(sourceError).toBeVisible(); await expect(sourceError).toHaveText('Not Found'); await expect(page.getByRole('heading', { level: 2, name: copy('documentsYourDocuments'), exact: true })).toHaveCount(0); await capture(page, info, desktop, 'existing-missing-source-editor'); await page.goBack(); await expect(results(page)).toBeVisible(); expect(indexPath(page)).toBe(from);
const refreshed = waitResponse(page, 'GET', '/api/documents'); await keyboard(page, page.getByRole('button', { name: copy('documentsRefresh'), exact: true })); expect(metadata(await json<Documents>(await refreshed)).total).toBe(0); await expect(row(page, item)).toHaveCount(0); await expect(page.getByText(copy('documentsEmpty'), { exact: true })).toBeVisible(); expect(writes(session)).toEqual([]);
});
test('[P7-07 Documents] explicit authenticated attachment GET retries exact bytes and a real404 offers only list Refresh', async ({ page, session }, info) => {
test.setTimeout(150_000); const parent = await job(page.request, session.owner); const file = await upload(page.request, session.owner, parent); await enter(page, session.owner); const item = (await readIndex(page.request)).items[0]; await expect(row(page, item)).toBeVisible(); noImplicitSourceWork(session); const path = `/api/attachments/download/${file.attachment.id}`; let attempts = 0; await page.route(`${apiUrl}/attachments/download/${file.attachment.id}`, async route => { attempts += 1; if (attempts === 1) return route.fulfill({ status: 503, json: {} }); return route.fulfill({ response: await route.fetch() }); });
await keyboard(page, row(page, item).getByRole('button', { name: copy('documentsDownloadAttachment'), exact: true })); const failed = page.getByRole('alert').filter({ hasText: copy('documentsDownloadFailed') }); await expect(failed).toBeVisible(); expect(count(session, 'GET', path)).toHaveLength(1); const listReads = count(session, 'GET', '/api/documents').length; const downloaded = page.waitForEvent('download'); const actual = waitResponse(page, 'GET', path); await keyboard(page, failed.getByRole('button', { name: copy('retry'), exact: true })); const response = await actual; expect(response.status()).toBe(200); expect(response.headers()['content-type']).toContain('text/plain'); const download = await downloaded; expect(download.suggestedFilename()).toBe(file.name); const saved = await download.path(); expect(saved).not.toBeNull(); expect(await readFile(saved!)).toEqual(file.bytes); expect(attempts).toBe(2); expect(count(session, 'GET', '/api/documents')).toHaveLength(listReads); await expect(row(page, item)).toBeVisible();
expect((await page.request.delete(`${apiUrl}/attachments/${file.attachment.id}`, { headers: session.owner.headers })).status()).toBe(204); const unavailable = waitResponse(page, 'GET', path); await keyboard(page, row(page, item).getByRole('button', { name: copy('documentsDownloadAttachment'), exact: true })); expect((await unavailable).status()).toBe(404); const stale = page.getByRole('alert').filter({ hasText: copy('documentsDownloadUnavailable') }); await expect(stale).toBeVisible(); await expect(stale.getByRole('button', { name: copy('retry'), exact: true })).toHaveCount(0); await expect(row(page, item)).toBeVisible(); expect(count(session, 'GET', '/api/documents')).toHaveLength(listReads); await capture(page, info, desktop, 'real-deleted-attachment-stale-metadata'); const refreshed = waitResponse(page, 'GET', '/api/documents'); await keyboard(page, stale.getByRole('button', { name: copy('documentsRefresh'), exact: true })); expect(metadata(await json<Documents>(await refreshed)).total).toBe(0); await expect(rows(page)).toHaveCount(0); expect(attempts).toBe(3); expect(writes(session)).toEqual([]);
});
test('[P7-07 Documents] actual sign-out clears private rows and a separate owner sees only its own metadata', async ({ page, session }, info) => {
test.setTimeout(150_000); const parent = await job(page.request, session.owner, 'First private owner role'); const privateA = await email(page.request, session.owner, parent, 'First private owner document'); const otherEmail = await account(session.admin, session.administrator); await enter(page, session.owner); await expect(row(page, { name: privateA.subject, type: 'email-draft' })).toBeVisible(); await keyboard(page, page.locator('header').getByRole('button', { name: copy('user'), exact: true })); const logout = waitResponse(page, 'POST', '/api/auth/logout'); await keyboard(page, page.getByRole('menuitem', { name: copy('signOut'), exact: true })); expect((await logout).status()).toBe(204); await expect(page).toHaveURL(/\/login(?:\?|$)/); await expect(page.getByText(privateA.subject, { exact: true })).toHaveCount(0); expect((await page.request.get(`${apiUrl}/documents`)).status()).toBe(401);
const second = await login(page.request, otherEmail); expect(second.id).not.toBe(session.owner.id); const secondJob = await job(page.request, second, 'Second private owner role'); const privateB = await email(page.request, second, secondJob, 'Second private owner document'); await enter(page, second); await expect(rows(page)).toHaveCount(1); await expect(row(page, { name: privateB.subject, type: 'email-draft' })).toBeVisible(); await expect(page.getByText(privateA.subject, { exact: true })).toHaveCount(0); expect((await page.request.get(`${apiUrl}/email/drafts/${privateA.id}`)).status()).toBe(404); expect((await readIndex(page.request, { ownerUserId: session.owner.id })).items.map(value => value.id)).toEqual([`email-draft:${privateB.id}`]); await capture(page, info, desktop, 'second-owner-no-cached-private-rows'); expect(writes(session)).toEqual([]);
});
test('[P7-07 Documents] existing letter source keeps dirty edits on navigation Cancel and reindexes only the explicit saved revision', async ({ page, session }, info) => {
test.setTimeout(150_000); const parent = await job(page.request, session.owner); const original = await json<CoverLetter>(await page.request.put(`${apiUrl}/jobapplications/${parent.id}/cover-letter`, { headers: session.owner.headers, data: { text: 'Original source letter — æøå', source: 'manual' } })); await enter(page, session.owner); const item = (await readIndex(page.request)).items[0]; const from = indexPath(page); await keyboard(page, row(page, item).getByRole('link', { name: copy('documentsOpenSource'), exact: true })); await expect(page).toHaveURL(new RegExp(`/jobs/${parent.id}\\?section=application&sub=letter$`)); const field = page.getByRole('textbox', { name: copy('jobDetailsCoverLetter'), exact: true }); await expect(field).toHaveValue(original.text!); const dirty = 'Reviewed local source change survives Documents navigation Cancel — æøå'; await field.fill(dirty);
const navigation = page.getByRole('button', { name: copy('documentsTitle'), exact: true }); await keyboard(page, navigation); const dialog = page.getByRole('dialog', { name: copy('workspaceUnsavedTitle'), exact: true }); await keyboard(page, dialog.getByRole('button', { name: copy('workspaceKeepEditing'), exact: true })); await expect(dialog).toHaveCount(0); await expect(field).toHaveValue(dirty); expect(count(session, 'PUT', `/api/jobapplications/${parent.id}/cover-letter`)).toHaveLength(0); expect(await json<CoverLetter>(await page.request.get(`${apiUrl}/jobapplications/${parent.id}/cover-letter`))).toEqual(original); await capture(page, info, desktop, 'existing-letter-dirty-navigation-cancel');
const body = page.getByRole('region', { name: copy('workspaceCoverLetter'), exact: true }); const saved = waitResponse(page, 'PUT', `/api/jobapplications/${parent.id}/cover-letter`); await keyboard(page, body.getByRole('button', { name: copy('save'), exact: true })); const revision = await json<CoverLetter>(await saved); expect(revision.text).toBe(dirty); expect(revision.currentVersion).toBe(2); await expect(body.getByRole('button', { name: copy('save'), exact: true })).toBeDisabled(); expect(count(session, 'PUT', `/api/jobapplications/${parent.id}/cover-letter`).map(value => value.body)).toEqual([{ text: dirty, source: 'manual' }]); await page.goBack(); await expect(results(page)).toBeVisible(); expect(indexPath(page)).toBe(from); const indexed = (await readIndex(page.request)).items[0]; expect(indexed).toMatchObject({ id: item.id, historyCount: 2, version: 2, hasCurrentContent: true }); await expect(row(page, indexed)).toContainText(copy('documentsHistoryVersions', { count: 2 })); expect(writes(session)).toHaveLength(1);
});
test('[P7-07 Documents] eight finite bilingual theme widths retain long metadata keyboard actions and complete axe vectors', async ({ page, session }, info) => {
test.setTimeout(300_000); const seeded = await seven(page, session.owner, true); const samples: Sample[] = [{ language: 'en', theme: 'light', width: 320, height: 740 }, { language: 'nb', theme: 'dark', width: 360, height: 844 }, { language: 'en', theme: 'dark', width: 390, height: 844 }, { language: 'nb', theme: 'light', width: 768, height: 1024 }, { language: 'en', theme: 'dark', width: 1024, height: 768 }, { language: 'nb', theme: 'light', width: 1280, height: 320 }, { language: 'en', theme: 'light', width: 1440, height: 900 }, { language: 'nb', theme: 'dark', width: 1920, height: 1080 }];
for (const sample of samples) {
await enter(page, session.owner, sample); await expect(rows(page, sample.language)).toHaveCount(7); const control = search(page, sample.language); await control.focus(); await expect(control).toBeFocused(); await page.keyboard.press('Tab'); expect(await page.evaluate(() => document.activeElement !== document.body && document.activeElement?.isConnected)).toBe(true); const frozen = seeded.items.find(value => value.type === 'submitted-package')!; const action = row(page, frozen, sample.language).getByRole('link', { name: copy('documentsOpenSubmissionHistory', {}, sample.language), exact: true }); await action.focus(); await action.scrollIntoViewIfNeeded(); await expect(action).toBeFocused(); await expect(action).toHaveAttribute('href', frozen.sourceHref);
const beforeCapture = await action.boundingBox(); expect(beforeCapture).not.toBeNull(); expect(beforeCapture!.y).toBeGreaterThanOrEqual(0); expect(beforeCapture!.y + beforeCapture!.height).toBeLessThanOrEqual(sample.height); await capture(page, info, sample, 'long-metadata-keyboard');
await action.scrollIntoViewIfNeeded(); await expect(action).toBeFocused(); const afterCapture = await action.boundingBox(); expect(afterCapture).not.toBeNull(); expect(afterCapture!.y).toBeGreaterThanOrEqual(0); expect(afterCapture!.y + afterCapture!.height).toBeLessThanOrEqual(sample.height); await info.attach(`p707-documents-${sample.language}-${sample.width}-focus-bounds`, { body: JSON.stringify({ beforeFullPageCapture: beforeCapture, afterScrollRestoration: afterCapture, viewport: sample, qualification: 'Full-page screenshot can change scroll. Both actual viewport bounds are asserted after explicit focus/scroll, without relaxing the 0..height requirement.' }), contentType: 'application/json' });
if ([320, 1280].includes(sample.width)) await info.attach(`p707-documents-${sample.language}-${sample.width}-focused-viewport`, { body: await page.screenshot({ fullPage: false, animations: 'disabled' }), contentType: 'image/png' });
}
noImplicitSourceWork(session); expect((await readIndex(page.request)).items).toEqual(seeded.items);
});
+7 -1
View File
@@ -55,6 +55,7 @@ const RemindersView = lazy(() => import("./components/RemindersView"));
const QuickCommandDialog = lazy(() => import("./components/QuickCommandDialog"));
const ProfilePage = lazy(() => import("./views/ProfilePage"));
const CareerWorkspacePage = lazy(() => import("./views/CareerWorkspacePage"));
const DocumentsPage = lazy(() => import("./views/DocumentsPage"));
const CvBuilderPage = lazy(() => import("./views/CvBuilderPage"));
const ApplicationWorkspacePage = lazy(() => import("./views/ApplicationWorkspacePage"));
const CvBuilderEditor = lazy(() => import("./views/CvBuilderEditor"));
@@ -121,6 +122,7 @@ function breadcrumbsFor(path: string, t: (k: any) => string): string[] {
if (path.startsWith("/profile")) return [t("home"), t("account"), t("profile")];
if (path.startsWith("/career/builder")) return [t("home"), t("careerWorkspace"), t("cvBuilder")];
if (path.startsWith("/career")) return [t("home"), t("careerWorkspace")];
if (path.startsWith("/documents")) return [t("home"), t("careerWorkspace"), t("documentsTitle")];
if (path.startsWith("/admin/audit")) return [t("home"), t("admin"), t("auditLog")];
if (path.startsWith("/admin/users")) return [t("home"), t("admin"), t("users")];
if (path.startsWith("/admin/system")) return [t("home"), t("admin"), t("system")];
@@ -146,6 +148,7 @@ function titleFor(path: string, t: (k: any) => string): string {
if (path.startsWith("/profile")) return t("profile");
if (path.startsWith("/career/builder")) return t("cvBuilder");
if (path.startsWith("/career")) return t("careerWorkspace");
if (path.startsWith("/documents")) return t("documentsTitle");
if (path.startsWith("/admin/audit")) return t("auditLog");
if (path.startsWith("/admin/users")) return t("users");
if (path.startsWith("/admin/system")) return t("systemStatus");
@@ -164,6 +167,7 @@ function subtitleFor(path: string, t: (k: any) => string): string | undefined {
if (path.startsWith("/offers")) return t("offerComparisonPageSubtitle");
if (path.startsWith("/correspondence/review")) return t("gmailReviewPageSubtitle");
if (path.startsWith("/correspondence")) return t("correspondencePageSubtitle");
if (path.startsWith("/documents")) return t("documentsPageSubtitle");
return undefined;
}
@@ -318,6 +322,7 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo
{ to: "/correspondence", label: t("correspondenceInbox"), icon: <MailOutlineIcon fontSize="small" />, section: t("jobSearch") },
{ to: "/career", label: t("careerWorkspaceProfileTab"), icon: <AccountCircleIcon fontSize="small" />, section: t("career") },
{ to: "/career/builder", label: t("cvBuilder"), icon: <DescriptionOutlinedIcon fontSize="small" />, section: t("career") },
{ to: "/documents", label: t("documentsTitle"), icon: <DescriptionOutlinedIcon fontSize="small" />, section: t("career") },
{ to: "/analytics", label: t("analytics"), icon: <InsightsOutlinedIcon fontSize="small" />, section: t("insights") },
];
@@ -379,7 +384,7 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo
<AppShell
pageTitle={pageTitle}
pageSubtitle={pageSubtitle}
pageLayout={/^\/settings\/?$/.test(path) ? "reading" : undefined}
pageLayout={/^\/settings\/?$/.test(path) ? "reading" : path === "/documents" ? "data" : undefined}
breadcrumbs={breadcrumbs}
pathname={path}
nav={nav}
@@ -416,6 +421,7 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo
<Route path="/career" element={<CareerWorkspacePage />} />
<Route path="/career/builder" element={<CvBuilderPage />} />
<Route path="/career/builder/:id" element={<CvBuilderEditor />} />
<Route path="/documents" element={<DocumentsPage />} />
<Route path="/admin/audit" element={<AdminAuditPage />} />
<Route path="/admin/users" element={<AdminUsersPage />} />
<Route path="/admin/system" element={<AdminSystemPage />} />
@@ -102,6 +102,7 @@ export default function QuickCommandDialog({ open, onClose, onNavigate, onOpenAd
{ id: "go-reminders", label: t("goToReminders"), hint: t("followUpQueue"), action: () => onNavigate("/reminders") },
{ id: "go-companies", label: t("goToCompanies"), hint: t("crmAndSourceTracking"), action: () => onNavigate("/companies") },
{ id: "go-settings", label: t("goToSettings"), hint: t("preferencesAndAdminTools"), action: () => onNavigate("/settings") },
{ id: "go-documents", label: t("goToDocuments"), hint: t("documentsPageSubtitle"), action: () => onNavigate("/documents") },
{ id: "add-job", label: t("addNewJob"), hint: t("openAddJobModal"), action: onOpenAddJob },
];
+333
View File
@@ -0,0 +1,333 @@
import React from 'react';
import '@testing-library/jest-dom';
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { MemoryRouter, Route, Routes, useLocation, useNavigate } from 'react-router-dom';
import { api } from './api';
import { I18nProvider } from './i18n/I18nProvider';
import { translations, type UiLanguage } from './i18n/translations';
import DocumentsPage from './views/DocumentsPage';
const mockedApi = api as jest.Mocked<typeof api>;
const defaults = { page: 1, pageSize: 25, q: undefined, type: 'all', state: 'all', visibility: 'all', sort: 'date-desc' };
function copy(key: string, params: Record<string, string | number> = {}, language: UiLanguage = document.documentElement.lang === 'nb-NO' ? 'nb' : 'en') {
return ((translations[language] as Record<string, string>)[key] ?? key).replace(/\{(\w+)\}/g, (_, name) => String(params[name] ?? `{${name}}`));
}
function item(overrides: Record<string, unknown> = {}) {
return { id: 'cv:12', resourceId: '12', type: 'cv', name: 'Metadata CV ÆØÅ', state: 'current', visibility: 'public', jobApplicationId: 42, jobTitle: 'Recorded role', companyName: 'Nordlys AS', jobIsDeleted: false, recordedAt: '2026-02-13T11:30:00.0000000Z', dateKind: 'updated-utc', version: 4, historyCount: null, attachmentCount: null, cvVariantId: null, cvVariantName: null, cvVariantVersion: null, fileType: null, fileSize: null, purpose: null, useForAi: null, provider: null, sourceHref: '/career/builder/12', downloadHref: null, publicHref: '/cv/metadata-cv', hasCurrentContent: null, ...overrides };
}
function page(items: ReturnType<typeof item>[] = [item()], currentPage = 1, total = items.length) {
return { items, page: currentPage, pageSize: 25, total, totalPages: Math.ceil(total / 25) };
}
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 };
}
function Probe() {
const location = useLocation();
const navigate = useNavigate();
return <><output data-testid="documents-location">{JSON.stringify({ pathname: location.pathname, search: location.search, hash: location.hash, state: location.state })}</output><button onClick={() => navigate(-1)}>History Back</button><button onClick={() => navigate('/away')}>Leave documents</button></>;
}
function renderPage(path = '/documents', state: unknown = null) {
const url = new URL(path, 'https://local.test');
return render(<I18nProvider><MemoryRouter initialEntries={[{ pathname: url.pathname, search: url.search, hash: url.hash, state }]}><Probe /><Routes><Route path="/documents" element={<DocumentsPage />} /><Route path="/away" element={<p>Other workflow</p>} /></Routes></MemoryRouter></I18nProvider>);
}
function location() { return JSON.parse(screen.getByTestId('documents-location').textContent!) as { pathname: string; search: string; hash: string; state: unknown }; }
function reads() { return mockedApi.get.mock.calls.filter(([url]) => url === '/documents'); }
function row(name: string) { return within(screen.getByText(name, { exact: true }).closest('tr, article') as HTMLElement); }
async function select(labelKey: string, optionKey: string) { fireEvent.mouseDown(screen.getByRole('combobox', { name: copy(labelKey) })); fireEvent.click(await screen.findByRole('option', { name: copy(optionKey) })); }
async function language(value: UiLanguage) { await act(async () => { window.dispatchEvent(new StorageEvent('storage', { key: 'uiLanguage', newValue: value })); }); }
beforeEach(() => {
localStorage.clear();
document.documentElement.lang = 'en';
jest.clearAllMocks();
mockedApi.get.mockReset().mockImplementation((url: string) => url === '/documents'
? Promise.resolve({ data: page() } as never)
: Promise.reject(new Error(`Unexpected read ${url}`)));
for (const method of [mockedApi.post, mockedApi.put, mockedApi.patch, mockedApi.delete]) method.mockReset().mockRejectedValue(new Error('Unexpected document-index write'));
Object.defineProperty(URL, 'createObjectURL', { configurable: true, writable: true, value: jest.fn(() => 'blob:uploaded-file') });
Object.defineProperty(URL, 'revokeObjectURL', { configurable: true, writable: true, value: jest.fn() });
});
afterEach(() => {
expect(mockedApi.post).not.toHaveBeenCalled();
expect(mockedApi.put).not.toHaveBeenCalled();
expect(mockedApi.patch).not.toHaveBeenCalled();
expect(mockedApi.delete).not.toHaveBeenCalled();
jest.restoreAllMocks();
});
test('[P7-07 documents] validates URL filters and reads one metadata page with literal Nordic search', async () => {
renderPage('/documents?q=100%25_%21+%C3%86%C3%98%C3%85&page=bad&type=unknown&state=unknown&visibility=unknown&sort=unknown&jobApplicationId=-2&keep=1#context');
expect(await screen.findByText('Metadata CV ÆØÅ', { exact: true })).toBeVisible();
expect(reads()).toHaveLength(1);
expect(reads()[0][1]?.params).toEqual({ ...defaults, q: '100%_! ÆØÅ' });
expect(screen.getByRole('textbox', { name: copy('documentsSearch') })).toHaveValue('100%_! ÆØÅ');
expect(mockedApi.get.mock.calls.map(([url]) => url)).toEqual(['/documents']);
expect(URL.createObjectURL).not.toHaveBeenCalled();
});
test('[P7-07 documents] server pagination and filter history preserve unknown query, hash and router state', async () => {
mockedApi.get.mockImplementation((_url: string, config?: any) => Promise.resolve({ data: page([item({ name: `Page ${config.params.page} ${config.params.type}` })], config.params.page, 26) } as never));
const state = { from: '/jobs/42', draftContext: 'preserve-navigation-context' };
renderPage('/documents?page=2&jobApplicationId=42&keep=%C3%A6%26x#materials', state);
expect(await screen.findByText('Page 2 all')).toBeVisible();
expect(reads()[0][1]?.params).toEqual({ ...defaults, page: 2, jobApplicationId: 42 });
await select('documentsType', 'documentsTypeCv');
expect(await screen.findByText('Page 1 cv')).toBeVisible();
const current = location(); const query = new URLSearchParams(current.search);
expect(query.get('keep')).toBe('æ&x'); expect(query.get('jobApplicationId')).toBe('42'); expect(query.get('page')).not.toBe('2');
expect(current.hash).toBe('#materials'); expect(current.state).toEqual(state);
expect(reads().at(-1)?.[1]?.params).toEqual({ ...defaults, type: 'cv', jobApplicationId: 42 });
fireEvent.click(screen.getByRole('button', { name: 'History Back' }));
expect(await screen.findByText('Page 2 all')).toBeVisible();
expect(reads().at(-1)?.[1]?.params).toEqual({ ...defaults, page: 2, jobApplicationId: 42 });
});
test('[P7-07 documents] literal search all remains free text and preserves unrelated navigation context', async () => {
mockedApi.get.mockImplementation((_url: string, config?: any) => Promise.resolve({ data: page([item({ name: config.params.q === 'all' ? 'Literal all metadata' : 'Initial metadata' })]) } as never));
const state = { from: '/jobs/42', draftContext: 'preserve-literal-search-context' };
renderPage('/documents?keep=%C3%A6%26x#materials', state);
await screen.findByText('Initial metadata');
const search = screen.getByRole('textbox', { name: copy('documentsSearch') });
fireEvent.change(search, { target: { value: 'all' } });
expect(search).toHaveValue('all');
expect(await screen.findByText('Literal all metadata')).toBeVisible();
expect(mockedApi.get.mock.calls.map(([url]) => url)).toEqual(['/documents', '/documents']);
expect(reads()[1][1]?.params).toEqual({ ...defaults, q: 'all' });
const current = location(); const query = new URLSearchParams(current.search);
expect(query.get('q')).toBe('all'); expect(query.get('keep')).toBe('æ&x');
expect(current.hash).toBe('#materials'); expect(current.state).toEqual(state);
});
test('[P7-07 documents] a batched sort selection and immediate search preserve both filters and navigation context', async () => {
mockedApi.get.mockImplementation((_url: string, config?: any) => Promise.resolve({ data: page([item({ name: config.params.q === 'Indexed' && config.params.sort === 'name-asc' ? 'Indexed sorted metadata' : 'Initial metadata' })]) } as never));
const state = { from: '/jobs/42', draftContext: 'preserve-rapid-filter-context' };
renderPage('/documents?keep=%C3%A6%26x#context', state);
await screen.findByText('Initial metadata');
const search = screen.getByRole('textbox', { name: copy('documentsSearch') });
fireEvent.mouseDown(screen.getByRole('combobox', { name: copy('documentsSort') }));
const option = await screen.findByRole('option', { name: copy('documentsSortNameAsc') });
act(() => { fireEvent.click(option); fireEvent.change(search, { target: { value: 'Indexed' } }); });
const current = location(); const query = new URLSearchParams(current.search);
expect(query.get('sort')).toBe('name-asc'); expect(query.get('q')).toBe('Indexed');
expect(query.get('keep')).toBe('æ&x'); expect(current.hash).toBe('#context'); expect(current.state).toEqual(state);
expect(await screen.findByText('Indexed sorted metadata')).toBeVisible();
expect(reads().at(-1)?.[1]?.params).toEqual({ ...defaults, q: 'Indexed', sort: 'name-asc' });
expect(mockedApi.get.mock.calls.every(([url]) => url === '/documents')).toBe(true);
fireEvent.click(screen.getByRole('button', { name: 'History Back' }));
await waitFor(() => {
const restored = new URLSearchParams(location().search);
expect(restored.get('q')).toBeNull(); expect(restored.get('sort')).toBeNull();
expect(reads().at(-1)?.[1]?.params).toEqual(defaults);
});
expect(await screen.findByText('Initial metadata')).toBeVisible();
fireEvent.change(screen.getByRole('textbox', { name: copy('documentsSearch') }), { target: { value: 'After back' } });
await waitFor(() => expect(reads().at(-1)?.[1]?.params).toEqual({ ...defaults, q: 'After back' }));
const afterBack = location(); const afterBackQuery = new URLSearchParams(afterBack.search);
expect(afterBackQuery.get('q')).toBe('After back'); expect(afterBackQuery.get('sort')).toBeNull();
expect(afterBackQuery.get('keep')).toBe('æ&x'); expect(afterBack.hash).toBe('#context'); expect(afterBack.state).toEqual(state);
expect(screen.getByRole('textbox', { name: copy('documentsSearch') })).toHaveValue('After back');
});
test('[P7-07 documents] incompatible pending and failed queries hide old actions and late results cannot replace current metadata', async () => {
const old = deferred<any>();
mockedApi.get.mockImplementation((_url: string, config?: any) => config.params.q === 'old' ? old.promise
: config.params.q === 'failed' ? Promise.reject(new Error('offline'))
: Promise.resolve({ data: page([item({ name: config.params.q === 'new' ? 'New owned metadata' : 'Initial metadata' })]) }) as never);
renderPage(); await screen.findByText('Initial metadata');
const search = screen.getByRole('textbox', { name: copy('documentsSearch') });
fireEvent.change(search, { target: { value: 'old' } });
await waitFor(() => expect((reads().at(-1)?.[1]?.params as Record<string, unknown> | undefined)?.q).toBe('old'));
expect(screen.queryByText('Initial metadata')).not.toBeInTheDocument();
expect(screen.queryByRole('link', { name: copy('documentsOpenSource') })).not.toBeInTheDocument();
fireEvent.change(search, { target: { value: 'new' } });
expect(await screen.findByText('New owned metadata')).toBeVisible();
await act(async () => old.resolve({ data: page([item({ name: 'Obsolete private metadata' })]) }));
expect(screen.queryByText('Obsolete private metadata')).not.toBeInTheDocument();
fireEvent.change(search, { target: { value: 'failed' } });
expect(await screen.findByRole('alert')).toHaveTextContent(copy('documentsLoadFailed'));
expect(screen.queryByText('New owned metadata')).not.toBeInTheDocument();
expect(screen.queryByRole('link', { name: copy('documentsOpenSource') })).not.toBeInTheDocument();
});
test('[P7-07 documents] Retry is one guarded GET and a completion after leaving cannot publish or start follow-up work', async () => {
const pending = deferred<any>();
mockedApi.get.mockRejectedValueOnce(new Error('offline')).mockImplementation(() => pending.promise);
renderPage('/documents?q=owned&type=attachment');
expect(await screen.findByRole('alert')).toHaveTextContent(copy('documentsLoadFailed'));
const retry = screen.getByRole('button', { name: copy('retry') });
act(() => { fireEvent.click(retry); fireEvent.click(retry); });
await waitFor(() => expect(reads()).toHaveLength(2));
expect(reads()[1][1]?.params).toEqual({ ...defaults, q: 'owned', type: 'attachment' });
fireEvent.click(screen.getByRole('button', { name: 'Leave documents' }));
await screen.findByText('Other workflow');
await act(async () => pending.resolve({ data: page([item({ name: 'Late document result' })]) }));
expect(screen.queryByText('Late document result')).not.toBeInTheDocument();
expect(reads()).toHaveLength(2); expect(URL.createObjectURL).not.toHaveBeenCalled();
});
test('[P7-07 documents] a true empty index, filtered no-match and request failure remain distinct', async () => {
mockedApi.get.mockImplementation((_url: string, config?: any) => config.params.q === 'offline'
? Promise.reject(new Error('offline')) : Promise.resolve({ data: page([]) }) as never);
renderPage();
expect(await screen.findByText(copy('documentsEmpty'))).toBeVisible();
const search = screen.getByRole('textbox', { name: copy('documentsSearch') });
fireEvent.change(search, { target: { value: 'no match æøå' } });
expect(await screen.findByText(copy('documentsNoMatches'))).toBeVisible();
expect(screen.queryByText(copy('documentsEmpty'))).not.toBeInTheDocument();
fireEvent.change(search, { target: { value: 'offline' } });
expect(await screen.findByRole('alert')).toHaveTextContent(copy('documentsLoadFailed'));
expect(screen.queryByText(copy('documentsEmpty'))).not.toBeInTheDocument();
expect(screen.queryByText(copy('documentsNoMatches'))).not.toBeInTheDocument();
});
test('[P7-07 documents] English and Bokmål presentation changes preserve query context without a metadata refetch', async () => {
renderPage('/documents?q=%C3%A6%C3%B8%C3%A5&state=current&visibility=public&keep=one#context', { draft: 'unsaved-source-context' });
await screen.findByText('Metadata CV ÆØÅ'); const before = location(); const count = reads().length;
expect(screen.getByRole('textbox', { name: copy('documentsSearch') })).toHaveValue('æøå');
await language('nb');
expect(screen.getByRole('textbox', { name: copy('documentsSearch', {}, 'nb') })).toHaveValue('æøå');
expect(row('Metadata CV ÆØÅ').getByText(copy('documentsCurrent', {}, 'nb'))).toBeVisible();
expect(row('Metadata CV ÆØÅ').getByRole('link', { name: copy('documentsOpenSource', {}, 'nb') })).toHaveAttribute('href', '/career/builder/12');
await language('en');
expect(screen.getByRole('textbox', { name: copy('documentsSearch', {}, 'en') })).toHaveValue('æøå');
expect(location()).toEqual(before); expect(reads()).toHaveLength(count);
});
test('[P7-07 documents] an out-of-range page offers explicit first-page recovery without claiming an empty index', async () => {
mockedApi.get.mockImplementation((_url: string, config?: any) => Promise.resolve({ data: config.params.page === 999 ? page([], 999, 1) : page([item()]) } as never));
const state = { from: '/jobs/42', draftContext: 'preserve-out-of-range-context' };
renderPage('/documents?page=999&keep=%C3%A6%26x#materials', state);
expect(await screen.findByText(copy('documentsPageEmpty'))).toBeVisible();
expect(screen.getByText(copy('documentsPageEmptyHelp'))).toBeVisible();
expect(screen.queryByText(copy('documentsEmpty'))).not.toBeInTheDocument();
expect(screen.queryByText(copy('documentsNoMatches'))).not.toBeInTheDocument();
expect(reads()).toHaveLength(1);
expect(reads()[0][1]?.params).toEqual({ ...defaults, page: 999 });
fireEvent.click(screen.getByRole('button', { name: copy('documentsFirstPage') }));
expect(await screen.findByText('Metadata CV ÆØÅ')).toBeVisible();
expect(mockedApi.get.mock.calls.map(([url]) => url)).toEqual(['/documents', '/documents']);
expect(reads()[1][1]?.params).toEqual(defaults);
const current = location(); const query = new URLSearchParams(current.search);
expect(query.get('page')).not.toBe('999'); expect(query.get('keep')).toBe('æ&x');
expect(current.hash).toBe('#materials'); expect(current.state).toEqual(state);
});
test('[P7-07 documents] seven resource kinds retain exact source actions, frozen provenance and honest date labels', async () => {
const letter = item({ id: 'cover-letter:42', resourceId: '42', type: 'cover-letter', name: 'Versions-only letter', state: 'draft', visibility: null, dateKind: 'history-recorded-utc', version: 3, historyCount: 3, sourceHref: '/jobs/42?section=application&sub=letter', publicHref: null, hasCurrentContent: false });
const material = item({ id: 'application-material:42', resourceId: '42', type: 'application-material', name: 'Application answers', state: 'draft', visibility: null, recordedAt: null, dateKind: 'unknown', version: null, sourceHref: '/jobs/42?section=application&sub=answers', publicHref: null });
const tailored = item({ id: 'tailored-cv:42', resourceId: '42', type: 'tailored-cv', name: 'Tailored CV text', state: 'draft', visibility: null, recordedAt: null, dateKind: 'unknown', version: null, sourceHref: '/jobs/42?section=application&sub=cv', publicHref: null });
const upload = item({ id: 'attachment:9', resourceId: '9', type: 'attachment', name: 'Certificate æøå.pdf', visibility: null, recordedAt: '2026-02-12T09:15:00.0000000', dateKind: 'uploaded-local', version: null, fileType: 'application/pdf', fileSize: 1234, purpose: 'Certificate', useForAi: false, sourceHref: '/jobs/42?section=documents', downloadHref: '/api/attachments/download/9', publicHref: null });
const frozen = item({ id: 'submitted-package:86', resourceId: '86', type: 'submitted-package', name: 'Captured package', state: 'frozen', visibility: null, dateKind: 'captured-utc', version: 9, attachmentCount: 2, cvVariantId: 3, cvVariantName: 'Captured CV', cvVariantVersion: 2, jobIsDeleted: true, sourceHref: '/jobs/42?section=application&sub=submission', publicHref: null });
const email = item({ id: 'email-draft:11111111-1111-4111-8111-111111111111', resourceId: '11111111-1111-4111-8111-111111111111', type: 'email-draft', name: 'Saved email draft', state: 'draft', visibility: null, provider: 'gmail', version: 6, sourceHref: '/correspondence', publicHref: null });
mockedApi.get.mockResolvedValue({ data: page([item(), letter, material, tailored, upload, frozen, email]) } as never);
renderPage(); await screen.findByText('Metadata CV ÆØÅ');
for (const entry of [item(), letter, material, tailored, upload]) expect(row(entry.name).getByRole('link', { name: copy('documentsOpenSource') })).toHaveAttribute('href', entry.sourceHref);
expect(row('Metadata CV ÆØÅ').getByRole('link', { name: copy('documentsOpenPublicCv') })).toHaveAttribute('href', '/cv/metadata-cv');
expect(row(letter.name).getByText(copy('documentsHistoryOnly'))).toBeVisible();
expect(row(letter.name).getByText(copy('documentsHistoryVersions', { count: 3 }))).toBeVisible();
expect(row(upload.name).getByText(copy('documentsUploadedLocal'), { exact: false })).toBeVisible();
expect(row(material.name).getByText(copy('documentsDateUnknown'))).toBeVisible();
expect(row(tailored.name).getByText(copy('documentsDateUnknown'))).toBeVisible();
expect(row(frozen.name).getByText(copy('documentsFrozen'))).toBeVisible();
expect(row(frozen.name).getByText(copy('documentsCapturedCv', { name: 'Captured CV', version: 2 }))).toBeVisible();
expect(row(frozen.name).getByText(copy('documentsDeletedJob'))).toBeVisible();
expect(row(frozen.name).getByRole('link', { name: copy('documentsOpenSubmissionHistory') })).toHaveAttribute('href', frozen.sourceHref);
expect(row(email.name).getByRole('link', { name: copy('documentsOpenCorrespondence') })).toHaveAttribute('href', '/correspondence');
expect(row(email.name).queryByRole('button', { name: /resume|send/i })).not.toBeInTheDocument();
expect(screen.getByText(copy('documentsDateOrderHelp'))).toBeVisible();
expect(mockedApi.get.mock.calls.map(([url]) => url)).toEqual(['/documents']);
expect(URL.createObjectURL).not.toHaveBeenCalled();
});
test('[P7-07 documents] attachment download is explicit, API-relative and single-flight with original MIME, filename and URL disposal', async () => {
const upload = item({ id: 'attachment:9', resourceId: '9', type: 'attachment', name: 'Certificate æøå.pdf', visibility: null, fileType: 'application/pdf', sourceHref: '/jobs/42?section=documents', downloadHref: '/api/attachments/download/9', publicHref: null });
const pending = deferred<any>(); const bytes = new Blob(['%PDF uploaded bytes'], { type: 'application/pdf' }); const clicks: Array<{ href: string; filename: string }> = [];
jest.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function (this: HTMLAnchorElement) { clicks.push({ href: this.href, filename: this.download }); });
mockedApi.get.mockImplementation((url: string) => url === '/documents' ? Promise.resolve({ data: page([upload]) }) : url === '/attachments/download/9' ? pending.promise : Promise.reject(new Error(`Unexpected read ${url}`)) as never);
renderPage(); await screen.findByText(upload.name);
expect(mockedApi.get.mock.calls.map(([url]) => url)).toEqual(['/documents']);
const download = row(upload.name).getByRole('button', { name: copy('documentsDownloadAttachment') });
act(() => { fireEvent.click(download); fireEvent.click(download); });
expect(mockedApi.get.mock.calls.filter(([url]) => url === '/attachments/download/9')).toHaveLength(1);
expect(mockedApi.get).toHaveBeenLastCalledWith('/attachments/download/9', expect.objectContaining({ responseType: 'blob' }));
await act(async () => pending.resolve({ data: bytes, headers: { 'content-type': 'application/pdf' } }));
await waitFor(() => expect(clicks).toEqual([{ href: 'blob:uploaded-file', filename: upload.name }]));
const downloaded = (URL.createObjectURL as jest.Mock).mock.calls[0][0] as Blob;
expect(downloaded.type).toBe('application/pdf'); expect(downloaded.size).toBe(bytes.size);
expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:uploaded-file'); expect(reads()).toHaveLength(1);
await waitFor(() => expect(download).toHaveFocus());
});
test('[P7-07 documents] a completed download preserves deliberate Search focus instead of restoring the old button', async () => {
const upload = item({ id: 'attachment:9', resourceId: '9', type: 'attachment', name: 'Focus-safe uploaded file.pdf', visibility: null, fileType: 'application/pdf', sourceHref: '/jobs/42?section=documents', downloadHref: '/api/attachments/download/9', publicHref: null });
const pending = deferred<any>(); const bytes = new Blob(['%PDF uploaded bytes'], { type: 'application/pdf' });
const click = jest.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {});
mockedApi.get.mockImplementation((url: string) => url === '/documents' ? Promise.resolve({ data: page([upload]) }) : url === '/attachments/download/9' ? pending.promise : Promise.reject(new Error(`Unexpected read ${url}`)) as never);
renderPage(); await screen.findByText(upload.name);
const download = row(upload.name).getByRole('button', { name: copy('documentsDownloadAttachment') });
fireEvent.click(download);
expect(screen.getByRole('region', { name: copy('documentsYourDocuments') })).toHaveFocus();
const search = screen.getByRole('textbox', { name: copy('documentsSearch') });
act(() => search.focus()); expect(search).toHaveFocus();
await act(async () => pending.resolve({ data: bytes, headers: { 'content-type': 'application/pdf' } }));
await waitFor(() => expect(download).toBeEnabled());
expect(click).toHaveBeenCalledTimes(1);
expect(URL.createObjectURL).toHaveBeenCalledWith(bytes);
expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:uploaded-file');
expect(search).toHaveFocus();
expect(mockedApi.get.mock.calls.map(([url]) => url)).toEqual(['/documents', '/attachments/download/9']);
});
test('[P7-07 documents] download errors retry only that file and late download completion after leaving creates no URL', async () => {
const upload = item({ id: 'attachment:9', resourceId: '9', type: 'attachment', name: 'Private uploaded file.pdf', visibility: null, fileType: 'application/pdf', sourceHref: '/jobs/42?section=documents', downloadHref: '/api/attachments/download/9', publicHref: null });
const pending = deferred<any>(); let attempts = 0;
mockedApi.get.mockImplementation((url: string) => url === '/documents' ? Promise.resolve({ data: page([upload]) }) : url === '/attachments/download/9'
? ++attempts === 1 ? Promise.reject({ response: { status: 403 } }) : pending.promise : Promise.reject(new Error(`Unexpected read ${url}`)) as never);
renderPage(); await screen.findByText(upload.name);
fireEvent.click(row(upload.name).getByRole('button', { name: copy('documentsDownloadAttachment') }));
expect(await row(upload.name).findByRole('alert')).toHaveTextContent(copy('documentsDownloadFailed'));
await language('nb');
const error = row(upload.name).getByRole('alert'); expect(error).toHaveTextContent(copy('documentsDownloadFailed', {}, 'nb'));
const retry = within(error).getByRole('button', { name: copy('retry', {}, 'nb') });
act(() => { fireEvent.click(retry); fireEvent.click(retry); });
expect(attempts).toBe(2); expect(reads()).toHaveLength(1);
fireEvent.click(screen.getByRole('button', { name: 'Leave documents' })); await screen.findByText('Other workflow');
await act(async () => pending.resolve({ data: new Blob(['private file'], { type: 'application/pdf' }) }));
expect(URL.createObjectURL).not.toHaveBeenCalled(); expect(URL.revokeObjectURL).not.toHaveBeenCalled(); expect(reads()).toHaveLength(1);
});
test('[P7-07 documents] a published historical CV retains its percent-encoded Nordic public slug without fetching its body', async () => {
const historical = item({ name: 'Historical published CV', publicHref: '/cv/legacy-%C3%A6%C3%B8%C3%A5%20cv' });
mockedApi.get.mockResolvedValue({ data: page([historical]) } as never);
renderPage(); await screen.findByText(historical.name);
expect(row(historical.name).getByRole('link', { name: copy('documentsOpenPublicCv') })).toHaveAttribute('href', historical.publicHref);
expect(row(historical.name).getByRole('link', { name: copy('documentsOpenSource') })).toHaveAttribute('href', '/career/builder/12');
expect(mockedApi.get.mock.calls.map(([url]) => url)).toEqual(['/documents']);
expect(URL.createObjectURL).not.toHaveBeenCalled();
});
test('[P7-07 documents] malformed external and mismatched resource paths never become source, public or download actions', async () => {
const invalidCv = item({ name: 'Unsafe external CV', sourceHref: 'https://external.test/career/builder/12', publicHref: 'javascript:alert(1)' });
const wrongId = item({ id: 'cv:13', resourceId: '13', name: 'Mismatched CV source', sourceHref: '/career/builder/999', publicHref: '//external.test/cv/other' });
const invalidUpload = item({ id: 'attachment:9', resourceId: '9', type: 'attachment', name: 'Mismatched file', visibility: null, sourceHref: '/jobs/99?section=documents', downloadHref: '/api/attachments/download/999', publicHref: null });
mockedApi.get.mockResolvedValue({ data: page([invalidCv, wrongId, invalidUpload]) } as never);
renderPage(); await screen.findByText(invalidCv.name);
for (const entry of [invalidCv, wrongId, invalidUpload]) {
expect(row(entry.name).queryByRole('link', { name: copy('documentsOpenSource') })).not.toBeInTheDocument();
expect(row(entry.name).queryByRole('link', { name: copy('documentsOpenPublicCv') })).not.toBeInTheDocument();
expect(row(entry.name).queryByRole('button', { name: copy('documentsDownloadAttachment') })).not.toBeInTheDocument();
}
expect(mockedApi.get.mock.calls.map(([url]) => url)).toEqual(['/documents']);
expect(URL.createObjectURL).not.toHaveBeenCalled();
});
+164
View File
@@ -21,6 +21,88 @@ export const translations = {
skipToMainContent: "Skip to main content",
jobSearch: "Job search",
career: "Career",
documentsTitle: "Documents",
goToDocuments: "Go to documents",
documentsPageSubtitle: "Find existing documents across your applications.",
documentsYourDocuments: "Your documents",
documentsIndexHelp: "Open the original workspace to edit a CV, draft or file. Recorded application snapshots keep their captured contents.",
documentsSearch: "Search documents",
documentsType: "Type",
documentsState: "State",
documentsVisibility: "CV visibility",
documentsJobFilter: "Linked job",
documentsJobFilterHelp: "Search your jobs to narrow the list. Existing links to jobs in trash are retained.",
documentsJobNumber: "Job {id}",
documentsNoJobs: "No matching jobs",
documentsJobsLoadFailed: "Could not load job choices.",
documentsOpenJobChoices: "Choose a linked job",
documentsCloseJobChoices: "Close job choices",
documentsClearJob: "Clear linked job filter",
documentsSort: "Sort",
documentsAllTypes: "All types",
documentsAllStates: "All states",
documentsAllVisibility: "All visibility",
documentsTypeCv: "CV",
documentsTypeLetter: "Cover letter",
documentsTypeMaterial: "Application answers and recruiter draft",
documentsTypeTailoredCv: "Tailored CV draft",
documentsTypeAttachment: "Uploaded file",
documentsTypePackage: "Application snapshot",
documentsTypeEmailDraft: "Email draft",
documentsCurrent: "Current",
documentsDraft: "Draft",
documentsFrozen: "Frozen",
documentsPrivate: "Private",
documentsPublic: "Public",
documentsSortDateDesc: "Recorded date: newest first",
documentsSortDateAsc: "Recorded date: oldest first",
documentsSortNameAsc: "Name: A–Z",
documentsSortNameDesc: "Name: Z–A",
documentsName: "Document",
documentsRecordedDate: "Recorded date",
documentsActions: "Actions",
documentsUntitled: "Untitled document",
documentsVersion: "Version {version}",
documentsHistoryVersions: "Saved versions: {count}",
documentsHistoryOnly: "Saved versions only; the current letter is empty.",
documentsAttachmentCount: "Archived files: {count}",
documentsCapturedCv: "Captured CV: {name}, version {version}",
documentsDeletedJob: "Job in trash",
documentsUnlinked: "Not linked to a job",
documentsUpdated: "Updated",
documentsCaptured: "Snapshot recorded",
documentsHistoryRecorded: "Latest saved version",
documentsUploadedLocal: "Uploaded; time zone not recorded",
documentsDateUnknown: "Date not recorded",
documentsDateOrderHelp: "Dates without a recorded time zone and missing dates appear last in either date order.",
documentsFrozenHelp: "A frozen snapshot records application material; it does not confirm delivery to an employer.",
documentsPublicHelp: "Public visibility does not record an application submission.",
documentsEmailHelp: "A saved draft does not confirm that an email was sent. Open Correspondence to find and review the draft.",
documentsCertificateHelp: "An uploaded certificate is not a verified Career Profile qualification.",
documentsFileSize: "{size} KB",
documentsOpenSource: "Open source",
documentsOpenCorrespondence: "Open correspondence",
documentsOpenSubmissionHistory: "Open submission history",
documentsOpenPublicCv: "View published CV",
documentsDownloadAttachment: "Download attachment",
documentsSourceUnavailable: "The original workspace is unavailable. Refresh the list to check this entry.",
documentsLoadFailed: "Could not load documents.",
documentsDownloadFailed: "Could not download this file.",
documentsDownloadUnavailable: "This file is no longer available. Refresh the document list.",
documentsRefresh: "Refresh",
documentsEmpty: "No documents yet",
documentsEmptyHelp: "Saved CVs, application drafts, uploaded files and application snapshots appear here. Create or edit them in their existing workspaces.",
documentsNoMatches: "No matching documents",
documentsNoMatchesHelp: "Try another search or change the filters.",
documentsPageEmpty: "No documents on this page",
documentsPageEmptyHelp: "The list has changed. Open the first page to see available documents.",
documentsResultCount: "Showing {shown} of {total} documents",
documentsPages: "Document pages",
documentsGoToPage: "Go to page {page}",
documentsPreviousPage: "Previous page",
documentsNextPage: "Next page",
documentsFirstPage: "First page",
documentsLastPage: "Last page",
applicationVersion: "Application version {version}",
expandSidebar: "Expand sidebar",
collapseSidebar: "Collapse sidebar",
@@ -2681,6 +2763,88 @@ export const translations = {
skipToMainContent: "Hopp til hovedinnhold",
jobSearch: "Jobbsøk",
career: "Karriere",
documentsTitle: "Dokumenter",
goToDocuments: "Gå til dokumenter",
documentsPageSubtitle: "Finn eksisterende dokumenter på tvers av søknadene dine.",
documentsYourDocuments: "Dine dokumenter",
documentsIndexHelp: "Åpne det opprinnelige arbeidsområdet for å redigere en CV, en kladd eller en fil. Lagrede øyeblikksbilder av søknader beholder innholdet som ble lagret.",
documentsSearch: "Søk i dokumenter",
documentsType: "Type",
documentsState: "Tilstand",
documentsVisibility: "CV-synlighet",
documentsJobFilter: "Tilknyttet stilling",
documentsJobFilterHelp: "Søk blant stillingene dine for å avgrense listen. Eksisterende koblinger til stillinger i papirkurven beholdes.",
documentsJobNumber: "Stilling {id}",
documentsNoJobs: "Ingen samsvarende stillinger",
documentsJobsLoadFailed: "Kunne ikke laste stillingsvalg.",
documentsOpenJobChoices: "Velg en tilknyttet stilling",
documentsCloseJobChoices: "Lukk stillingsvalg",
documentsClearJob: "Fjern stillingsfilter",
documentsSort: "Sortering",
documentsAllTypes: "Alle typer",
documentsAllStates: "Alle tilstander",
documentsAllVisibility: "All synlighet",
documentsTypeCv: "CV",
documentsTypeLetter: "Søknadsbrev",
documentsTypeMaterial: "Søknadssvar og kladd til rekrutterer",
documentsTypeTailoredCv: "Kladd til skreddersydd CV",
documentsTypeAttachment: "Opplastet fil",
documentsTypePackage: "Øyeblikksbilde av søknad",
documentsTypeEmailDraft: "E-postkladd",
documentsCurrent: "Gjeldende",
documentsDraft: "Kladd",
documentsFrozen: "Låst",
documentsPrivate: "Privat",
documentsPublic: "Offentlig",
documentsSortDateDesc: "Registrert dato: nyeste først",
documentsSortDateAsc: "Registrert dato: eldste først",
documentsSortNameAsc: "Navn: A–Å",
documentsSortNameDesc: "Navn: Å–A",
documentsName: "Dokument",
documentsRecordedDate: "Registrert dato",
documentsActions: "Handlinger",
documentsUntitled: "Dokument uten tittel",
documentsVersion: "Versjon {version}",
documentsHistoryVersions: "Lagrede versjoner: {count}",
documentsHistoryOnly: "Kun lagrede versjoner; gjeldende søknadsbrev er tomt.",
documentsAttachmentCount: "Arkiverte filer: {count}",
documentsCapturedCv: "Lagret CV: {name}, versjon {version}",
documentsDeletedJob: "Stillingen er i papirkurven",
documentsUnlinked: "Ikke knyttet til en stilling",
documentsUpdated: "Oppdatert",
documentsCaptured: "Øyeblikksbilde lagret",
documentsHistoryRecorded: "Siste lagrede versjon",
documentsUploadedLocal: "Lastet opp; tidssone er ikke registrert",
documentsDateUnknown: "Dato er ikke registrert",
documentsDateOrderHelp: "Datoer uten registrert tidssone og manglende datoer vises sist i begge datosorteringer.",
documentsFrozenHelp: "Et låst øyeblikksbilde lagrer søknadsmateriale; det bekrefter ikke levering til en arbeidsgiver.",
documentsPublicHelp: "Offentlig synlighet registrerer ikke en innsendt søknad.",
documentsEmailHelp: "En lagret kladd bekrefter ikke at en e-post er sendt. Åpne korrespondanse for å finne og gjennomgå kladden.",
documentsCertificateHelp: "Et opplastet sertifikat er ikke en bekreftet kvalifikasjon i karriereprofilen.",
documentsFileSize: "{size} KB",
documentsOpenSource: "Åpne opprinnelig arbeidsområde",
documentsOpenCorrespondence: "Åpne korrespondanse",
documentsOpenSubmissionHistory: "Åpne innsendingshistorikk",
documentsOpenPublicCv: "Se publisert CV",
documentsDownloadAttachment: "Last ned vedlegg",
documentsSourceUnavailable: "Det opprinnelige arbeidsområdet er utilgjengelig. Oppdater listen for å kontrollere oppføringen.",
documentsLoadFailed: "Kunne ikke laste dokumenter.",
documentsDownloadFailed: "Kunne ikke laste ned denne filen.",
documentsDownloadUnavailable: "Denne filen er ikke lenger tilgjengelig. Oppdater dokumentlisten.",
documentsRefresh: "Oppdater",
documentsEmpty: "Ingen dokumenter ennå",
documentsEmptyHelp: "Lagrede CV-er, søknadskladder, opplastede filer og øyeblikksbilder av søknader vises her. Opprett eller rediger dem i de eksisterende arbeidsområdene.",
documentsNoMatches: "Ingen samsvarende dokumenter",
documentsNoMatchesHelp: "Prøv et annet søk eller endre filtrene.",
documentsPageEmpty: "Ingen dokumenter på denne siden",
documentsPageEmptyHelp: "Listen er endret. Åpne den første siden for å se tilgjengelige dokumenter.",
documentsResultCount: "Viser {shown} av {total} dokumenter",
documentsPages: "Dokumentsider",
documentsGoToPage: "Gå til side {page}",
documentsPreviousPage: "Forrige side",
documentsNextPage: "Neste side",
documentsFirstPage: "Første side",
documentsLastPage: "Siste side",
applicationVersion: "Applikasjonsversjon {version}",
expandSidebar: "Utvid sidemenyen",
collapseSidebar: "Skjul sidemenyen",
@@ -3,6 +3,7 @@ import "@testing-library/jest-dom";
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { api } from "./api";
import { I18nProvider } from "./i18n/I18nProvider";
import { translations } from "./i18n/translations";
import QuickCommandDialog from "./components/QuickCommandDialog";
const mockedApi = api as jest.Mocked<typeof api>;
@@ -48,7 +49,7 @@ test("[PRES-E07 PRES-J04] the capture command opens review without creating a jo
test("[P5-00 PRES-E07] command and empty results use valid list semantics", async () => {
render(<I18nProvider><QuickCommandDialog open onClose={jest.fn()} onNavigate={jest.fn()} onOpenAddJob={jest.fn()} /></I18nProvider>);
expect(screen.getAllByRole("listitem")).toHaveLength(6);
expect(screen.getAllByRole("listitem")).toHaveLength(7);
expect(screen.getAllByRole("list").every(list => Array.from(list.children).every(item => item.tagName === "LI"))).toBe(true);
fireEvent.change(screen.getByRole("textbox"), { target: { value: "no-such-result" } });
mockedApi.get.mockResolvedValue({ data: [] } as never);
@@ -125,3 +126,19 @@ test("[P5-02 PRES-E07] arrowing from a focused result keeps focus and activation
fireEvent.click(jobs);
expect(onNavigate).toHaveBeenCalledWith("/jobs");
});
test("[P7-07 PRES-E07] the Documents command is localized and opens its owner index without fetching bodies", async () => {
const onNavigate = jest.fn();
const onClose = jest.fn();
render(<I18nProvider><QuickCommandDialog open onClose={onClose} onNavigate={onNavigate} onOpenAddJob={jest.fn()} /></I18nProvider>);
const english = (translations.en as Record<string, string>).goToDocuments;
expect(screen.getByRole("button", { name: name => name.includes(english) })).toBeVisible();
await act(async () => { window.dispatchEvent(new StorageEvent("storage", { key: "uiLanguage", newValue: "nb" })); });
const bokmal = (translations.nb as Record<string, string>).goToDocuments;
fireEvent.click(screen.getByRole("button", { name: name => name.includes(bokmal) }));
expect(onNavigate).toHaveBeenCalledTimes(1);
expect(onNavigate).toHaveBeenCalledWith("/documents");
expect(onClose).toHaveBeenCalledTimes(1);
expect(mockedApi.get).not.toHaveBeenCalled();
expect(mockedApi.post).not.toHaveBeenCalled();
});
+299
View File
@@ -0,0 +1,299 @@
import React, { useCallback, useEffect, useId, useMemo, useRef, useState } from "react";
import { Link as RouterLink, useLocation, useNavigate, useSearchParams } from "react-router-dom";
import { Alert, Autocomplete, Box, Button, Chip, FormControl, InputLabel, MenuItem, Pagination, Paper, Select, Stack, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, TextField, Typography, useMediaQuery, useTheme } from "@mui/material";
import RefreshIcon from "@mui/icons-material/Refresh";
import { api } from "../api";
import ViewStateNotice from "../components/ViewStateNotice";
import type { ViewResourceError } from "../hooks/useViewResource";
import { useI18n } from "../i18n/I18nProvider";
import type { TranslationKey } from "../i18n/translations";
import { PageHeader } from "../layout/PageLayout";
type DocumentItem = {
id: string; resourceId: string; type: string; name: string; state: string; visibility: string | null;
jobApplicationId: number | null; jobTitle: string | null; companyName: string | null; jobIsDeleted: boolean | null;
recordedAt: string | null; dateKind: string; version: number | null; historyCount: number | null;
attachmentCount: number | null; cvVariantId: number | null; cvVariantName: string | null; cvVariantVersion: number | null;
fileType: string | null; fileSize: number | null; purpose: string | null; useForAi: boolean | null;
provider: string | null; sourceHref: string; downloadHref: string | null; publicHref: string | null;
hasCurrentContent: boolean | null;
};
type DocumentsResult = { items: DocumentItem[]; page: number; pageSize: number; total: number; totalPages: number };
type JobChoice = { id: number; jobTitle: string; companyName: string };
type DownloadState = { id: string; busy: boolean; status?: number };
const types: Array<[string, TranslationKey]> = [["all", "documentsAllTypes"], ["cv", "documentsTypeCv"], ["cover-letter", "documentsTypeLetter"], ["application-material", "documentsTypeMaterial"], ["tailored-cv", "documentsTypeTailoredCv"], ["attachment", "documentsTypeAttachment"], ["submitted-package", "documentsTypePackage"], ["email-draft", "documentsTypeEmailDraft"]];
const states: Array<[string, TranslationKey]> = [["all", "documentsAllStates"], ["current", "documentsCurrent"], ["draft", "documentsDraft"], ["frozen", "documentsFrozen"]];
const visibilityOptions: Array<[string, TranslationKey]> = [["all", "documentsAllVisibility"], ["private", "documentsPrivate"], ["public", "documentsPublic"]];
const sorts: Array<[string, TranslationKey]> = [["date-desc", "documentsSortDateDesc"], ["date-asc", "documentsSortDateAsc"], ["name-asc", "documentsSortNameAsc"], ["name-desc", "documentsSortNameDesc"]];
const option = (value: string | null, choices: Array<[string, TranslationKey]>, fallback = "all") => choices.some(([key]) => key === value) ? value! : fallback;
const positiveInteger = (value: string | null, maximum = Number.MAX_SAFE_INTEGER) => value && /^\d+$/.test(value) && Number.isSafeInteger(Number(value)) && Number(value) > 0 && Number(value) <= maximum ? Number(value) : undefined;
const errorState = (error: any): ViewResourceError => ({ kind: [401, 403].includes(error?.response?.status) ? "unauthorized" : "error", status: error?.response?.status, message: "", retryable: ![401, 403].includes(error?.response?.status) });
function sourcePath(item: DocumentItem) {
if (item.id !== `${item.type}:${item.resourceId}`) return null;
if (item.type === "email-draft") return /^[\da-f]{8}(-[\da-f]{4}){3}-[\da-f]{12}$/i.test(item.resourceId) && item.sourceHref === "/correspondence" ? item.sourceHref : null;
if (!/^[1-9]\d*$/.test(item.resourceId)) return null;
const job = item.jobApplicationId;
const jobPath = Number.isSafeInteger(job) && Number(job) > 0 ? `/jobs/${job}` : null;
const expected = item.type === "cv" ? `/career/builder/${item.resourceId}`
: item.type === "attachment" ? jobPath && `${jobPath}?section=documents`
: item.type === "cover-letter" ? jobPath && `${jobPath}?section=application&sub=letter`
: item.type === "application-material" ? jobPath && `${jobPath}?section=application&sub=answers`
: item.type === "tailored-cv" ? jobPath && `${jobPath}?section=application&sub=cv`
: item.type === "submitted-package" ? jobPath && `${jobPath}?section=application&sub=submission` : null;
return expected && item.sourceHref === expected ? expected : null;
}
function publicPath(item: DocumentItem) {
if (!sourcePath(item) || item.type !== "cv" || item.visibility !== "public" || !/^\/cv\/(?:[\w.~\-]|%[\da-f]{2})+$/i.test(item.publicHref ?? "")) return null;
try {
const slug = decodeURIComponent(item.publicHref!.slice(4));
return slug && slug !== "." && slug !== ".." ? item.publicHref : null;
} catch { return null; }
}
export default function DocumentsPage() {
const { language, t } = useI18n();
const location = useLocation();
const navigate = useNavigate();
const [params] = useSearchParams();
const theme = useTheme();
const desktop = useMediaQuery(theme.breakpoints.up("md"));
const labelId = useId();
const q = (params.get("q") ?? "").slice(0, 200);
const search = q.trim();
const type = option(params.get("type"), types);
const state = option(params.get("state"), states);
const visibility = option(params.get("visibility"), visibilityOptions);
const sort = option(params.get("sort"), sorts, "date-desc");
const page = positiveInteger(params.get("page"), 10000) ?? 1;
// React Compiler needs a primitive dependency after the shared ID validator.
const jobApplicationId = Number(positiveInteger(params.get("jobApplicationId"), 2147483647)) || undefined;
const key = JSON.stringify([search, type, state, visibility, sort, page, jobApplicationId]);
const currentKey = useRef(key);
currentKey.current = key;
const [result, setResult] = useState<DocumentsResult | null>(null);
const [loadedKey, setLoadedKey] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<ViewResourceError | null>(null);
const request = useRef(0);
const pendingKey = useRef<string | null>(null);
const listController = useRef<AbortController | null>(null);
const mounted = useRef(true);
const resultsRef = useRef<HTMLDivElement>(null);
const [downloadState, setDownloadState] = useState<DownloadState | null>(null);
const downloadController = useRef<AbortController | null>(null);
const downloadFocus = useRef<{ button: HTMLButtonElement; key: string } | null>(null);
const [jobsOpen, setJobsOpen] = useState(false);
const [jobQuery, setJobQuery] = useState("");
const [jobs, setJobs] = useState<JobChoice[]>([]);
const [jobsKey, setJobsKey] = useState<string | null>(null);
const [jobsLoading, setJobsLoading] = useState(false);
const [jobsError, setJobsError] = useState<ViewResourceError | null>(null);
const jobsRequest = useRef(0);
const jobsPending = useRef<string | null>(null);
const queryEdits = useRef({ location, params });
const updateQuery = (changes: Record<string, string>, replace = false) => {
const next = new URLSearchParams(queryEdits.current.location === location ? queryEdits.current.params : params);
for (const [name, value] of Object.entries(changes)) {
if (!value || (value === "all" && ["type", "state", "visibility"].includes(name)) || (name === "page" && value === "1")) next.delete(name);
else next.set(name, value);
}
queryEdits.current = { location, params: next };
navigate({ pathname: location.pathname, search: next.size ? `?${next}` : "", hash: location.hash }, { state: location.state, replace });
};
const load = useCallback(async () => {
if (!mounted.current || pendingKey.current === key) return;
const sequence = ++request.current;
pendingKey.current = key;
listController.current?.abort();
const controller = new AbortController();
listController.current = controller;
setLoading(true);
setError(null);
try {
const response = await api.get<DocumentsResult>("/documents", { params: { page, pageSize: 25, q: search || undefined, type, state, visibility, sort, ...(jobApplicationId ? { jobApplicationId } : {}) }, signal: controller.signal });
if (!mounted.current || sequence !== request.current || key !== currentKey.current) return;
setResult(response.data);
setLoadedKey(key);
} catch (failure) {
if (mounted.current && sequence === request.current && !controller.signal.aborted) setError(errorState(failure));
} finally {
if (mounted.current && sequence === request.current) { pendingKey.current = null; setLoading(false); }
}
}, [jobApplicationId, key, page, search, sort, state, type, visibility]);
useEffect(() => {
mounted.current = true;
return () => { mounted.current = false; request.current += 1; jobsRequest.current += 1; listController.current?.abort(); downloadController.current?.abort(); };
}, []);
useEffect(() => {
setDownloadState(null);
downloadController.current?.abort();
downloadController.current = null;
void load();
return () => { request.current += 1; pendingKey.current = null; listController.current?.abort(); };
}, [load]);
useEffect(() => {
const target = downloadFocus.current;
if (downloadState?.busy || !target) return;
downloadFocus.current = null;
if (document.activeElement === resultsRef.current && target.key === currentKey.current && target.button.isConnected && !target.button.disabled) target.button.focus();
}, [downloadState]);
const loadJobs = useCallback(async () => {
const search = jobQuery.trim();
if (!mounted.current || jobsPending.current === search) return;
const sequence = ++jobsRequest.current;
jobsPending.current = search;
setJobsLoading(true);
setJobsError(null);
try {
const response = await api.get<JobChoice[]>("/jobapplications/choices", { params: { q: search || undefined, limit: 20 } });
if (mounted.current && sequence === jobsRequest.current) { setJobs(response.data); setJobsKey(search); }
} catch (failure) {
if (mounted.current && sequence === jobsRequest.current) setJobsError(errorState(failure));
} finally {
if (sequence === jobsRequest.current) { jobsPending.current = null; if (mounted.current) setJobsLoading(false); }
}
}, [jobQuery]);
useEffect(() => {
if (!jobsOpen) return;
const timer = window.setTimeout(() => void loadJobs(), jobQuery.trim() ? 250 : 0);
return () => { window.clearTimeout(timer); jobsRequest.current += 1; jobsPending.current = null; };
}, [jobQuery, jobsOpen, loadJobs]);
const refresh = () => { resultsRef.current?.focus(); setDownloadState(null); void load(); };
const download = async (item: DocumentItem, button?: HTMLButtonElement) => {
if (!sourcePath(item) || item.type !== "attachment" || item.downloadHref !== `/api/attachments/download/${item.resourceId}` || downloadController.current) return;
if (button) downloadFocus.current = { button, key };
resultsRef.current?.focus();
const capturedKey = key;
const controller = new AbortController();
downloadController.current = controller;
setDownloadState({ id: item.id, busy: true });
try {
const response = await api.get<Blob>(`/attachments/download/${item.resourceId}`, { responseType: "blob", signal: controller.signal });
if (!mounted.current || controller.signal.aborted || capturedKey !== currentKey.current) return;
const blob = response.data instanceof Blob ? response.data : new Blob([response.data], { type: item.fileType ?? "application/octet-stream" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
try {
link.href = url;
link.download = item.name;
document.body.appendChild(link);
link.click();
} finally { link.remove(); URL.revokeObjectURL(url); }
setDownloadState(null);
} catch (failure: any) {
if (mounted.current && !controller.signal.aborted && capturedKey === currentKey.current) setDownloadState({ id: item.id, busy: false, status: failure?.response?.status ?? 0 });
} finally {
if (downloadController.current === controller) downloadController.current = null;
}
};
const current = loadedKey === key && !loading && !error;
const items = current ? result?.items ?? [] : [];
const filtered = !!q.trim() || type !== "all" || state !== "all" || visibility !== "all" || !!jobApplicationId;
const locale = language === "nb" ? "nb-NO" : "en-GB";
const selectedJob = useMemo(() => {
if (!jobApplicationId) return null;
const choice = jobs.find((job) => job.id === jobApplicationId);
if (choice) return choice;
const item = result?.items.find((document) => document.jobApplicationId === jobApplicationId);
return { id: jobApplicationId, jobTitle: item?.jobTitle ?? t("documentsJobNumber", { id: jobApplicationId }), companyName: item?.companyName ?? "" };
}, [jobApplicationId, jobs, result, t]);
const from = { from: `${location.pathname}${location.search}${location.hash}`, fromState: location.state };
const buttonSx = { whiteSpace: "normal", overflowWrap: "anywhere", maxWidth: "100%", minHeight: 40 } as const;
const metadataSx = { overflowWrap: "anywhere", minWidth: 0 } as const;
const date = (item: DocumentItem) => {
const label: TranslationKey = item.dateKind === "uploaded-local" ? "documentsUploadedLocal" : item.dateKind === "captured-utc" ? "documentsCaptured" : item.dateKind === "history-recorded-utc" ? "documentsHistoryRecorded" : "documentsUpdated";
let value: Date | null = null;
if (item.recordedAt && item.dateKind === "uploaded-local") {
const local = /^(\d{4})-(\d\d)-(\d\d)T(\d\d):(\d\d):(\d\d)/.exec(item.recordedAt);
// UTC is only a formatting carrier for the unzoned wall-clock components.
if (local) value = new Date(Date.UTC(+local[1], +local[2] - 1, +local[3], +local[4], +local[5], +local[6]));
} else if (item.recordedAt && ["updated-utc", "captured-utc", "history-recorded-utc"].includes(item.dateKind)) value = new Date(item.recordedAt);
if (!value || Number.isNaN(value.valueOf())) return <Typography variant="body2" color="text.secondary">{t("documentsDateUnknown")}</Typography>;
return <Box><Typography variant="caption" color="text.secondary">{t(label)}</Typography><Typography variant="body2" component="time" dateTime={item.recordedAt!} sx={{ display: "block" }}>{value.toLocaleString(locale, { dateStyle: "medium", timeStyle: "short", ...(item.dateKind === "uploaded-local" ? { timeZone: "UTC" } : {}) })}</Typography></Box>;
};
const badges = (item: DocumentItem) => <Stack direction="row" useFlexGap flexWrap="wrap" gap={0.75} sx={{ "& .MuiChip-root": { maxWidth: "100%", height: "auto", minHeight: 24 }, "& .MuiChip-label": { whiteSpace: "normal", overflowWrap: "anywhere", py: 0.25 } }}>
<Chip size="small" variant="outlined" label={t(states.find(([value]) => value === item.state)?.[1] ?? "documentsCurrent")} />
{item.type === "cv" && item.visibility ? <Chip size="small" variant="outlined" label={t(item.visibility === "public" ? "documentsPublic" : "documentsPrivate")} /> : null}
{item.jobIsDeleted ? <Chip size="small" variant="outlined" label={t("documentsDeletedJob")} /> : null}
</Stack>;
const details = (item: DocumentItem) => <Stack spacing={0.5} sx={metadataSx}>
<Typography component={desktop ? "span" : "h3"} variant="body1" sx={{ fontWeight: 700, overflowWrap: "anywhere" }}>{item.name || t("documentsUntitled")}</Typography>
<Box><Typography component="span" variant="body2" color="text.secondary">{t(types.find(([value]) => value === item.type)?.[1] ?? "documentsTypeAttachment")}</Typography>{item.version != null ? <Typography component="span" variant="body2" color="text.secondary">{` · ${t("documentsVersion", { version: item.version })}`}</Typography> : null}</Box>
{item.type === "cv" && item.visibility === "public" ? <Typography variant="caption" color="text.secondary">{t("documentsPublicHelp")}</Typography> : null}
{item.type === "cover-letter" && item.hasCurrentContent === false ? <Typography variant="body2">{t("documentsHistoryOnly")}</Typography> : null}
{item.historyCount != null && item.historyCount > 0 ? <Typography variant="body2" color="text.secondary">{t("documentsHistoryVersions", { count: item.historyCount })}</Typography> : null}
{item.type === "submitted-package" ? <>
{item.cvVariantName && item.cvVariantVersion != null ? <Typography variant="body2">{t("documentsCapturedCv", { name: item.cvVariantName, version: item.cvVariantVersion })}</Typography> : null}
{item.attachmentCount != null ? <Typography variant="body2" color="text.secondary">{t("documentsAttachmentCount", { count: item.attachmentCount })}</Typography> : null}
<Typography variant="caption" color="text.secondary">{t("documentsFrozenHelp")}</Typography>
</> : null}
{item.type === "email-draft" ? <Typography variant="caption" color="text.secondary">{t("documentsEmailHelp")}</Typography> : null}
{item.type === "attachment" ? <>
<Typography variant="caption" color="text.secondary">{[item.fileType, item.fileSize != null ? t("documentsFileSize", { size: new Intl.NumberFormat(locale, { maximumFractionDigits: 1 }).format(item.fileSize / 1024) }) : null].filter(Boolean).join(" · ")}</Typography>
{item.purpose ? <Typography variant="caption" color="text.secondary">{t("attachmentsPurpose")}: {t(({ resume: "attachmentsPurposeResume", "cover-letter": "attachmentsPurposeCoverLetter", portfolio: "attachmentsPurposePortfolio", "case-study": "attachmentsPurposeCaseStudy", certificate: "attachmentsPurposeCertificate" } as Record<string, TranslationKey>)[item.purpose.toLowerCase()] ?? "attachmentsPurposeOther")}</Typography> : null}
{item.purpose?.toLowerCase() === "certificate" ? <Typography variant="caption" color="text.secondary">{t("documentsCertificateHelp")}</Typography> : null}
{item.useForAi != null ? <Typography variant="caption" color="text.secondary">{t("attachmentsAiUse")}: {t(item.useForAi ? "attachmentsAiEnabled" : "attachmentsAiDisabled")}</Typography> : null}
</> : null}
</Stack>;
const job = (item: DocumentItem) => <Typography variant="body2" sx={metadataSx}>{item.jobTitle ? [item.jobTitle, item.companyName].filter(Boolean).join(" · ") : t("documentsUnlinked")}</Typography>;
const actions = (item: DocumentItem) => {
const source = sourcePath(item);
const ownDownload = source && item.type === "attachment" && item.downloadHref === `/api/attachments/download/${item.resourceId}`;
const publicLink = publicPath(item);
const failed = downloadState?.id === item.id && !downloadState.busy && downloadState.status !== undefined;
return <Stack spacing={1} alignItems="flex-start" sx={{ minWidth: 0 }}>
<Stack direction="row" useFlexGap flexWrap="wrap" gap={0.75}>
{source ? <Button component={RouterLink} to={source} state={from} variant="outlined" size="small" sx={buttonSx}>{t(item.type === "email-draft" ? "documentsOpenCorrespondence" : item.type === "submitted-package" ? "documentsOpenSubmissionHistory" : "documentsOpenSource")}</Button> : <Typography variant="body2" color="text.secondary">{t("documentsSourceUnavailable")}</Typography>}
{publicLink ? <Button component={RouterLink} to={publicLink} state={from} size="small" sx={buttonSx}>{t("documentsOpenPublicCv")}</Button> : null}
{ownDownload ? <Button size="small" onClick={(event) => void download(item, event.currentTarget)} disabled={!!downloadState?.busy} sx={buttonSx}>{t(downloadState?.id === item.id && downloadState.busy ? "loading" : "documentsDownloadAttachment")}</Button> : null}
</Stack>
{failed ? <Alert severity="error" sx={{ maxWidth: "100%", overflowWrap: "anywhere", display: "grid", gridTemplateColumns: "auto minmax(0, 1fr)", "& .MuiAlert-message": { minWidth: 0 }, "& .MuiAlert-action": { gridColumn: 2, m: 0, p: 0, justifySelf: "start" } }} action={<Button color="inherit" size="small" sx={buttonSx} onClick={(event) => downloadState?.status === 404 ? refresh() : void download(item, event.currentTarget)}>{t(downloadState?.status === 404 ? "documentsRefresh" : "retry")}</Button>}>{t(downloadState?.status === 404 ? "documentsDownloadUnavailable" : "documentsDownloadFailed")}</Alert> : null}
</Stack>;
};
const filter = (name: string, title: TranslationKey, value: string, options: Array<[string, TranslationKey]>) => <FormControl fullWidth size="small">
<InputLabel id={`${labelId}-${name}`}>{t(title)}</InputLabel>
<Select labelId={`${labelId}-${name}`} label={t(title)} value={value} onChange={(event) => updateQuery({ [name]: event.target.value, page: "1" })}>{options.map(([id, label]) => <MenuItem key={id} value={id}>{t(label)}</MenuItem>)}</Select>
</FormControl>;
return <Stack spacing={2} data-content-density="comfortable" sx={{ minWidth: 0 }}>
<PageHeader level="h2" title={t("documentsYourDocuments")} description={t("documentsIndexHelp")} actions={<Button variant="outlined" startIcon={<RefreshIcon />} onClick={refresh} disabled={loading} sx={buttonSx}>{t("documentsRefresh")}</Button>} />
<Stack spacing={2}>
<TextField fullWidth size="small" label={t("documentsSearch")} value={q} onChange={(event) => updateQuery({ q: event.target.value, page: "1" }, true)} slotProps={{ htmlInput: { maxLength: 200 } }} />
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "minmax(0, 1fr)", sm: "repeat(2, minmax(0, 1fr))", lg: "repeat(3, minmax(0, 1fr))" }, gap: 2, minWidth: 0 }}>
{filter("type", "documentsType", type, types)}
{filter("state", "documentsState", state, states)}
{filter("visibility", "documentsVisibility", visibility, visibilityOptions)}
<Autocomplete size="small" options={jobsKey === jobQuery.trim() && !jobsError ? jobs : []} value={selectedJob} loading={jobsLoading} filterOptions={(choices) => choices} isOptionEqualToValue={(a, b) => a.id === b.id} getOptionLabel={(choice) => [choice.jobTitle, choice.companyName].filter(Boolean).join(" · ")} onOpen={() => setJobsOpen(true)} onClose={() => setJobsOpen(false)} onInputChange={(_, value, reason) => { if (reason === "input") setJobQuery(value); else if (reason === "reset") setJobQuery(""); }} onChange={(_, choice) => updateQuery({ jobApplicationId: choice ? String(choice.id) : "", page: "1" })} noOptionsText={t(jobsError ? "documentsJobsLoadFailed" : "documentsNoJobs")} loadingText={t("loading")} openText={t("documentsOpenJobChoices")} closeText={t("documentsCloseJobChoices")} clearText={t("documentsClearJob")} renderInput={(input) => <TextField {...input} label={t("documentsJobFilter")} helperText={t("documentsJobFilterHelp")} />} />
{filter("sort", "documentsSort", sort, sorts)}
</Box>
{jobsError ? <ViewStateNotice compact title={t("documentsJobsLoadFailed")} error={{ ...jobsError, message: t("documentsJobsLoadFailed") }} onRetry={() => { resultsRef.current?.focus(); return loadJobs(); }} /> : null}
<Typography variant="caption" color="text.secondary">{t("documentsDateOrderHelp")}</Typography>
</Stack>
<Box ref={resultsRef} role="region" aria-label={t("documentsYourDocuments")} tabIndex={-1} aria-busy={loading || !current && !error} sx={{ minWidth: 0 }}>
<ViewStateNotice compact loading={loading} title={t("documentsLoadFailed")} error={error ? { ...error, message: t("documentsLoadFailed") } : null} onRetry={refresh} />
{current && result ? <>
<Typography role="status" variant="body2" color="text.secondary" sx={{ mb: 2 }}>{t("documentsResultCount", { shown: items.length, total: result.total })}</Typography>
{items.length === 0 ? <Paper variant="outlined" sx={{ p: "var(--app-card-padding)", borderRadius: 1 }}>
<Typography component="h3" variant="sectionTitle">{t(result.total > 0 ? "documentsPageEmpty" : filtered ? "documentsNoMatches" : "documentsEmpty")}</Typography>
<Typography color="text.secondary" sx={{ mt: 1 }}>{t(result.total > 0 ? "documentsPageEmptyHelp" : filtered ? "documentsNoMatchesHelp" : "documentsEmptyHelp")}</Typography>
{result.total > 0 ? <Button variant="outlined" sx={{ ...buttonSx, mt: 2 }} onClick={() => { resultsRef.current?.focus(); updateQuery({ page: "1" }); }}>{t("documentsFirstPage")}</Button> : null}
</Paper> : desktop ?
<TableContainer component={Paper} variant="outlined" sx={{ borderRadius: 1 }}><Table size="small" aria-label={t("documentsYourDocuments")} sx={{ tableLayout: "fixed", "& th, & td": { verticalAlign: "top", overflowWrap: "anywhere" } }}>
<TableHead><TableRow><TableCell sx={{ width: "30%" }}>{t("documentsName")}</TableCell><TableCell sx={{ width: "17%" }}>{t("documentsJobFilter")}</TableCell><TableCell sx={{ width: "15%" }}>{t("documentsState")}</TableCell><TableCell sx={{ width: "18%" }}>{t("documentsRecordedDate")}</TableCell><TableCell sx={{ width: "20%" }}>{t("documentsActions")}</TableCell></TableRow></TableHead>
<TableBody>{items.map((item) => <TableRow key={item.id}><TableCell component="th" scope="row">{details(item)}</TableCell><TableCell>{job(item)}</TableCell><TableCell>{badges(item)}</TableCell><TableCell>{date(item)}</TableCell><TableCell>{actions(item)}</TableCell></TableRow>)}</TableBody>
</Table></TableContainer> : <Stack spacing={2}>{items.map((item) => <Paper key={item.id} component="article" aria-label={item.name} variant="outlined" sx={{ p: "var(--app-card-padding)", borderRadius: 1, minWidth: 0 }}><Stack spacing={1.5}>{details(item)}{job(item)}{badges(item)}{date(item)}{actions(item)}</Stack></Paper>)}</Stack>}
{result.totalPages > 1 ? <Pagination page={page} count={Math.min(result.totalPages, 10000)} onChange={(_, next) => { resultsRef.current?.focus(); updateQuery({ page: String(next) }); }} aria-label={t("documentsPages")} getItemAriaLabel={(kind, target) => t(kind === "page" ? "documentsGoToPage" : kind === "previous" ? "documentsPreviousPage" : kind === "next" ? "documentsNextPage" : kind === "first" ? "documentsFirstPage" : "documentsLastPage", { page: target ?? 1 })} sx={{ mt: 2, "& .MuiPagination-ul": { flexWrap: "wrap", gap: 0.5 } }} /> : null}
</> : null}
</Box>
</Stack>;
}