feat(documents): add owner-scoped document workspace
This commit is contained in:
@@ -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 },
|
||||
];
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
Reference in New Issue
Block a user