feat(ui): migrate CV library actions and recovery

This commit is contained in:
cesnimda
2026-10-12 20:03:38 +02:00
parent 5680c0031f
commit fcacae7384
13 changed files with 4289 additions and 85 deletions
@@ -12,9 +12,10 @@ type Props = {
onSelect: (theme: CvTheme) => void;
previewHeight?: number;
showDescriptions?: boolean;
disabled?: boolean;
};
export default function CvTemplateGallery({ themes, selectedId, onSelect, previewHeight = 170, showDescriptions = true }: Props) {
export default function CvTemplateGallery({ themes, selectedId, onSelect, previewHeight = 170, showDescriptions = true, disabled = false }: Props) {
const { t } = useI18n();
const id = useId();
const [atsOnly, setAtsOnly] = useState(true);
@@ -23,19 +24,20 @@ export default function CvTemplateGallery({ themes, selectedId, onSelect, previe
return (
<Stack spacing={1.25}>
<Stack direction="row" spacing={0.75} alignItems="center" flexWrap="wrap" useFlexGap>
<Button size="small" aria-pressed={atsOnly} variant={atsOnly ? "contained" : "outlined"} onClick={() => setAtsOnly(true)}>{t("cvEditorAtsTemplates")}</Button>
<Button size="small" aria-pressed={!atsOnly} variant={!atsOnly ? "contained" : "outlined"} onClick={() => setAtsOnly(false)}>{t("cvEditorAllTemplates")}</Button>
<Button size="small" disabled={disabled} aria-pressed={atsOnly} variant={atsOnly ? "contained" : "outlined"} onClick={() => setAtsOnly(true)}>{t("cvEditorAtsTemplates")}</Button>
<Button size="small" disabled={disabled} aria-pressed={!atsOnly} variant={!atsOnly ? "contained" : "outlined"} onClick={() => setAtsOnly(false)}>{t("cvEditorAllTemplates")}</Button>
{atsOnly && <Typography variant="caption" color="text.secondary">{t("cvEditorAtsTemplateHelp")}</Typography>}
</Stack>
<Box sx={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(150px, 1fr))", gap: 1.25 }}>
{visibleThemes.map((theme) => {
const active = theme.id === selectedId;
const locked = theme.available === false;
const unavailable = locked || disabled;
return (
<Paper key={theme.id} variant="outlined" role="button" tabIndex={locked ? -1 : 0} aria-label={theme.name} aria-describedby={`${id}-${theme.id}-category ${showDescriptions ? `${id}-${theme.id}-description ` : ""}${id}-${theme.id}-features`} aria-disabled={locked} aria-pressed={active}
onClick={() => { if (!locked) onSelect(theme); }}
onKeyDown={(event) => { if (!locked && (event.key === "Enter" || event.key === " ")) { event.preventDefault(); onSelect(theme); } }}
sx={{ p: 1, cursor: locked ? "not-allowed" : "pointer", position: "relative", borderColor: active ? "primary.main" : "divider", borderWidth: active ? 2 : 1, "&:hover": locked ? undefined : { borderColor: "primary.main" } }}>
<Paper key={theme.id} variant="outlined" role="button" tabIndex={unavailable ? -1 : 0} aria-label={theme.name} aria-describedby={`${id}-${theme.id}-category ${showDescriptions ? `${id}-${theme.id}-description ` : ""}${id}-${theme.id}-features`} aria-disabled={unavailable} aria-pressed={active}
onClick={() => { if (!unavailable) onSelect(theme); }}
onKeyDown={(event) => { if (!unavailable && (event.key === "Enter" || event.key === " ")) { event.preventDefault(); onSelect(theme); } }}
sx={{ p: 1, cursor: unavailable ? "not-allowed" : "pointer", position: "relative", minWidth: 0, overflowWrap: "anywhere", borderColor: active ? "primary.main" : "divider", borderWidth: active ? 2 : 1, "&:hover": unavailable ? undefined : { borderColor: "primary.main" } }}>
{active && <CheckCircleIcon aria-label={t("cvEditorSelectedTemplate")} color="primary" sx={{ position: "absolute", zIndex: 2, top: 6, right: 6, bgcolor: "background.paper", borderRadius: "50%" }} />}
<Box sx={{ mb: 1 }}><CvTemplateThumbnail theme={theme} height={previewHeight} /></Box>
<Typography variant="body2" sx={{ fontWeight: 750 }}>{theme.name}</Typography>
+288 -4
View File
@@ -1,6 +1,6 @@
import React from 'react';
import '@testing-library/jest-dom';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import CvBuilderPage from './views/CvBuilderPage';
@@ -9,6 +9,7 @@ import { ToastProvider } from './toast';
import { api } from './api';
import { ConfirmProvider } from './confirm';
import { PromptProvider } from './prompt';
import { emptyCvVariantSettings } from './cvBuilder';
const mockNavigate = jest.fn();
jest.mock('react-router-dom', () => ({
@@ -30,13 +31,14 @@ jest.mock('./api', () => ({
const mockedApi = api as jest.Mocked<typeof api>;
function renderPage() {
function renderPage(extra?: React.ReactNode) {
return render(
<MemoryRouter>
<ToastProvider>
<I18nProvider>
<ConfirmProvider>
<PromptProvider>
{extra}
<CvBuilderPage />
</PromptProvider>
</ConfirmProvider>
@@ -48,11 +50,293 @@ function renderPage() {
beforeEach(() => {
window.localStorage.removeItem('uiLanguage');
jest.clearAllMocks();
jest.resetAllMocks();
mockedApi.get.mockImplementation(async (url) => ({ data: url === '/cv/themes' ? themes : [] }) as any);
});
afterEach(() => window.localStorage.removeItem('uiLanguage'));
const themes = [
{ id: 'modern', name: 'Modern', category: 'Professional', description: '', layout: 'header-band', accent: '#3157d5', photoShape: 'none', supportsIcons: false, atsFriendly: true, requiresPro: false, available: true, swatches: ['#3157d5'] },
{ id: 'clean', name: 'Clean', category: 'Professional', description: '', layout: 'single-column', accent: '#444444', photoShape: 'none', supportsIcons: false, atsFriendly: true, requiresPro: false, available: true, swatches: ['#444444'] },
{ id: 'premium', name: 'Premium', category: 'Professional', description: '', layout: 'sidebar', accent: '#333333', photoShape: 'none', supportsIcons: true, atsFriendly: true, requiresPro: true, available: false, swatches: ['#333333'] },
];
const existingCv = {
id: 1, name: 'Frontend CV', themeId: 'modern', language: 'en', version: 2,
jobApplicationId: 17, jobTitle: 'Frontend Engineer', companyName: 'Northstar',
publicSlug: 'original-slug', isPublic: true, updatedAtUtc: '2026-10-12T10:00:00Z',
};
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 };
}
async function selectAction(name: string) {
fireEvent.click(screen.getByRole('button', { name: 'Actions for Frontend CV' }));
const item = await screen.findByRole('menuitem', { name });
item.focus();
fireEvent.click(item);
}
test('the CV library has a section heading and announces its held initial read without showing an empty result', async () => {
const read = deferred<any>();
mockedApi.get.mockImplementation((url) => url === '/cv/variants' ? read.promise : Promise.resolve({ data: themes }) as any);
renderPage();
expect(screen.getByRole('heading', { level: 2, name: 'Your CVs' })).toBeInTheDocument();
expect(screen.getByRole('status')).toHaveTextContent(/Loading/i);
expect(screen.queryByText('No CVs yet')).not.toBeInTheDocument();
await act(async () => { read.resolve({ data: [existingCv] }); });
expect(await screen.findByRole('link', { name: 'Open Frontend CV' })).toBeInTheDocument();
expect(screen.getByRole('status')).toHaveTextContent('Content loaded');
expect(screen.getByRole('status')).not.toHaveTextContent(/Loading/i);
});
test('a failed CV list read has an explicit retry that clears the stale failure after recovery', async () => {
const retry = deferred<any>();
let listReads = 0;
mockedApi.get.mockImplementation((url) => url === '/cv/variants'
? ++listReads === 1 ? Promise.reject(new Error('synthetic unavailable')) : retry.promise
: Promise.resolve({ data: themes }) as any);
renderPage();
expect(await screen.findByRole('alert')).toHaveTextContent('Could not load your CVs.');
expect(screen.queryByText('No CVs yet')).not.toBeInTheDocument();
const retryButton = screen.getByRole('button', { name: 'Retry' });
retryButton.focus();
fireEvent.click(retryButton);
await waitFor(() => expect(mockedApi.get.mock.calls.filter(([url]) => url === '/cv/variants')).toHaveLength(2));
expect(screen.getByRole('status')).toHaveTextContent(/Loading/i);
expect(mockedApi.post).not.toHaveBeenCalled();
await act(async () => { retry.resolve({ data: [existingCv] }); });
expect(await screen.findByRole('link', { name: 'Open Frontend CV' })).toBeInTheDocument();
expect(screen.queryByText('Could not load your CVs.')).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Retry' })).not.toBeInTheDocument();
});
test('a live language change retains an open CV name and template draft without refetching library data', async () => {
renderPage();
await screen.findByText('No CVs yet');
fireEvent.click(screen.getAllByRole('button', { name: 'New CV' })[0]);
await screen.findByRole('dialog', { name: 'Create a CV' });
fireEvent.change(screen.getByRole('textbox', { name: 'CV name' }), { target: { value: 'My unsaved application CV' } });
fireEvent.click(screen.getByRole('button', { name: 'Clean' }));
const requests = mockedApi.get.mock.calls.length;
act(() => { window.dispatchEvent(new StorageEvent('storage', { key: 'uiLanguage', newValue: 'nb' })); });
expect(await screen.findByRole('dialog', { name: 'Opprett en CV' })).toBeInTheDocument();
expect(screen.getByRole('textbox', { name: 'CV-navn' })).toHaveValue('My unsaved application CV');
expect(screen.getByRole('button', { name: 'Clean' })).toHaveAttribute('aria-pressed', 'true');
expect(mockedApi.get).toHaveBeenCalledTimes(requests);
expect(mockedApi.post).not.toHaveBeenCalled();
});
test('held CV creation locks the draft and gallery, then keeps a failed draft for an explicit retry', async () => {
const write = deferred<any>();
mockedApi.post.mockImplementationOnce(() => write.promise).mockResolvedValueOnce({ data: { id: 42 } } as any);
renderPage();
await screen.findByText('No CVs yet');
fireEvent.click(screen.getAllByRole('button', { name: 'New CV' })[0]);
const dialog = await screen.findByRole('dialog', { name: 'Create a CV' });
const name = within(dialog).getByRole('textbox', { name: 'CV name' });
fireEvent.change(name, { target: { value: 'Application-specific CV' } });
fireEvent.click(within(dialog).getByRole('button', { name: 'Clean' }));
fireEvent.click(within(dialog).getByRole('button', { name: 'Create CV' }));
await waitFor(() => expect(mockedApi.post).toHaveBeenCalledTimes(1));
expect(name).toBeDisabled();
expect(within(dialog).getByRole('button', { name: 'ATS templates' })).toBeDisabled();
expect(within(dialog).getByRole('button', { name: 'All templates' })).toBeDisabled();
expect(within(dialog).getByRole('button', { name: 'Modern' })).toHaveAttribute('aria-disabled', 'true');
expect(within(dialog).getByRole('button', { name: 'Clean' })).toHaveAttribute('aria-disabled', 'true');
expect(within(dialog).getByRole('button', { name: 'Cancel' })).toBeDisabled();
fireEvent.click(within(dialog).getByRole('button', { name: /Creating/ }));
fireEvent.click(within(dialog).getByRole('button', { name: 'Modern' }));
expect(mockedApi.post).toHaveBeenCalledTimes(1);
expect(within(dialog).getByRole('button', { name: 'Clean' })).toHaveAttribute('aria-pressed', 'true');
await act(async () => { write.reject(new Error('synthetic failed create')); });
expect(await within(dialog).findByRole('alert')).toHaveTextContent('Could not create a CV.');
expect(name).toBeEnabled();
expect(name).toHaveValue('Application-specific CV');
expect(within(dialog).getByRole('button', { name: 'Clean' })).toHaveAttribute('aria-pressed', 'true');
expect(mockedApi.post).toHaveBeenCalledTimes(1);
expect(mockNavigate).not.toHaveBeenCalled();
fireEvent.click(within(dialog).getByRole('button', { name: 'Create CV' }));
await waitFor(() => expect(mockedApi.post).toHaveBeenLastCalledWith('/cv/variants', { name: 'Application-specific CV', settings: emptyCvVariantSettings('clean') }));
await waitFor(() => expect(mockNavigate).toHaveBeenCalledWith('/career/builder/42'));
});
test('a locked CV template remains identifiable and cannot change the creation request', async () => {
mockedApi.post.mockResolvedValueOnce({ data: { id: 42 } } as any);
renderPage();
await screen.findByText('No CVs yet');
fireEvent.click(screen.getAllByRole('button', { name: 'New CV' })[0]);
const dialog = await screen.findByRole('dialog', { name: 'Create a CV' });
const premium = within(dialog).getByRole('button', { name: 'Premium' });
expect(premium).toHaveAttribute('aria-disabled', 'true');
expect(premium).toHaveAttribute('tabindex', '-1');
fireEvent.click(premium);
fireEvent.keyDown(premium, { key: 'Enter' });
expect(within(dialog).getByRole('button', { name: 'Modern' })).toHaveAttribute('aria-pressed', 'true');
fireEvent.click(within(dialog).getByRole('button', { name: 'Create CV' }));
await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith('/cv/variants', expect.objectContaining({ settings: emptyCvVariantSettings('modern') })));
});
test('a held duplicate blocks another action for the same CV and shows the persisted refreshed copy', async () => {
const write = deferred<any>();
let listReads = 0;
mockedApi.get.mockImplementation(async (url) => ({ data: url === '/cv/variants'
? ++listReads === 1 ? [existingCv] : [existingCv, { ...existingCv, id: 2, name: 'Frontend CV (copy)', publicSlug: 'copy-slug', isPublic: false }]
: themes }) as any);
mockedApi.post.mockImplementationOnce(() => write.promise);
renderPage();
await screen.findByText('Frontend CV');
const actions = screen.getByRole('button', { name: 'Actions for Frontend CV' });
await selectAction('Duplicate');
await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith('/cv/variants/1/duplicate', { name: undefined }));
expect(actions).toBeDisabled();
fireEvent.click(actions);
const duplicate = screen.queryByRole('menuitem', { name: 'Duplicate' });
if (duplicate) fireEvent.click(duplicate);
expect(mockedApi.post).toHaveBeenCalledTimes(1);
expect(listReads).toBe(1);
await act(async () => { write.resolve({ data: { id: 2 } }); });
expect(await screen.findByRole('link', { name: 'Open Frontend CV (copy)' })).toBeInTheDocument();
await waitFor(() => expect(actions).toBeEnabled());
expect(mockedApi.post).toHaveBeenCalledTimes(1);
expect(listReads).toBe(2);
});
test('rename reads the current revision and preserves its settings when saving the new name', async () => {
const settings = { ...emptyCvVariantSettings('clean'), overrides: { summary: { text: 'Owned override' } }, showPageNumbers: true };
mockedApi.get.mockImplementation(async (url) => ({ data: url === '/cv/variants' ? [existingCv]
: url === '/cv/variants/1' ? { ...existingCv, settings, version: 7 } : themes }) as any);
mockedApi.put.mockResolvedValueOnce({ data: { ...existingCv, name: 'Renamed application CV', version: 8, updatedAtUtc: '2026-10-12T11:00:00Z' } } as any);
renderPage();
await screen.findByText('Frontend CV');
await selectAction('Rename');
const input = await screen.findByRole('textbox', { name: 'Give this CV a clear name.' });
fireEvent.change(input, { target: { value: ' Renamed application CV ' } });
fireEvent.click(screen.getByRole('button', { name: 'Rename' }));
await waitFor(() => expect(mockedApi.put).toHaveBeenCalledWith('/cv/variants/1', { name: 'Renamed application CV', settings, source: 'manual', expectedVersion: 7 }));
expect(await screen.findByRole('link', { name: 'Open Renamed application CV' })).toBeInTheDocument();
expect(screen.getByText(/Frontend Engineer at Northstar/)).toBeInTheDocument();
expect(screen.getByText('Public')).toBeInTheDocument();
expect(mockedApi.put).toHaveBeenCalledTimes(1);
});
test('a rename conflict keeps the original library entry and never retries the write automatically', async () => {
const settings = { ...emptyCvVariantSettings('modern'), overrides: { summary: { text: 'Owned override' } } };
mockedApi.get.mockImplementation(async (url) => ({ data: url === '/cv/variants' ? [existingCv]
: url === '/cv/variants/1' ? { ...existingCv, settings, version: 7 } : themes }) as any);
mockedApi.put.mockRejectedValueOnce({ response: { status: 409 } });
renderPage();
await screen.findByText('Frontend CV');
await selectAction('Rename');
fireEvent.change(await screen.findByRole('textbox', { name: 'Give this CV a clear name.' }), { target: { value: 'Conflicting CV name' } });
fireEvent.click(screen.getByRole('button', { name: 'Rename' }));
expect((await screen.findAllByText('Rename failed.')).length).toBeGreaterThan(0);
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
expect(screen.getByRole('link', { name: 'Open Frontend CV' })).toBeInTheDocument();
expect(screen.queryByRole('link', { name: 'Open Conflicting CV name' })).not.toBeInTheDocument();
expect(mockedApi.put).toHaveBeenCalledTimes(1);
expect(mockedApi.delete).not.toHaveBeenCalled();
});
test('cancelling deletion returns focus to the surviving CV action control without removing the CV', async () => {
mockedApi.get.mockImplementation(async (url) => ({ data: url === '/cv/variants' ? [existingCv] : themes }) as any);
renderPage();
await screen.findByText('Frontend CV');
const actions = screen.getByRole('button', { name: 'Actions for Frontend CV' });
actions.focus();
await selectAction('Delete');
await screen.findByRole('heading', { name: 'Delete CV' });
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }));
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
await waitFor(() => expect(actions).toHaveFocus());
expect(screen.getByRole('link', { name: 'Open Frontend CV' })).toBeInTheDocument();
expect(mockedApi.delete).not.toHaveBeenCalled();
});
test('a failed creation keeps its draft and translates its failure after closing and reopening in another language', async () => {
mockedApi.post.mockRejectedValueOnce(new Error('synthetic unavailable'));
renderPage();
await screen.findByText('No CVs yet');
fireEvent.click(screen.getByRole('button', { name: 'New CV' }));
const dialog = await screen.findByRole('dialog', { name: 'Create a CV' });
fireEvent.change(within(dialog).getByRole('textbox', { name: 'CV name' }), { target: { value: 'Keep this failed CV draft' } });
fireEvent.click(within(dialog).getByRole('button', { name: 'Clean' }));
fireEvent.click(within(dialog).getByRole('button', { name: 'Create CV' }));
expect(await within(dialog).findByRole('alert')).toHaveTextContent('Could not create a CV.');
fireEvent.click(within(dialog).getByRole('button', { name: 'Cancel' }));
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
const reads = mockedApi.get.mock.calls.length;
act(() => { window.dispatchEvent(new StorageEvent('storage', { key: 'uiLanguage', newValue: 'nb' })); });
fireEvent.click(screen.getByRole('button', { name: 'Ny CV' }));
const reopened = await screen.findByRole('dialog', { name: 'Opprett en CV' });
expect(within(reopened).getByRole('textbox', { name: 'CV-navn' })).toHaveValue('Keep this failed CV draft');
expect(within(reopened).getByRole('button', { name: 'Clean' })).toHaveAttribute('aria-pressed', 'true');
expect(within(reopened).getByRole('alert')).toHaveTextContent('Kunne ikke opprette en CV.');
expect(within(reopened).queryByText('Could not create a CV.')).not.toBeInTheDocument();
expect(mockedApi.get).toHaveBeenCalledTimes(reads);
expect(mockedApi.post).toHaveBeenCalledTimes(1);
expect(mockNavigate).not.toHaveBeenCalled();
});
test('generic PDF failure follows the current language both after failure and when a held request fails', async () => {
const secondDownload = deferred<any>();
mockedApi.get.mockImplementation(async (url) => ({ data: url === '/cv/variants' ? [existingCv] : themes }) as any);
mockedApi.post.mockRejectedValueOnce(new Error('synthetic unavailable')).mockImplementationOnce(() => secondDownload.promise);
renderPage();
await screen.findByText('Frontend CV');
await selectAction('Download PDF');
expect(await screen.findByRole('alert')).toHaveTextContent('PDF download failed.');
const reads = mockedApi.get.mock.calls.length;
act(() => { window.dispatchEvent(new StorageEvent('storage', { key: 'uiLanguage', newValue: 'nb' })); });
expect(screen.getByRole('alert')).toHaveTextContent('Nedlasting av PDF mislyktes.');
expect(screen.queryByText('PDF download failed.')).not.toBeInTheDocument();
expect(mockedApi.get).toHaveBeenCalledTimes(reads);
expect(mockedApi.post).toHaveBeenCalledTimes(1);
fireEvent.click(screen.getByRole('button', { name: 'Handlinger for Frontend CV' }));
fireEvent.click(await screen.findByRole('menuitem', { name: 'Last ned PDF' }));
await waitFor(() => expect(mockedApi.post).toHaveBeenCalledTimes(2));
act(() => { window.dispatchEvent(new StorageEvent('storage', { key: 'uiLanguage', newValue: 'en' })); });
await act(async () => { secondDownload.reject(new Error('synthetic unavailable')); });
expect(await screen.findByRole('alert')).toHaveTextContent('PDF download failed.');
expect(screen.queryByText('Nedlasting av PDF mislyktes.')).not.toBeInTheDocument();
expect(mockedApi.get).toHaveBeenCalledTimes(reads);
expect(mockedApi.post).toHaveBeenCalledTimes(2);
expect(mockedApi.put).not.toHaveBeenCalled();
});
test('a held deletion preserves focus moved to another surviving control before the response arrives', async () => {
const write = deferred<any>();
mockedApi.get.mockImplementation(async (url) => ({ data: url === '/cv/variants' ? [existingCv] : themes }) as any);
mockedApi.delete.mockImplementationOnce(() => write.promise);
renderPage(<button type="button">Separate workflow control</button>);
await screen.findByText('Frontend CV');
await selectAction('Delete');
fireEvent.click(await screen.findByRole('button', { name: 'Delete CV' }));
await waitFor(() => expect(mockedApi.delete).toHaveBeenCalledWith('/cv/variants/1'));
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
const otherControl = screen.getByRole('button', { name: 'Separate workflow control' });
otherControl.focus();
expect(otherControl).toHaveFocus();
await act(async () => { write.resolve({ data: null }); });
await waitFor(() => expect(screen.queryByRole('link', { name: 'Open Frontend CV' })).not.toBeInTheDocument());
expect(otherControl).toHaveFocus();
expect(mockedApi.delete).toHaveBeenCalledTimes(1);
expect(mockedApi.post).not.toHaveBeenCalled();
});
test.each([
[503, 'PDF generation is busy. Please try again shortly.'],
[504, 'PDF generation took too long. Please try again.'],
@@ -113,7 +397,7 @@ test('CV dashboard and create flow render in Bokmål', async () => {
expect(await screen.findByText('Ingen CV-er ennå')).toBeInTheDocument();
fireEvent.click(screen.getAllByRole('button', { name: 'Ny CV' })[0]);
expect(await screen.findByRole('dialog', { name: 'Opprett en CV' })).toBeInTheDocument();
expect(screen.getByLabelText('CV-navn')).toHaveValue('CV uten tittel');
expect(screen.getByLabelText(/^CV-navn/)).toHaveValue('CV uten tittel');
expect(screen.getByText('Velg en mal')).toBeInTheDocument();
});
+4
View File
@@ -784,6 +784,7 @@ export const translations = {
cvEditorRestoredVersion: "Restored version {version}.",
cvEditorRestoreFailed: "Restore failed.",
cvDashboardTitle: "CV Builder",
cvDashboardLibraryTitle: "Your CVs",
cvDashboardSubtitle: "Build polished, job-specific resumes from one trusted career profile. Every version keeps its own template, content choices and history.",
cvDashboardUntitled: "Untitled CV",
cvDashboardLoadFailed: "Could not load your CVs.",
@@ -816,6 +817,7 @@ export const translations = {
cvDashboardEnterName: "Enter a name.",
cvDashboardNameExample: "For example: Backend Engineer — Acme",
cvDashboardChooseTemplate: "Choose a template",
cvDashboardDefaultTemplate: "Modern",
cvDashboardCreating: "Creating…",
cvDashboardCreateCv: "Create CV",
cvAiFailed: "AI suggestion failed.",
@@ -3416,6 +3418,7 @@ export const translations = {
cvEditorRestoredVersion: "Versjon {version} er gjenopprettet.",
cvEditorRestoreFailed: "Gjenoppretting mislyktes.",
cvDashboardTitle: "CV-bygger",
cvDashboardLibraryTitle: "Dine CV-er",
cvDashboardSubtitle: "Lag profesjonelle, jobbtilpassede CV-er fra én pålitelig karriereprofil. Hver versjon beholder sin egen mal, sine innholdsvalg og sin historikk.",
cvDashboardUntitled: "CV uten tittel",
cvDashboardLoadFailed: "Kunne ikke laste CV-ene dine.",
@@ -3448,6 +3451,7 @@ export const translations = {
cvDashboardEnterName: "Skriv inn et navn.",
cvDashboardNameExample: "For eksempel: Backendutvikler — Acme",
cvDashboardChooseTemplate: "Velg en mal",
cvDashboardDefaultTemplate: "Moderne",
cvDashboardCreating: "Oppretter…",
cvDashboardCreateCv: "Opprett CV",
cvAiFailed: "AI-forslaget mislyktes.",
+97 -73
View File
@@ -1,11 +1,10 @@
import React, { useCallback, useEffect, useState } from "react";
import React, { useEffect, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import {
Alert, Box, Button, Chip, Dialog, DialogActions, DialogContent, DialogTitle, IconButton, Menu, MenuItem, Paper, Stack, TextField, Typography,
} from "@mui/material";
import AddIcon from "@mui/icons-material/Add";
import DescriptionOutlinedIcon from "@mui/icons-material/DescriptionOutlined";
import MoreVertIcon from "@mui/icons-material/MoreVert";
import PublicIcon from "@mui/icons-material/Public";
@@ -16,98 +15,126 @@ import { useDialogActions } from "../dialogs";
import CvTemplateGallery from "../components/CvTemplateGallery";
import CvTemplateThumbnail from "../components/CvTemplateThumbnail";
import { useI18n } from "../i18n/I18nProvider";
import type { TranslationKey } from "../i18n/translations";
import { PageHeader } from "../layout/PageLayout";
import ViewStateNotice from "../components/ViewStateNotice";
import { useViewResource } from "../hooks/useViewResource";
export default function CvBuilderPage() {
const navigate = useNavigate();
const { toast } = useToast();
const { language, t } = useI18n();
const localeRef = useRef(t);
localeRef.current = t;
const { confirmAction, promptForValue } = useDialogActions();
const [variants, setVariants] = useState<CvVariantSummary[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const resource = useViewResource(async () => ({
variants: await cvBuilderApi.list(),
themes: await cvBuilderApi.themes().catch(() => []),
}), { initialData: { variants: [] as CvVariantSummary[], themes: [] as CvTheme[] }, errorMessage: "" });
const { variants, themes } = resource.data;
const loading = resource.loading || resource.refreshing;
const contentRef = useRef<HTMLDivElement>(null);
const createTitleRef = useRef<HTMLHeadingElement>(null);
const actionBusy = useRef(false);
const actionAnchor = useRef<HTMLElement | null>(null);
const [pending, setPending] = useState(false);
const [actionError, setActionError] = useState<{ error?: unknown; key: TranslationKey } | null>(null);
const [createError, setCreateError] = useState<{ error: unknown } | null>(null);
const [menu, setMenu] = useState<{ anchor: HTMLElement; id: number } | null>(null);
const [themes, setThemes] = useState<CvTheme[]>([]);
const [createOpen, setCreateOpen] = useState(false);
const [newName, setNewName] = useState(t("cvDashboardUntitled"));
const [newTheme, setNewTheme] = useState("modern");
const [creating, setCreating] = useState(false);
const load = useCallback(async () => {
try {
setVariants(await cvBuilderApi.list());
try { setThemes(await cvBuilderApi.themes()); } catch { setThemes([]); }
} catch (err) {
setError(getApiErrorMessage(err, t("cvDashboardLoadFailed")));
} finally {
setLoading(false);
}
}, [t]);
useEffect(() => {
void load();
}, [load]);
if (pending) contentRef.current?.focus();
else if (document.activeElement === contentRef.current && actionAnchor.current?.isConnected) actionAnchor.current.focus();
}, [pending]);
const createNew = async () => {
const startAction = (showPending = true) => {
if (actionBusy.current) return false;
actionBusy.current = true;
actionAnchor.current = menu?.anchor ?? null;
setMenu(null);
setActionError(null);
if (showPending) setPending(true);
return true;
};
const finishAction = () => { actionBusy.current = false; setPending(false); };
const setVariants = (update: (items: CvVariantSummary[]) => CvVariantSummary[]) => {
resource.setData((data) => ({ ...data, variants: update(data.variants) }));
};
const createNew = () => {
if (actionBusy.current) return;
setCreateOpen(true);
};
const confirmCreate = async () => {
if (!newName.trim()) return;
if (!newName.trim() || actionBusy.current) return;
actionBusy.current = true;
createTitleRef.current?.focus();
setCreateError(null);
setCreating(true);
try {
const variant = await cvBuilderApi.create({ name: newName.trim(), settings: emptyCvVariantSettings(newTheme) });
setCreateOpen(false);
navigate(`/career/builder/${variant.id}`);
} catch (err) {
toast(getApiErrorMessage(err, t("cvDashboardCreateFailed")), "error");
} finally { setCreating(false); }
setCreateError({ error: err });
} finally { actionBusy.current = false; setCreating(false); }
};
const duplicate = async (id: number) => {
if (!startAction()) return;
try {
await cvBuilderApi.duplicate(id);
await load();
toast(t("cvDashboardDuplicated"), "success");
await resource.reload();
toast(localeRef.current("cvDashboardDuplicated"), "success");
} catch (err) {
toast(getApiErrorMessage(err, t("cvDashboardDuplicateFailed")), "error");
setActionError({ error: err, key: "cvDashboardDuplicateFailed" });
} finally {
setMenu(null);
finishAction();
}
};
const remove = async (id: number) => {
if (!startAction(false)) return;
const variant = variants.find((item) => item.id === id);
setMenu(null);
if (!(await confirmAction(t("cvDashboardDeleteMessage", { name: variant?.name ?? t("cvDashboardThisCv") }), {
title: t("cvDashboardDeleteCv"),
confirmLabel: t("cvDashboardDeleteCv"),
destructive: true,
}))) return;
try {
if (!(await confirmAction(t("cvDashboardDeleteMessage", { name: variant?.name ?? t("cvDashboardThisCv") }), {
title: t("cvDashboardDeleteCv"),
confirmLabel: t("cvDashboardDeleteCv"),
destructive: true,
}))) return;
setPending(true);
await cvBuilderApi.remove(id);
setVariants((v) => v.filter((x) => x.id !== id));
toast(t("cvDashboardDeleted"), "success");
toast(localeRef.current("cvDashboardDeleted"), "success");
} catch (err) {
toast(getApiErrorMessage(err, t("cvDashboardDeleteFailed")), "error");
setActionError({ error: err, key: "cvDashboardDeleteFailed" });
} finally {
setMenu(null);
finishAction();
}
};
const rename = async (id: number) => {
if (!startAction(false)) return;
const current = variants.find((item) => item.id === id);
setMenu(null);
const nextName = await promptForValue(t("cvDashboardRenameMessage"), current?.name ?? "", { title: t("cvDashboardRenameCv"), confirmLabel: t("cvDashboardRename") });
if (!nextName?.trim() || nextName.trim() === current?.name) return;
try {
const nextName = await promptForValue(t("cvDashboardRenameMessage"), current?.name ?? "", { title: t("cvDashboardRenameCv"), confirmLabel: t("cvDashboardRename") });
if (!nextName?.trim() || nextName.trim() === current?.name) return;
setPending(true);
const variant = await cvBuilderApi.get(id);
const updated = await cvBuilderApi.save(id, { name: nextName.trim(), settings: variant.settings, source: "manual", expectedVersion: variant.version });
setVariants((items) => items.map((item) => item.id === id ? { ...item, name: updated.name, updatedAtUtc: updated.updatedAtUtc, version: updated.version } : item));
toast(t("cvDashboardRenamed"), "success");
} catch (err) { toast(getApiErrorMessage(err, t("cvDashboardRenameFailed")), "error"); }
toast(localeRef.current("cvDashboardRenamed"), "success");
} catch (err) { setActionError({ error: err, key: "cvDashboardRenameFailed" }); }
finally { finishAction(); }
};
const download = async (id: number, cvName: string) => {
setMenu(null);
if (!startAction()) return;
try {
const response = await api.post(cvBuilderApi.exportPdfUrl(id), {}, { responseType: "blob" });
const url = URL.createObjectURL(response.data as Blob);
@@ -118,42 +145,38 @@ export default function CvBuilderPage() {
URL.revokeObjectURL(url);
} catch (err) {
const status = (err as { response?: { status?: number } })?.response?.status;
toast(status === 503 ? t("cvPdfBusy") : status === 504 ? t("cvPdfTimedOut") : getApiErrorMessage(err, t("cvDashboardDownloadFailed")), "error");
}
setActionError({ error: status === 503 || status === 504 ? undefined : err, key: status === 503 ? "cvPdfBusy" : status === 504 ? "cvPdfTimedOut" : "cvDashboardDownloadFailed" });
} finally { finishAction(); }
};
return (
<Box sx={{ display: "grid", gap: 2 }}>
<Paper sx={{ p: { xs: 2, sm: 3 }, borderRadius: 3, display: "flex", alignItems: "center", justifyContent: "space-between", flexWrap: "wrap", gap: 2, background: "linear-gradient(120deg, rgba(49,87,213,.09), transparent 58%)", border: "1px solid", borderColor: "divider" }}>
<Box>
<Typography variant="h5" sx={{ fontWeight: 900 }}>{t("cvDashboardTitle")}</Typography>
<Typography color="text.secondary" sx={{ maxWidth: 720 }}>{t("cvDashboardSubtitle")}</Typography>
</Box>
<Button variant="contained" startIcon={<AddIcon />} onClick={createNew}>{t("cvDashboardNewCv")}</Button>
</Paper>
{error && <Alert severity="error">{error}</Alert>}
{!loading && variants.length === 0 && !error && (
<Paper sx={{ p: 4, borderRadius: 4, textAlign: "center" }}>
<DescriptionOutlinedIcon sx={{ fontSize: 48, color: "text.disabled" }} />
<Typography sx={{ mt: 1, fontWeight: 700 }}>{t("cvDashboardEmptyTitle")}</Typography>
<Box ref={contentRef} role="region" aria-label={t("cvDashboardTitle")} tabIndex={-1} sx={{ display: "grid", gap: "var(--app-section-gap)", minWidth: 0, overflowWrap: "anywhere" }}>
<PageHeader level="h2" title={t("cvDashboardLibraryTitle")} description={t("cvDashboardSubtitle")}
actions={<Button variant="contained" disabled={pending || loading} startIcon={<AddIcon />} onClick={createNew}>{t("cvDashboardNewCv")}</Button>} />
<ViewStateNotice title={t("cvDashboardLoadFailed")} loading={loading}
error={resource.error ? { ...resource.error, message: resource.error.message || t("cvDashboardLoadFailed") } : null}
onRetry={() => { if (actionBusy.current || loading) return; contentRef.current?.focus(); void resource.reload(); }} compact />
{actionError && <Alert severity="error">{getApiErrorMessage(actionError.error, t(actionError.key))}</Alert>}
{pending && <Typography role="status" variant="body2">{t("loading")}</Typography>}
{!loading && variants.length === 0 && !resource.error && (
<Paper variant="outlined" sx={{ p: "var(--app-card-padding)", borderRadius: 1 }}>
<Typography component="h2" variant="sectionTitle">{t("cvDashboardEmptyTitle")}</Typography>
<Typography color="text.secondary" sx={{ mb: 2 }}>{t("cvDashboardEmptyBody")}</Typography>
<Button variant="contained" startIcon={<AddIcon />} onClick={createNew}>{t("cvDashboardNewCv")}</Button>
</Paper>
)}
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", sm: "repeat(2, minmax(0, 1fr))", lg: "repeat(3, minmax(0, 1fr))" }, gap: 2 }}>
<Box sx={{ display: "grid", minWidth: 0, gridTemplateColumns: { xs: "minmax(0, 1fr)", sm: "repeat(2, minmax(0, 1fr))", lg: "repeat(3, minmax(0, 1fr))" }, gap: 2 }}>
{variants.map((v) => (
<Paper
key={v.id}
role="link"
tabIndex={0}
aria-disabled={pending}
aria-label={t("cvDashboardOpenNamed", { name: v.name })}
sx={{ p: 1.5, borderRadius: 3, cursor: "pointer", border: "1px solid", borderColor: "divider", transition: "transform 150ms ease, box-shadow 150ms ease", "&:hover": { boxShadow: 5, transform: "translateY(-2px)" }, "&:focus-visible": { outline: "3px solid", outlineColor: "primary.main", outlineOffset: 2 } }}
onClick={() => navigate(`/career/builder/${v.id}`)}
sx={{ p: "var(--app-card-padding)", minWidth: 0, borderRadius: 1, cursor: pending ? "default" : "pointer", border: "1px solid", borderColor: "divider", "&:hover": { borderColor: "primary.main" }, "&:focus-visible": { outline: "3px solid", outlineColor: "primary.main", outlineOffset: 2 } }}
onClick={() => { if (!actionBusy.current) navigate(`/career/builder/${v.id}`); }}
onKeyDown={(event) => {
if (event.target === event.currentTarget && (event.key === "Enter" || event.key === " ")) {
if (!actionBusy.current && event.target === event.currentTarget && (event.key === "Enter" || event.key === " ")) {
event.preventDefault();
navigate(`/career/builder/${v.id}`);
}
@@ -161,12 +184,12 @@ export default function CvBuilderPage() {
>
<CvTemplateThumbnail theme={themes.find((theme) => theme.id === v.themeId)} />
<Stack direction="row" alignItems="flex-start" justifyContent="space-between">
<Typography sx={{ fontWeight: 800, mt: 1 }}>{v.name}</Typography>
<IconButton size="small" aria-label={t("cvDashboardActionsFor", { name: v.name })} onClick={(e) => { e.stopPropagation(); setMenu({ anchor: e.currentTarget, id: v.id }); }}>
<Typography component="h3" variant="sectionTitle" sx={{ mt: 1, minWidth: 0 }}>{v.name}</Typography>
<IconButton disabled={pending} aria-haspopup="menu" size="small" aria-label={t("cvDashboardActionsFor", { name: v.name })} onClick={(e) => { e.stopPropagation(); if (!actionBusy.current) setMenu({ anchor: e.currentTarget, id: v.id }); }}>
<MoreVertIcon fontSize="small" />
</IconButton>
</Stack>
<Stack direction="row" spacing={1} sx={{ mt: 1 }}>
<Stack direction="row" spacing={1} useFlexGap flexWrap="wrap" sx={{ mt: 1, minWidth: 0 }}>
<Chip size="small" label={v.themeId} />
<Chip size="small" variant="outlined" label={(v.language || "en").toUpperCase()} />
{v.isPublic && <Chip size="small" color="primary" icon={<PublicIcon />} label={t("cvEditorPublic")} />}
@@ -187,13 +210,14 @@ export default function CvBuilderPage() {
<MenuItem onClick={() => menu && remove(menu.id)} sx={{ color: "error.main" }}>{t("cvDashboardDelete")}</MenuItem>
</Menu>
<Dialog open={createOpen} onClose={() => { if (!creating) setCreateOpen(false); }} fullWidth maxWidth="md" aria-labelledby="create-cv-title">
<DialogTitle id="create-cv-title">{t("cvDashboardCreateTitle")}</DialogTitle>
<DialogContent>
<Typography color="text.secondary" sx={{ mb: 2 }}>{t("cvDashboardCreateHelp")}</Typography>
<TextField autoFocus fullWidth label={t("cvEditorName")} value={newName} onChange={(event) => setNewName(event.target.value)} error={!newName.trim()} helperText={!newName.trim() ? t("cvDashboardEnterName") : t("cvDashboardNameExample")} sx={{ mb: 2 }} />
<Typography variant="subtitle2" sx={{ fontWeight: 800, mb: 1 }}>{t("cvDashboardChooseTemplate")}</Typography>
<CvTemplateGallery themes={themes.length ? themes.filter((theme) => theme.available) : [{ id: "modern", name: "Modern", category: "Professional", description: "", layout: "header-band", accent: "#3157d5", photoShape: "none", supportsIcons: false, atsFriendly: true, requiresPro: false, available: true, swatches: ["#3157d5", "#eef1f4", "#fff"] }]} selectedId={newTheme} onSelect={(theme) => setNewTheme(theme.id)} previewHeight={125} showDescriptions={false} />
<Dialog open={createOpen} onClose={() => { if (!actionBusy.current) setCreateOpen(false); }} fullWidth maxWidth="md" aria-labelledby="create-cv-title" aria-describedby={`create-cv-help${createError ? " create-cv-error" : ""}`}>
<DialogTitle ref={createTitleRef} tabIndex={-1} id="create-cv-title">{t("cvDashboardCreateTitle")}</DialogTitle>
<DialogContent role="region" aria-labelledby="create-cv-title" tabIndex={creating ? 0 : -1}>
<Typography id="create-cv-help" color="text.secondary" sx={{ mb: 2 }}>{t("cvDashboardCreateHelp")}</Typography>
{createError && <Alert id="create-cv-error" severity="error" sx={{ mb: 2 }}>{getApiErrorMessage(createError.error, t("cvDashboardCreateFailed"))}</Alert>}
<TextField autoFocus fullWidth disabled={creating} required label={t("cvEditorName")} value={newName} onChange={(event) => setNewName(event.target.value)} error={!newName.trim()} helperText={!newName.trim() ? t("cvDashboardEnterName") : t("cvDashboardNameExample")} sx={{ mb: 2, "& .MuiFormHelperText-root.Mui-disabled": { color: "text.secondary" } }} />
<Typography component="h3" variant="sectionTitle" sx={{ mb: 1 }}>{t("cvDashboardChooseTemplate")}</Typography>
<CvTemplateGallery disabled={creating} themes={themes.length ? themes : [{ id: "modern", name: t("cvDashboardDefaultTemplate"), category: t("cvEditorTemplate"), description: "", layout: "header-band", accent: "#3157d5", photoShape: "none", supportsIcons: false, atsFriendly: true, requiresPro: false, available: true, swatches: ["#3157d5", "#eef1f4", "#fff"] }]} selectedId={newTheme} onSelect={(theme) => { if (!actionBusy.current) setNewTheme(theme.id); }} previewHeight={125} showDescriptions={false} />
</DialogContent>
<DialogActions><Button onClick={() => setCreateOpen(false)} disabled={creating}>{t("cancel")}</Button><Button variant="contained" disabled={creating || !newName.trim()} onClick={() => void confirmCreate()}>{creating ? t("cvDashboardCreating") : t("cvDashboardCreateCv")}</Button></DialogActions>
</Dialog>