feat(cv): redesign editor frame and section navigation
This commit is contained in:
@@ -55,9 +55,20 @@ async function scan(page: Page, info: TestInfo, state: string) {
|
||||
}
|
||||
|
||||
async function enterPreview(page: Page, region: Locator, start = page.getByRole("tab").last()) {
|
||||
// Traverse the real tab order from a preceding control, not a DOM focus call on the region.
|
||||
// Content is primary in P5-09. Reach the named pane-navigation action through
|
||||
// the real tab order, rather than traversing every collapsed section control.
|
||||
await start.focus();
|
||||
for (let i = 0; i < 80 && !(await region.evaluate(node => node === document.activeElement)); i++) await page.keyboard.press("Tab");
|
||||
const previewEntry = page.getByRole("button", { name: /^(Go to preview|Gå til forhåndsvisning)$/ });
|
||||
if (await previewEntry.isVisible()) {
|
||||
await page.keyboard.press("Tab");
|
||||
await expect(page.getByRole("combobox", { name: /^(Go to section|Gå til seksjon)/ })).toBeFocused();
|
||||
await page.keyboard.press("Tab");
|
||||
await expect(previewEntry).toBeFocused();
|
||||
await page.keyboard.press("Enter");
|
||||
} else {
|
||||
// Mobile retains its existing Edit/Preview button and native tab entry.
|
||||
for (let i = 0; i < 80 && !(await region.evaluate(node => node === document.activeElement)); i++) await page.keyboard.press("Tab");
|
||||
}
|
||||
await expect(region).toBeFocused();
|
||||
const ring = await region.evaluate(node => {
|
||||
const css = getComputedStyle(node);
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
import { expect, test, type APIRequestContext, type Page, type TestInfo } from '@playwright/test';
|
||||
import AxeBuilder from '@axe-core/playwright';
|
||||
import { translations } from '../src/i18n/translations';
|
||||
|
||||
const apiUrl = 'http://localhost:5302/api';
|
||||
const password = 'E2ePassword123!';
|
||||
async function login(api: APIRequestContext, email: string) {
|
||||
expect((await api.post(`${apiUrl}/auth/login`, { data: { email, password } })).status()).toBe(200);
|
||||
const me = await (await api.get(`${apiUrl}/auth/me`)).json();
|
||||
return { 'X-CSRF-TOKEN': (await api.storageState()).cookies.find(cookie => cookie.name === 'XSRF-TOKEN')!.value, 'X-Session-Context': me.sessionContext as string };
|
||||
}
|
||||
async function capture(page: Page, info: TestInfo, state: string) {
|
||||
await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))));
|
||||
await page.screenshot({ path: info.outputPath(`${state}.png`) });
|
||||
}
|
||||
async function scan(page: Page, info: TestInfo, state: string) {
|
||||
const result = await new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa']).analyze();
|
||||
await info.attach(`${state}-axe`, { body: JSON.stringify({ violations: result.violations, incomplete: result.incomplete }), contentType: 'application/json' });
|
||||
expect(result.violations).toEqual([]);
|
||||
}
|
||||
|
||||
for (const [mode, language] of [['light', 'en'], ['dark', 'nb'], ['light', 'nb'], ['dark', 'en']] as const) {
|
||||
test(`[PRES-C05 C06 C07 C10 W05 E08] ${mode}/${language} CV frame navigation, actions and pending editing`, async ({ page, context, playwright }, info) => {
|
||||
test.setTimeout(180_000);
|
||||
const t = translations[language];
|
||||
const label = (key: keyof typeof t, section: string) => t[key].replace('{section}', section);
|
||||
const admin = await playwright.request.newContext();
|
||||
const adminHeaders = await login(admin, 'e2e@example.test');
|
||||
const email = `p509-${Date.now()}-${mode}-${language}@example.test`;
|
||||
expect((await admin.post(`${apiUrl}/users`, { headers: adminHeaders, data: { email, password, roles: [] } })).status()).toBe(200);
|
||||
await admin.dispose();
|
||||
const headers = await login(page.request, email);
|
||||
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();
|
||||
});
|
||||
await context.addInitScript(({ mode, language }) => { localStorage.setItem('jobtracker.themeMode', mode); localStorage.setItem('uiLanguage', language); }, { mode, language });
|
||||
expect((await page.request.put(`${apiUrl}/auth/preferences/language`, { headers, data: { language: language === 'nb' ? 'nb-NO' : 'en' } })).status()).toBe(204);
|
||||
const profile = { version: '1', contact: { fullName: 'Åse Eksempel', headline: 'Canonical career information', location: 'Oslo, Norge' },
|
||||
summary: ['Verified source experience.'], jobs: [{ id: 'job-1', title: 'Platform engineer', company: 'Nordlys AS', startDate: '2021-01', isCurrent: true, bullets: ['Maintained verified, accessible workflows.'] }],
|
||||
education: [{ id: 'education-1', qualification: 'Computer science', institution: 'Fixture University', startDate: '2016-08', endDate: '2020-06' }],
|
||||
projects: [{ id: 'project-1', name: 'Evidence workspace', bullets: ['Documented source-grounded reviews.'] }], skills: ['C#', 'React', 'Norsk'],
|
||||
};
|
||||
expect((await page.request.put(`${apiUrl}/career/profile`, { headers, data: { profile } })).status()).toBe(200);
|
||||
const source = await (await page.request.get(`${apiUrl}/career/profile`)).json();
|
||||
const company = await page.request.post(`${apiUrl}/companies`, { headers, data: { name: 'Nordlys fixture AS' } });
|
||||
expect(company.status()).toBe(201);
|
||||
const job = await page.request.post(`${apiUrl}/jobapplications`, { headers, data: { companyId: (await company.json()).id, jobTitle: 'Application-linked CV fixture', status: 'Saved' } });
|
||||
expect(job.status()).toBe(201); const jobId = (await job.json()).id;
|
||||
const created = await page.request.post(`${apiUrl}/cv/variants`, { headers, data: { name: 'Åse — dokumenterte kvalifikasjoner og erfaring', jobApplicationId: jobId, settings: {
|
||||
themeId: 'minimal', sections: [{ key: 'summary' }, { key: 'experience' }, { key: 'education' }, { key: 'projects' }, { key: 'skills', hidden: true }],
|
||||
customSections: [{ key: 'volunteer', title: 'Frivillig arbeid og dokumenterte bidrag', contentType: 'paragraphs', items: ['Eksisterende innhold — æøå.'] }],
|
||||
} } });
|
||||
expect(created.status()).toBe(200); const cv = await created.json();
|
||||
const cvUrl = `${apiUrl}/cv/variants/${cv.id}`;
|
||||
const read = async () => (await page.request.get(cvUrl)).json();
|
||||
const saved = async () => { await expect(page.getByText(t.cvEditorSaved, { exact: true })).toBeVisible(); };
|
||||
await page.setViewportSize({ width: 1440, height: 900 });
|
||||
await page.goto(`/career/builder/${cv.id}`);
|
||||
await expect(page.getByLabel(t.cvEditorName, { exact: true })).toHaveValue(cv.name);
|
||||
await expect(page.getByRole('tab', { name: t.cvEditorContent, exact: true })).toHaveAttribute('aria-selected', 'true');
|
||||
await expect(page.getByRole('tabpanel', { name: t.cvEditorContent, exact: true })).toBeVisible();
|
||||
await expect(page.getByText(t.cvEditorScope, { exact: true })).toBeVisible();
|
||||
await expect(page.getByRole('link', { name: t.cvEditorLinkedApplication })).toHaveAttribute('href', `/jobs/${jobId}`);
|
||||
await expect(page.getByTitle(t.cvEditorPreviewPage.replace('{page}', '1'), { exact: true })).toBeVisible();
|
||||
await saved(); await capture(page, info, 'desktop-content'); await scan(page, info, 'desktop-content');
|
||||
|
||||
const sectionSelect = page.getByRole('combobox', { name: new RegExp(`^${t.cvEditorGoToSection}`) });
|
||||
await sectionSelect.click();
|
||||
await page.getByRole('option', { name: `${t.cvSectionSkills} · ${t.cvEditorOmitted}`, exact: true }).click();
|
||||
await expect(page.getByRole('button', { name: label('cvEditorCollapseSection', t.cvSectionSkills), exact: true })).toBeFocused();
|
||||
await expect(page.getByRole('button', { name: label('cvEditorShowSection', t.cvSectionSkills), exact: true })).toBeVisible();
|
||||
expect((await read()).version).toBe(cv.version);
|
||||
await capture(page, info, 'omitted-section-focus');
|
||||
|
||||
// Native HTML drag path and the existing keyboard alternative both change the
|
||||
// persisted document order; no simulated request or alternative order model.
|
||||
await page.getByRole('group', { name: t.cvSectionProjects, exact: true }).dragTo(page.getByRole('group', { name: t.cvSectionExperience, exact: true }));
|
||||
await saved();
|
||||
await expect.poll(async () => (await read()).settings.sections.slice(0, 4).map((row: { key: string }) => row.key)).toEqual(['summary', 'projects', 'experience', 'education']);
|
||||
const move = page.getByRole('button', { name: label('cvEditorMoveSectionDown', t.cvSectionExperience), exact: true });
|
||||
await move.focus(); await page.keyboard.press('Enter');
|
||||
await expect(move).toBeFocused(); await saved();
|
||||
expect((await read()).settings.sections.slice(0, 4).map((row: { key: string }) => row.key)).toEqual(['summary', 'projects', 'education', 'experience']);
|
||||
await capture(page, info, 'reordered-section-focus');
|
||||
|
||||
await sectionSelect.focus(); await page.keyboard.press('Space');
|
||||
await page.keyboard.press('End'); await page.keyboard.press('Enter');
|
||||
await expect(page.getByRole('button', { name: label('cvEditorCollapseSection', 'Frivillig arbeid og dokumenterte bidrag'), exact: true })).toBeFocused();
|
||||
let release!: () => void;
|
||||
const hold = new Promise<void>(resolve => { release = resolve; });
|
||||
let pending = false;
|
||||
await page.route(cvUrl, async route => {
|
||||
if (route.request().method() === 'PUT') { pending = true; await hold; }
|
||||
await route.continue();
|
||||
});
|
||||
const headline = page.getByLabel(t.cvEditorHeadlineOverride, { exact: true });
|
||||
const editing = page.getByRole('region', { name: t.cvEditorEditingRegion, exact: true });
|
||||
try {
|
||||
await headline.fill('Local CV draft — æøå');
|
||||
await expect.poll(() => pending).toBe(true);
|
||||
await expect(page.getByText(t.cvEditorSaving, { exact: true })).toBeVisible();
|
||||
await capture(page, info, 'pending-save');
|
||||
await page.getByRole('tab', { name: t.cvEditorCustomize, exact: true }).click();
|
||||
await expect(page.getByLabel(t.cvEditorPageSize, { exact: true })).toBeVisible();
|
||||
await page.getByRole('tab', { name: t.cvEditorTemplate, exact: true }).click();
|
||||
await expect(page.getByRole('button', { name: t.cvEditorAllTemplates, exact: true })).toBeVisible();
|
||||
await page.getByRole('tab', { name: t.cvEditorContent, exact: true }).click();
|
||||
await expect(headline).toHaveValue('Local CV draft — æøå');
|
||||
await expect(sectionSelect).toContainText('Frivillig arbeid og dokumenterte bidrag');
|
||||
for (const width of [899, 900, 1024]) {
|
||||
await headline.focus(); await page.setViewportSize({ width, height: 900 });
|
||||
await expect(headline).toBeFocused(); await expect(editing).toBeVisible();
|
||||
await expect(headline).toHaveValue('Local CV draft — æøå');
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1)).toBe(true);
|
||||
await capture(page, info, `pending-${width}`);
|
||||
}
|
||||
} finally { release(); }
|
||||
await saved(); await page.unroute(cvUrl);
|
||||
expect((await read()).settings.headline).toBe('Local CV draft — æøå');
|
||||
expect(await (await page.request.get(`${apiUrl}/career/profile`)).json()).toEqual(source);
|
||||
await page.getByRole('button', { name: t.cvEditorUndo, exact: true }).click(); await saved();
|
||||
await expect(headline).toHaveValue('');
|
||||
await page.getByRole('button', { name: t.cvEditorRedo, exact: true }).click(); await saved();
|
||||
await expect(headline).toHaveValue('Local CV draft — æøå');
|
||||
|
||||
// Publication and PDF stay in the existing client handlers. Menu Escape returns
|
||||
// focus; public state is explicit, and unpublishing revokes anonymous access.
|
||||
const more = page.getByRole('button', { name: t.cvEditorMoreActions, exact: true });
|
||||
await more.click(); await page.keyboard.press('Escape'); await expect(more).toBeFocused();
|
||||
await more.click(); await page.getByRole('menuitem', { name: t.cvEditorShare, exact: true }).click();
|
||||
await expect(page.getByText(t.cvEditorShared, { exact: true })).toBeVisible();
|
||||
const publicCv = await read();
|
||||
const anonymous = await playwright.request.newContext();
|
||||
expect((await anonymous.get(`${apiUrl}/public-cv/${publicCv.publicSlug}`)).status()).toBe(200);
|
||||
await more.click(); await expect(page.getByRole('menuitem', { name: t.cvEditorCopyLink, exact: true })).toBeVisible();
|
||||
await page.getByRole('menuitem', { name: t.cvEditorStopSharing, exact: true }).click();
|
||||
await expect(page.getByText(t.cvEditorShared, { exact: true })).toHaveCount(0);
|
||||
expect((await anonymous.get(`${apiUrl}/public-cv/${publicCv.publicSlug}`)).status()).toBe(404); await anonymous.dispose();
|
||||
const pdfResponse = page.waitForResponse(response => response.url() === `${cvUrl}/export-pdf` && response.request().method() === 'POST');
|
||||
const download = page.waitForEvent('download');
|
||||
await page.getByRole('button', { name: t.cvEditorDownloadPdf, exact: true }).click();
|
||||
expect((await pdfResponse).status()).toBe(200); expect((await download).suggestedFilename()).toMatch(/\.pdf$/i);
|
||||
|
||||
await page.setViewportSize({ width: 1440, height: 900 });
|
||||
await page.getByRole('tab', { name: t.cvEditorHistory, exact: true }).click();
|
||||
const history = page.getByRole('tabpanel', { name: t.cvEditorHistory, exact: true });
|
||||
await expect(history.getByText(t.cvEditorVersion.replace('{version}', '1'), { exact: true })).toBeVisible();
|
||||
await capture(page, info, 'history'); await scan(page, info, 'history');
|
||||
await page.getByRole('tab', { name: t.cvEditorAiTools, exact: true }).click();
|
||||
await expect(page.getByRole('tabpanel', { name: t.cvEditorAiTools, exact: true })).toBeVisible();
|
||||
await page.getByRole('tab', { name: t.cvEditorContent, exact: true }).click();
|
||||
await expect(sectionSelect).toContainText('Frivillig arbeid og dokumenterte bidrag');
|
||||
await expect(page.getByRole('button', { name: label('cvEditorCollapseSection', 'Frivillig arbeid og dokumenterte bidrag'), exact: true })).toBeVisible();
|
||||
for (const width of [768, 390, 320]) {
|
||||
await headline.focus(); await page.setViewportSize({ width, height: 844 });
|
||||
await expect(editing).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: t.cvEditorEdit, exact: true })).toHaveAttribute('aria-pressed', 'true');
|
||||
await page.getByLabel(t.cvEditorName, { exact: true }).scrollIntoViewIfNeeded();
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1)).toBe(true);
|
||||
await capture(page, info, `edit-${width}`);
|
||||
await page.getByRole('button', { name: t.cvEditorPreview, exact: true }).click();
|
||||
await expect(page.getByRole('button', { name: t.cvEditorPreview, exact: true })).toHaveAttribute('aria-pressed', 'true');
|
||||
const preview = page.getByRole('region', { name: t.cvEditorLivePreview, exact: true });
|
||||
await expect(preview).toBeVisible(); await preview.focus(); await page.keyboard.press('End');
|
||||
await capture(page, info, `preview-${width}`);
|
||||
await page.getByRole('button', { name: t.cvEditorEdit, exact: true }).click();
|
||||
await expect(headline).toHaveValue('Local CV draft — æøå');
|
||||
}
|
||||
await scan(page, info, 'phone-edit');
|
||||
await page.setViewportSize({ width: 1440, height: 900 });
|
||||
await more.click();
|
||||
const copiedResponse = page.waitForResponse(response => response.url() === `${cvUrl}/duplicate`);
|
||||
await page.getByRole('menuitem', { name: t.cvEditorDuplicate, exact: true }).click();
|
||||
const copied = await (await copiedResponse).json();
|
||||
await expect(page).toHaveURL(new RegExp(`/career/builder/${copied.id}$`));
|
||||
expect((await (await page.request.get(`${apiUrl}/cv/variants/${copied.id}`)).json()).settings).toEqual((await read()).settings);
|
||||
});
|
||||
}
|
||||
@@ -211,6 +211,8 @@ for (const mode of ["light", "dark"] as const) {
|
||||
const customize = page.getByRole("tab", { name: "Customize", exact: true });
|
||||
await page.getByRole("tab", { name: "Content", exact: true }).focus();
|
||||
await page.keyboard.press("ArrowRight");
|
||||
await expect(page.getByRole("tab", { name: "Template", exact: true })).toBeFocused();
|
||||
await page.keyboard.press("ArrowRight");
|
||||
await expect(customize).toBeFocused();
|
||||
await customize.press("Enter");
|
||||
await keyboardFocus(page, customize);
|
||||
|
||||
@@ -509,10 +509,14 @@ test("the Code template exports a long structured CV as searchable multi-page PD
|
||||
|
||||
await page.goto(`/career/builder/${variant.id}`);
|
||||
await expect(page.getByLabel("CV name")).toHaveValue("Code PDF Verification");
|
||||
await expect(page.getByRole("tab", { name: "Template" })).toHaveAttribute("aria-selected", "true");
|
||||
await expect(page.getByRole("tab", { name: "Content" })).toHaveAttribute("aria-selected", "true");
|
||||
await page.getByRole("tab", { name: "Content" }).click();
|
||||
await page.getByRole("button", { name: "Expand Experience" }).click();
|
||||
await page.getByRole("button", { name: "Expand Education" }).click();
|
||||
await expect(page.getByRole("button", { name: "Collapse Experience" })).toBeVisible();
|
||||
// Open the second disclosure with its keyboard contract while the long first
|
||||
// section expands, avoiding a pointer target moving during the Collapse reflow.
|
||||
await page.getByRole("button", { name: "Expand Education" }).focus();
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(page.getByRole("button", { name: "Collapse Experience" })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Collapse Education" })).toBeVisible();
|
||||
|
||||
|
||||
@@ -81,6 +81,93 @@ async function openContentTab() {
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Content' }));
|
||||
}
|
||||
|
||||
test('section navigation opens and focuses omitted/custom sections without changing their data', async () => {
|
||||
const settings = { ...variant.settings,
|
||||
sections: [{ key: 'skills', hidden: true }, { key: 'custom:sample', hidden: false }],
|
||||
customSections: [{ key: 'sample', title: 'Volunteer work', items: ['Bokmål: frivillig arbeid'], hidden: false }],
|
||||
};
|
||||
routeGet(() => Promise.resolve({ data: { ...variant, settings } }));
|
||||
renderAt(3);
|
||||
await openContentTab();
|
||||
const navigation = screen.getByRole('navigation', { name: 'CV section navigation' });
|
||||
fireEvent.mouseDown(within(navigation).getByRole('combobox', { name: 'Go to section' }));
|
||||
fireEvent.click(await screen.findByRole('option', { name: 'Skills · Omitted' }));
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: 'Collapse Skills' })).toHaveFocus());
|
||||
expect(screen.getByRole('button', { name: 'Show Skills section' })).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Collapse Skills' }));
|
||||
fireEvent.mouseDown(within(navigation).getByRole('combobox', { name: /Go to section/ }));
|
||||
fireEvent.click(await screen.findByRole('option', { name: 'Skills · Omitted' }));
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: 'Collapse Skills' })).toHaveFocus());
|
||||
fireEvent.mouseDown(within(navigation).getByRole('combobox', { name: 'Go to section' }));
|
||||
fireEvent.click(await screen.findByRole('option', { name: 'Volunteer work · Included' }));
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: 'Collapse Volunteer work' })).toHaveFocus());
|
||||
expect(screen.getByDisplayValue('Bokmål: frivillig arbeid')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Customize' }));
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Content' }));
|
||||
expect(within(navigation).getByRole('combobox')).toHaveTextContent('Volunteer work');
|
||||
expect(screen.getByRole('button', { name: 'Collapse Skills' })).toHaveAttribute('aria-expanded', 'true');
|
||||
expect(screen.getByRole('button', { name: 'Collapse Volunteer work' })).toHaveAttribute('aria-expanded', 'true');
|
||||
expect(mockedApi.put).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('keyboard move controls retain focus and persist the same section order shown by navigation', async () => {
|
||||
routeGet(() => Promise.resolve({ data: { ...variant, settings: { ...variant.settings,
|
||||
sections: [{ key: 'summary' }, { key: 'experience' }, { key: 'skills', hidden: true }],
|
||||
} } }));
|
||||
renderAt(3);
|
||||
await openContentTab();
|
||||
const move = screen.getByRole('button', { name: 'Move Experience section down' });
|
||||
move.focus();
|
||||
fireEvent.click(move);
|
||||
expect(move).toHaveFocus();
|
||||
fireEvent.mouseDown(screen.getByRole('combobox', { name: 'Go to section' }));
|
||||
expect(screen.getAllByRole('option').slice(0, 3).map(option => option.textContent)).toEqual([
|
||||
'Professional Summary · Included', 'Skills · Omitted', 'Experience · Included',
|
||||
]);
|
||||
fireEvent.keyDown(screen.getByRole('listbox'), { key: 'Escape' });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save now' }));
|
||||
await screen.findByText('Saved');
|
||||
expect(mockedApi.put).toHaveBeenLastCalledWith('/cv/variants/3', expect.objectContaining({
|
||||
expectedVersion: 4, settings: expect.objectContaining({ sections: expect.arrayContaining([
|
||||
{ key: 'summary' }, { key: 'skills', hidden: true }, { key: 'experience' },
|
||||
]) }),
|
||||
}));
|
||||
expect((mockedApi.put.mock.calls[mockedApi.put.mock.calls.length - 1][1] as any).settings.sections.slice(0, 3).map((row: any) => row.key)).toEqual(['summary', 'skills', 'experience']);
|
||||
});
|
||||
|
||||
test('secondary duplicate action saves the current draft before creating the copy', async () => {
|
||||
routeGet(() => Promise.resolve({ data: variant }));
|
||||
mockedApi.post.mockImplementation((url: string) => Promise.resolve({ data: url.endsWith('/duplicate')
|
||||
? { ...variant, id: 8, name: 'Backend CV copy' } : { html: '<p>CV</p>' } } as any));
|
||||
renderAt(3);
|
||||
await openContentTab();
|
||||
fireEvent.change(screen.getByLabelText('Headline override'), { target: { value: 'Draft before copy' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'More document actions' }));
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'Duplicate' }));
|
||||
await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith('/cv/variants/3/duplicate', expect.anything()));
|
||||
expect(mockedApi.put).toHaveBeenCalledWith('/cv/variants/3', expect.objectContaining({
|
||||
expectedVersion: 4, settings: expect.objectContaining({ headline: 'Draft before copy' }),
|
||||
}));
|
||||
expect(mockedApi.put.mock.invocationCallOrder[0]).toBeLessThan(mockedApi.post.mock.invocationCallOrder[
|
||||
mockedApi.post.mock.calls.findIndex(call => call[0] === '/cv/variants/3/duplicate')
|
||||
]);
|
||||
});
|
||||
|
||||
test('pane navigation focuses the existing preview without changing the pending draft', async () => {
|
||||
routeGet(() => Promise.resolve({ data: variant }));
|
||||
renderAt(3);
|
||||
await openContentTab();
|
||||
const headline = screen.getByLabelText('Headline override');
|
||||
fireEvent.change(headline, { target: { value: 'Retained draft' } });
|
||||
const preview = screen.getByRole('region', { name: 'Live preview' });
|
||||
preview.scrollIntoView = jest.fn();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Go to preview' }));
|
||||
expect(preview).toHaveFocus();
|
||||
expect(headline).toHaveValue('Retained draft');
|
||||
expect(screen.getByText('Unsaved')).toBeInTheDocument();
|
||||
expect(mockedApi.put).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('deep link loads the exact variant named in the route', async () => {
|
||||
routeGet(() => Promise.resolve({ data: variant } as any));
|
||||
|
||||
@@ -256,12 +343,17 @@ test('restoring history uses the current version and never replaces edits made d
|
||||
expect(mockedApi.put).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('professional editor starts with templates and combines design and layout controls', async () => {
|
||||
test('Content is the primary studio panel and all advanced tabs retain their controls', async () => {
|
||||
routeGet(() => Promise.resolve({ data: variant } as any));
|
||||
renderAt(3);
|
||||
|
||||
await screen.findByDisplayValue('Backend CV');
|
||||
expect(screen.getByRole('tab', { name: 'Template' })).toHaveAttribute('aria-selected', 'true');
|
||||
const content = screen.getByRole('tab', { name: 'Content' });
|
||||
expect(content).toHaveAttribute('aria-selected', 'true');
|
||||
expect(screen.getByRole('tablist', { name: 'CV studio sections' }).firstElementChild).toBe(content);
|
||||
expect(screen.getByRole('tabpanel', { name: 'Content' })).toBeInTheDocument();
|
||||
expect(screen.getByText('This CV only')).toBeInTheDocument();
|
||||
expect(screen.getByRole('link', { name: 'Linked application' })).toHaveAttribute('href', '/jobs/7');
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Customize' }));
|
||||
expect(screen.getByText('Typography')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Body size')).toBeInTheDocument();
|
||||
@@ -304,6 +396,7 @@ test('choosing a template restores its own structure instead of inheriting the p
|
||||
renderAt(3);
|
||||
|
||||
await screen.findByDisplayValue('Backend CV');
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Template' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'All templates' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /Technical/ }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save now' }));
|
||||
|
||||
@@ -528,6 +528,15 @@ export const translations = {
|
||||
cvEditorEdit: "Edit",
|
||||
cvEditorEditingRegion: "CV editing controls",
|
||||
cvEditorStudioNavigation: "CV studio sections",
|
||||
cvEditorScope: "This CV only",
|
||||
cvEditorLinkedApplication: "Linked application",
|
||||
cvEditorShared: "Shared publicly",
|
||||
cvEditorMoreActions: "More document actions",
|
||||
cvEditorSectionNavigation: "CV section navigation",
|
||||
cvEditorGoToSection: "Go to section",
|
||||
cvEditorFocusPreview: "Go to preview",
|
||||
cvEditorIncluded: "Included",
|
||||
cvEditorOmitted: "Omitted",
|
||||
cvEditorPreview: "Preview",
|
||||
cvEditorDuplicate: "Duplicate",
|
||||
cvEditorUpdating: "Updating…",
|
||||
@@ -3066,6 +3075,15 @@ export const translations = {
|
||||
cvEditorEdit: "Rediger",
|
||||
cvEditorEditingRegion: "Redigeringskontroller for CV",
|
||||
cvEditorStudioNavigation: "Seksjoner i CV-studioet",
|
||||
cvEditorScope: "Bare denne CV-en",
|
||||
cvEditorLinkedApplication: "Tilknyttet søknad",
|
||||
cvEditorShared: "Delt offentlig",
|
||||
cvEditorMoreActions: "Flere dokumenthandlinger",
|
||||
cvEditorSectionNavigation: "Navigasjon mellom CV-seksjoner",
|
||||
cvEditorGoToSection: "Gå til seksjon",
|
||||
cvEditorFocusPreview: "Gå til forhåndsvisning",
|
||||
cvEditorIncluded: "Inkludert",
|
||||
cvEditorOmitted: "Utelatt",
|
||||
cvEditorPreview: "Forhåndsvis",
|
||||
cvEditorDuplicate: "Dupliser",
|
||||
cvEditorUpdating: "Oppdaterer…",
|
||||
|
||||
@@ -2,8 +2,8 @@ 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, Divider, IconButton,
|
||||
MenuItem, Paper, Skeleton, Stack, Tab, Tabs, TextField, Tooltip, Typography,
|
||||
Alert, Box, Button, Chip, Collapse, Dialog, DialogActions, DialogContent, DialogTitle, IconButton,
|
||||
Menu, MenuItem, Paper, Skeleton, Stack, Tab, Tabs, TextField, Tooltip, Typography,
|
||||
} from "@mui/material";
|
||||
import useMediaQuery from "@mui/material/useMediaQuery";
|
||||
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
|
||||
@@ -20,6 +20,7 @@ import DragIndicatorIcon from "@mui/icons-material/DragIndicator";
|
||||
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
|
||||
import UndoIcon from "@mui/icons-material/Undo";
|
||||
import RedoIcon from "@mui/icons-material/Redo";
|
||||
import MoreHorizIcon from "@mui/icons-material/MoreHoriz";
|
||||
import AutoFixHighIcon from "@mui/icons-material/AutoFixHigh";
|
||||
import TranslateIcon from "@mui/icons-material/Translate";
|
||||
import SpellcheckIcon from "@mui/icons-material/Spellcheck";
|
||||
@@ -59,7 +60,9 @@ export default function CvBuilderEditor() {
|
||||
const [outline, setOutline] = useState<CvOutline | null>(null);
|
||||
const [isPublic, setIsPublic] = useState(false);
|
||||
const [publicSlug, setPublicSlug] = useState("");
|
||||
const [tab, setTab] = useState(0);
|
||||
const [jobApplicationId, setJobApplicationId] = useState<number | null>(null);
|
||||
const [actionsAnchor, setActionsAnchor] = useState<HTMLElement | null>(null);
|
||||
const [tab, setTab] = useState(1);
|
||||
const [html, setHtml] = useState("");
|
||||
const [zoom, setZoom] = useState(0.62);
|
||||
const [previewing, setPreviewing] = useState(false);
|
||||
@@ -127,6 +130,7 @@ export default function CvBuilderEditor() {
|
||||
latestSettings.current = variant.settings;
|
||||
setIsPublic(variant.isPublic);
|
||||
setPublicSlug(variant.publicSlug);
|
||||
setJobApplicationId(variant.jobApplicationId ?? null);
|
||||
setSaveState("saved");
|
||||
undoStack.current = [];
|
||||
redoStack.current = [];
|
||||
@@ -456,47 +460,68 @@ export default function CvBuilderEditor() {
|
||||
<Button size="small" onClick={() => void reloadSavedVariant()}>{t("cvEditorConflictReload")}</Button>
|
||||
</Stack>
|
||||
</Alert>}
|
||||
<Paper component="header" sx={{ px: { xs: 1, sm: 1.5 }, py: 1, borderRadius: 3, border: "1px solid", borderColor: "divider" }}>
|
||||
<Stack direction="row" alignItems="center" gap={1} flexWrap="wrap">
|
||||
<Tooltip title={t("cvEditorBack")}><IconButton size="small" aria-label={t("cvEditorBack")} onClick={() => navigate("/career/builder")}><ArrowBackIcon fontSize="small" /></IconButton></Tooltip>
|
||||
<TextField variant="standard" value={name} onChange={(e) => renameVariant(e.target.value)}
|
||||
error={!name.trim()} helperText={!name.trim() ? t("cvEditorNameRequired") : undefined}
|
||||
sx={{ minWidth: { xs: 150, sm: 220 }, flex: "1 1 240px", maxWidth: 420 }}
|
||||
slotProps={{ input: { style: { fontWeight: 800, fontSize: "1.05rem" } }, htmlInput: { "aria-label": t("cvEditorName") } }} />
|
||||
<SaveBadge state={saveState} canRetry={!!name.trim()} onRetry={() => void retrySave()} />
|
||||
<Divider orientation="vertical" flexItem sx={{ display: { xs: "none", sm: "block" } }} />
|
||||
<Tooltip title={t("cvEditorUndo")}><span><IconButton size="small" aria-label={t("cvEditorUndo")} disabled={!canUndo} onClick={undo}><UndoIcon fontSize="small" /></IconButton></span></Tooltip>
|
||||
<Tooltip title={t("cvEditorRedo")}><span><IconButton size="small" aria-label={t("cvEditorRedo")} disabled={!canRedo} onClick={redo}><RedoIcon fontSize="small" /></IconButton></span></Tooltip>
|
||||
{compactEditor && <Stack direction="row" sx={{ border: "1px solid", borderColor: "divider", borderRadius: 2, p: 0.25 }}>
|
||||
<Button size="small" variant={mobilePane === "edit" ? "contained" : "text"} onClick={() => setMobilePane("edit")}>{t("cvEditorEdit")}</Button>
|
||||
<Button size="small" variant={mobilePane === "preview" ? "contained" : "text"} onClick={() => setMobilePane("preview")}>{t("cvEditorPreview")}</Button>
|
||||
</Stack>}
|
||||
<Box sx={{ flex: { sm: 1 } }} />
|
||||
<Button size="small" variant="text" startIcon={<ContentCopyIcon />} disabled={!name.trim()} onClick={() => void duplicateVariant()}>{t("cvEditorDuplicate")}</Button>
|
||||
<Button size="small" variant={isPublic ? "contained" : "outlined"} startIcon={<PublicIcon />} disabled={publishing || exporting || !name.trim()} onClick={togglePublic}>
|
||||
{publishing ? t("cvEditorUpdating") : isPublic ? t("cvEditorStopSharing") : t("cvEditorShare")}
|
||||
</Button>
|
||||
{isPublic && <Button size="small" startIcon={<ContentCopyIcon />} onClick={copyPublicLink}>{t("cvEditorCopyLink")}</Button>}
|
||||
<Button size="small" variant="contained" startIcon={<PictureAsPdfIcon />} disabled={exporting || publishing || !name.trim()} onClick={exportPdf}>{exporting ? t("cvEditorExporting") : t("cvEditorDownloadPdf")}</Button>
|
||||
<Paper component="header" variant="outlined" sx={{ p: 1.5 }}>
|
||||
<Stack direction={{ xs: "column", sm: "row" }} alignItems={{ sm: "center" }} gap={1.5}>
|
||||
<Stack direction="row" alignItems="flex-start" gap={1} sx={{ flex: 1, minWidth: 0 }}>
|
||||
<Tooltip title={t("cvEditorBack")}><IconButton size="small" aria-label={t("cvEditorBack")} onClick={() => navigate("/career/builder")}><ArrowBackIcon fontSize="small" /></IconButton></Tooltip>
|
||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||
<TextField variant="standard" value={name} onChange={(e) => renameVariant(e.target.value)} fullWidth
|
||||
error={!name.trim()} helperText={!name.trim() ? t("cvEditorNameRequired") : undefined}
|
||||
sx={{ "& input": { typography: "sectionTitle" } }}
|
||||
slotProps={{ htmlInput: { "aria-label": t("cvEditorName") } }} />
|
||||
<Stack direction="row" gap={1} alignItems="center" flexWrap="wrap" sx={{ mt: 0.5 }}>
|
||||
<Typography variant="caption" color="text.secondary">{t("cvEditorScope")}</Typography>
|
||||
<Typography variant="caption" color="text.secondary">{t("cvEditorVersion", { version: serverVersion.current })}</Typography>
|
||||
{jobApplicationId && <Button component={RouterLink} to={`/jobs/${jobApplicationId}`} size="small">{t("cvEditorLinkedApplication")}</Button>}
|
||||
{isPublic && <Chip size="small" icon={<PublicIcon />} label={t("cvEditorShared")} variant="outlined" />}
|
||||
<SaveBadge state={saveState} canRetry={!!name.trim()} onRetry={() => void retrySave()} />
|
||||
</Stack>
|
||||
</Box>
|
||||
</Stack>
|
||||
<Stack direction="row" alignItems="center" gap={0.5}>
|
||||
<Button size="small" variant="contained" startIcon={<PictureAsPdfIcon />} sx={{ flex: { xs: 1, sm: "none" } }} disabled={exporting || publishing || !name.trim()} onClick={exportPdf}>{exporting ? t("cvEditorExporting") : t("cvEditorDownloadPdf")}</Button>
|
||||
<Tooltip title={t("cvEditorMoreActions")}><IconButton size="small" aria-label={t("cvEditorMoreActions")} aria-haspopup="menu" aria-expanded={!!actionsAnchor} aria-controls={actionsAnchor ? "cv-document-actions" : undefined} onClick={(event) => setActionsAnchor(event.currentTarget)}><MoreHorizIcon /></IconButton></Tooltip>
|
||||
</Stack>
|
||||
</Stack>
|
||||
<Menu id="cv-document-actions" anchorEl={actionsAnchor} open={!!actionsAnchor} onClose={() => setActionsAnchor(null)} slotProps={{ list: { "aria-label": t("cvEditorMoreActions") } }}>
|
||||
<MenuItem disabled={!name.trim()} onClick={() => { setActionsAnchor(null); void duplicateVariant(); }}><ContentCopyIcon fontSize="small" sx={{ mr: 1 }} />{t("cvEditorDuplicate")}</MenuItem>
|
||||
<MenuItem disabled={publishing || exporting || !name.trim()} onClick={() => { setActionsAnchor(null); void togglePublic(); }}><PublicIcon fontSize="small" sx={{ mr: 1 }} />{publishing ? t("cvEditorUpdating") : isPublic ? t("cvEditorStopSharing") : t("cvEditorShare")}</MenuItem>
|
||||
{isPublic && <MenuItem onClick={() => { setActionsAnchor(null); copyPublicLink(); }}><ContentCopyIcon fontSize="small" sx={{ mr: 1 }} />{t("cvEditorCopyLink")}</MenuItem>}
|
||||
</Menu>
|
||||
{compactEditor && <Stack direction="row" gap={0.5} sx={{ mt: 1.5 }}>
|
||||
<Button fullWidth size="small" aria-pressed={mobilePane === "edit"} variant={mobilePane === "edit" ? "contained" : "outlined"} onClick={() => setMobilePane("edit")}>{t("cvEditorEdit")}</Button>
|
||||
<Button fullWidth size="small" aria-pressed={mobilePane === "preview"} variant={mobilePane === "preview" ? "contained" : "outlined"} onClick={() => setMobilePane("preview")}>{t("cvEditorPreview")}</Button>
|
||||
</Stack>}
|
||||
</Paper>
|
||||
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "minmax(0, 1fr)", md: "minmax(360px, 480px) minmax(0, 1fr)" }, gap: 1.5, alignItems: "start" }}>
|
||||
<Paper role="region" aria-label={t("cvEditorEditingRegion")} onFocusCapture={() => setMobilePane("edit")} sx={{ display: compactEditor && mobilePane !== "edit" ? "none" : "block", p: 2, borderRadius: 3, position: { md: "sticky" }, top: 12, minWidth: 0, maxHeight: { md: "calc(100vh - 104px)" }, overflowY: { md: "auto" }, scrollbarWidth: "none", "&::-webkit-scrollbar": { display: "none" }, border: "1px solid", borderColor: "divider" }}>
|
||||
|
||||
<Tabs aria-label={t("cvEditorStudioNavigation")} value={tab} onChange={(_, v) => { setTab(v); if (v === 4) loadVersions(); }} variant="scrollable" scrollButtons="auto" allowScrollButtonsMobile sx={{ mb: 1.5, minHeight: 38 }}>
|
||||
<Tab label={t("cvEditorTemplate")} />
|
||||
<Tab label={t("cvEditorContent")} />
|
||||
<Tab label={t("cvEditorCustomize")} />
|
||||
<Tab label={t("cvEditorAiTools")} />
|
||||
<Tab label={t("cvEditorHistory")} />
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "minmax(0, 1fr)", md: "minmax(360px, 1fr) minmax(0, 1fr)" }, gap: 1.5, alignItems: "start" }}>
|
||||
<Paper variant="outlined" role="region" aria-label={t("cvEditorEditingRegion")} onFocusCapture={() => setMobilePane("edit")} sx={{ display: compactEditor && mobilePane !== "edit" ? "none" : "block", position: { md: "sticky" }, top: 12, minWidth: 0, maxHeight: { md: "calc(100vh - 104px)" }, overflowY: { md: "auto" }, scrollbarWidth: "none", "&::-webkit-scrollbar": { display: "none" } }}>
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="center" sx={{ px: 1.5, py: 0.5, borderBottom: "1px solid", borderColor: "divider" }}>
|
||||
<Typography component="h2" variant="subtitle2">{t("cvEditorEditingRegion")}</Typography>
|
||||
<Stack direction="row">
|
||||
<Tooltip title={t("cvEditorUndo")}><span><IconButton size="small" aria-label={t("cvEditorUndo")} disabled={!canUndo} onClick={undo}><UndoIcon fontSize="small" /></IconButton></span></Tooltip>
|
||||
<Tooltip title={t("cvEditorRedo")}><span><IconButton size="small" aria-label={t("cvEditorRedo")} disabled={!canRedo} onClick={redo}><RedoIcon fontSize="small" /></IconButton></span></Tooltip>
|
||||
</Stack>
|
||||
</Stack>
|
||||
<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>
|
||||
|
||||
{tab === 0 && <CvCustomizeTab mode="template" settings={settings} update={update} themes={themes} sectionLabel={(key, fallback) => sectionName(key, fallback, t)} />}
|
||||
{tab === 1 && <ContentTab settings={settings} update={update} outline={outline} />}
|
||||
{tab === 2 && <CvCustomizeTab mode="customize" settings={settings} update={update} themes={themes} sectionLabel={(key, fallback) => sectionName(key, fallback, t)} />}
|
||||
{tab === 3 && <DocumentAiTab settings={settings} outline={outline} update={update} />}
|
||||
{tab === 4 && <HistoryTab versions={versions} onRestore={restore} />}
|
||||
<Box sx={{ p: { xs: 1, sm: 1.5 } }}>
|
||||
<Box role="tabpanel" id="cv-studio-panel-1" aria-labelledby="cv-studio-tab-1" hidden={tab !== 1}>
|
||||
<ContentTab settings={settings} update={update} outline={outline} showPreviewEntry={!compactEditor} onFocusPreview={() => {
|
||||
scrollRef.current?.focus({ preventScroll: true });
|
||||
scrollRef.current?.scrollIntoView({ block: "nearest", inline: "nearest" });
|
||||
}} />
|
||||
</Box>
|
||||
{[0, 2, 3, 4].map((value) => <Box key={value} role="tabpanel" id={`cv-studio-panel-${value}`} aria-labelledby={`cv-studio-tab-${value}`} hidden={tab !== value}>
|
||||
{tab === value && <>
|
||||
{value === 0 && <CvCustomizeTab mode="template" settings={settings} update={update} themes={themes} sectionLabel={(key, fallback) => sectionName(key, fallback, t)} />}
|
||||
{value === 2 && <CvCustomizeTab mode="customize" settings={settings} update={update} themes={themes} sectionLabel={(key, fallback) => sectionName(key, fallback, t)} />}
|
||||
{value === 3 && <DocumentAiTab settings={settings} outline={outline} update={update} />}
|
||||
{value === 4 && <HistoryTab versions={versions} onRestore={restore} />}
|
||||
</>}
|
||||
</Box>)}
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
<Box onFocusCapture={() => setMobilePane("preview")} sx={{ display: compactEditor && mobilePane !== "preview" ? "none" : "block", minWidth: 0 }}>
|
||||
@@ -562,8 +587,8 @@ function SaveBadge({ state, canRetry, onRetry }: { state: SaveState; canRetry: b
|
||||
const m = map[state];
|
||||
if (!m.label) return null;
|
||||
return (
|
||||
<Stack spacing={0.5} alignItems="flex-end" aria-live="polite">
|
||||
<Chip size="small" label={m.label} color={m.color} variant="outlined" />
|
||||
<Stack direction="row" gap={0.5} alignItems="center" aria-live="polite">
|
||||
{state === "saved" ? <Typography variant="caption" color="text.secondary">{m.label}</Typography> : <Chip size="small" label={m.label} color={m.color} variant="outlined" />}
|
||||
{(state === "unsaved" || state === "error") ? (
|
||||
<Button size="small" disabled={!canRetry} onClick={onRetry}>{t("cvEditorSaveNow")}</Button>
|
||||
) : null}
|
||||
@@ -783,14 +808,19 @@ function presetLabel(key: string, fallback: string, t: Translate) {
|
||||
return labels[key] ?? fallback;
|
||||
}
|
||||
|
||||
function ContentTab({ settings, update, outline }: {
|
||||
function ContentTab({ settings, update, outline, showPreviewEntry, onFocusPreview }: {
|
||||
settings: CvVariantSettings;
|
||||
update: (p: Partial<CvVariantSettings>) => void;
|
||||
outline: CvOutline | null;
|
||||
showPreviewEntry: boolean;
|
||||
onFocusPreview: () => void;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const { confirmAction } = useDialogActions();
|
||||
const [addContentOpen, setAddContentOpen] = useState(false);
|
||||
const [activeSection, setActiveSection] = useState<string | null>(null);
|
||||
const [expandedSections, setExpandedSections] = useState<Set<string>>(() => new Set());
|
||||
const sectionButtons = useRef<Record<string, HTMLButtonElement | null>>({});
|
||||
// Full section list = configured order (once touched) else default, always including every known key.
|
||||
const sectionRows: CvSectionSetting[] = useMemo(() => {
|
||||
const populated = new Set((outline?.sections ?? []).map((section) => section.key));
|
||||
@@ -813,6 +843,19 @@ function ContentTab({ settings, update, outline }: {
|
||||
|
||||
const writeSections = (rows: CvSectionSetting[]) => update({ sections: rows });
|
||||
const sectionDrag = useDragReorder((from, to) => writeSections(moveItem(sectionRows, from, to)));
|
||||
const toggleSection = (key: string) => setExpandedSections((previous) => {
|
||||
const next = new Set(previous);
|
||||
if (next.has(key)) next.delete(key); else next.add(key);
|
||||
return next;
|
||||
});
|
||||
const goToSection = (key: string) => {
|
||||
setActiveSection(key);
|
||||
setExpandedSections((previous) => new Set(previous).add(key));
|
||||
requestAnimationFrame(() => {
|
||||
sectionButtons.current[key]?.focus({ preventScroll: true });
|
||||
sectionButtons.current[key]?.scrollIntoView?.({ block: "nearest", inline: "nearest" });
|
||||
});
|
||||
};
|
||||
|
||||
const patchSection = (key: string, patch: Partial<CvSectionSetting>) =>
|
||||
writeSections(sectionRows.map((r) => (r.key === key ? { ...r, ...patch } : r)));
|
||||
@@ -862,7 +905,19 @@ function ContentTab({ settings, update, outline }: {
|
||||
};
|
||||
return (
|
||||
<Stack spacing={2}>
|
||||
<Alert severity="info" action={<Button component={RouterLink} to="/career/profile" size="small">{t("cvEditorEditMasterProfile")}</Button>}>
|
||||
<Stack direction="row" gap={1} alignItems="center">
|
||||
<Box component="nav" aria-label={t("cvEditorSectionNavigation")} sx={{ flex: 1, minWidth: 0 }}>
|
||||
<TextField select fullWidth size="small" 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)}>
|
||||
{customBySectionKey[row.key]?.title ?? row.title ?? sectionName(row.key, SECTION_LABELS[row.key] ?? row.key, t)} · {t(row.hidden ? "cvEditorOmitted" : "cvEditorIncluded")}
|
||||
</MenuItem>)}
|
||||
</TextField>
|
||||
</Box>
|
||||
{showPreviewEntry && <Button size="small" onClick={onFocusPreview}>{t("cvEditorFocusPreview")}</Button>}
|
||||
</Stack>
|
||||
<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/profile" size="small">{t("cvEditorEditMasterProfile")}</Button>}>
|
||||
{t("cvEditorMasterProfileHelp")}
|
||||
</Alert>
|
||||
<TextField label={t("cvEditorHeadlineOverride")} size="small" fullWidth value={settings.headline ?? ""}
|
||||
@@ -885,6 +940,10 @@ function ContentTab({ settings, update, outline }: {
|
||||
outlineSection={outlineByKey[row.key]}
|
||||
customSection={customBySectionKey[row.key]}
|
||||
settings={settings}
|
||||
expanded={expandedSections.has(row.key)}
|
||||
onToggle={() => toggleSection(row.key)}
|
||||
onSectionFocus={() => setActiveSection(row.key)}
|
||||
toggleRef={(button) => { sectionButtons.current[row.key] = button; }}
|
||||
onMove={(d) => writeSections(moveItem(sectionRows, i, i + d))}
|
||||
onPatch={(p) => patchSection(row.key, p)}
|
||||
onRenameCustom={(title) => {
|
||||
@@ -945,7 +1004,7 @@ function AddContentCard({ title, description, added, onAdd }: { title: string; d
|
||||
}
|
||||
|
||||
function SectionRow({
|
||||
row, index, total, dragProps, dragging, over, outlineSection, customSection, settings, onMove, onPatch, onRenameCustom, onUpdateCustom, onRemoveCustom, onUpdateSettings,
|
||||
row, index, total, dragProps, dragging, over, outlineSection, customSection, settings, expanded, onToggle, onSectionFocus, toggleRef, onMove, onPatch, onRenameCustom, onUpdateCustom, onRemoveCustom, onUpdateSettings,
|
||||
}: {
|
||||
row: CvSectionSetting;
|
||||
index: number;
|
||||
@@ -956,6 +1015,10 @@ function SectionRow({
|
||||
outlineSection?: CvOutlineSection;
|
||||
customSection?: CvCustomSectionSetting;
|
||||
settings: CvVariantSettings;
|
||||
expanded: boolean;
|
||||
onToggle: () => void;
|
||||
onSectionFocus: () => void;
|
||||
toggleRef: (button: HTMLButtonElement | null) => void;
|
||||
onMove: (delta: number) => void;
|
||||
onPatch: (patch: Partial<CvSectionSetting>) => void;
|
||||
onRenameCustom: (title: string) => void;
|
||||
@@ -965,7 +1028,6 @@ function SectionRow({
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const { confirmAction } = useDialogActions();
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const sectionLabel = customSection?.title ?? row.title ?? sectionName(row.key, SECTION_LABELS[row.key] ?? row.key, t);
|
||||
const sourceItems = outlineSection?.kind === "tags" ? outlineSection.tags : outlineSection?.bullets ?? [];
|
||||
const editableItems = row.items ?? sourceItems;
|
||||
@@ -975,6 +1037,7 @@ function SectionRow({
|
||||
<Paper
|
||||
variant="outlined"
|
||||
{...dragProps}
|
||||
role="group" aria-label={sectionLabel} onFocusCapture={onSectionFocus}
|
||||
sx={{
|
||||
// Hidden in the document is not disabled in the editor: keep names and
|
||||
// controls readable. The visibility icon/label still conveys inclusion.
|
||||
@@ -1001,7 +1064,7 @@ function SectionRow({
|
||||
if (customSection) onRemoveCustom();
|
||||
else void confirmAction(t("cvEditorRemoveSectionMessage", { section: sectionLabel }), { title: t("cvEditorRemoveSectionTitle"), confirmLabel: t("cvEditorRemoveFromCv"), destructive: true }).then((confirmed) => { if (confirmed) onPatch({ hidden: true }); });
|
||||
}}><DeleteOutlineIcon fontSize="small" /></IconButton>
|
||||
<IconButton size="small" aria-label={t(expanded ? "cvEditorCollapseSection" : "cvEditorExpandSection", { section: sectionLabel })} aria-expanded={expanded} onClick={() => setExpanded((value) => !value)}
|
||||
<IconButton ref={toggleRef} size="small" aria-label={t(expanded ? "cvEditorCollapseSection" : "cvEditorExpandSection", { section: sectionLabel })} aria-expanded={expanded} onClick={onToggle}
|
||||
sx={{ transform: expanded ? "rotate(180deg)" : "none", transition: "transform 150ms" }}>
|
||||
<ExpandMoreIcon fontSize="small" />
|
||||
</IconButton>
|
||||
|
||||
Reference in New Issue
Block a user