feat(ui): complete career and document workspaces

This commit is contained in:
cesnimda
2026-11-03 14:29:21 +01:00
parent 027907a806
commit 6f801251b0
168 changed files with 6120 additions and 39 deletions
+130
View File
@@ -0,0 +1,130 @@
// Actual exported React UI; synthetic API/render responses, no backend, model or provider access.
import { chromium, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
import ts from 'typescript';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
const compiled = ts.transpileModule(fs.readFileSync(new URL('../src/i18n/translations.ts', import.meta.url), 'utf8'), { compilerOptions: { module: ts.ModuleKind.ESNext } }).outputText;
const { translations } = await import(`data:text/javascript;base64,${Buffer.from(compiled).toString('base64')}`);
const out = path.resolve(import.meta.dirname, '../../docs/ui-modernisation/evidence/career/browser');
fs.mkdirSync(out, { recursive: true });
const report = { screens: [], journeys: [], errors: [], unexpectedWrites: [], mockedWrites: [], renderReads: 0, unavailableReads: [], blockedExternal: [] };
const browser = await chromium.launch();
try {
const configurations = process.env.A3_BOUNDARY ? [320, 390, 768, 1000, 1001, 1440].map(width => [width, 'en', 'light']) : process.env.A3_PILOT ? [[320, 'en', 'light']] : [320, 390, 768, 1440].flatMap(width => ['en', 'nb'].flatMap(language => ['light', 'dark'].map(mode => [width, language, mode])));
for (const [width, language, mode] of configurations) {
console.log(`Checking ${width}/${language}/${mode}`);
const t = key => { assert.ok(translations[language][key], key); return translations[language][key]; };
const context = await browser.newContext({ viewport: { width, height: 1000 }, reducedMotion: 'reduce' });
const page = await context.newPage(); page.on('pageerror', error => report.errors.push(error.message));
await page.addInitScript(({ language, mode }) => { localStorage.setItem('uiLanguage', language); localStorage.setItem('jobtracker.themeMode', mode); }, { language, mode });
let career = { revision: 7, cvText: 'Retained raw source', profile: {
version: '1', metadata: { profileVersion: 4, fields: {} }, contact: { fullName: 'Alex Sample', email: 'alex@example.test' }, summary: ['Recorded teacher summary'], skills: ['Teaching'], languages: [{ name: 'Norwegian', level: 'A2' }],
projects: [{ id: 'project-one', name: 'Community project', role: 'Coordinator', start: '2024', end: '2025', bullets: ['Recorded outcome'], skills: ['Teaching'] }],
certifications: [{ id: 'cert-one', name: 'First aid', issuer: 'Recorded issuer', date: '2024', details: ['Recorded detail'] }],
jobs: [], education: [], interests: [], awards: [], publications: [], organisations: [], references: [], otherSections: [], sections: [],
}, completeness: { percent: 70, missing: ['Experience', 'Education'], sections: ['personal', 'summary', 'experience', 'education', 'skills', 'projects', 'languages'].map(key => ({ key, label: key, complete: !['experience', 'education'].includes(key), count: 1 })) } };
let variant = { id: 3, name: 'Teacher CV', version: 4, settings: { themeId: 'modern', showPhoto: false, showPageNumbers: false, showIcons: true, sections: [], overrides: {}, customSections: [] }, isPublic: false, publicSlug: 'synthetic', jobApplicationId: null, updatedAtUtc: '2026-11-03T10:00:00Z' };
let letter = { revision: 7, text: 'Dear team\n\nRecorded teaching experience.', currentVersion: 2, versions: [{ version: 2, source: 'manual', length: 45, createdAtUtc: '2026-11-03T10:00:00Z', isCurrent: true }], aiSuggestionCount: 0 };
const theme = { id: 'modern', name: 'Modern', category: 'Professional', description: 'Synthetic template', layout: 'header-band', accent: '#3157d5', photoShape: 'none', supportsIcons: false, atsFriendly: true, requiresPro: false, available: true, swatches: ['#3157d5'] };
await page.route('**/*', route => {
const req = route.request(), url = new URL(req.url()), p = url.pathname;
if (!['localhost', '127.0.0.1'].includes(url.hostname)) { report.blockedExternal.push(url.origin); return route.abort(); }
if (!p.startsWith('/api/')) return route.continue();
const answer = data => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(data) });
const note = action => report.mockedWrites.push({ width, language, mode, action });
if (req.method() === 'PUT' && p === '/api/career/profile') {
const body = req.postDataJSON(); assert.equal(body.expectedVersion, career.revision); assert.equal(body.cvText, career.cvText);
assert.equal(body.profile.projects[0].id, 'project-one'); assert.equal(body.profile.projects[0].name, 'Updated community project');
assert.deepEqual(body.profile.certifications, career.profile.certifications); assert.deepEqual(body.profile.metadata, career.profile.metadata);
career = { ...career, profile: body.profile, revision: career.revision + 1 }; note('canonicalProjectSave'); return answer(career);
}
if (req.method() === 'PUT' && p === '/api/cv/variants/3') {
const body = req.postDataJSON(); assert.equal(body.expectedVersion, variant.version); assert.deepEqual(body.settings, variant.settings);
variant = { ...variant, name: body.name, version: variant.version + 1 }; note('variantRename'); return answer(variant);
}
if (req.method() === 'PUT' && p === '/api/jobapplications/42/cover-letter') {
const body = req.postDataJSON(); assert.equal(body.expectedVersion, letter.revision); assert.equal(body.source, 'manual'); assert.equal(body.text, 'Dear team\n\nUpdated teaching experience.');
letter = { ...letter, text: body.text, revision: letter.revision + 1, currentVersion: 3 }; note('manualLetterSave'); return answer(letter);
}
if (req.method() === 'POST' && p === '/api/cv/preview') {
report.renderReads++; return answer({ themeId: 'modern', html: '<!doctype html><html lang="en"><head><title>Controlled CV preview</title></head><body><main><h1>Alex Sample</h1><p>Recorded teaching experience</p></main></body></html>', suggestedFileName: 'teacher.pdf' });
}
if (req.method() !== 'GET') { report.unexpectedWrites.push(p); return route.abort(); }
const fixtures = {
'/api/auth/config': { requireAuth: true }, '/api/auth/me': { id: 'a3-career-owner', sessionContext: 'a3-career-session', provider: 'local', firstName: 'Alex', displayName: 'Alex Sample', roles: [], uiLanguage: language === 'nb' ? 'nb-NO' : 'en', aiEnabled: false },
'/api/jobapplications/reminders': [], '/api/notifications/unread-count': { count: 0 },
'/api/career/profile': career, '/api/career/profile/versions': [], '/api/career/evidence': [],
'/api/profile-cv/runs': [{ id: 41, trigger: 'upload', status: 'pending_review', startedAtUtc: '2026-11-03T10:00:00Z', parserVersion: 'offline-fixture', normalizerVersion: 'offline-fixture', llmPromptVersion: 'offline-fixture' }],
'/api/profile-cv/runs/41/diff': { runId: 41, status: 'pending_review', revision: career.revision, diff: { totalAdded: 1, totalUpdated: 1, totalLowConfidence: 1, hasChanges: true, categories: [{ category: 'Languages', unchangedCount: 0, lowConfidenceCount: 1, added: [{ id: 'Languages|french', label: 'French', confidence: 'Low', fieldChanges: [] }], updated: [{ id: 'Languages|norwegian', label: 'Norwegian', confidence: 'Medium', fieldChanges: [{ field: 'Level', oldValue: 'A2', newValue: 'B1' }] }] }] } },
'/api/cv/variants': [{ ...variant, themeId: 'modern', language: 'en' }], '/api/cv/variants/3': variant, '/api/cv/themes': [theme], '/api/cv/variants/3/versions': [],
'/api/cv/outline': { fullName: 'Alex Sample', headline: 'Teacher', sections: [{ key: 'summary', title: 'Professional Summary', kind: 'bullets', bullets: ['Recorded summary'], tags: [], entries: [] }] },
'/api/jobapplications/42/workspace': { id: 42, revision: letter.revision, jobTitle: 'Primary teacher', company: 'Synthetic school', status: 'Saved', stageGroup: 'Applied', stageOrder: 1, location: 'Oslo', countryCode: 'NO', jobUrl: null, source: 'nav', description: 'Recorded source advertisement.', descriptionLanguage: 'en', tags: [], notes: '', applicationAnswerDraft: '', recruiterMessageDraft: '', hasJobDescription: true, cv: { variantId: 3, variantName: variant.name, themeId: 'modern', hasTailoredCvText: false }, hasCoverLetter: true, documentCount: 0, hasPortfolio: false, aiInteractionCount: 0, recentActivity: [], nextStep: null, checklistProgress: { total: 0, completed: 0, dismissed: 0, percent: 0 } },
'/api/jobapplications/42/cover-letter': letter, '/api/jobapplications/42/status-suggestion': null,
};
if (Object.hasOwn(fixtures, p)) return answer(fixtures[p]);
report.unavailableReads.push(p); return route.fulfill({ status: 404, contentType: 'application/json', body: '{}' });
});
const scan = async state => {
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth > innerWidth + 1), false, `overflow:${state}`);
const result = await new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa', 'wcag21aa', 'wcag22aa']).analyze();
report.screens.push({ width, language, mode, state, violations: result.violations }); assert.deepEqual(result.violations, [], `${state} accessibility`);
await page.evaluate(() => window.scrollTo(0, 0)); await page.screenshot({ path: path.join(out, `${state}-${language}-${mode}-${width}.png`), fullPage: true });
};
const careerSection = async key => {
if (width >= 600) await page.getByRole('tab', { name: t(key), exact: true }).click();
else { await page.getByRole('combobox', { name: t('careerWorkspaceNavigation'), exact: true }).click(); await page.getByRole('option', { name: t(key), exact: true }).click(); }
};
await page.goto('http://localhost:4338/career');
await expect(page.locator('[data-edit-career-section]')).toHaveCount(14); await scan('overview');
for (const key of ['contact', 'summary', 'skills', 'interests', 'awards', 'publications', 'organisations', 'references', 'languages', 'jobs', 'education', 'certifications', 'projects', 'otherSections']) {
await page.locator(`[data-edit-career-section="${key}"]`).press('Enter');
await expect.poll(() => page.locator(`#career-section-${key}`).evaluate(node => node.contains(document.activeElement))).toBe(true);
if (process.env.A3_BOUNDARY) await expect.poll(async () => page.evaluate(() => {
const active = document.activeElement, rect = active.getBoundingClientRect();
const x = Math.min(innerWidth - 1, Math.max(0, rect.x + rect.width / 2)), y = Math.min(innerHeight - 1, Math.max(0, rect.y + rect.height / 2));
const hit = document.elementFromPoint(x, y);
return rect.bottom > 0 && rect.top < innerHeight && active.contains(hit);
}), { message: `Focused ${key} must remain visible and unobscured at ${width}px` }).toBe(true);
await careerSection('careerWorkspaceOverviewTab');
}
await page.locator('[data-edit-career-section="projects"]').click();
const projectName = page.locator('#career-section-projects input').first(); await projectName.fill('Updated community project');
await page.getByRole('button', { name: t('profileSaveChanges'), exact: true }).click(); await expect.poll(() => career.revision).toBe(8); await expect(projectName).toBeEnabled(); await scan('profile');
await careerSection('careerWorkspaceImportTab');
const run = page.locator('#career-import-run-41'); await expect(run).toContainText('A2'); await expect(run).toContainText('B1');
await run.getByRole('checkbox').check(); await expect(run.getByRole('checkbox')).toBeChecked(); await scan('import-review');
assert.equal(report.mockedWrites.filter(row => row.width === width && row.language === language && row.mode === mode).length, 1);
await page.goto('http://localhost:4338/career/builder');
const cvCard = page.getByRole('link', { name: t('cvDashboardOpenNamed').replace('{name}', 'Teacher CV'), exact: true });
await cvCard.waitFor(); await expect(cvCard).toContainText(t('cvTemplateModernName')); await scan('library'); await cvCard.press('Enter');
const editor = page.getByRole('region', { name: t('cvEditorEditingRegion'), exact: true });
const preview = page.getByRole('region', { name: t('cvEditorLivePreview'), exact: true }); await editor.waitFor();
if (width < 768) { await page.getByRole('button', { name: t('cvEditorPreview'), exact: true }).click(); await expect(editor).toBeHidden(); await expect(preview).toBeVisible(); await page.getByRole('button', { name: t('cvEditorEdit'), exact: true }).click(); }
else {
await expect(preview).toBeVisible(); const left = await editor.boundingBox(), right = await preview.locator('..').boundingBox();
assert.ok(width > 1000 ? right.x >= left.x + left.width - 2 : right.y >= left.y + left.height - 2, 'CV pane geometry');
}
const cvName = page.getByRole('textbox', { name: t('cvEditorName'), exact: true }); await cvName.fill('Updated teacher CV');
await expect.poll(() => variant.name, { timeout: 15000 }).toBe('Updated teacher CV'); await scan('cv-editor');
await page.goto('http://localhost:4338/jobs/42?section=application&sub=letter');
const text = page.getByRole('textbox', { name: t('jobDetailsCoverLetter'), exact: true }); await expect(text).toBeEnabled();
await text.fill('Dear team\n\nUpdated teaching experience.');
const letterPreview = page.getByRole('region', { name: t('assetsLetterTextPreview'), exact: true });
if (width < 768) { await page.getByRole('tab', { name: t('assetsLetterPreview'), exact: true }).click(); await expect(text).toBeHidden(); await expect(letterPreview).toContainText('Updated teaching experience.'); await page.getByRole('tab', { name: t('assetsLetterWrite'), exact: true }).click(); }
else {
await expect(letterPreview).toContainText('Updated teaching experience.'); const panes = page.locator('[data-letter-studio-layout] > div'); const left = await panes.nth(0).boundingBox(), right = await panes.nth(1).boundingBox();
assert.ok(width > 1000 ? right.x >= left.x + left.width - 2 : right.y >= left.y + left.height - 2, 'Letter pane geometry');
}
await page.getByRole('button', { name: t('save'), exact: true }).click(); await expect.poll(() => letter.revision).toBe(8); await expect(text).toBeEnabled(); await scan('letter');
report.journeys.push({ width, language, mode, all14KeyboardEditTargets: 'PASS', canonicalSaveStableIds: 'PASS', legacyImportComparisonSelectionNoApply: 'PASS', libraryKeyboardOpen: 'PASS', cvResponsiveRename: 'PASS', letterResponsiveManualSave: 'PASS' });
await context.close();
}
assert.equal(report.screens.length, configurations.length * 6); assert.equal(report.journeys.length, configurations.length); assert.equal(report.mockedWrites.length, configurations.length * 3);
assert.deepEqual(report.errors, []); assert.deepEqual(report.unexpectedWrites, []); assert.ok(report.blockedExternal.every(origin => origin === 'https://fonts.googleapis.com'));
console.log(`PASS:${report.journeys.length} controlled journeys,${report.screens.length} axe scans/captures,${report.mockedWrites.length} fixture mutations.`);
} finally {
fs.writeFileSync(path.join(out, process.env.A3_BOUNDARY ? 'BOUNDARY.json' : process.env.A3_PILOT ? 'PILOT.json' : 'BROWSER.json'), JSON.stringify({ ...report, limits: ['All API and preview responses are controlled fixtures; not backend end-to-end or physical PDF qualification.', 'Legacy import comparison and selection only; no parser, model, import acceptance or source mutation.', 'No native device, screen reader, provider, integrated security, deployment or release acceptance.'] }, null, 2));
await browser.close();
}
@@ -965,10 +965,10 @@ test("keyed letter job changes discard the old preview and ignore its late save"
expect(mockedApi.put).toHaveBeenCalledTimes(1);
});
test("wide letter workspace keeps one editor beside its live preview", async () => {
test.each([768, 1000, 1440])("letter workspace at %spx keeps one editor and its preview available without writing", async width => {
const previous = Object.getOwnPropertyDescriptor(window, "matchMedia");
Object.defineProperty(window, "matchMedia", { configurable: true, value: (query: string) => ({
matches: query.includes("min-width:900px"), media: query, onchange: null,
matches: /min-width:\s*([\d.]+)px/.test(query) && width >= Number(/min-width:\s*([\d.]+)px/.exec(query)![1]), media: query, onchange: null,
addListener: jest.fn(), removeListener: jest.fn(), addEventListener: jest.fn(), removeEventListener: jest.fn(), dispatchEvent: jest.fn(),
}) });
try {
+50 -1
View File
@@ -116,6 +116,55 @@ test("a locale update retains dirty structured and raw drafts without reading th
expect(mockedApi.post).not.toHaveBeenCalled();
});
test.each([
['en', 'Projects', 'Project name', 'Certifications', 'Certification name'],
['nb', 'Prosjekter', 'Prosjektnavn', 'Sertifiseringer', 'Navn på sertifisering'],
])('edits %s canonical projects and certifications through the existing versioned Save without changing their IDs or sibling facts', async (language, projectsName, projectLabel, certificationsName, certificationLabel) => {
localStorage.setItem('uiLanguage', language);
const before = JSON.parse(JSON.stringify(profile)) as StructuredCvProfile;
renderPage();
const projects = await screen.findByRole('region', { name: projectsName });
const certifications = screen.getByRole('region', { name: certificationsName });
const projectField = await within(projects).findByRole('textbox', { name: projectLabel });
await waitFor(() => expect(projectField).toBeEnabled());
expect(projectField).toHaveValue('Recorded project');
fireEvent.change(projectField, { target: { value: 'Reviewed community project' } });
fireEvent.change(within(certifications).getByRole('textbox', { name: certificationLabel }), { target: { value: 'Reviewed credential' } });
fireEvent.click(screen.getByRole('button', { name: language === 'nb' ? nb.profileSaveChanges : en.profileSaveChanges }));
await waitFor(() => expect(mockedApi.put).toHaveBeenCalledTimes(1));
const payload = mockedApi.put.mock.calls[0][1] as { profile: StructuredCvProfile; expectedVersion: number; cvText: string };
expect(payload.expectedVersion).toBe(7);
expect(payload.cvText).toBe(rawText);
expect(payload.profile.projects).toEqual([{ ...before.projects[0], name: 'Reviewed community project' }]);
expect(payload.profile.certifications).toEqual([{ ...before.certifications[0], name: 'Reviewed credential' }]);
expect(payload.profile.contact).toEqual(before.contact);
expect(payload.profile.jobs).toEqual(before.jobs);
expect(payload.profile.education).toEqual(before.education);
expect(payload.profile.metadata).toEqual(before.metadata);
expect(mockedApi.post).not.toHaveBeenCalled();
});
test('project and certification add/remove controls keep sibling entries and focus before the explicit canonical Save', async () => {
renderPage();
await ready();
const projects = screen.getByRole('region', { name: en.cvSectionProjects });
fireEvent.click(within(projects).getByRole('button', { name: en.careerProfileAddProject }));
fireEvent.change(within(projects).getAllByRole('textbox', { name: en.careerProfileProjectName })[1], { target: { value: 'Added project' } });
fireEvent.click(within(projects).getByRole('button', { name: en.careerProfileRemoveProject.replace('{index}', '1') }));
expect(within(projects).getByRole('button', { name: en.careerProfileAddProject })).toHaveFocus();
const certifications = screen.getByRole('region', { name: en.cvSectionCertifications });
fireEvent.click(within(certifications).getByRole('button', { name: en.careerProfileAddCertification }));
expect(within(certifications).getAllByRole('textbox', { name: en.careerProfileCertificationName })).toHaveLength(2);
fireEvent.click(within(certifications).getByRole('button', { name: en.careerProfileRemoveCertification.replace('{index}', '2') }));
expect(within(certifications).getByRole('button', { name: en.careerProfileAddCertification })).toHaveFocus();
expect(mockedApi.put).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole('button', { name: en.profileSaveChanges }));
await waitFor(() => expect(mockedApi.put).toHaveBeenCalledTimes(1));
const payload = mockedApi.put.mock.calls[0][1] as { profile: StructuredCvProfile };
expect(payload.profile.projects.map(project => project.name)).toEqual(['Added project']);
expect(payload.profile.certifications).toEqual(profile.certifications);
});
test("a pending initial read keeps one request and reports its failure in the latest locale without language writes", async () => {
const pending = deferred<any>();
const read = mockedApi.get.getMockImplementation()!;
@@ -154,7 +203,7 @@ test("a held Save locks every editor mutation, raw input and history restore unt
expect(snapshot.profile.contact.fullName).toBe("Snapshot owner");
expect(snapshot.cvText).toBe(rawText);
expect(screen.getAllByRole("textbox").filter((field) => !(field as HTMLInputElement).disabled).map((field) => field.getAttribute("aria-label") || field.id)).toEqual([]);
for (const name of [en.profileCvAddLink, en.profileCvStructuredAddLanguage, en.profileCvStructuredAddJob, en.profileCvStructuredAddEducation, en.profileCvStructuredAddOtherSection, en.careerRestoreVersion.replace("{version}", "3")]) {
for (const name of [en.profileCvAddLink, en.profileCvStructuredAddLanguage, en.profileCvStructuredAddJob, en.profileCvStructuredAddEducation, en.profileCvStructuredAddOtherSection, en.careerProfileAddProject, en.careerProfileAddCertification, en.careerRestoreVersion.replace("{version}", "3")]) {
expect(screen.getByRole("button", { name })).toBeDisabled();
}
for (const button of screen.getAllByRole("button", { name: /^(Remove|Delete custom link|Bold|Italic|Underline|Link$)/ })) expect(button).toBeDisabled();
@@ -194,6 +194,52 @@ function renderCareer() {
return render(<ToastProvider><I18nProvider><ConfirmProvider><CareerProfilePage /></ConfirmProvider></I18nProvider></ToastProvider>);
}
test.each(['en', 'nb'] as const)('%s overview opens every canonical section directly without saving or reloading the profile', async language => {
localStorage.setItem('uiLanguage', language);
window.history.replaceState({}, '', '/career?keep=1#context');
renderCareer();
await screen.findByText('70%');
const canonicalReads = mockedApi.get.mock.calls.filter(([url]) => url === '/career/profile').length;
const sections = ['contact', 'summary', 'skills', 'interests', 'awards', 'publications', 'organisations', 'references', 'languages', 'jobs', 'education', 'certifications', 'projects', 'otherSections'];
for (const section of sections) {
const action = document.querySelector<HTMLButtonElement>(`button[data-edit-career-section="${section}"]`);
expect(action).not.toBeNull();
fireEvent.click(action!);
await waitFor(() => expect(document.getElementById(`career-section-${section}`)).toContainElement(document.activeElement as HTMLElement));
expect(new URLSearchParams(location.search).get('keep')).toBe('1');
expect(location.hash).toBe('#context');
fireEvent.click(screen.getByRole('tab', { name: translations[language].careerWorkspaceOverviewTab }));
}
expect(mockedApi.get.mock.calls.filter(([url]) => url === '/career/profile')).toHaveLength(canonicalReads);
expect(mockedApi.put).not.toHaveBeenCalled();
expect(mockedApi.post).not.toHaveBeenCalled();
});
test.each(['en', 'nb'] as const)('%s section indicators retain server scoring and stop claiming saved status for an unsaved draft', async language => {
localStorage.setItem('uiLanguage', language);
const originalGet = mockedApi.get.getMockImplementation()!;
mockedApi.get.mockImplementation((url: string) => url === '/career/profile' ? Promise.resolve({ data: {
...careerResponse,
completeness: { ...careerResponse.completeness, sections: [
{ key: 'personal', complete: false, count: 1 },
{ key: 'summary', complete: true, count: 1 },
{ key: 'projects', complete: false, count: 0 },
] },
} } as any) : originalGet(url));
renderCareer();
await screen.findByText('70%');
const row = (key: string) => document.querySelector(`button[data-edit-career-section="${key}"]`)!.closest('article')!;
expect(row('contact')).toHaveTextContent(translations[language].careerOverviewSectionMissing);
expect(row('summary')).toHaveTextContent(translations[language].careerOverviewSectionRecorded);
expect(row('certifications')).toHaveTextContent(translations[language].careerOverviewSectionReview);
fireEvent.click(document.querySelector<HTMLButtonElement>('button[data-edit-career-section="contact"]')!);
fireEvent.change(await screen.findByLabelText(translations[language].profileCvContactFullName), { target: { value: 'Unsaved career name' } });
fireEvent.click(screen.getByRole('tab', { name: translations[language].careerWorkspaceOverviewTab }));
expect(row('summary')).toHaveTextContent(translations[language].careerOverviewSectionReview);
expect(screen.getByText(translations[language].careerOverviewSectionDraftHelp)).toBeVisible();
expect(mockedApi.put).not.toHaveBeenCalled();
});
test("the parent exposes failed import-read retry without canonical saves or history restores", async () => {
let fail = true;
const originalGet = mockedApi.get.getMockImplementation()!;
@@ -3,7 +3,7 @@ import { createPortal } from "react-dom";
import {
Alert, Box, Button, Chip, Dialog, DialogActions, DialogContent, DialogTitle, FormControl, IconButton, InputLabel, MenuItem, Paper, Select,
Stack, Tab, Tabs, TextField, Tooltip, Typography, useMediaQuery, useTheme,
Stack, Tab, Tabs, TextField, Tooltip, Typography, useMediaQuery,
} from "@mui/material";
import RichTextField from "./RichTextField";
import Markdown from "./Markdown";
@@ -516,7 +516,7 @@ export function ApplicationCoverLetterSection({ jobId, onDirtyChange }: { jobId:
const [aiToolbarHost, setAiToolbarHost] = useState<HTMLDivElement | null>(null);
const { language, t } = useI18n();
const workspaceId = useId();
const wide = useMediaQuery(useTheme().breakpoints.up("md"));
const showBothPanes = useMediaQuery("(min-width:768px)");
const [pane, setPane] = useState("write");
const { confirm } = useConfirm();
const { data, error, loading, setData, setError, reload, isCurrent } = useAsset<CoverLetter>(
@@ -616,12 +616,12 @@ export function ApplicationCoverLetterSection({ jobId, onDirtyChange }: { jobId:
contentRef={contentRef}
>
<Stack spacing={2}>
{!wide && <Tabs value={pane} onChange={(_, value) => setPane(value)} aria-label={t("assetsLetterWorkspace")} variant="fullWidth">
{!showBothPanes && <Tabs value={pane} onChange={(_, value) => setPane(value)} aria-label={t("assetsLetterWorkspace")} variant="fullWidth">
<Tab value="write" label={t("assetsLetterWrite")} id={`${workspaceId}-write`} aria-controls={`${workspaceId}-write-panel`} />
<Tab value="preview" label={t("assetsLetterPreview")} id={`${workspaceId}-preview`} aria-controls={`${workspaceId}-preview-panel`} />
</Tabs>}
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "minmax(0, 1fr)", md: "repeat(2, minmax(0, 1fr))" }, gap: 2 }}>
<Box hidden={!wide && pane !== "write"} id={`${workspaceId}-write-panel`} role={wide ? undefined : "tabpanel"} aria-labelledby={wide ? undefined : `${workspaceId}-write`} sx={{ minWidth: 0 }}>
<Box data-letter-studio-layout sx={{ display: "grid", gridTemplateColumns: "minmax(0, 1fr)", "@media (min-width:1001px)": { gridTemplateColumns: "repeat(2, minmax(0, 1fr))" }, gap: 2 }}>
<Box hidden={!showBothPanes && pane !== "write"} id={`${workspaceId}-write-panel`} role={showBothPanes ? undefined : "tabpanel"} aria-labelledby={showBothPanes ? undefined : `${workspaceId}-write`} sx={{ minWidth: 0 }}>
{settings && <CoverLetterDocumentFields settings={settings} disabled={busy || confirming || loading} onChange={next => { if (!busyRef.current) setSettingsDraft(next); }} />}
<RichTextField
minRows={12}
@@ -633,8 +633,8 @@ export function ApplicationCoverLetterSection({ jobId, onDirtyChange }: { jobId:
/>
<div ref={setAiToolbarHost} />
</Box>
<Box hidden={!wide && pane !== "preview"} id={`${workspaceId}-preview-panel`} role={wide ? undefined : "tabpanel"} aria-labelledby={wide ? undefined : `${workspaceId}-preview`} sx={{ minWidth: 0 }}>
{settings && <Box sx={{ mb: 2 }}><CoverLetterDocumentPreview key={jobId} jobId={jobId} text={text} settings={settings} active={wide || pane === "preview"} /></Box>}
<Box hidden={!showBothPanes && pane !== "preview"} id={`${workspaceId}-preview-panel`} role={showBothPanes ? undefined : "tabpanel"} aria-labelledby={showBothPanes ? undefined : `${workspaceId}-preview`} sx={{ minWidth: 0 }}>
{settings && <Box sx={{ mb: 2 }}><CoverLetterDocumentPreview key={jobId} jobId={jobId} text={text} settings={settings} active={showBothPanes || pane === "preview"} /></Box>}
{!settings && <Paper variant="outlined" role="region" aria-labelledby={`${workspaceId}-preview-title`} sx={{ p: 2, borderRadius: 2, overflowWrap: "anywhere" }}>
<Typography id={`${workspaceId}-preview-title`} component="h4" variant="subtitle2">{t("assetsLetterTextPreview")}</Typography>
<Typography variant="caption" color="text.secondary" component="p" sx={{ mb: 2 }}>{t("assetsLetterTextPreviewHelp")}</Typography>
@@ -47,10 +47,12 @@ export default function RichTextField({
};
const btn = (title: string, icon: React.ReactNode, before: string, after: string, ph: string) => (
<Tooltip title={title} slotProps={{ popper: { sx: { "&[data-popper-reference-hidden]": { visibility: "hidden" } } } }}>
<Tooltip title={title} describeChild slotProps={{ popper: { sx: { "&[data-popper-reference-hidden]": { visibility: "hidden" } } } }}>
<span style={{ display: 'inline-flex' }}>
<IconButton size="small" disabled={disabled} aria-label={title} onMouseDown={(e) => e.preventDefault()} onClick={() => apply(before, after, ph)}>
{icon}
</IconButton>
</span>
</Tooltip>
);
@@ -95,8 +95,12 @@ test('Strict Mode groups a typing burst into one undo step and preserves redo th
await openContentTab();
const headline = screen.getByLabelText('Headline override');
act(() => headline.focus());
fireEvent.change(headline, { target: { value: 'D' } });
fireEvent.change(headline, { target: { value: 'Developer' } });
// A simulated typing burst must not depend on the test host's render latency.
const clock = jest.spyOn(Date, 'now').mockReturnValue(Date.now());
try {
fireEvent.change(headline, { target: { value: 'D' } });
fireEvent.change(headline, { target: { value: 'Developer' } });
} finally { clock.mockRestore(); }
act(() => headline.blur());
fireEvent.click(screen.getByRole('button', { name: 'Save now' }));
await screen.findByText('Saved');
@@ -107,6 +111,47 @@ test('Strict Mode groups a typing burst into one undo step and preserves redo th
expect(headline).toHaveValue('Developer');
});
test('a pause between typing bursts leaves a separate undo step', async () => {
routeGet(() => Promise.resolve({ data: variant }));
renderAt(3, true);
await openContentTab();
const headline = screen.getByLabelText('Headline override');
act(() => headline.focus());
const now = Date.now();
const clock = jest.spyOn(Date, 'now').mockReturnValue(now);
try {
fireEvent.change(headline, { target: { value: 'D' } });
fireEvent.change(headline, { target: { value: 'Developer' } });
clock.mockReturnValue(now + 2000);
fireEvent.change(headline, { target: { value: 'Developer after pause' } });
} finally { clock.mockRestore(); }
act(() => headline.blur());
fireEvent.click(screen.getByRole('button', { name: 'Undo' }));
expect(headline).toHaveValue('Developer');
fireEvent.click(screen.getByRole('button', { name: 'Undo' }));
expect(headline).toHaveValue('');
});
test('tablet width keeps both editing and preview available in the stacked document flow', async () => {
const originalMedia = Object.getOwnPropertyDescriptor(window, 'matchMedia');
Object.defineProperty(window, 'matchMedia', { configurable: true, writable: true, value: (query: string) => ({
matches: /max-width:\s*([\d.]+)px/.test(query) && 768 <= Number(/max-width:\s*([\d.]+)px/.exec(query)![1]),
media: query, onchange: null, addListener: jest.fn(), removeListener: jest.fn(), addEventListener: jest.fn(), removeEventListener: jest.fn(), dispatchEvent: () => true,
}) });
try {
routeGet(() => Promise.resolve({ data: variant }));
renderAt(3);
await openContentTab();
expect(screen.queryByRole('button', { name: 'Edit' })).not.toBeInTheDocument();
expect(screen.getByRole('region', { name: 'CV editing controls' })).toBeVisible();
expect(screen.getByRole('region', { name: 'Live preview' })).toBeVisible();
expect(mockedApi.put).not.toHaveBeenCalled();
} finally {
if (originalMedia) Object.defineProperty(window, 'matchMedia', originalMedia);
else delete (window as Partial<Window>).matchMedia;
}
});
test('document shortcuts undo and redo renaming, preserve native input undo and discard branched redo', async () => {
routeGet(() => Promise.resolve({ data: variant }));
renderAt(3, true);
@@ -10,6 +10,7 @@ import { api } from './api';
import { ConfirmProvider } from './confirm';
import { PromptProvider } from './prompt';
import { emptyCvVariantSettings, getCvExportFailureKey } from './cvBuilder';
import { translations } from './i18n/translations';
const mockNavigate = jest.fn();
jest.mock('react-router-dom', () => ({
@@ -68,6 +69,17 @@ const existingCv = {
publicSlug: 'original-slug', isPublic: true, updatedAtUtc: '2026-10-12T10:00:00Z',
};
test.each(['en', 'nb'] as const)('%s library identifies templates by their human label and retains an unknown legacy identifier', async language => {
localStorage.setItem('uiLanguage', language);
mockedApi.get.mockImplementation(async url => ({ data: url === '/cv/themes' ? themes : url === '/cv/variants' ? [existingCv, { ...existingCv, id: 2, name: 'Legacy CV', themeId: 'legacy-template' }] : [] }) as any);
renderPage();
const card = (await screen.findByText(existingCv.name)).closest('[role="link"]') as HTMLElement;
expect(within(card).getByText(language === 'nb' ? translations.nb.cvTemplateModernName : 'Modern')).toBeVisible();
const legacy = screen.getByText('Legacy CV').closest('[role="link"]') as HTMLElement;
expect(within(legacy).getByText('legacy-template')).toBeVisible();
expect(mockedApi.put).not.toHaveBeenCalled();
});
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason: unknown) => void;
+36
View File
@@ -1807,6 +1807,24 @@ export const translations = {
profileCvStructuredAddEducation: "Add education",
profileCvStructuredAddOtherSection: "Add section",
profileCvStructuredRemove: "Remove",
careerProfileProjectName: "Project name",
careerOverviewSections: "Profile sections",
careerOverviewSectionsHelp: "Open any section to review or edit it. Indicators use the saved profile score; they do not assess suitability for a job.",
careerOverviewSectionRecorded: "Content recorded",
careerOverviewSectionMissing: "More information needed for this indicator",
careerOverviewSectionReview: "Open to review",
careerOverviewEditSection: "Edit {section}",
careerOverviewSectionDraftHelp: "You have unsaved profile changes. Save them to refresh the completion indicators.",
careerProfileProjectRole: "Project role",
careerProfileProjectSkills: "Project skills",
careerProfileProjectOutcomes: "Project outcomes",
careerProfileAddProject: "Add project",
careerProfileRemoveProject: "Remove project {index}",
careerProfileCertificationName: "Certification name",
careerProfileCertificationDate: "Certification date",
careerProfileCertificationDetails: "Credential details",
careerProfileAddCertification: "Add certification",
careerProfileRemoveCertification: "Remove certification {index}",
profileCvStructuredListHelp: "One item per line.",
profileCvStructuredEmpty: "Nothing added yet.",
profileCvLanguageName: "Language",
@@ -4965,6 +4983,24 @@ export const translations = {
profileCvStructuredAddEducation: "Legg til utdanning",
profileCvStructuredAddOtherSection: "Legg til seksjon",
profileCvStructuredRemove: "Fjern",
careerProfileProjectName: "Prosjektnavn",
careerOverviewSections: "Profilseksjoner",
careerOverviewSectionsHelp: "Åpne en seksjon for å se gjennom eller redigere den. Indikatorene bruker den lagrede profilskåren; de vurderer ikke om du passer til en jobb.",
careerOverviewSectionRecorded: "Innhold registrert",
careerOverviewSectionMissing: "Mer informasjon trengs for denne indikatoren",
careerOverviewSectionReview: "Åpne for å se gjennom",
careerOverviewEditSection: "Rediger {section}",
careerOverviewSectionDraftHelp: "Du har ulagrede profilendringer. Lagre dem for å oppdatere indikatorene for fullføring.",
careerProfileProjectRole: "Rolle i prosjektet",
careerProfileProjectSkills: "Ferdigheter for prosjektet",
careerProfileProjectOutcomes: "Resultater fra prosjektet",
careerProfileAddProject: "Legg til prosjekt",
careerProfileRemoveProject: "Fjern prosjekt {index}",
careerProfileCertificationName: "Navn på sertifisering",
careerProfileCertificationDate: "Dato for sertifisering",
careerProfileCertificationDetails: "Detaljer om sertifisering",
careerProfileAddCertification: "Legg til sertifisering",
careerProfileRemoveCertification: "Fjern sertifisering {index}",
profileCvStructuredListHelp: "Én post per linje.",
profileCvStructuredEmpty: "Ingenting lagt til ennå.",
profileCvLanguageName: "Språk",
+26 -15
View File
@@ -14,6 +14,8 @@ import CareerWorkspaceOverview from "./career/CareerWorkspaceOverview";
import CareerEvidenceBank from "./career/CareerEvidenceBank";
import CvImportReview from "./career/CvImportReview";
import {
CertificationsSection,
type CareerProfileSectionKey,
EducationSection,
InterestsSection,
LanguagesSection,
@@ -21,6 +23,7 @@ import {
OtherSectionsSection,
PersonalInformationSection,
ProfessionalSummarySection,
ProjectsSection,
SkillsSection,
WorkExperienceSection,
} from "./career/CareerProfileSections";
@@ -201,6 +204,7 @@ export default function CareerProfilePage() {
// The raw import/section parser remains available as an advanced recovery tool.
const [showAdvancedCvTools, setShowAdvancedCvTools] = useState(false);
const [workspaceSection, setWorkspaceSection] = useState<CareerWorkspaceSection>(initialWorkspaceSection);
const [focusSection, setFocusSection] = useState<CareerProfileSectionKey | null>(null);
const [evidenceVisited, setEvidenceVisited] = useState(() => initialWorkspaceSection() === "evidence");
const navigateWorkspace = useCallback((next: CareerWorkspaceSection) => {
@@ -487,6 +491,13 @@ export default function CareerProfilePage() {
const initials = useMemo(() => initialsFrom([me?.displayName, me?.firstName, me?.lastName, me?.userName, me?.email]), [me]);
const isLocal = me?.provider === "local";
const editorDisabled = !isLocal || loading || saving || parsingCvSections || (reviewingRunId !== null && reviewAction === "accept");
useEffect(() => {
if (!focusSection || workspaceSection !== 'profile' || editorDisabled) return;
const region = document.getElementById(`career-section-${focusSection}`);
const target = region?.querySelector<HTMLElement>('input:not([disabled]), textarea:not([disabled]), [contenteditable="true"]')
?? region?.querySelector<HTMLElement>('button:not([disabled])');
if (target) { target.focus(); setFocusSection(null); }
}, [focusSection, workspaceSection, editorDisabled]);
const loadErrorMessage = loadError === null ? null : loadError || t("profileLoadFailed");
const fullName = [me?.firstName, me?.lastName].filter(Boolean).join(" ");
const cvWordCount = profileCvText.trim() ? profileCvText.trim().split(/\s+/).length : 0;
@@ -551,6 +562,7 @@ export default function CareerProfilePage() {
onRetryProfile={() => { if (!profileDirty && !profileMutationPending.current) void loadProfile(); }}
profileDirty={profileDirty}
onNavigate={navigateWorkspace}
onEditSection={section => { setFocusSection(section); navigateWorkspace('profile'); }}
/>
) : null}
@@ -1000,29 +1012,28 @@ export default function CareerProfilePage() {
</Box>
<Box sx={{ mt: workspaceSection === "profile" ? 2 : 0, display: workspaceSection === "profile" ? "block" : "none" }}>
<PageSection title={t("profileCvStructuredEditor")} description={t("profileCvStructuredEditorHelp")} surface>
<Box sx={{ display: 'grid', gap: 3 }}>
<Box id="career-section-contact">
<PersonalInformationSection
value={structuredCv.contact}
onChange={(next) => editStructuredCv((prev) => ({ ...prev, contact: next }))}
getMetadata={metaFor}
disabled={editorDisabled}
/>
<Box sx={{ mt: 2, display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 1.5 }}>
<ProfessionalSummarySection value={structuredCv.summary} onChange={(next) => editStructuredCv((prev) => ({ ...prev, summary: next }))} getMetadata={metaFor} disabled={editorDisabled} />
<SkillsSection value={structuredCv.skills} onChange={(next) => editStructuredCv((prev) => ({ ...prev, skills: next }))} getMetadata={metaFor} disabled={editorDisabled} />
<InterestsSection value={structuredCv.interests} onChange={(next) => editStructuredCv((prev) => ({ ...prev, interests: next }))} getMetadata={metaFor} disabled={editorDisabled} />
</Box>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 1.5 }}>
<Box id="career-section-summary"><ProfessionalSummarySection value={structuredCv.summary} onChange={(next) => editStructuredCv((prev) => ({ ...prev, summary: next }))} getMetadata={metaFor} disabled={editorDisabled} /></Box>
<Box id="career-section-skills"><SkillsSection value={structuredCv.skills} onChange={(next) => editStructuredCv((prev) => ({ ...prev, skills: next }))} getMetadata={metaFor} disabled={editorDisabled} /></Box>
<Box id="career-section-interests"><InterestsSection value={structuredCv.interests} onChange={(next) => editStructuredCv((prev) => ({ ...prev, interests: next }))} getMetadata={metaFor} disabled={editorDisabled} /></Box>
</Box>
<LongTailSections values={{ awards: structuredCv.awards, publications: structuredCv.publications, organisations: structuredCv.organisations, references: structuredCv.references }} onChange={(key, next) => editStructuredCv((prev) => ({ ...prev, [key]: next }))} disabled={editorDisabled} />
<LanguagesSection value={structuredCv.languages} onChange={(next) => editStructuredCv((prev) => ({ ...prev, languages: next }))} getMetadata={metaFor} disabled={editorDisabled} />
<WorkExperienceSection value={structuredCv.jobs} onChange={(next) => editStructuredCv((prev) => ({ ...prev, jobs: next }))} disabled={editorDisabled} />
<EducationSection value={structuredCv.education} onChange={(next) => editStructuredCv((prev) => ({ ...prev, education: next }))} disabled={editorDisabled} />
<OtherSectionsSection value={structuredCv.otherSections} onChange={(next) => editStructuredCv((prev) => ({ ...prev, otherSections: next }))} disabled={editorDisabled} />
<Box id="career-section-languages"><LanguagesSection value={structuredCv.languages} onChange={(next) => editStructuredCv((prev) => ({ ...prev, languages: next }))} getMetadata={metaFor} disabled={editorDisabled} /></Box>
<Box id="career-section-jobs"><WorkExperienceSection value={structuredCv.jobs} onChange={(next) => editStructuredCv((prev) => ({ ...prev, jobs: next }))} disabled={editorDisabled} /></Box>
<Box id="career-section-education"><EducationSection value={structuredCv.education} onChange={(next) => editStructuredCv((prev) => ({ ...prev, education: next }))} disabled={editorDisabled} /></Box>
<Box id="career-section-certifications"><CertificationsSection value={structuredCv.certifications} onChange={(next) => editStructuredCv((prev) => ({ ...prev, certifications: next }))} disabled={editorDisabled} /></Box>
<Box id="career-section-projects"><ProjectsSection value={structuredCv.projects} onChange={(next) => editStructuredCv((prev) => ({ ...prev, projects: next }))} disabled={editorDisabled} /></Box>
<Box id="career-section-otherSections"><OtherSectionsSection value={structuredCv.otherSections} onChange={(next) => editStructuredCv((prev) => ({ ...prev, otherSections: next }))} disabled={editorDisabled} /></Box>
</Box>
</PageSection>
</Box>
<Box sx={{ mt: 1, display: workspaceSection === "profile" ? "flex" : "none", justifyContent: "space-between", gap: 1, flexWrap: "wrap" }}>
+4 -4
View File
@@ -59,7 +59,7 @@ export default function CvBuilderEditor() {
const { toast } = useToast();
const { t } = useI18n();
const { confirmAction } = useDialogActions();
const compactEditor = useMediaQuery("(max-width:899.95px)");
const compactEditor = useMediaQuery("(max-width:767.95px)");
const [name, setName] = useState("");
const [settings, setSettings] = useState<CvVariantSettings | null>(null);
@@ -655,8 +655,8 @@ export default function CvBuilderEditor() {
</Stack>}
</Paper>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "minmax(0, 1fr)", md: "minmax(360px, 1fr) minmax(0, 1fr)" }, gap: 1.5, alignItems: "start" }}>
<Paper ref={editorRef} tabIndex={-1} variant="outlined" role="region" aria-label={t("cvEditorEditingRegion")} onFocusCapture={() => setMobilePane("edit")} sx={{ display: compactEditor && mobilePane !== "edit" ? "none" : "block", position: { md: "sticky" }, top: "var(--cv-pane-top)", minWidth: 0, maxHeight: { md: "calc(100dvh - var(--cv-pane-top) - 12px)" }, overflowY: { md: "auto" }, scrollbarWidth: "thin" }}>
<Box data-cv-studio-layout sx={{ display: "grid", gridTemplateColumns: "minmax(0, 1fr)", "@media (min-width:1001px)": { gridTemplateColumns: "minmax(360px, 1fr) minmax(0, 1fr)" }, gap: 1.5, alignItems: "start" }}>
<Paper ref={editorRef} tabIndex={-1} variant="outlined" role="region" aria-label={t("cvEditorEditingRegion")} onFocusCapture={() => setMobilePane("edit")} sx={{ display: compactEditor && mobilePane !== "edit" ? "none" : "block", top: "var(--cv-pane-top)", minWidth: 0, "@media (min-width:1001px)": { position: "sticky", maxHeight: "calc(100dvh - var(--cv-pane-top) - 12px)", overflowY: "auto" }, scrollbarWidth: "thin" }}>
<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">
@@ -694,7 +694,7 @@ export default function CvBuilderEditor() {
requestAnimationFrame(() => {
if (pane.contains(document.activeElement)) pane.scrollIntoView?.({ block: "start", inline: "nearest" });
});
}} sx={{ display: compactEditor && mobilePane !== "preview" ? "none" : "flex", flexDirection: "column", minWidth: 0, position: { md: "sticky" }, top: "var(--cv-pane-top)", height: "calc(100dvh - var(--cv-pane-top) - 12px)", scrollMarginTop: "var(--cv-pane-top)" }}>
}} sx={{ display: compactEditor && mobilePane !== "preview" ? "none" : "flex", flexDirection: "column", minWidth: 0, "@media (min-width:1001px)": { position: "sticky" }, top: "var(--cv-pane-top)", height: "calc(100dvh - var(--cv-pane-top) - 12px)", scrollMarginTop: "var(--cv-pane-top)" }}>
<Box sx={{ flex: "0 1 auto", minHeight: 0, maxHeight: "50%", overflowY: "auto", scrollbarWidth: "thin" }}>
<Stack direction="row" alignItems="center" gap={1} sx={{ px: 1.5, py: 1, flexWrap: "wrap", borderBottom: "1px solid", borderColor: "divider" }}>
<Box sx={{ flex: "1 1 140px", minWidth: 0 }}>
+8 -5
View File
@@ -12,7 +12,7 @@ import { api, getApiErrorMessage } from "../api";
import { useToast } from "../toast";
import { CvOutline, CvTheme, CvVariantSummary, cvBuilderApi, newCvVariantSettings, getCvExportFailureKey } from "../cvBuilder";
import { useDialogActions } from "../dialogs";
import CvTemplateGallery from "../components/CvTemplateGallery";
import CvTemplateGallery, { getCvTemplateLabels } from "../components/CvTemplateGallery";
import CvTemplateThumbnail from "../components/CvTemplateThumbnail";
import { useI18n } from "../i18n/I18nProvider";
import type { TranslationKey } from "../i18n/translations";
@@ -185,7 +185,9 @@ export default function CvBuilderPage() {
)}
<Box sx={{ display: "grid", minWidth: 0, gridTemplateColumns: { xs: "minmax(0, 1fr)", sm: "repeat(2, minmax(0, 1fr))", lg: "repeat(3, minmax(0, 1fr))" }, gap: 2 }}>
{variants.map((v) => (
{variants.map((v) => {
const theme = themes.find(item => item.id === v.themeId);
return (
<Paper
key={v.id}
role="link"
@@ -201,7 +203,7 @@ export default function CvBuilderPage() {
}
}}
>
<CvTemplateThumbnail theme={themes.find((theme) => theme.id === v.themeId)} />
<CvTemplateThumbnail theme={theme} />
<Stack direction="row" alignItems="flex-start" justifyContent="space-between">
<Typography component="h3" variant="sectionTitle" sx={{ mt: 1, minWidth: 0 }}>{v.name}</Typography>
<IconButton disabled={pending} aria-haspopup="menu" size="small" aria-label={t("cvDashboardActionsFor", { name: v.name })} onClick={(e) => { e.stopPropagation(); if (!actionBusy.current) setMenu({ anchor: e.currentTarget, id: v.id }); }}>
@@ -209,7 +211,7 @@ export default function CvBuilderPage() {
</IconButton>
</Stack>
<Stack direction="row" spacing={1} useFlexGap flexWrap="wrap" sx={{ mt: 1, minWidth: 0 }}>
<Chip size="small" label={v.themeId} />
<Chip size="small" label={theme ? getCvTemplateLabels(theme, language, t).name : v.themeId} />
<Chip size="small" variant="outlined" label={(v.language || "en").toUpperCase()} />
{v.isPublic && <Chip size="small" color="primary" icon={<PublicIcon />} label={t("cvEditorPublic")} />}
</Stack>
@@ -218,7 +220,8 @@ export default function CvBuilderPage() {
{v.jobApplicationId ? ` · ${[v.jobTitle, v.companyName].filter(Boolean).join(t("cvDashboardAt")) || t("cvDashboardJobNumber", { id: v.jobApplicationId })}` : ""}
</Typography>
</Paper>
))}
);
})}
</Box>
<Menu anchorEl={menu?.anchor} open={!!menu} onClose={() => setMenu(null)}>
@@ -10,11 +10,13 @@ import {
joinLines,
splitLines,
StructuredCvContact,
StructuredCvCertification,
StructuredCvEducation,
StructuredCvFieldMetadata,
StructuredCvJob,
StructuredCvLanguage,
StructuredCvOtherSection,
StructuredCvProject,
} from "../../profileCv";
// Career Profile editing sections, extracted from CareerProfilePage (Phase 1 increment 2).
@@ -28,6 +30,8 @@ import {
// profile), passed as a callback so sections stay decoupled from the full profile shape.
type MetadataLookup = (path: string) => StructuredCvFieldMetadata | undefined;
export type CareerProfileSectionKey = 'contact' | 'summary' | 'skills' | 'interests' | 'awards' | 'publications' | 'organisations' | 'references' | 'languages' | 'jobs' | 'education' | 'certifications' | 'projects' | 'otherSections';
// Field-review chip + tone, moved verbatim from CareerProfilePage so the sections and the parent
// share one definition. Behaviour (thresholds, labels, source snippet) is unchanged.
function confidenceTone(confidence?: number) {
@@ -141,7 +145,7 @@ export function InterestsSection({ value, onChange, getMetadata, disabled }: { v
export function LongTailSections({ values, onChange, disabled }: { values: Record<"awards" | "publications" | "organisations" | "references", string[]>; onChange: (key: keyof typeof values, next: string[]) => void; disabled: boolean }) {
const { t } = useI18n();
const labels = { awards: t("profileCvStructuredAwards"), publications: t("profileCvStructuredPublications"), organisations: t("profileCvStructuredOrganisations"), references: t("profileCvStructuredReferences") };
return <Box sx={{ mt: 2, display: "grid", gridTemplateColumns: { xs: "minmax(0, 1fr)", md: "minmax(0, 1fr) minmax(0, 1fr)" }, gap: 1.5 }}>{(Object.keys(values) as (keyof typeof values)[]).map((key) => <LinesField key={key} label={labels[key]} value={values[key]} onChange={(next) => onChange(key, next)} minRows={3} disabled={disabled} />)}</Box>;
return <Box sx={{ display: "grid", gridTemplateColumns: { xs: "minmax(0, 1fr)", md: "minmax(0, 1fr) minmax(0, 1fr)" }, gap: 1.5 }}>{(Object.keys(values) as (keyof typeof values)[]).map((key) => <Box key={key} id={`career-section-${key}`}><LinesField label={labels[key]} value={values[key]} onChange={(next) => onChange(key, next)} minRows={3} disabled={disabled} /></Box>)}</Box>;
}
export function LanguagesSection({ value, onChange, getMetadata, disabled }: { value: StructuredCvLanguage[]; onChange: (next: StructuredCvLanguage[]) => void; getMetadata: MetadataLookup; disabled: boolean }) {
@@ -223,6 +227,48 @@ export function EducationSection({ value, onChange, disabled }: { value: Structu
);
}
export function ProjectsSection({ value, onChange, disabled }: { value: StructuredCvProject[]; onChange: (next: StructuredCvProject[]) => void; disabled: boolean }) {
const { t } = useI18n();
const addButton = React.useRef<HTMLButtonElement>(null);
const update = (index: number, patch: Partial<StructuredCvProject>) => onChange(value.map((entry, i) => i === index ? { ...entry, ...patch } : entry));
return <PageSection title={t("cvSectionProjects")} level="h3" actions={<Button ref={addButton} disabled={disabled} variant="outlined" size="small" onClick={() => onChange([...value, { name: "", role: "", location: "", start: "", end: "", bullets: [], skills: [] }])}>{t("careerProfileAddProject")}</Button>}>
{value.length === 0 ? <Typography color="text.secondary">{t("profileCvStructuredEmpty")}</Typography> : null}
{value.map((project, index) => <Box key={project.id ?? `project-${index}`} sx={{ p: "var(--app-card-padding)", mt: 1.5, border: 1, borderColor: "divider", borderRadius: 1, minWidth: 0 }}>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "minmax(0, 1fr)", md: "repeat(2, minmax(0, 1fr))" }, gap: 1 }}>
<TextField disabled={disabled} label={t("careerProfileProjectName")} value={project.name ?? ""} onChange={event => update(index, { name: event.target.value || undefined })} />
<TextField disabled={disabled} label={t("careerProfileProjectRole")} value={project.role ?? ""} onChange={event => update(index, { role: event.target.value || undefined })} />
<TextField disabled={disabled} label={t("profileCvJobLocation")} value={project.location ?? ""} onChange={event => update(index, { location: event.target.value || undefined })} />
<Box sx={{ display: "grid", gridTemplateColumns: "repeat(2, minmax(0, 1fr))", gap: 1 }}>
<TextField disabled={disabled} label={t("profileCvJobStart")} value={project.start ?? ""} onChange={event => update(index, { start: event.target.value || undefined })} />
<TextField disabled={disabled} label={t("profileCvJobEnd")} value={project.end ?? ""} onChange={event => update(index, { end: event.target.value || undefined })} />
</Box>
<RichTextField disabled={disabled} label={t("careerProfileProjectOutcomes")} value={joinLines(project.bullets)} onChange={text => update(index, { bullets: splitLines(text) })} minRows={3} />
<LinesField disabled={disabled} label={t("careerProfileProjectSkills")} value={project.skills} onChange={skills => update(index, { skills })} minRows={3} />
</Box>
<Button disabled={disabled} onClick={() => { addButton.current?.focus(); onChange(value.filter((_, i) => i !== index)); }} aria-label={t("careerProfileRemoveProject", { index: index + 1 })}>{t("profileCvStructuredRemove")}</Button>
</Box>)}
</PageSection>;
}
export function CertificationsSection({ value, onChange, disabled }: { value: StructuredCvCertification[]; onChange: (next: StructuredCvCertification[]) => void; disabled: boolean }) {
const { t } = useI18n();
const addButton = React.useRef<HTMLButtonElement>(null);
const update = (index: number, patch: Partial<StructuredCvCertification>) => onChange(value.map((entry, i) => i === index ? { ...entry, ...patch } : entry));
return <PageSection title={t("cvSectionCertifications")} level="h3" actions={<Button ref={addButton} disabled={disabled} variant="outlined" size="small" onClick={() => onChange([...value, { name: "", issuer: "", location: "", date: "", details: [] }])}>{t("careerProfileAddCertification")}</Button>}>
{value.length === 0 ? <Typography color="text.secondary">{t("profileCvStructuredEmpty")}</Typography> : null}
{value.map((certification, index) => <Box key={certification.id ?? `certification-${index}`} sx={{ p: "var(--app-card-padding)", mt: 1.5, border: 1, borderColor: "divider", borderRadius: 1, minWidth: 0 }}>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "minmax(0, 1fr)", md: "repeat(2, minmax(0, 1fr))" }, gap: 1 }}>
<TextField disabled={disabled} label={t("careerProfileCertificationName")} value={certification.name ?? ""} onChange={event => update(index, { name: event.target.value || undefined })} />
<TextField disabled={disabled} label={t("careerImportIssuer")} value={certification.issuer ?? ""} onChange={event => update(index, { issuer: event.target.value || undefined })} />
<TextField disabled={disabled} label={t("profileCvJobLocation")} value={certification.location ?? ""} onChange={event => update(index, { location: event.target.value || undefined })} />
<TextField disabled={disabled} label={t("careerProfileCertificationDate")} value={certification.date ?? ""} onChange={event => update(index, { date: event.target.value || undefined })} />
</Box>
<RichTextField disabled={disabled} label={t("careerProfileCertificationDetails")} value={joinLines(certification.details)} onChange={text => update(index, { details: splitLines(text) })} minRows={3} />
<Button disabled={disabled} onClick={() => { addButton.current?.focus(); onChange(value.filter((_, i) => i !== index)); }} aria-label={t("careerProfileRemoveCertification", { index: index + 1 })}>{t("profileCvStructuredRemove")}</Button>
</Box>)}
</PageSection>;
}
export function OtherSectionsSection({ value, onChange, disabled }: { value: StructuredCvOtherSection[]; onChange: (next: StructuredCvOtherSection[]) => void; disabled: boolean }) {
const { t } = useI18n();
const update = (index: number, patch: Partial<StructuredCvOtherSection>) => onChange(value.map((entry, i) => (i === index ? { ...entry, ...patch } : entry)));
@@ -11,10 +11,12 @@ import type { UserOperation } from "../../types";
import { useI18n } from "../../i18n/I18nProvider";
import { PageSection } from "../../layout/PageLayout";
import { localizeCompletenessLabel } from "./careerCompletenessLabels";
import type { CareerProfileSectionKey } from './CareerProfileSections';
export type CareerWorkspaceCompleteness = {
percent: number;
missing: string[];
sections?: Array<{ key: string; complete: boolean; count: number }>;
};
export type CareerWorkspaceImportRun = {
@@ -59,12 +61,13 @@ function ActionCard({ title, body, href, label, icon, onNavigate }: {
);
}
export default function CareerWorkspaceOverview({ completeness, runs, loading, loadError, onNavigate, runsLoading = false, runsLoadError = null, onRetryProfile, onRetryRuns, profileDirty = false }: {
export default function CareerWorkspaceOverview({ completeness, runs, loading, loadError, onNavigate, onEditSection, runsLoading = false, runsLoadError = null, onRetryProfile, onRetryRuns, profileDirty = false }: {
completeness: CareerWorkspaceCompleteness | null;
runs: CareerWorkspaceImportRun[];
loading: boolean;
loadError: string | null;
onNavigate?: (section: "profile" | "import") => void;
onEditSection?: (section: CareerProfileSectionKey) => void;
runsLoading?: boolean;
runsLoadError?: string | null;
onRetryProfile?: () => void;
@@ -115,6 +118,22 @@ export default function CareerWorkspaceOverview({ completeness, runs, loading, l
const importUnknown = runsLoading && runs.length === 0;
const importLabel = importUnknown || runsLoadError ? t("careerWorkspaceImportTab") : importState.label;
const isFirstRun = profileKnown && !runsLoading && !runsLoadError && !recentLoading && !recentError && completeness.percent === 0 && runs.length === 0 && recentCvs.length === 0;
const sections: Array<{ key: CareerProfileSectionKey; label: string; statusKey?: string }> = [
{ key: 'contact', label: t('careerProfileContactSection'), statusKey: 'personal' },
{ key: 'summary', label: t('profileCvStructuredSummary'), statusKey: 'summary' },
{ key: 'skills', label: t('profileCvStructuredSkills'), statusKey: 'skills' },
{ key: 'interests', label: t('profileCvStructuredInterests') },
{ key: 'awards', label: t('profileCvStructuredAwards') },
{ key: 'publications', label: t('profileCvStructuredPublications') },
{ key: 'organisations', label: t('profileCvStructuredOrganisations') },
{ key: 'references', label: t('profileCvStructuredReferences') },
{ key: 'languages', label: t('profileCvStructuredLanguages'), statusKey: 'languages' },
{ key: 'jobs', label: t('profileCvStructuredJobs'), statusKey: 'experience' },
{ key: 'education', label: t('profileCvStructuredEducation'), statusKey: 'education' },
{ key: 'certifications', label: t('cvSectionCertifications') },
{ key: 'projects', label: t('cvSectionProjects'), statusKey: 'projects' },
{ key: 'otherSections', label: t('profileCvStructuredOtherSections') },
];
return (
<PageSection title={t("careerWorkspaceOverviewTab")} description={t("careerOverviewSubtitle")} actions={
@@ -146,6 +165,20 @@ export default function CareerWorkspaceOverview({ completeness, runs, loading, l
</PageSection>
</Box>
{onEditSection ? <PageSection title={t('careerOverviewSections')} description={t('careerOverviewSectionsHelp')} level="h3">
{profileDirty ? <Alert severity="info" sx={{ mb: 2 }}>{t('careerOverviewSectionDraftHelp')}</Alert> : null}
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: 'minmax(0, 1fr)', md: 'repeat(2, minmax(0, 1fr))' }, gap: 2 }}>
{sections.map(section => {
const status = profileKnown && !profileDirty && section.statusKey ? completeness?.sections?.find(item => item.key === section.statusKey) : undefined;
return <Box component="article" key={section.key} sx={{ borderBottom: 1, borderColor: 'divider', pb: 2, minWidth: 0 }}>
<Typography component="h4" variant="body1" sx={{ fontWeight: 700 }}>{section.label}</Typography>
<Typography variant="body2" color="text.secondary">{loading ? t('loading') : loadError ? t('careerOverviewUnavailable') : status ? status.complete ? t('careerOverviewSectionRecorded') : t('careerOverviewSectionMissing') : t('careerOverviewSectionReview')}</Typography>
<Button data-edit-career-section={section.key} disabled={loading || !!loadError} onClick={() => onEditSection(section.key)} sx={{ mt: 1, whiteSpace: 'normal', textAlign: 'left' }}>{t('careerOverviewEditSection', { section: section.label })}</Button>
</Box>;
})}
</Box>
</PageSection> : null}
<Box component="nav" aria-label={t("careerOverviewActions")} sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", sm: "repeat(2, minmax(0, 1fr))", xl: "repeat(4, minmax(0, 1fr))" }, gap: 1.5 }}>
<ActionCard title={t("careerOverviewCareerProfile")} body={t("careerOverviewCareerProfileBody")} href="/career?section=profile" onNavigate={onNavigate ? () => onNavigate("profile") : undefined} label={t("careerOverviewEditProfile")} icon={<PersonOutlineIcon />} />
<ActionCard title={t("careerOverviewImportCv")} body={t("careerOverviewImportBody")} href="/career?section=import" onNavigate={onNavigate ? () => onNavigate("import") : undefined} label={importLabel} icon={<UploadFileOutlinedIcon />} />