fix: preserve blank CVs and reliable editor undo

This commit is contained in:
cesnimda
2026-10-27 07:37:03 +01:00
parent 5d4b8f23ce
commit 663023a088
22 changed files with 950 additions and 101 deletions
@@ -0,0 +1,148 @@
import { expect, test, type APIRequestContext, type Page } from '@playwright/test';
import { translations } from '../src/i18n/translations';
import { emptyStructuredCv } from '../src/profileCv';
import AxeBuilder from '@axe-core/playwright';
const api = 'http://localhost:5302/api';
const password = 'E2ePassword123!';
async function login(request: APIRequestContext, email: string) {
expect((await request.post(`${api}/auth/login`, { data: { email, password } })).status()).toBe(200);
const me = await (await request.get(`${api}/auth/me`)).json();
return { 'X-CSRF-TOKEN': (await request.storageState()).cookies.find(cookie => cookie.name === 'XSRF-TOKEN')!.value,
'X-Session-Context': me.sessionContext as string };
}
async function owner(page: Page, admin: APIRequestContext, language: 'en' | 'nb' = 'en') {
const adminHeaders = await login(admin, 'e2e@example.test');
const email = `pre-p10-${Date.now()}-${Math.random().toString(36).slice(2)}@example.test`;
expect((await admin.post(`${api}/users`, { headers: adminHeaders, data: { email, password, roles: [] } })).status()).toBe(200);
const headers = await login(page.request, email);
const profile = { ...emptyStructuredCv(), contact: { ...emptyStructuredCv().contact,
fullName: 'Canonical Identity Marker', headline: 'Canonical Headline Marker', email: 'canonical-contact@example.test' },
summary: ['Canonical Summary Marker'], jobs: [{ id: 'pre-p10-job', title: 'Canonical Role Marker', company: 'Synthetic Employer', bullets: ['Canonical Detail Marker'] }] };
const before = await (await page.request.get(`${api}/career/profile`)).json();
expect((await page.request.put(`${api}/career/profile`, { headers, data: { expectedVersion: before.revision, profile } })).status()).toBe(200);
expect((await page.request.put(`${api}/auth/preferences/language`, { headers, data: { language: language === 'nb' ? 'nb-NO' : 'en' } })).status()).toBe(204);
await page.addInitScript(language => { localStorage.setItem('uiLanguage', language); localStorage.setItem('jobtracker.themeMode', 'light'); }, language);
await page.setViewportSize({ width: language === 'nb' ? 390 : 1440, height: 1000 });
return headers;
}
test.beforeEach(async ({ context }) => {
await context.route('**/*', route => {
const url = new URL(route.request().url());
return ['localhost', '127.0.0.1'].includes(url.hostname) || ['data:', 'blob:', 'about:'].includes(url.protocol)
? route.continue() : route.abort();
});
});
for (const language of ['en', 'nb'] as const) {
test(`[PRE-P10 A] ${language} blank CV survives rename, reload, template apply and empty export validation`, async ({ page, playwright }, info) => {
test.setTimeout(180_000);
const t = translations[language];
const admin = await playwright.request.newContext();
let headers: Record<string, string>;
try { headers = await owner(page, admin, language); } finally { await admin.dispose(); }
const sourceBefore = await (await page.request.get(`${api}/career/profile`)).json();
await page.goto('/career/builder');
await page.getByRole('button', { name: t.cvDashboardNewCv, exact: true }).click();
const dialog = page.getByRole('dialog', { name: t.cvDashboardCreateTitle, exact: true });
await expect(dialog).toBeVisible();
await dialog.getByRole('radio', { name: t.cvDashboardStartBlank, exact: true }).check();
await expect(dialog.getByRole('radio', { name: t.cvDashboardStartBlank, exact: true })).toBeChecked();
const dialogAxe = await new AxeBuilder({ page }).include('[role="dialog"]').withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa']).analyze();
await info.attach('create-dialog-axe', { body: JSON.stringify({ violations: dialogAxe.violations, incomplete: dialogAxe.incomplete }), contentType: 'application/json' });
expect(dialogAxe.violations).toEqual([]);
await dialog.getByLabel(t.cvEditorName, { exact: false }).fill('New blank CV');
const createdResponse = page.waitForResponse(response => response.url() === `${api}/cv/variants` && response.request().method() === 'POST');
await dialog.getByRole('button', { name: t.cvDashboardCreateCv, exact: true }).click();
const response = await createdResponse;
expect(response.status()).toBe(200);
const created = await response.json();
await expect(page).toHaveURL(new RegExp(`/career/builder/${created.id}$`));
const read = async () => (await page.request.get(`${api}/cv/variants/${created.id}`)).json();
const assertBlank = async () => {
const saved = await read();
expect(saved.settings).toMatchObject({ sectionsExplicit: true, includeProfileContact: false, sections: [] });
const preview = await page.request.get(`${api}/cv/variants/${created.id}/preview`);
expect(preview.status()).toBe(200);
const rendered = await preview.json();
for (const marker of ['Canonical Identity Marker', 'Canonical Headline Marker', 'Canonical Summary Marker', 'Canonical Role Marker', 'canonical-contact@example.test']) {
expect(rendered.html.includes(marker), `Blank CV must not inherit ${marker}`).toBe(false);
}
return saved;
};
await expect(page.getByText(t.cvEditorEmptyBody, { exact: true })).toBeVisible();
await assertBlank();
await page.getByLabel(t.cvEditorName, { exact: true }).fill('Saved empty CV');
await expect.poll(async () => (await read()).name).toBe('Saved empty CV');
await page.reload();
await expect(page.getByLabel(t.cvEditorName, { exact: true })).toHaveValue('Saved empty CV');
await expect(page.getByRole('checkbox', { name: t.cvEditorIncludeProfileContact, exact: true })).not.toBeChecked();
await assertBlank();
await page.getByRole('tab', { name: t.cvEditorTemplate, exact: true }).click();
await page.getByRole('button', { name: t.cvTemplateMinimalName, exact: true }).click();
await page.getByRole('button', { name: t.cvEditorApplyTemplate, exact: true }).click();
await expect.poll(async () => (await read()).settings.themeId).toBe('minimal');
await assertBlank();
await page.getByRole('tab', { name: t.cvEditorContent, exact: true }).click();
const editorAxe = await new AxeBuilder({ page }).include('#cv-studio-panel-1').withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa']).analyze();
await info.attach('blank-content-axe', { body: JSON.stringify({ violations: editorAxe.violations, incomplete: editorAxe.incomplete }), contentType: 'application/json' });
expect(editorAxe.violations).toEqual([]);
const exportResponse = page.waitForResponse(response => new URL(response.url()).pathname === `/api/cv/variants/${created.id}/export-pdf` && response.request().method() === 'POST');
await page.getByRole('button', { name: t.cvEditorDownloadPdf, exact: true }).click();
const exported = await exportResponse;
expect(exported.status()).toBe(400);
expect(await exported.json()).toEqual({ code: 'cv_empty_content' });
await expect(page.getByText(t.cvEmptyContent, { exact: true })).toBeVisible();
const saved = await assertBlank();
const independent = await page.request.post(`${api}/cv/variants/${created.id}/export-pdf?expectedVersion=${saved.version}`, { headers });
expect(independent.status()).toBe(400);
expect(await independent.json()).toEqual({ code: 'cv_empty_content' });
expect(await (await page.request.get(`${api}/career/profile`)).json()).toEqual(sourceBefore);
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1)).toBe(true);
await info.attach('blank-roundtrip', { body: JSON.stringify({ language, created, saved, exportStatus: exported.status(), sourceUnchanged: true }), contentType: 'application/json' });
await page.screenshot({ path: info.outputPath('blank-roundtrip.png'), fullPage: true });
});
test(`[PRE-P10 A] ${language} document undo and both redo shortcuts preserve native text undo`, async ({ page, playwright }, info) => {
test.setTimeout(180_000);
const t = translations[language];
const admin = await playwright.request.newContext();
let headers: Record<string, string>;
try { headers = await owner(page, admin, language); } finally { await admin.dispose(); }
const response = await page.request.post(`${api}/cv/variants`, { headers, data: { name: 'Keyboard undo runtime reproduction', settings: { themeId: 'minimal', sections: [{ key: 'summary' }, { key: 'experience' }] } } });
expect(response.status()).toBe(200);
const cv = await response.json();
await page.goto(`/career/builder/${cv.id}`);
const headline = page.getByLabel(t.cvEditorHeadlineOverride, { exact: true });
await expect(headline).toBeVisible();
const readOrder = async () => (await (await page.request.get(`${api}/cv/variants/${cv.id}`)).json()).settings.sections.slice(0, 2).map((section: { key: string }) => section.key);
await page.getByRole('button', { name: t.cvEditorMoveSectionDown.replace('{section}', t.cvSectionSummary), exact: true }).click();
const undo = page.getByRole('button', { name: t.cvEditorUndo, exact: true });
const redo = page.getByRole('button', { name: t.cvEditorRedo, exact: true });
await expect(undo).toBeEnabled();
await expect.poll(readOrder).toEqual(['experience', 'summary']);
for (const shortcut of ['Control+z', 'Control+y', 'Control+z', 'Control+Shift+z']) {
const reverting = shortcut === 'Control+z';
await (reverting ? undo : redo).focus();
await page.keyboard.press(shortcut);
await expect.poll(readOrder, { message: shortcut }).toEqual(reverting ? ['summary', 'experience'] : ['experience', 'summary']);
}
await headline.focus();
await headline.pressSequentially('Native input marker');
await expect(headline).toHaveValue('Native input marker');
await page.evaluate(() => { document.addEventListener('keydown', event => {
if (event.ctrlKey && event.key.toLowerCase() === 'z') document.documentElement.dataset.nativeUndoPrevented = String(event.defaultPrevented);
}); });
await page.keyboard.press('Control+z');
await expect(headline).not.toHaveValue('Native input marker');
await expect(headline).toBeFocused();
expect(await page.evaluate(() => document.documentElement.dataset.nativeUndoPrevented)).toBe('false');
expect(await readOrder()).toEqual(['experience', 'summary']);
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1)).toBe(true);
await info.attach('shortcut-roundtrip', { body: JSON.stringify({ language, finalOrder: await readOrder(), nativeInputValue: await headline.inputValue(), nativeUndoPrevented: false }), contentType: 'application/json' });
await page.screenshot({ path: info.outputPath('shortcut-roundtrip.png'), fullPage: true });
});
}
@@ -26,6 +26,8 @@ jest.mock('./api', () => ({
}));
const mockedApi = api as jest.Mocked<typeof api>;
const originalRequest = global.Request;
const originalAbortController = global.AbortController;
const variant = {
id: 3,
@@ -38,12 +40,12 @@ const variant = {
updatedAtUtc: '2026-07-19T10:00:00Z',
};
function renderAt(id: number) {
function renderAt(id: number, strict = false) {
const router = createMemoryRouter([
{ path: '/career/builder/:id', element: <CvBuilderEditor /> },
{ path: '/career/builder', element: <div>CV list destination</div> },
], { initialEntries: [`/career/builder/${id}`] });
return render(
const tree = (
<I18nProvider>
<ToastProvider>
<ConfirmProvider>
@@ -52,8 +54,9 @@ function renderAt(id: number) {
</PromptProvider>
</ConfirmProvider>
</ToastProvider>
</I18nProvider>,
</I18nProvider>
);
return { ...render(strict ? <React.StrictMode>{tree}</React.StrictMode> : tree), router };
}
beforeEach(() => {
@@ -65,7 +68,11 @@ beforeEach(() => {
mockedApi.post.mockResolvedValue({ data: { themeId: 'nordic', html: '<p>cv</p>', suggestedFileName: 'cv.pdf' } } as any);
});
afterEach(() => window.localStorage.removeItem('uiLanguage'));
afterEach(() => {
window.localStorage.removeItem('uiLanguage');
global.Request = originalRequest;
global.AbortController = originalAbortController;
});
function routeGet(onVariant: () => Promise<any>, outline: any = { sections: [] }, history: any[] = []) {
mockedApi.get.mockImplementation((url: string) => {
@@ -82,6 +89,143 @@ async function openContentTab() {
fireEvent.click(screen.getByRole('tab', { name: 'Content' }));
}
test('Strict Mode groups a typing burst into one undo step and preserves redo through autosave', async () => {
routeGet(() => Promise.resolve({ data: variant }));
renderAt(3, true);
await openContentTab();
const headline = screen.getByLabelText('Headline override');
act(() => headline.focus());
fireEvent.change(headline, { target: { value: 'D' } });
fireEvent.change(headline, { target: { value: 'Developer' } });
act(() => headline.blur());
fireEvent.click(screen.getByRole('button', { name: 'Save now' }));
await screen.findByText('Saved');
fireEvent.click(screen.getByRole('button', { name: 'Undo' }));
expect(headline).toHaveValue('');
expect(screen.getByRole('button', { name: 'Undo' })).toBeDisabled();
fireEvent.click(screen.getByRole('button', { name: 'Redo' }));
expect(headline).toHaveValue('Developer');
});
test('document shortcuts undo and redo renaming, preserve native input undo and discard branched redo', async () => {
routeGet(() => Promise.resolve({ data: variant }));
renderAt(3, true);
await openContentTab();
const name = screen.getByDisplayValue('Backend CV');
act(() => name.focus());
fireEvent.change(name, { target: { value: 'Frontend CV' } });
fireEvent.keyDown(name, { key: 'z', ctrlKey: true });
expect(name).toHaveValue('Frontend CV');
act(() => name.blur());
fireEvent.keyDown(document, { key: 'z', ctrlKey: true });
expect(name).toHaveValue('Backend CV');
fireEvent.keyDown(document, { key: 'y', ctrlKey: true });
expect(name).toHaveValue('Frontend CV');
fireEvent.keyDown(document, { key: 'z', ctrlKey: true });
fireEvent.keyDown(document, { key: 'Z', ctrlKey: true, shiftKey: true });
expect(name).toHaveValue('Frontend CV');
fireEvent.keyDown(document, { key: 'z', ctrlKey: true });
fireEvent.change(screen.getByLabelText('Headline override'), { target: { value: 'New branch' } });
expect(screen.getByRole('button', { name: 'Redo' })).toBeDisabled();
});
test('a revision conflict disables document history without silently writing an undo', async () => {
mockedApi.put.mockRejectedValue({ response: { status: 409, data: { code: 'cv_revision_conflict' } } });
routeGet(() => Promise.resolve({ data: variant }));
renderAt(3);
await openContentTab();
fireEvent.change(screen.getByLabelText('Headline override'), { target: { value: 'Retained local edit' } });
fireEvent.click(screen.getByRole('button', { name: 'Save now' }));
await screen.findByRole('button', { name: 'Reload saved CV' });
expect(screen.getByRole('button', { name: 'Undo' })).toBeDisabled();
fireEvent.keyDown(document, { key: 'z', ctrlKey: true });
expect(screen.getByLabelText('Headline override')).toHaveValue('Retained local edit');
expect(mockedApi.put).toHaveBeenCalledTimes(1);
});
test('switching documents hides the previous draft while loading and starts fresh history', async () => {
// JSDOM omits Node's fetch globals, which React Router needs for navigation.
global.Request = jest.requireActual('node:vm').runInThisContext('Request');
global.AbortController = jest.requireActual('node:vm').runInThisContext('AbortController');
let loadNext!: (value: any) => void;
routeGet(() => Promise.resolve({ data: variant }));
const previousGet = mockedApi.get.getMockImplementation()!;
mockedApi.get.mockImplementation((url: string, ...args: any[]) => url === '/cv/variants/4'
? new Promise(resolve => { loadNext = resolve; }) : previousGet(url, ...args));
const { router } = renderAt(3);
await openContentTab();
fireEvent.change(screen.getByLabelText('Headline override'), { target: { value: 'Only first document' } });
fireEvent.click(screen.getByRole('button', { name: 'Save now' }));
await screen.findByText('Saved');
await act(async () => { await router.navigate('/career/builder/4'); });
expect(screen.queryByDisplayValue('Backend CV')).not.toBeInTheDocument();
expect(screen.queryByDisplayValue('Only first document')).not.toBeInTheDocument();
await act(async () => loadNext({ data: { ...variant, id: 4, name: 'Second CV' } }));
await screen.findByDisplayValue('Second CV');
expect(screen.getByRole('button', { name: 'Undo' })).toBeDisabled();
fireEvent.keyDown(document, { key: 'z', ctrlKey: true });
expect(screen.getByLabelText('Headline override')).toHaveValue('');
});
test('explicit blank editing excludes master content and includes a profile section only after an Add action', async () => {
routeGet(() => Promise.resolve({ data: { ...variant, settings: { ...variant.settings,
sectionsExplicit: true, includeProfileContact: false,
} } }), { headline: 'Private master title', sections: [
{ key: 'summary', kind: 'bullets', title: 'Professional Summary', bullets: ['Private master summary'] },
] });
renderAt(3);
await openContentTab();
expect(screen.queryByRole('button', { name: 'Expand Professional Summary' })).not.toBeInTheDocument();
expect(screen.getByRole('combobox', { name: 'Go to section' })).toHaveAttribute('aria-disabled', 'true');
fireEvent.click(screen.getByRole('button', { name: 'Add content' }));
const card = screen.getByText('Professional Summary', { selector: 'h6' }).parentElement!;
fireEvent.click(within(card).getByRole('button', { name: 'Add' }));
expect(await screen.findByRole('button', { name: 'Expand Professional Summary' })).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Undo' }));
expect(screen.queryByRole('button', { name: 'Expand Professional Summary' })).not.toBeInTheDocument();
});
test('a blank CV can deliberately add an additional Career Profile section', async () => {
routeGet(() => Promise.resolve({ data: { ...variant, settings: { ...variant.settings,
sectionsExplicit: true, includeProfileContact: false,
} } }), { sections: [
{ key: 'other:0', kind: 'bullets', title: 'Community service', bullets: ['Explicitly sourced volunteering'] },
] });
renderAt(3);
await openContentTab();
expect(screen.queryByRole('button', { name: 'Expand Community service' })).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Add content' }));
const card = screen.getByText('Community service', { selector: 'h6' }).parentElement!;
fireEvent.click(within(card).getByRole('button', { name: 'Add' }));
expect(await screen.findByRole('button', { name: 'Expand Community service' })).toBeInTheDocument();
});
test('a historical restore response from the previous document cannot change the newly opened CV', async () => {
global.Request = jest.requireActual('node:vm').runInThisContext('Request');
global.AbortController = jest.requireActual('node:vm').runInThisContext('AbortController');
let finishRestore!: (value: any) => void;
routeGet(() => Promise.resolve({ data: variant }), { sections: [] }, [
{ version: 3, source: 'manual', createdAtUtc: variant.updatedAtUtc, isCurrent: false },
]);
const previousGet = mockedApi.get.getMockImplementation()!;
mockedApi.get.mockImplementation((url: string, ...args: any[]) => url === '/cv/variants/4'
? Promise.resolve({ data: { ...variant, id: 4, name: 'Second CV' } } as any) : previousGet(url, ...args));
mockedApi.post.mockImplementation((url: string) => url.endsWith('/restore')
? new Promise(resolve => { finishRestore = resolve; })
: Promise.resolve({ data: { html: '<p>CV</p>' } } as any));
const { router } = renderAt(3);
await openContentTab();
fireEvent.click(screen.getByRole('tab', { name: 'History' }));
fireEvent.click(await screen.findByRole('button', { name: 'Restore version 3' }));
fireEvent.click(within(await screen.findByRole('dialog')).getByRole('button', { name: 'Restore version' }));
await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith('/cv/variants/3/versions/3/restore', { expectedVersion: 4 }));
await act(async () => { await router.navigate('/career/builder/4'); });
await screen.findByDisplayValue('Second CV');
await act(async () => finishRestore({ data: { ...variant, name: 'Old restored name', version: 5 } }));
expect(screen.getByDisplayValue('Second CV')).toBeInTheDocument();
expect(screen.queryByText('CV changed elsewhere')).not.toBeInTheDocument();
});
test('content sections follow the editing-region heading without mutating CV settings', async () => {
routeGet(() => Promise.resolve({ data: variant }));
renderAt(3);
@@ -999,6 +1143,11 @@ test('a newer draft is flushed before stale proposal acceptance fails without re
expect(screen.getByDisplayValue('Newer retained name')).toBeInTheDocument();
fireEvent.click(screen.getByRole('tab', { name: 'Content' }));
expect(screen.getByLabelText('Headline override')).toHaveValue('');
// Renaming is now an editor transaction; the rejected proposal must add no history step.
expect(screen.getByRole('button', { name: 'Undo' })).toBeEnabled();
fireEvent.click(screen.getByRole('button', { name: 'Undo' }));
expect(screen.getByDisplayValue('Backend CV')).toBeInTheDocument();
expect(screen.getByLabelText('Headline override')).toHaveValue('');
expect(screen.getByRole('button', { name: 'Undo' })).toBeDisabled();
});
+70 -3
View File
@@ -9,7 +9,7 @@ import { ToastProvider } from './toast';
import { api } from './api';
import { ConfirmProvider } from './confirm';
import { PromptProvider } from './prompt';
import { emptyCvVariantSettings } from './cvBuilder';
import { emptyCvVariantSettings, getCvExportFailureKey } from './cvBuilder';
const mockNavigate = jest.fn();
jest.mock('react-router-dom', () => ({
@@ -82,6 +82,73 @@ async function selectAction(name: string) {
fireEvent.click(item);
}
test('blank creation explicitly excludes all profile sections and contact even after template selection', 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' });
fireEvent.click(within(dialog).getByRole('button', { name: 'Clean' }));
fireEvent.click(within(dialog).getByRole('button', { name: 'Create CV' }));
await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith('/cv/variants', expect.objectContaining({
settings: expect.objectContaining({ themeId: 'clean', sectionsExplicit: true, includeProfileContact: false, sections: [], customSections: [], overrides: {} }),
})));
});
test('profile creation includes only deliberately selected available sections and contact', async () => {
mockedApi.get.mockImplementation(async (url) => ({ data: url === '/cv/themes' ? themes : url === '/cv/outline'
? { fullName: 'Synthetic Person', sections: [
{ key: 'skills', title: 'Skills', kind: 'tags', tags: ['TypeScript'], bullets: [], entries: [] },
{ key: 'experience', title: 'Experience', kind: 'entries', tags: [], bullets: [], entries: [{ key: 'job-1', title: 'Engineer', bullets: [], tags: [] }] },
] } : [] }) as any);
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' });
fireEvent.click(within(dialog).getByRole('radio', { name: 'From Career Profile' }));
const skills = await within(dialog).findByRole('checkbox', { name: 'Skills' });
expect(skills).not.toBeChecked();
expect(within(dialog).getByRole('checkbox', { name: 'Experience' })).not.toBeChecked();
const contact = within(dialog).getByRole('checkbox', { name: 'Include Career Profile contact details' });
expect(contact).not.toBeChecked();
fireEvent.click(skills);
fireEvent.click(contact);
fireEvent.click(within(dialog).getByRole('button', { name: 'Create CV' }));
await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith('/cv/variants', expect.objectContaining({
settings: expect.objectContaining({ sectionsExplicit: true, includeProfileContact: true, sections: [{ key: 'skills' }] }),
})));
});
test('unavailable profile sections block profile creation and allow an explicit blank fallback', async () => {
mockedApi.get.mockImplementation(async (url) => {
if (url === '/cv/outline') throw new Error('Synthetic profile read failure');
return { data: url === '/cv/themes' ? themes : [] } as any;
});
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' });
fireEvent.click(within(dialog).getByRole('radio', { name: 'From Career Profile' }));
expect(await within(dialog).findByRole('alert')).toHaveTextContent('Could not load Career Profile sections.');
expect(within(dialog).getByRole('button', { name: 'Create CV' })).toBeDisabled();
fireEvent.click(within(dialog).getByRole('radio', { name: 'Start blank' }));
fireEvent.click(within(dialog).getByRole('button', { name: 'Create CV' }));
await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith('/cv/variants', expect.objectContaining({
settings: expect.objectContaining({ sections: [], includeProfileContact: false }),
})));
});
test('export validation decodes structured and blob errors without relabelling unknown failures', async () => {
expect(await getCvExportFailureKey({ response: { status: 400, data: { code: 'cv_empty_content' } } })).toBe('cvEmptyContent');
const body = new Blob();
Object.defineProperty(body, 'text', { value: async () => '{"code":"cv_empty_content"}' });
expect(await getCvExportFailureKey({ response: { status: 400, data: body } })).toBe('cvEmptyContent');
expect(await getCvExportFailureKey({ response: { status: 400, data: { code: 'unrelated' } } })).toBeUndefined();
expect(await getCvExportFailureKey(new Error('network'))).toBeUndefined();
});
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);
@@ -168,7 +235,7 @@ test('held CV creation locks the draft and gallery, then keeps a failed draft fo
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(mockedApi.post).toHaveBeenLastCalledWith('/cv/variants', { name: 'Application-specific CV', settings: { ...emptyCvVariantSettings('clean'), sectionsExplicit: true, includeProfileContact: false } }));
await waitFor(() => expect(mockNavigate).toHaveBeenCalledWith('/career/builder/42'));
});
@@ -185,7 +252,7 @@ test('a locked CV template remains identifiable and cannot change the creation r
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') })));
await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith('/cv/variants', expect.objectContaining({ settings: { ...emptyCvVariantSettings('modern'), sectionsExplicit: true, includeProfileContact: false } })));
});
test('a held duplicate blocks another action for the same CV and shows the persisted refreshed copy', async () => {
+13
View File
@@ -51,6 +51,8 @@ export type CvVariantSettings = {
showPhoto: boolean;
showPageNumbers: boolean;
showIcons: boolean;
sectionsExplicit?: boolean | null;
includeProfileContact?: boolean | null;
sections: CvSectionSetting[];
overrides: Record<string, CvItemOverride>;
customSections: CvCustomSectionSetting[];
@@ -241,6 +243,17 @@ export function emptyCvVariantSettings(themeId = "modern"): CvVariantSettings {
};
}
export async function getCvExportFailureKey(error: unknown): Promise<"cvEmptyContent" | "cvPdfBusy" | "cvPdfTimedOut" | undefined> {
const response = (error as { response?: { status?: number; data?: unknown } })?.response;
if (response?.status === 503) return "cvPdfBusy";
if (response?.status === 504) return "cvPdfTimedOut";
if (response?.status !== 400) return undefined;
try {
const data = response.data instanceof Blob ? JSON.parse(await response.data.text()) : response.data;
return (data as { code?: string })?.code === "cv_empty_content" ? "cvEmptyContent" : undefined;
} catch { return undefined; }
}
// --- API ---
// --- Pure helpers (unit-tested) ---
+28 -4
View File
@@ -887,7 +887,7 @@ export const translations = {
cvEditorEditMasterProfile: "Open Career Profile",
cvEditorMasterProfileHelp: "Career Profile is your source. Editing wording, order or inclusion here changes only this CV.",
cvEditorHeadlineOverride: "Headline override",
cvEditorHeadlineHelp: "Blank uses your Career Profile headline. Changes here affect only this CV.",
cvEditorHeadlineHelp: "An empty field uses your Career Profile headline only when profile contact is included. Changes here affect only this CV.",
cvEditorSections: "Sections",
cvEditorSectionsHelp: "Drag to reorder, or use the arrows. Omitted content stays editable.",
cvEditorUsingSource: "Using Career Profile",
@@ -1109,7 +1109,19 @@ export const translations = {
cvDashboardOpen: "Open",
cvDashboardDelete: "Delete",
cvDashboardCreateTitle: "Create a CV",
cvDashboardCreateHelp: "Start with your saved career profile, choose a visual direction, then tailor what appears.",
cvDashboardCreateHelp: "Choose your content source and a template. Templates change presentation only.",
cvDashboardContentSource: "CV content source",
cvDashboardStartBlank: "Start blank",
cvDashboardFromProfile: "From Career Profile",
cvDashboardBlankHelp: "Start with no sections or inherited contact details. Add your own content in the editor.",
cvDashboardProfileSelectionHelp: "Select only the Career Profile sections and contact details you want to include. Nothing is selected automatically.",
cvDashboardProfileLoadFailed: "Could not load Career Profile sections. Try again or start blank.",
cvDashboardProfileEmpty: "Your Career Profile has no sections yet. You can add content in the editor.",
cvEditorUndoSessionHelp: "Undo and redo apply to this editing session. Saved versions remain available in History.",
cvEditorEmptyBody: "Your CV has no sections yet. Add a section to begin.",
cvEditorProfileContentHint: "Adding a Career Profile section includes its saved content. Custom sections start empty.",
cvEditorIncludeProfileContact: "Include Career Profile contact details",
cvEmptyContent: "Add content to your CV before exporting a PDF.",
cvDashboardEnterName: "Enter a name.",
cvDashboardNameExample: "For example: Backend Engineer — Acme",
cvDashboardChooseTemplate: "Choose a template",
@@ -3887,7 +3899,7 @@ export const translations = {
cvEditorEditMasterProfile: "Åpne Karriereprofil",
cvEditorMasterProfileHelp: "Karriereprofil er kilden din. Endringer i tekst, rekkefølge og synlighet her gjelder bare denne CV-en.",
cvEditorHeadlineOverride: "Egen overskrift",
cvEditorHeadlineHelp: "Tomt felt bruker overskriften fra Karriereprofil. Endringer her gjelder bare denne CV-en.",
cvEditorHeadlineHelp: "Et tomt felt bruker overskriften fra Karriereprofil bare når profilens kontaktinformasjon er inkludert. Endringer her gjelder bare denne CV-en.",
cvEditorSections: "Seksjoner",
cvEditorSectionsHelp: "Dra for å endre rekkefølgen, eller bruk pilene. Utelatt innhold kan fortsatt redigeres.",
cvEditorUsingSource: "Bruker Karriereprofil",
@@ -4109,7 +4121,19 @@ export const translations = {
cvDashboardOpen: "Åpne",
cvDashboardDelete: "Slett",
cvDashboardCreateTitle: "Opprett en CV",
cvDashboardCreateHelp: "Start med den lagrede karriereprofilen, velg en visuell retning og tilpass deretter hva som skal vises.",
cvDashboardCreateHelp: "Velg innholdskilde og mal. Maler endrer bare utformingen.",
cvDashboardContentSource: "Innholdskilde for CV",
cvDashboardStartBlank: "Start med en tom CV",
cvDashboardFromProfile: "Fra karriereprofilen",
cvDashboardBlankHelp: "Start uten seksjoner eller kontaktopplysninger fra profilen. Legg til eget innhold i redigeringsverktøyet.",
cvDashboardProfileSelectionHelp: "Velg seksjonene og kontaktopplysningene fra karriereprofilen som du vil ta med. Ingenting velges automatisk.",
cvDashboardProfileLoadFailed: "Kunne ikke laste seksjonene fra karriereprofilen. Prøv igjen eller start med en tom CV.",
cvDashboardProfileEmpty: "Karriereprofilen har ingen seksjoner ennå. Du kan legge til innhold i redigeringsverktøyet.",
cvEditorUndoSessionHelp: "Angre og gjør om gjelder denne redigeringsøkten. Lagrede versjoner er fortsatt tilgjengelige i historikken.",
cvEditorEmptyBody: "CV-en har ingen seksjoner ennå. Legg til en seksjon for å begynne.",
cvEditorProfileContentHint: "Når du legger til en seksjon fra karriereprofilen, tas det lagrede innholdet med. Egendefinerte seksjoner starter tomme.",
cvEditorIncludeProfileContact: "Ta med kontaktopplysninger fra karriereprofilen",
cvEmptyContent: "Legg til innhold i CV-en før du eksporterer en PDF.",
cvDashboardEnterName: "Skriv inn et navn.",
cvDashboardNameExample: "For eksempel: Backendutvikler — Acme",
cvDashboardChooseTemplate: "Velg en mal",
+115 -54
View File
@@ -2,7 +2,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { Link as RouterLink, useBlocker, useNavigate, useParams } from "react-router-dom";
import {
Alert, Box, Button, Chip, Collapse, Dialog, DialogActions, DialogContent, DialogTitle, IconButton,
Alert, Box, Button, Checkbox, Chip, Collapse, Dialog, DialogActions, DialogContent, DialogTitle, FormControlLabel, IconButton,
Menu, MenuItem, Paper, Skeleton, Stack, Tab, Tabs, TextField, Tooltip, Typography,
} from "@mui/material";
import useMediaQuery from "@mui/material/useMediaQuery";
@@ -36,7 +36,7 @@ import { useDragReorder } from "../hooks/useDragReorder";
import {
CUSTOM_SECTION_PRESETS, CvAiCallbacks, CvAiInput, CvAiProposal, CvCustomSectionSetting, CvItemOverride, CvOutline, CvOutlineSection, CvSectionSetting,
CvTheme, CvVariant, CvVariantSettings, CvVariantVersionInfo, DEFAULT_SECTION_ORDER, SECTION_LABELS,
cvBuilderApi, getCvPageMetrics, getCvPreviewFitZoom, moveItem, normalizeCvLanguage,
cvBuilderApi, getCvExportFailureKey, getCvPageMetrics, getCvPreviewFitZoom, moveItem, normalizeCvLanguage,
} from "../cvBuilder";
import { previewDocumentHtml } from "../cvPreview";
import { useDialogActions } from "../dialogs";
@@ -46,6 +46,11 @@ import ViewStateNotice from "../components/ViewStateNotice";
const MIN_PREVIEW_ZOOM = 0.2;
type SaveState = "idle" | "unsaved" | "saving" | "saved" | "error" | "conflict";
type EditorSnapshot = { name: string; settings: CvVariantSettings };
function isTextInput(element: Element | null) {
return !!element?.matches('textarea,input:not([type=checkbox]):not([type=radio]):not([type=range]),[contenteditable=true]');
}
export default function CvBuilderEditor() {
const { id } = useParams();
@@ -101,15 +106,27 @@ export default function CvBuilderEditor() {
const loadRegion = useRef<HTMLDivElement | null>(null);
const retryFocus = useRef(false);
const blockerPromptOpen = useRef(false);
const undoStack = useRef<CvVariantSettings[]>([]);
const redoStack = useRef<CvVariantSettings[]>([]);
const undoStack = useRef<EditorSnapshot[]>([]);
const redoStack = useRef<EditorSnapshot[]>([]);
const typingGroup = useRef<{ element: Element; time: number } | null>(null);
const loadedVariantId = useRef<number | null>(null);
const pageMetrics = useMemo(() => getCvPageMetrics(settings?.pageSize), [settings?.pageSize]);
const editorReady = settings !== null;
const editorReady = settings !== null && loadedVariantId.current === variantId && !opening;
const previewBusy = previewing || (!previewError && (!html || measuredHtml !== html));
const previewReady = !!html && measuredHtml === html && !previewBusy && !previewError;
useEffect(() => {
let alive = true;
loadedVariantId.current = null;
latestSettings.current = null;
if (saveTimer.current) clearTimeout(saveTimer.current);
if (previewTimer.current) clearTimeout(previewTimer.current);
previewRequest.current += 1;
setSettings(null);
setHtml("");
setMeasuredHtml(null);
setVersions([]);
setSaveState("idle");
setOpening(true);
(async () => {
try {
@@ -132,6 +149,8 @@ export default function CvBuilderEditor() {
return () => {
alive = false;
editorEpoch.current += 1;
previewRequest.current += 1;
if (saveTimer.current) clearTimeout(saveTimer.current);
};
}, [variantId, loadAttempt]);
@@ -148,6 +167,7 @@ export default function CvBuilderEditor() {
editorEpoch.current += 1;
serverVersion.current = variant.version;
revisionConflict.current = false;
loadedVariantId.current = variant.id;
setName(variant.name);
setSettings(variant.settings);
latestName.current = variant.name;
@@ -158,12 +178,13 @@ export default function CvBuilderEditor() {
setSaveState("saved");
undoStack.current = [];
redoStack.current = [];
typingGroup.current = null;
setHistoryRevision((value) => value + 1);
};
// Debounced live preview.
useEffect(() => {
if (!settings) return;
if (!editorReady || !settings) return;
const request = ++previewRequest.current;
setPreviewing(true);
if (previewTimer.current) clearTimeout(previewTimer.current);
@@ -183,7 +204,7 @@ export default function CvBuilderEditor() {
return () => {
if (previewTimer.current) clearTimeout(previewTimer.current);
};
}, [settings, previewRevision]);
}, [settings, previewRevision, editorReady]);
const performSave = useCallback(async (next: CvVariantSettings, nextName: string, revision: number) => {
const epoch = editorEpoch.current;
@@ -260,7 +281,7 @@ export default function CvBuilderEditor() {
const acceptAi = async (proposal: CvAiProposal, result?: string) => {
const { epoch, revision } = await flushAiSource();
const previous = latestSettings.current!;
const previous = { name: latestName.current, settings: latestSettings.current! };
const apply = async () => {
if (epoch !== editorEpoch.current || revision !== saveRevision.current || revisionConflict.current) throw new Error(t("cvAiFailed"));
try {
@@ -274,6 +295,7 @@ export default function CvBuilderEditor() {
}
undoStack.current = [...undoStack.current.slice(-59), previous];
redoStack.current = [];
typingGroup.current = null;
saveRevision.current += 1;
persistedRevision.current = saveRevision.current;
latestSettings.current = saved.settings;
@@ -299,46 +321,67 @@ export default function CvBuilderEditor() {
await queued;
};
const editDocument = (next: CvVariantSettings, nextName = latestName.current) => {
const previous = latestSettings.current;
if (!editorReady || !previous || (nextName === latestName.current && JSON.stringify(next) === JSON.stringify(previous))) return;
const element = document.activeElement;
const time = Date.now();
const grouped = isTextInput(element) && typingGroup.current?.element === element && time - typingGroup.current.time < 1000;
if (!grouped) undoStack.current = [...undoStack.current.slice(-59), { name: latestName.current, settings: previous }];
typingGroup.current = isTextInput(element) ? { element: element!, time } : null;
redoStack.current = [];
setSettings(next);
setName(nextName);
scheduleSave(next, nextName);
setHistoryRevision(value => value + 1);
};
const update = (patch: Partial<CvVariantSettings>) => {
setSettings((prev) => {
if (!prev) return prev;
const next = { ...prev, ...patch };
undoStack.current = [...undoStack.current.slice(-59), prev];
redoStack.current = [];
setHistoryRevision((value) => value + 1);
scheduleSave(next);
return next;
});
if (latestSettings.current) editDocument({ ...latestSettings.current, ...patch });
};
const undo = () => {
setSettings((current) => {
const previous = undoStack.current.pop();
if (!current || !previous) return current;
redoStack.current.push(current);
setHistoryRevision((value) => value + 1);
scheduleSave(previous);
return previous;
});
if (!editorReady || revisionConflict.current || !latestSettings.current) return;
const previous = undoStack.current.pop();
if (!previous) return;
redoStack.current.push({ name: latestName.current, settings: latestSettings.current });
typingGroup.current = null;
setSettings(previous.settings);
setName(previous.name);
scheduleSave(previous.settings, previous.name);
setHistoryRevision(value => value + 1);
};
const redo = () => {
setSettings((current) => {
const next = redoStack.current.pop();
if (!current || !next) return current;
undoStack.current.push(current);
setHistoryRevision((value) => value + 1);
scheduleSave(next);
return next;
});
if (!editorReady || revisionConflict.current || !latestSettings.current) return;
const next = redoStack.current.pop();
if (!next) return;
undoStack.current.push({ name: latestName.current, settings: latestSettings.current });
typingGroup.current = null;
setSettings(next.settings);
setName(next.name);
scheduleSave(next.settings, next.name);
setHistoryRevision(value => value + 1);
};
const renameVariant = (value: string) => {
setName(value);
latestName.current = value;
if (settings) scheduleSave(settings, value);
if (latestSettings.current) editDocument(latestSettings.current, value);
};
useEffect(() => {
const shortcut = (event: KeyboardEvent) => {
const target = event.target instanceof Element ? event.target : document.activeElement;
if (event.defaultPrevented || event.isComposing || event.altKey || !(event.ctrlKey || event.metaKey)
|| target?.closest('input,textarea,select,[contenteditable=true]') || !editorReady || revisionConflict.current) return;
const key = event.key.toLowerCase();
const wantsRedo = key === "y" || (key === "z" && event.shiftKey);
if (wantsRedo && redoStack.current.length) { event.preventDefault(); redo(); }
else if (key === "z" && !event.shiftKey && undoStack.current.length) { event.preventDefault(); undo(); }
};
document.addEventListener("keydown", shortcut);
return () => document.removeEventListener("keydown", shortcut);
});
const hasUnsavedChanges = saveState === "unsaved" || saveState === "saving" || saveState === "error" || saveState === "conflict";
const downloadUnsavedCopy = () => {
@@ -353,10 +396,11 @@ export default function CvBuilderEditor() {
const reloadSavedVariant = async () => {
if (!(await confirmAction(t("cvEditorConflictReloadMessage"), { title: t("cvEditorConflictReload"), confirmLabel: t("cvEditorConflictReload"), destructive: true }))) return;
const revision = saveRevision.current;
const epoch = editorEpoch.current;
await saveQueue.current;
try {
const saved = await cvBuilderApi.get(variantId);
if (saveRevision.current === revision) applyVariant(saved);
if (editorEpoch.current === epoch && saveRevision.current === revision) applyVariant(saved);
}
catch (error) { toast(getApiErrorMessage(error, t("cvEditorOpenFailed")), "error"); }
};
@@ -438,8 +482,8 @@ export default function CvBuilderEditor() {
a.click();
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("cvEditorPdfFailed")), "error");
const key = await getCvExportFailureKey(err);
toast(key ? t(key) : getApiErrorMessage(err, t("cvEditorPdfFailed")), "error");
} finally {
exportInFlight.current = false;
setExporting(false);
@@ -461,26 +505,32 @@ export default function CvBuilderEditor() {
};
const loadVersions = async () => {
const epoch = editorEpoch.current;
try {
setVersions(await cvBuilderApi.versions(variantId));
const result = await cvBuilderApi.versions(variantId);
if (epoch === editorEpoch.current) setVersions(result);
} catch (err) {
toast(getApiErrorMessage(err, t("cvEditorHistoryFailed")), "error");
if (epoch === editorEpoch.current) toast(getApiErrorMessage(err, t("cvEditorHistoryFailed")), "error");
}
};
const restore = async (version: number) => {
const epoch = editorEpoch.current;
if (!(await confirmAction(t("cvEditorRestoreMessage", { version }), {
title: t("cvEditorRestoreTitle", { version }),
confirmLabel: t("cvEditorRestoreVersion"),
}))) return;
if (epoch !== editorEpoch.current) return;
if (hasUnsavedChanges && !(await retrySave())) {
toast(t("cvEditorSaveBeforeRestore"), "error");
return;
}
try {
await saveQueue.current;
if (epoch !== editorEpoch.current) return;
const revision = saveRevision.current;
const updated = await cvBuilderApi.restore(variantId, version, serverVersion.current);
if (epoch !== editorEpoch.current) return;
if (saveRevision.current === revision) applyVariant(updated);
else {
// Edits made while restore was in flight remain recoverable; never replace them on response.
@@ -490,6 +540,7 @@ export default function CvBuilderEditor() {
await loadVersions();
toast(t("cvEditorRestoredVersion", { version }), "success");
} catch (err) {
if (epoch !== editorEpoch.current) return;
const code = (err as { response?: { data?: { code?: string } } })?.response?.data?.code;
if (code === "cv_revision_conflict" || code === "cv_revision_required") {
revisionConflict.current = true;
@@ -550,13 +601,14 @@ export default function CvBuilderEditor() {
</Box>
);
}
if (!settings) return <EditorSkeleton />;
if (!settings || !editorReady) return <EditorSkeleton />;
const canUndo = historyRevision >= 0 && undoStack.current.length > 0;
const canRedo = historyRevision >= 0 && redoStack.current.length > 0;
const canUndo = !revisionConflict.current && historyRevision >= 0 && undoStack.current.length > 0;
const canRedo = !revisionConflict.current && historyRevision >= 0 && redoStack.current.length > 0;
return (
<Stack spacing={1.5} data-content-density="comfortable" sx={{
<Stack spacing={1.5} data-content-density="comfortable" onBlurCapture={() => { typingGroup.current = null; }}
onPointerDownCapture={event => { if ((event.target as Element).closest('button,[role=button]')) typingGroup.current = null; }} sx={{
minWidth: 0,
"--cv-pane-top": "calc(var(--app-shell-header-height, 76px) + 12px)",
"& [tabindex], & button, & input, & textarea, & [role=combobox]": { scrollMarginTop: "var(--cv-pane-top)" },
@@ -612,6 +664,7 @@ export default function CvBuilderEditor() {
<Tooltip describeChild title={t("cvEditorRedo")}><span><IconButton size="small" aria-label={t("cvEditorRedo")} disabled={!canRedo} onClick={redo}><RedoIcon fontSize="small" /></IconButton></span></Tooltip>
</Stack>
</Stack>
<Typography variant="caption" color="text.secondary" sx={{ px: 1.5 }}>{t("cvEditorUndoSessionHelp")}</Typography>
<Tabs aria-label={t("cvEditorStudioNavigation")} value={tab} onChange={(_, v) => { setTab(v); if (v === 4) loadVersions(); }} variant="scrollable" scrollButtons="auto" allowScrollButtonsMobile sx={{ borderBottom: "1px solid", borderColor: "divider" }}>
{[1, 0, 2, 3, 4].map((value) => <Tab key={value} value={value} id={`cv-studio-tab-${value}`} aria-controls={`cv-studio-panel-${value}`} label={t((["cvEditorTemplate", "cvEditorContent", "cvEditorCustomize", "cvEditorAiTools", "cvEditorHistory"] as const)[value])} />)}
</Tabs>
@@ -755,12 +808,13 @@ type DocumentAiBlock = {
function documentAiBlocks(settings: CvVariantSettings, outline: CvOutline | null): DocumentAiBlock[] {
const blocks: DocumentAiBlock[] = [];
const configured = new Map(settings.sections.map((section) => [section.key, section]));
if ((settings.headline ?? outline?.headline)?.trim()) {
blocks.push({ id: "headline", items: [(settings.headline ?? outline?.headline ?? "").trim()] });
const headline = settings.headline ?? (settings.includeProfileContact !== false ? outline?.headline : null);
if (headline?.trim()) {
blocks.push({ id: "headline", items: [headline.trim()] });
}
for (const section of outline?.sections ?? []) {
const sectionSetting = configured.get(section.key);
if (sectionSetting?.hidden) continue;
if (sectionSetting?.hidden || (settings.sectionsExplicit && !sectionSetting)) continue;
const title = sectionSetting?.title ?? section.title;
if (section.kind === "entries") {
const entryKeys = Array.from(new Set(section.entries.map((entry, index) => entry.key ?? `${index}`)));
@@ -786,6 +840,7 @@ function documentAiBlocks(settings: CvVariantSettings, outline: CvOutline | null
}
}
for (const section of settings.customSections ?? []) {
if (settings.sectionsExplicit && !configured.has(`custom:${section.key}`)) continue;
if (!(configured.get(`custom:${section.key}`)?.hidden ?? section.hidden)) blocks.push({ id: `custom:${section.key}`, title: section.title, items: section.items });
}
return blocks.filter((block) => block.title?.trim() || block.subtitle?.trim() || block.items?.some((item) => item.trim()));
@@ -982,8 +1037,9 @@ function ContentTab({ settings, update, outline, showPreviewEntry, onFocusPrevie
const [expandedSections, setExpandedSections] = useState<Set<string>>(() => new Set());
const [reorderMessage, setReorderMessage] = useState("");
const sectionButtons = useRef<Record<string, HTMLButtonElement | null>>({});
// Full section list = configured order (once touched) else default, always including every known key.
// Legacy documents inherit profile sections; new explicit documents include only chosen keys.
const sectionRows: CvSectionSetting[] = useMemo(() => {
if (settings.sectionsExplicit) return settings.sections;
const populated = new Set((outline?.sections ?? []).map((section) => section.key));
const base: CvSectionSetting[] = settings.sections.length ? [...settings.sections] : DEFAULT_SECTION_ORDER.map((key) => ({ key, hidden: !populated.has(key) }));
const have = new Set(base.map((s) => s.key));
@@ -1000,7 +1056,7 @@ function ContentTab({ settings, update, outline, showPreviewEntry, onFocusPrevie
}
}
return base;
}, [outline?.sections, settings.customSections, settings.sections]);
}, [outline?.sections, settings.customSections, settings.sections, settings.sectionsExplicit]);
const writeSections = (rows: CvSectionSetting[]) => update({ sections: rows });
const reorderSections = (from: number, to: number, retainFocus = false) => {
@@ -1060,7 +1116,8 @@ function ContentTab({ settings, update, outline, showPreviewEntry, onFocusPrevie
setAddContentOpen(false);
};
const addBuiltIn = (key: string) => {
patchSection(key, { hidden: false });
if (sectionRows.some(row => row.key === key)) patchSection(key, { hidden: false });
else writeSections([...sectionRows, { key, title: outlineByKey[key]?.title }]);
setAddContentOpen(false);
};
const updateCustom = (key: string, patch: Partial<CvCustomSectionSetting>) =>
@@ -1081,7 +1138,7 @@ function ContentTab({ settings, update, outline, showPreviewEntry, onFocusPrevie
<Stack spacing={2}>
<Stack direction="row" gap={1} alignItems="center" flexWrap="wrap">
<Box component="nav" aria-label={t("cvEditorSectionNavigation")} sx={{ flex: "1 1 14rem", minWidth: 0 }}>
<TextField select fullWidth size="small" label={t("cvEditorGoToSection")}
<TextField select fullWidth size="small" disabled={!sectionRows.length} label={t("cvEditorGoToSection")}
value={sectionRows.some((row) => row.key === activeSection) ? activeSection : ""}
onChange={(event) => setActiveSection(event.target.value)}>
{sectionRows.map((row) => <MenuItem key={row.key} value={row.key} onClick={() => goToSection(row.key)}>
@@ -1094,6 +1151,8 @@ function ContentTab({ settings, update, outline, showPreviewEntry, onFocusPrevie
<Alert severity="info" sx={{ flexWrap: "wrap", "& .MuiAlert-message": { flex: "1 1 12rem", minWidth: 0, overflowWrap: "anywhere" }, "& .MuiAlert-action": { ml: "auto" } }} action={<Button component={RouterLink} to="/career?section=profile" size="small">{t("cvEditorEditMasterProfile")}</Button>}>
{t("cvEditorMasterProfileHelp")}
</Alert>
<FormControlLabel control={<Checkbox checked={settings.includeProfileContact !== false}
onChange={(_, checked) => update({ includeProfileContact: checked })} />} label={t("cvEditorIncludeProfileContact")} />
<TextField label={t("cvEditorHeadlineOverride")} size="small" fullWidth value={settings.headline ?? ""}
onChange={(e) => update({ headline: e.target.value || null })}
helperText={t("cvEditorHeadlineHelp")} />
@@ -1102,6 +1161,7 @@ function ContentTab({ settings, update, outline, showPreviewEntry, onFocusPrevie
<Typography component="h3" variant="subtitle2" sx={{ fontWeight: 800, mb: 0.5 }}>{t("cvEditorSections")}</Typography>
<Typography variant="caption" color="text.secondary">{t("cvEditorSectionsHelp")}</Typography>
<Typography variant="caption" color="text.secondary" role="status" aria-label={t("cvEditorSectionOrderStatus")} sx={{ display: "block", minHeight: "1.5em" }}>{reorderMessage}</Typography>
{!sectionRows.length && <Alert severity="info">{t("cvEditorEmptyBody")}</Alert>}
<Stack spacing={0.5} sx={{ mt: 1 }}>
{sectionRows.map((row, i) => (
<SectionRow
@@ -1146,11 +1206,12 @@ function ContentTab({ settings, update, outline, showPreviewEntry, onFocusPrevie
<DialogTitle id="cv-add-content-title" sx={{ fontWeight: 850 }}>{t("cvEditorAddContent")}</DialogTitle>
<DialogContent dividers>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>{t("cvEditorAddContentHelp")}</Typography>
{settings.sectionsExplicit && <Alert severity="info" sx={{ mb: 2 }}>{t("cvEditorProfileContentHint")}</Alert>}
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", sm: "repeat(2, minmax(0, 1fr))", md: "repeat(3, minmax(0, 1fr))" }, gap: 1 }}>
{DEFAULT_SECTION_ORDER.map((key) => {
{Array.from(new Set([...DEFAULT_SECTION_ORDER, ...(outline?.sections ?? []).map(section => section.key)])).map((key) => {
const row = sectionRows.find((section) => section.key === key);
const added = !!row && !row.hidden;
const label = sectionName(key, SECTION_LABELS[key] ?? key, t);
const label = sectionName(key, outlineByKey[key]?.title ?? SECTION_LABELS[key] ?? key, t);
return <AddContentCard key={key} title={label} description={t("cvEditorAddBuiltInHelp", { section: label })} added={added} onAdd={() => addBuiltIn(key)} />;
})}
{CUSTOM_SECTION_PRESETS.map((preset) => {
+42 -6
View File
@@ -2,7 +2,7 @@ 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,
Alert, Box, Button, Checkbox, Chip, Dialog, DialogActions, DialogContent, DialogTitle, FormControlLabel, IconButton, Menu, MenuItem, Paper, Radio, RadioGroup, Stack, TextField, Typography,
} from "@mui/material";
import AddIcon from "@mui/icons-material/Add";
import MoreVertIcon from "@mui/icons-material/MoreVert";
@@ -10,7 +10,7 @@ import PublicIcon from "@mui/icons-material/Public";
import { api, getApiErrorMessage } from "../api";
import { useToast } from "../toast";
import { CvTheme, CvVariantSummary, cvBuilderApi, emptyCvVariantSettings } from "../cvBuilder";
import { CvOutline, CvTheme, CvVariantSummary, cvBuilderApi, emptyCvVariantSettings, getCvExportFailureKey } from "../cvBuilder";
import { useDialogActions } from "../dialogs";
import CvTemplateGallery from "../components/CvTemplateGallery";
import CvTemplateThumbnail from "../components/CvTemplateThumbnail";
@@ -45,6 +45,20 @@ export default function CvBuilderPage() {
const [newName, setNewName] = useState(t("cvDashboardUntitled"));
const [newTheme, setNewTheme] = useState("modern");
const [creating, setCreating] = useState(false);
const [contentSource, setContentSource] = useState("blank");
const [profileOutline, setProfileOutline] = useState<CvOutline | null>(null);
const [profileLoading, setProfileLoading] = useState(false);
const [profileFailed, setProfileFailed] = useState(false);
const [selectedSections, setSelectedSections] = useState<string[]>([]);
const [includeContact, setIncludeContact] = useState(false);
const loadProfile = async () => {
setProfileLoading(true);
setProfileFailed(false);
try { setProfileOutline(await cvBuilderApi.outline()); }
catch { setProfileFailed(true); }
finally { setProfileLoading(false); }
};
useEffect(() => {
if (pending) contentRef.current?.focus();
@@ -77,7 +91,12 @@ export default function CvBuilderPage() {
setCreateError(null);
setCreating(true);
try {
const variant = await cvBuilderApi.create({ name: newName.trim(), settings: emptyCvVariantSettings(newTheme) });
const variant = await cvBuilderApi.create({ name: newName.trim(), settings: {
...emptyCvVariantSettings(newTheme),
sectionsExplicit: true,
includeProfileContact: contentSource === "profile" && includeContact,
sections: contentSource === "profile" ? selectedSections.map((key) => ({ key })) : [],
} });
setCreateOpen(false);
navigate(`/career/builder/${variant.id}`);
} catch (err) {
@@ -144,8 +163,8 @@ export default function CvBuilderPage() {
link.click();
URL.revokeObjectURL(url);
} catch (err) {
const status = (err as { response?: { status?: number } })?.response?.status;
setActionError({ error: status === 503 || status === 504 ? undefined : err, key: status === 503 ? "cvPdfBusy" : status === 504 ? "cvPdfTimedOut" : "cvDashboardDownloadFailed" });
const key = await getCvExportFailureKey(err);
setActionError({ error: key ? undefined : err, key: key ?? "cvDashboardDownloadFailed" });
} finally { finishAction(); }
};
@@ -216,10 +235,27 @@ export default function CvBuilderPage() {
<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" } }} />
<RadioGroup aria-label={t("cvDashboardContentSource")} value={contentSource} onChange={(event) => {
setContentSource(event.target.value);
if (event.target.value === "profile" && !profileOutline && !profileLoading) void loadProfile();
}}>
<FormControlLabel value="blank" control={<Radio disabled={creating} />} label={t("cvDashboardStartBlank")} />
<FormControlLabel value="profile" control={<Radio disabled={creating} />} label={t("cvDashboardFromProfile")} />
</RadioGroup>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>{t(contentSource === "profile" ? "cvDashboardProfileSelectionHelp" : "cvDashboardBlankHelp")}</Typography>
{contentSource === "profile" && <Stack sx={{ mb: 2 }}>
{profileLoading && <Typography role="status">{t("loading")}</Typography>}
{profileFailed && <Alert severity="error" action={<Button onClick={() => void loadProfile()}>{t("retry")}</Button>}>{t("cvDashboardProfileLoadFailed")}</Alert>}
{profileOutline && <>
<FormControlLabel control={<Checkbox disabled={creating} checked={includeContact} onChange={(event) => setIncludeContact(event.target.checked)} />} label={t("cvEditorIncludeProfileContact")} />
{profileOutline.sections.length === 0 && <Typography>{t("cvDashboardProfileEmpty")}</Typography>}
{profileOutline.sections.map((section) => <FormControlLabel key={section.key} label={section.title} control={<Checkbox disabled={creating} checked={selectedSections.includes(section.key)} onChange={(event) => setSelectedSections((previous) => event.target.checked ? [...previous, section.key] : previous.filter((key) => key !== section.key))} />} />)}
</>}
</Stack>}
<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>
<DialogActions><Button onClick={() => setCreateOpen(false)} disabled={creating}>{t("cancel")}</Button><Button variant="contained" disabled={creating || !newName.trim() || (contentSource === "profile" && (!profileOutline || profileLoading))} onClick={() => void confirmCreate()}>{creating ? t("cvDashboardCreating") : t("cvDashboardCreateCv")}</Button></DialogActions>
</Dialog>
</Box>
);