fix: preserve blank CVs and reliable editor undo
This commit is contained in:
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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) ---
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user