feat(ui): migrate Career Profile structured editor

This commit is contained in:
cesnimda
2026-10-12 13:32:55 +02:00
parent 704637ab37
commit f7f6b774b9
17 changed files with 8314 additions and 158 deletions
+285
View File
@@ -0,0 +1,285 @@
import React from "react";
import "@testing-library/jest-dom";
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { api } from "./api";
import { I18nProvider } from "./i18n/I18nProvider";
import { translations } from "./i18n/translations";
import { emptyStructuredCv, StructuredCvProfile } from "./profileCv";
import { ToastProvider } from "./toast";
import CareerProfilePage from "./views/CareerProfilePage";
jest.mock("./api", () => ({
getApiErrorMessage: jest.requireActual("./api").getApiErrorMessage,
api: { get: jest.fn(), post: jest.fn(), put: jest.fn(), patch: jest.fn(), delete: jest.fn() },
}));
jest.mock("./components/CropImageDialog", () => () => null);
const mockedApi = api as jest.Mocked<typeof api>;
const en = translations.en;
const nb = translations.nb;
const rawText = "Original stored source\nNo silent import acceptance";
let profile: StructuredCvProfile;
let provider: "local" | "external";
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason: unknown) => void;
const promise = new Promise<T>((res, rej) => { resolve = res; reject = rej; });
return { promise, resolve, reject };
}
function sourceResponse() {
return { data: { profile, cvText: rawText, completeness: { percent: 72, missing: [], sections: [] } } };
}
function renderPage() {
return render(<ToastProvider><I18nProvider><CareerProfilePage /></I18nProvider></ToastProvider>);
}
async function ready() {
await waitFor(() => expect(screen.getByLabelText(en.profileCvContactFullName)).toHaveValue("Recorded owner"));
await waitFor(() => expect(screen.getByRole("button", { name: en.profileSaveChanges })).toBeEnabled());
}
async function changeLocale() {
await act(async () => window.dispatchEvent(new StorageEvent("storage", { key: "uiLanguage", newValue: "nb" })));
}
function readCount(url: string) {
return mockedApi.get.mock.calls.filter(([requested]) => requested === url).length;
}
function editor() {
const element = document.getElementById("career-profile-editor");
expect(element).not.toBeNull();
return within(element!);
}
beforeEach(() => {
jest.resetAllMocks();
window.localStorage.clear();
window.history.replaceState({}, "", "/career?section=profile&retained=1#editor");
provider = "local";
profile = {
...emptyStructuredCv(),
metadata: { profileVersion: 4, fields: {} },
contact: {
fullName: "Recorded owner", headline: "Backend engineer", email: "career@example.test",
phone: "+47 12345678", location: "Oslo", website: "https://example.test",
linkedIn: "https://example.test/linkedin", gitHub: "https://example.test/github",
links: [{ label: "Portfolio", url: "https://example.test/portfolio" }],
},
summary: ["Recorded summary"], skills: ["C#", "SQL"], interests: ["Walking"],
awards: ["Recorded award"], publications: ["Recorded publication"],
organisations: ["Recorded organisation"], references: ["Recorded reference"],
jobs: [{ id: "opaque-job-key", title: "Recorded role", company: "Recorded company", location: "Oslo", start: "2020", end: "Current", isCurrent: true, bullets: ["Recorded outcome"], skills: ["C#"] }],
education: [{ id: "opaque-education-key", qualification: "Recorded degree", qualificationLevel: "Master", institution: "Recorded school", location: "Oslo", start: "2016", end: "2019", details: ["Recorded distinction"] }],
certifications: [{ id: "opaque-certification-key", name: "Recorded certification", issuer: "Recorded issuer", date: "2024", details: ["Recorded credential detail"] }],
projects: [{ id: "opaque-project-key", name: "Recorded project", role: "Owner", start: "2023", end: "2024", bullets: ["Recorded project outcome"], skills: ["SQL"] }],
languages: [{ name: "English", level: "Professional", notes: "Recorded language note" }],
otherSections: [{ title: "Volunteering", items: ["Recorded community work"] }],
sections: [{ name: "Original source", content: "No silent import acceptance", wordCount: 5 }],
};
mockedApi.get.mockImplementation((url: string) => {
if (url === "/career/profile") return Promise.resolve(sourceResponse() as any);
if (url === "/auth/me") return Promise.resolve({ data: { id: "editor-owner", provider, email: "account@example.test", displayName: "Recorded owner" } } as any);
if (url === "/career/profile/versions") return Promise.resolve({ data: [
{ version: 4, source: "manual", createdAtUtc: "2026-10-12T01:00:00Z", isCurrent: true },
{ version: 3, source: "manual", createdAtUtc: "2026-10-11T01:00:00Z", isCurrent: false },
] } as any);
if (url === "/profile-cv/runs") return Promise.resolve({ data: [] } as any);
return Promise.resolve({ data: {} } as any);
});
mockedApi.put.mockResolvedValue({ data: {} } as any);
mockedApi.post.mockResolvedValue({ data: {} } as any);
});
test("a locale update retains dirty structured and raw drafts without reading their source again", async () => {
renderPage();
await ready();
fireEvent.change(screen.getByLabelText(en.profileCvContactFullName), { target: { value: "Unsaved canonical owner" } });
fireEvent.click(screen.getByRole("tab", { name: en.careerWorkspaceImportTab }));
fireEvent.click(screen.getByRole("button", { name: new RegExp(en.profileCvRawPanelTitle) }));
fireEvent.change(screen.getByLabelText(en.profileCvTextLabel), { target: { value: "Unsaved raw source" } });
fireEvent.click(screen.getByRole("tab", { name: en.careerWorkspaceProfileTab }));
const before = [readCount("/career/profile"), readCount("/auth/me"), readCount("/career/profile/versions"), readCount("/profile-cv/runs")];
await changeLocale();
expect(screen.getByLabelText(nb.profileCvContactFullName)).toHaveValue("Unsaved canonical owner");
expect([readCount("/career/profile"), readCount("/auth/me"), readCount("/career/profile/versions"), readCount("/profile-cv/runs")]).toEqual(before);
fireEvent.click(screen.getByRole("tab", { name: nb.careerWorkspaceImportTab }));
fireEvent.click(screen.getByRole("button", { name: new RegExp(nb.profileCvRawPanelTitle) }));
expect(screen.getByLabelText(nb.profileCvTextLabel)).toHaveValue("Unsaved raw source");
expect(window.location.search).toContain("retained=1");
expect(window.location.hash).toBe("#editor");
expect(mockedApi.put).not.toHaveBeenCalled();
expect(mockedApi.post).not.toHaveBeenCalled();
});
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()!;
mockedApi.get.mockImplementation((url: string) => url === "/career/profile" ? pending.promise : read(url));
renderPage();
expect(screen.getByRole("button", { name: en.profileSaveChanges })).toBeDisabled();
expect(screen.queryByRole("button", { name: en.saving })).not.toBeInTheDocument();
await changeLocale();
await act(async () => { pending.reject({ isAxiosError: true, message: "Request failed with status code 500", response: { status: 500, data: {} } }); });
expect(readCount("/career/profile")).toBe(1);
expect(readCount("/auth/me")).toBe(1);
const failure = await editor().findByRole("alert");
expect(failure).toHaveTextContent(nb.profileLoadFailed);
expect(failure).not.toHaveTextContent("Request failed with status code 500");
expect(screen.queryByRole("button", { name: nb.saving })).not.toBeInTheDocument();
await act(async () => window.dispatchEvent(new StorageEvent("storage", { key: "uiLanguage", newValue: "en" })));
expect(editor().getByRole("alert")).toHaveTextContent(en.profileLoadFailed);
expect(editor().getByRole("alert")).not.toHaveTextContent(nb.profileLoadFailed);
expect(readCount("/career/profile")).toBe(1);
expect(mockedApi.put).not.toHaveBeenCalled();
expect(mockedApi.post).not.toHaveBeenCalled();
});
test("a held Save locks every editor mutation, raw input and history restore until its snapshot settles", async () => {
const pending = deferred<any>();
mockedApi.put.mockReturnValue(pending.promise);
renderPage();
await ready();
fireEvent.change(screen.getByLabelText(en.profileCvContactFullName), { target: { value: "Snapshot owner" } });
fireEvent.click(screen.getByRole("button", { name: /Version history/ }));
fireEvent.click(screen.getByRole("button", { name: en.profileSaveChanges }));
try {
expect(mockedApi.put).toHaveBeenCalledTimes(1);
const snapshot = mockedApi.put.mock.calls[0][1] as { profile: StructuredCvProfile; cvText: string };
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.profileCvVersionRestore]) {
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();
expect(screen.getByRole("button", { name: en.saving })).toBeDisabled();
fireEvent.click(screen.getByRole("tab", { name: en.careerWorkspaceImportTab }));
fireEvent.click(screen.getByRole("button", { name: new RegExp(en.profileCvRawPanelTitle) }));
expect(screen.getByLabelText(en.profileCvTextLabel)).toBeDisabled();
expect(mockedApi.post).not.toHaveBeenCalled();
} finally {
await act(async () => { pending.resolve({ data: {} }); await pending.promise; });
}
fireEvent.click(screen.getByRole("tab", { name: en.careerWorkspaceProfileTab }));
expect(screen.getByLabelText(en.profileCvContactFullName)).toHaveValue("Snapshot owner");
expect(screen.getByLabelText(en.profileCvContactFullName)).toBeEnabled();
expect(screen.getByRole("button", { name: en.profileSaveChanges })).toBeEnabled();
expect(mockedApi.put).toHaveBeenCalledTimes(1);
});
test("two Save activations before React commits pending state send only one canonical PUT", async () => {
const pending = deferred<any>();
mockedApi.put.mockReturnValue(pending.promise);
renderPage();
await ready();
const save = screen.getByRole("button", { name: en.profileSaveChanges });
try {
act(() => {
save.dispatchEvent(new MouseEvent("click", { bubbles: true }));
save.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(mockedApi.put).toHaveBeenCalledTimes(1);
expect(mockedApi.post).not.toHaveBeenCalled();
} finally {
await act(async () => { pending.resolve({ data: {} }); await pending.promise; });
}
});
test("a failed Save retains a scoped localized error and draft for retry through the same Save button", async () => {
localStorage.setItem("uiLanguage", "nb");
mockedApi.put.mockRejectedValueOnce({ isAxiosError: true, message: "Request failed with status code 500", response: { status: 500, data: {} } });
renderPage();
await waitFor(() => expect(screen.getByLabelText(nb.profileCvContactFullName)).toHaveValue("Recorded owner"));
fireEvent.change(screen.getByLabelText(nb.profileCvContactFullName), { target: { value: "Beholdt utkast" } });
fireEvent.click(screen.getByRole("button", { name: nb.profileSaveChanges }));
const failure = await editor().findByRole("alert");
expect(failure).toHaveTextContent(nb.profileUpdateFailed);
expect(failure).not.toHaveTextContent("Request failed with status code 500");
expect(screen.getByLabelText(nb.profileCvContactFullName)).toHaveValue("Beholdt utkast");
expect(screen.getByText(nb.assetsUnsavedChanges)).toBeInTheDocument();
await act(async () => window.dispatchEvent(new StorageEvent("storage", { key: "uiLanguage", newValue: "en" })));
expect(editor().getByRole("alert")).toHaveTextContent(en.profileUpdateFailed);
expect(editor().getByRole("alert")).not.toHaveTextContent(nb.profileUpdateFailed);
await changeLocale();
const reads = mockedApi.get.mock.calls.length;
const save = screen.getByRole("button", { name: nb.profileSaveChanges });
expect(save).toBeEnabled();
expect(mockedApi.put).toHaveBeenCalledTimes(1);
fireEvent.click(save);
await waitFor(() => expect(mockedApi.put).toHaveBeenCalledTimes(2));
expect(mockedApi.put.mock.calls[1]).toEqual(mockedApi.put.mock.calls[0]);
await waitFor(() => expect(editor().queryByRole("alert")).not.toBeInTheDocument());
expect(screen.getByLabelText(nb.profileCvContactFullName)).toHaveValue("Beholdt utkast");
expect(screen.queryByText(nb.assetsUnsavedChanges)).not.toBeInTheDocument();
expect(mockedApi.get).toHaveBeenCalledTimes(reads);
expect(mockedApi.post).not.toHaveBeenCalled();
});
test("the editor exposes named H2/H3 groups and contextual repeated-item remove controls", async () => {
renderPage();
await ready();
expect(screen.getByRole("heading", { name: en.profileCvStructuredEditor, level: 2 })).toBeInTheDocument();
expect(screen.getByRole("region", { name: en.profileCvStructuredEditor })).toBeInTheDocument();
for (const name of [en.careerProfileContactSection, en.profileCvStructuredLanguages, en.profileCvStructuredJobs, en.profileCvStructuredEducation, en.profileCvStructuredOtherSections]) {
expect(screen.getByRole("heading", { name, level: 3 })).toBeInTheDocument();
expect(screen.getByRole("region", { name })).toBeInTheDocument();
}
for (const name of [en.careerProfileRemoveJob, en.careerProfileRemoveEducation, en.careerProfileRemoveLanguage, en.careerProfileRemoveOtherSection]) {
expect(screen.getByRole("button", { name: name.replace("{index}", "1") })).toBeEnabled();
}
expect(screen.getByRole("button", { name: en.profileCvDeleteCustomLink.replace("{index}", "1") })).toBeEnabled();
expect(mockedApi.put).not.toHaveBeenCalled();
expect(mockedApi.post).not.toHaveBeenCalled();
});
test("manual edits preserve long-tail data, custom links, opaque IDs and modeled fields without editor controls", async () => {
renderPage();
await ready();
fireEvent.change(screen.getByLabelText(en.profileCvStructuredAwards), { target: { value: "Recorded award\nManual award" } });
fireEvent.change(screen.getByLabelText(en.profileCvLinkLabel), { target: { value: "Edited portfolio" } });
fireEvent.change(screen.getByLabelText(en.profileCvJobTitle), { target: { value: "Edited role" } });
fireEvent.click(screen.getByRole("tab", { name: en.careerWorkspaceImportTab }));
fireEvent.click(screen.getByRole("button", { name: new RegExp(en.profileCvRawPanelTitle) }));
fireEvent.change(screen.getByLabelText(en.profileCvTextLabel), { target: { value: "Manually retained source\nNo extraction requested" } });
fireEvent.click(screen.getByRole("tab", { name: en.careerWorkspaceProfileTab }));
fireEvent.click(screen.getByRole("button", { name: en.profileSaveChanges }));
await waitFor(() => expect(mockedApi.put).toHaveBeenCalledTimes(1));
const saved = mockedApi.put.mock.calls[0][1] as { profile: StructuredCvProfile; cvText: string };
expect(saved).toEqual({ profile: {
...profile,
awards: ["Recorded award", "Manual award"],
contact: { ...profile.contact, links: [{ label: "Edited portfolio", url: "https://example.test/portfolio" }] },
jobs: [{ ...profile.jobs[0], title: "Edited role" }],
}, cvText: "Manually retained source\nNo extraction requested" });
expect(mockedApi.post).not.toHaveBeenCalled();
expect(mockedApi.patch).not.toHaveBeenCalled();
expect(mockedApi.delete).not.toHaveBeenCalled();
});
test("a read-only provider cannot edit canonical fields or trigger canonical writes", async () => {
provider = "external";
renderPage();
await waitFor(() => expect(screen.getByLabelText(en.profileCvContactFullName)).toHaveValue("Recorded owner"));
fireEvent.click(screen.getByRole("button", { name: /Version history/ }));
expect(screen.getAllByRole("textbox").filter((field) => !(field as HTMLInputElement).disabled)).toEqual([]);
for (const name of [en.profileSaveChanges, en.profileCvVersionRestore, en.profileCvAddLink, en.profileCvStructuredAddLanguage, en.profileCvStructuredAddJob, en.profileCvStructuredAddEducation, en.profileCvStructuredAddOtherSection]) {
const control = screen.getByRole("button", { name });
expect(control).toBeDisabled();
control.click();
}
for (const button of screen.getAllByRole("button", { name: /^(Remove|Delete custom link|Bold|Italic|Underline|Link$)/ })) expect(button).toBeDisabled();
fireEvent.click(screen.getByRole("tab", { name: en.careerWorkspaceImportTab }));
fireEvent.click(screen.getByRole("button", { name: new RegExp(en.profileCvRawPanelTitle) }));
expect(screen.getByLabelText(en.profileCvTextLabel)).toBeDisabled();
expect(mockedApi.put).not.toHaveBeenCalled();
expect(mockedApi.post).not.toHaveBeenCalled();
expect(mockedApi.patch).not.toHaveBeenCalled();
expect(mockedApi.delete).not.toHaveBeenCalled();
});
@@ -212,22 +212,16 @@ test("the parent exposes failed import-read retry without canonical saves or his
expect(mockedApi.put).not.toHaveBeenCalled();
});
test("overview retry cannot overwrite a dirty canonical draft after a failed source refresh", async () => {
test("changing language in overview cannot refetch or overwrite a dirty canonical draft", async () => {
renderCareer();
await screen.findByText("70%");
fireEvent.click(screen.getByRole("tab", { name: translations.en.careerWorkspaceProfileTab }));
fireEvent.change(screen.getByLabelText("Full name"), { target: { value: "Unsaved canonical name" } });
fireEvent.click(screen.getByRole("tab", { name: translations.en.careerWorkspaceOverviewTab }));
const originalGet = mockedApi.get.getMockImplementation()!;
mockedApi.get.mockImplementation((url: string) => url === "/career/profile"
? Promise.reject(new Error("Source refresh unavailable")) : originalGet(url));
const reads = mockedApi.get.mock.calls.length;
act(() => window.dispatchEvent(new StorageEvent("storage", { key: "uiLanguage", newValue: "nb" })));
const region = await screen.findByRole("region", { name: translations.nb.careerOverviewProfileStatus });
expect(await within(region).findByRole("alert")).toHaveTextContent(translations.nb.careerOverviewRetryDraftHint);
const reads = mockedApi.get.mock.calls.length;
const retry = within(region).getByRole("button", { name: translations.nb.retry });
expect(retry).toBeDisabled();
fireEvent.click(retry);
expect(within(region).queryByRole("alert")).not.toBeInTheDocument();
expect(mockedApi.get).toHaveBeenCalledTimes(reads);
fireEvent.click(screen.getByRole("tab", { name: translations.nb.careerWorkspaceProfileTab }));
expect(screen.getByLabelText("Fullt navn")).toHaveValue("Unsaved canonical name");
+15 -5
View File
@@ -1285,9 +1285,14 @@ export const translations = {
profileCvLinkLabel: "Label",
profileCvLinkUrl: "URL",
profileCvDeleteCustomLink: "Delete custom link {index}",
careerProfileContactSection: "Contact information",
careerProfileRemoveJob: "Remove work experience {index}",
careerProfileRemoveEducation: "Remove education {index}",
careerProfileRemoveLanguage: "Remove language {index}",
careerProfileRemoveOtherSection: "Remove custom section {index}",
profileCvTextLabel: "Profile CV / master resume text",
profileCvTextHelp: "Keep this updated and specific. Include recent roles, tools, achievements, measurable outcomes, and the work you want to be hired for next. If extraction misses something, edit it here manually.",
profileCvStructuredDefaultHint: "Your career information stays front and center. Open the original import only when you need to check or clean up what was read from your file.",
profileCvStructuredDefaultHint: "Career information is the main view. Open the original import to check what was read from your file.",
profileCvRawPanelTitle: "Original import",
profileCvRawPanelHelp: "Usually messy, but useful for checking what was actually read from the uploaded file.",
profileCvPreferredUploads: "Supported uploads: PDF, DOCX, TXT, MD, PNG, JPG, JPEG, WEBP.",
@@ -1300,7 +1305,7 @@ export const translations = {
profileCvStructureParseFailed: "Couldn't read sections from your CV.",
profileCvStructureEmpty: "No sections read yet.",
profileCvStructuredEditor: "Career information",
profileCvStructuredEditorHelp: "Your career facts. The CV Builder uses this information to create documents — edit it here and every CV stays up to date.",
profileCvStructuredEditorHelp: "Changes here update inherited information in live and published CVs. CV-specific edits and frozen submission packages stay separate.",
profileCvExtractionHistory: "Extraction history",
profileCvExtractionHistoryHelp: "See which parser run produced the current structured profile and reprocess from the stored source artifact when needed.",
profileCvExtractionHistoryEmpty: "No extraction runs yet.",
@@ -3875,9 +3880,14 @@ export const translations = {
profileCvLinkLabel: "Etikett",
profileCvLinkUrl: "URL",
profileCvDeleteCustomLink: "Slett egendefinert lenke {index}",
careerProfileContactSection: "Kontaktinformasjon",
careerProfileRemoveJob: "Fjern arbeidserfaring {index}",
careerProfileRemoveEducation: "Fjern utdanning {index}",
careerProfileRemoveLanguage: "Fjern språk {index}",
careerProfileRemoveOtherSection: "Fjern egendefinert seksjon {index}",
profileCvTextLabel: "Profil-CV / hovedtekst for CV",
profileCvTextHelp: "Hold denne oppdatert og konkret. Ta med nylige roller, verktøy, prestasjoner, målbare resultater og arbeidet du vil bli ansatt for neste gang. Hvis tekstuttrekket mangler noe, kan du redigere manuelt her.",
profileCvStructuredDefaultHint: "Den strukturerte CV-en er hovedvisningen. Åpne originaluttrekket bare når du vil kontrollere eller rydde opp i parserresultatet.",
profileCvStructuredDefaultHint: "Karriereprofilen er hovedvisningen. Åpne originalimporten når du vil kontrollere det som ble lest fra filen din.",
profileCvRawPanelTitle: "Originalt uttrekk",
profileCvRawPanelHelp: "Som regel rotete, men nyttig når du vil se nøyaktig hva parseren hentet ut fra den opplastede filen.",
profileCvPreferredUploads: "Støttede opplastinger: PDF, DOCX, TXT, MD, PNG, JPG, JPEG, WEBP.",
@@ -3889,8 +3899,8 @@ export const translations = {
profileCvStructureParsed: "CV-strukturen er analysert.",
profileCvStructureParseFailed: "Kunne ikke analysere CV-strukturen.",
profileCvStructureEmpty: "Ingen analyserte seksjoner ennå.",
profileCvStructuredEditor: "Strukturert CV-redigering",
profileCvStructuredEditorHelp: "Rediger gjenbrukbare CV-data direkte slik at generatorer og matching kan jobbe fra stabile felt i stedet for bare råtekst.",
profileCvStructuredEditor: "Karriereinformasjon",
profileCvStructuredEditorHelp: "CV-er som bruker profilen, inkludert publiserte CV-er, får oppdatert innhold. Tilpasninger i hver CV og fryste innsendingspakker holdes separate.",
profileCvExtractionHistory: "Ekstraksjonshistorikk",
profileCvExtractionHistoryHelp: "Se hvilken parserkjøring som produserte den nåværende strukturerte profilen, og kjør på nytt fra det lagrede kildeartefaktet ved behov.",
profileCvExtractionHistoryEmpty: "Ingen ekstraksjonskjøringer ennå.",
+2 -2
View File
@@ -206,7 +206,7 @@ test('Career Profile section headings describe the visible hierarchy without cha
renderPage();
await waitFor(() => expect(screen.getByLabelText(/full name/i)).toHaveValue('Demo User'));
expect(screen.getByRole('heading', { name: 'Career profile', level: 2 })).toBeInTheDocument();
expect(screen.getByRole('heading', { name: 'Career information', level: 3 })).toBeInTheDocument();
expect(screen.getByRole('heading', { name: 'Career information', level: 2 })).toBeInTheDocument();
expect(screen.getByLabelText(/full name/i)).toHaveValue('Demo User');
let previous = 1;
for (const heading of screen.getAllByRole('heading')) {
@@ -504,7 +504,7 @@ test('profile page shows durable CV operation state and retries a failed run', a
test('profile page keeps raw extraction collapsed until expanded', async () => {
renderPage('import');
expect(await screen.findByText(/your career information stays front and center/i)).toBeInTheDocument();
expect(await screen.findByText(/Career information is the main view/i)).toBeInTheDocument();
const originalExtractionToggle = screen.getByRole('button', { name: /original import/i });
expect(originalExtractionToggle).toHaveAttribute('aria-expanded', 'false');
+58 -38
View File
@@ -27,6 +27,7 @@ import { useToast } from "../toast";
import { useI18n } from "../i18n/I18nProvider";
import { useAccountPlan } from "../accountPlan";
import ProFeatureNotice from "../components/ProFeatureNotice";
import { PageSection } from "../layout/PageLayout";
import type { UserOperation } from "../types";
import {
emptyStructuredCv,
@@ -147,11 +148,16 @@ export default function CareerProfilePage() {
const careerOnly = true;
const { toast } = useToast();
const { language, t } = useI18n();
const translationRef = useRef(t);
useEffect(() => { translationRef.current = t; }, [t]);
const cvInputRef = useRef<HTMLInputElement | null>(null);
const avatarInputRef = useRef<HTMLInputElement | null>(null);
const [me, setMe] = useState<MeResponse | null>(null);
const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const profileMutationPending = useRef(false);
const [uploadingCv, setUploadingCv] = useState(false);
const [improvingCv, setImprovingCv] = useState(false);
const [rebuildingCv, setRebuildingCv] = useState(false);
@@ -192,6 +198,8 @@ export default function CareerProfilePage() {
}, []);
const restoreVersion = useCallback(async (version: number) => {
if (profileMutationPending.current || me?.provider !== "local" || loading) return;
profileMutationPending.current = true;
setLoading(true);
try {
const r = await api.post<CareerProfileResponse>(`/career/profile/versions/${version}/restore`);
@@ -203,9 +211,10 @@ export default function CareerProfilePage() {
} catch (e: any) {
toast(String(e?.response?.data || e?.message || t("profileUpdateFailed")), "error");
} finally {
profileMutationPending.current = false;
setLoading(false);
}
}, [loadVersions, t, toast]);
}, [loadVersions, loading, me?.provider, t, toast]);
const [extractionRuns, setExtractionRuns] = useState<ExtractionRun[]>([]);
const [runsLoading, setRunsLoading] = useState(true);
const [runsLoadError, setRunsLoadError] = useState<string | null>(null);
@@ -269,13 +278,13 @@ export default function CareerProfilePage() {
setProfileDirty(false);
setHeadline(profile.contact.headline ?? "");
setLoadError(null);
} catch (error: any) {
} catch (error) {
setMe(null);
setLoadError(String(error?.response?.data || error?.message || t("profileLoadFailed")));
setLoadError(getApiErrorMessage(error, ""));
} finally {
setLoading(false);
}
}, [t]);
}, []);
useEffect(() => {
void loadProfile();
@@ -328,6 +337,8 @@ 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;
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;
@@ -345,8 +356,8 @@ export default function CareerProfilePage() {
const pendingCvRun = extractionRuns.find((run) => run.status === "pending_review");
const extractionProgress = activeCvRun?.operation?.progressPercent;
return (
<Box sx={{ display: "grid", gap: 2 }}>
<Paper component="nav" aria-label={t("careerWorkspaceNavigation")} variant="outlined" sx={{ borderRadius: 3, overflow: "hidden" }}>
<Box sx={{ display: "grid", gridTemplateColumns: "minmax(0, 1fr)", gap: 2, minWidth: 0 }}>
<Paper component="nav" aria-label={t("careerWorkspaceNavigation")} variant="outlined" sx={{ borderRadius: 1, minWidth: 0, overflow: "hidden" }}>
<FormControl size="small" fullWidth sx={{ display: { xs: "flex", sm: "none" }, m: 1.25, width: "auto" }}>
<InputLabel id="career-workspace-section-label">{t("careerWorkspaceNavigation")}</InputLabel>
<Select
@@ -378,8 +389,8 @@ export default function CareerProfilePage() {
<CareerWorkspaceOverview
completeness={completeness}
runs={extractionRuns}
loading={loading}
loadError={loadError}
loading={loading || saving}
loadError={loadErrorMessage}
runsLoading={runsLoading}
runsLoadError={runsLoadError}
onRetryRuns={() => void loadExtractionRuns()}
@@ -391,7 +402,7 @@ export default function CareerProfilePage() {
{evidenceVisited ? <Box hidden={workspaceSection !== "evidence"}><CareerEvidenceBank /></Box> : null}
{workspaceSection === "profile" || workspaceSection === "import" ? <Paper id={workspaceSection === "profile" ? "career-profile-editor" : "career-cv-import"} sx={{ mt: 0, p: { xs: 1.5, sm: 2.5 }, borderRadius: 4, border: "none", boxShadow: "0px 1px 2px 0px rgba(15,23,42,0.04), 0px 8px 24px -12px rgba(15,23,42,0.12)", scrollMarginTop: 96 }}>
{workspaceSection === "profile" || workspaceSection === "import" ? <Paper id={workspaceSection === "profile" ? "career-profile-editor" : "career-cv-import"} sx={{ mt: 0, p: { xs: 1.5, sm: 2.5 }, borderRadius: 4, border: "none", boxShadow: "0px 1px 2px 0px rgba(15,23,42,0.04), 0px 8px 24px -12px rgba(15,23,42,0.12)", scrollMarginTop: 96, "& .MuiFormHelperText-root.Mui-disabled": { color: "text.secondary" } }}>
<input
ref={cvInputRef}
type="file"
@@ -406,7 +417,7 @@ export default function CareerProfilePage() {
{workspaceSection === "profile" ? <ProfileCompleteness
completeness={completeness}
versions={versions}
loading={loading}
loading={editorDisabled}
onRestore={(version) => void restoreVersion(version)}
showSummary={false}
/> : null}
@@ -438,10 +449,10 @@ export default function CareerProfilePage() {
}}
/> : null}
{loadError ? (
<Alert severity="error" sx={{ mb: 2, borderRadius: 2.5 }} action={<Button color="inherit" size="small" onClick={() => void loadProfile()}>{t("retry")}</Button>}>
{loadErrorMessage ? (
<Alert severity="error" sx={{ mb: 2 }} action={<Button color="inherit" size="small" disabled={profileDirty || loading || saving} onClick={() => { if (!profileDirty && !saving) void loadProfile(); }}>{t("retry")}</Button>}>
{t("profileLoadFailed")}
<Typography variant="body2" sx={{ mt: 0.5 }}>{loadError}</Typography>
{loadError ? <Typography variant="body2" sx={{ mt: 0.5 }}>{loadError}</Typography> : null}
</Alert>
) : null}
@@ -452,9 +463,9 @@ export default function CareerProfilePage() {
<Typography variant="body2" color="text.secondary">{t("careerProfileImportBody")}</Typography>
</Box>
<Button
variant="contained"
variant="outlined"
startIcon={<UploadFileOutlinedIcon />}
disabled={!canUseAi || !isLocal || uploadingCv || Boolean(activeCvRun)}
disabled={!canUseAi || editorDisabled || uploadingCv || Boolean(activeCvRun)}
onClick={() => cvInputRef.current?.click()}
>
{uploadingCv ? t("profileUploading") : t("profileUploadCv")}
@@ -476,7 +487,7 @@ export default function CareerProfilePage() {
{pendingCvRun ? <Alert severity="success" sx={{ mt: 1.5 }} action={<Button color="inherit" size="small" onClick={() => navigateWorkspace("import")}>{t("careerProfileImportReview")}</Button>}>
{t("careerProfileImportReady")}
</Alert> : null}
{!canUseAi ? <Box sx={{ mt: 1.5 }}><ProFeatureNotice featureKey="career-ai" title={t("profileProImportTitle")}>{t("profileProImportBody")}</ProFeatureNotice></Box> : null}
{!canUseAi ? <Box sx={{ mt: 1.5, "& .MuiAlert-root": { flexWrap: "wrap" }, "& .MuiAlert-message": { minWidth: 0, overflowWrap: "anywhere" } }}><ProFeatureNotice featureKey="career-ai" title={t("profileProImportTitle")}>{t("profileProImportBody")}</ProFeatureNotice></Box> : null}
</Paper> : null}
{workspaceSection === "profile" ? <Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, flexWrap: "wrap" }}>
@@ -643,7 +654,7 @@ export default function CareerProfilePage() {
helperText={t("profileCvTextHelp")}
multiline
minRows={12}
disabled={!isLocal}
disabled={editorDisabled}
fullWidth
/>
<Box sx={{ mt: 1.5, display: "flex", justifyContent: "flex-end" }}>
@@ -783,7 +794,7 @@ export default function CareerProfilePage() {
</Box>
<Button
variant="outlined"
disabled={!isLocal || !profileCvText.trim() || parsingCvSections}
disabled={editorDisabled || !profileCvText.trim() || parsingCvSections}
onClick={async () => {
setParsingCvSections(true);
try {
@@ -821,33 +832,32 @@ export default function CareerProfilePage() {
)}
</Box>
</Box>
<Box sx={{ mt: workspaceSection === "profile" ? 2 : 0, p: 1.5, borderRadius: 3, border: "1px solid", borderColor: "divider", backgroundColor: "background.paper", display: workspaceSection === "profile" ? "block" : "none" }}>
<Box sx={{ mb: 1.5 }}>
<Typography component="h3" variant="subtitle1" sx={{ fontWeight: 800 }}>{t("profileCvStructuredEditor")}</Typography>
<Typography variant="body2" sx={{ color: "text.secondary" }}>{t("profileCvStructuredEditorHelp")}</Typography>
</Box>
<Box sx={{ mt: workspaceSection === "profile" ? 2 : 0, display: workspaceSection === "profile" ? "block" : "none" }}>
<PageSection title={t("profileCvStructuredEditor")} description={t("profileCvStructuredEditorHelp")} surface>
<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} />
<SkillsSection value={structuredCv.skills} onChange={(next) => editStructuredCv((prev) => ({ ...prev, skills: next }))} getMetadata={metaFor} />
<InterestsSection value={structuredCv.interests} onChange={(next) => editStructuredCv((prev) => ({ ...prev, interests: next }))} getMetadata={metaFor} />
<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>
<LongTailSections values={{ awards: structuredCv.awards, publications: structuredCv.publications, organisations: structuredCv.organisations, references: structuredCv.references }} onChange={(key, next) => editStructuredCv((prev) => ({ ...prev, [key]: next }))} />
<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} />
<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 }))} />
<WorkExperienceSection value={structuredCv.jobs} onChange={(next) => editStructuredCv((prev) => ({ ...prev, jobs: next }))} disabled={editorDisabled} />
<EducationSection value={structuredCv.education} onChange={(next) => editStructuredCv((prev) => ({ ...prev, education: next }))} />
<EducationSection value={structuredCv.education} onChange={(next) => editStructuredCv((prev) => ({ ...prev, education: next }))} disabled={editorDisabled} />
<OtherSectionsSection value={structuredCv.otherSections} onChange={(next) => editStructuredCv((prev) => ({ ...prev, otherSections: next }))} />
<OtherSectionsSection value={structuredCv.otherSections} onChange={(next) => editStructuredCv((prev) => ({ ...prev, otherSections: next }))} disabled={editorDisabled} />
</PageSection>
</Box>
<Box sx={{ mt: 1, display: workspaceSection === "profile" ? "flex" : "none", justifyContent: "space-between", gap: 1, flexWrap: "wrap" }}>
<Typography variant="caption" sx={{ color: "text.secondary" }}>
@@ -860,12 +870,20 @@ export default function CareerProfilePage() {
</Box>
<Box sx={{ gridColumn: "1 / -1", display: workspaceSection === "profile" ? "flex" : "none", justifyContent: "flex-end", gap: 2, flexWrap: "wrap", alignItems: "center" }}>
<Typography variant="body2" color="text.secondary" sx={{ width: "100%" }}>{t("profileCvStructuredEditorHelp")}</Typography>
{saveError !== null ? <Alert severity="error" sx={{ width: "100%" }}>
{t("profileUpdateFailed")}
{saveError ? <Typography variant="body2" sx={{ mt: 0.5 }}>{saveError}</Typography> : null}
</Alert> : null}
{profileDirty ? <Chip size="small" color="warning" variant="outlined" label={t("assetsUnsavedChanges")} /> : null}
<Button
variant="contained"
disabled={!isLocal || loading}
disabled={editorDisabled}
onClick={async () => {
setLoading(true);
if (profileMutationPending.current || editorDisabled) return;
profileMutationPending.current = true;
setSaving(true);
setSaveError(null);
try {
// Save the master profile through the relational source of truth. The endpoint
// persists the structured children + version and keeps the legacy blob projection
@@ -875,15 +893,17 @@ export default function CareerProfilePage() {
setCompleteness(saved.data?.completeness ?? null);
setProfileDirty(false);
toast(t("profileUpdated"), "success");
} catch (e: any) {
const msg = e?.response?.data || e?.message || t("profileUpdateFailed");
toast(String(msg), "error");
} catch (error) {
const message = getApiErrorMessage(error, "");
setSaveError(message);
toast(message || translationRef.current("profileUpdateFailed"), "error");
} finally {
setLoading(false);
profileMutationPending.current = false;
setSaving(false);
}
}}
>
{t("profileSaveChanges")}
{saving ? t("saving") : t("profileSaveChanges")}
</Button>
</Box>
@@ -5,6 +5,7 @@ import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline";
import RichTextField from "../../components/RichTextField";
import { useI18n } from "../../i18n/I18nProvider";
import { PageSection } from "../../layout/PageLayout";
import {
joinLines,
splitLines,
@@ -62,190 +63,182 @@ export function PersonalInformationSection({
value,
onChange,
getMetadata,
disabled,
}: {
value: StructuredCvContact;
onChange: (next: StructuredCvContact) => void;
getMetadata: MetadataLookup;
disabled: boolean;
}) {
const { t } = useI18n();
const set = (patch: Partial<StructuredCvContact>) => onChange({ ...value, ...patch });
return (
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 1.5 }}>
<PageSection title={t("careerProfileContactSection")} level="h3">
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "minmax(0, 1fr)", md: "minmax(0, 1fr) minmax(0, 1fr)" }, gap: 1.5 }}>
<Box>
<TextField label={t("profileCvContactFullName")} value={value.fullName ?? ""} onChange={(e) => set({ fullName: e.target.value || undefined })} fullWidth />
<TextField disabled={disabled} label={t("profileCvContactFullName")} value={value.fullName ?? ""} onChange={(e) => set({ fullName: e.target.value || undefined })} fullWidth />
<FieldReviewNote metadata={getMetadata("contact.fullName")} />
</Box>
<Box>
<TextField label={t("profileCvContactHeadline")} value={value.headline ?? ""} onChange={(e) => set({ headline: e.target.value || undefined })} fullWidth />
<TextField disabled={disabled} label={t("profileCvContactHeadline")} value={value.headline ?? ""} onChange={(e) => set({ headline: e.target.value || undefined })} fullWidth />
<FieldReviewNote metadata={getMetadata("contact.headline")} />
</Box>
<Box>
<TextField label={t("profileCvContactEmail")} value={value.email ?? ""} onChange={(e) => set({ email: e.target.value || undefined })} fullWidth />
<TextField disabled={disabled} label={t("profileCvContactEmail")} value={value.email ?? ""} onChange={(e) => set({ email: e.target.value || undefined })} fullWidth />
<FieldReviewNote metadata={getMetadata("contact.email")} />
</Box>
<Box>
<TextField label={t("profileCvContactPhone")} value={value.phone ?? ""} onChange={(e) => set({ phone: e.target.value || undefined })} fullWidth />
<TextField disabled={disabled} label={t("profileCvContactPhone")} value={value.phone ?? ""} onChange={(e) => set({ phone: e.target.value || undefined })} fullWidth />
<FieldReviewNote metadata={getMetadata("contact.phone")} />
</Box>
<Box>
<TextField label={t("profileCvContactLocation")} value={value.location ?? ""} onChange={(e) => set({ location: e.target.value || undefined })} fullWidth />
<TextField disabled={disabled} label={t("profileCvContactLocation")} value={value.location ?? ""} onChange={(e) => set({ location: e.target.value || undefined })} fullWidth />
<FieldReviewNote metadata={getMetadata("contact.location")} />
</Box>
<TextField label={t("profileCvContactWebsite")} value={value.website ?? ""} onChange={(e) => set({ website: e.target.value || undefined })} fullWidth />
<TextField label={t("profileCvContactLinkedIn")} value={value.linkedIn ?? ""} onChange={(e) => set({ linkedIn: e.target.value || undefined })} fullWidth sx={{ gridColumn: { xs: "1 / -1", md: "1 / -1" } }} />
<TextField label="GitHub" value={value.gitHub ?? ""} onChange={(e) => set({ gitHub: e.target.value || undefined })} fullWidth sx={{ gridColumn: { xs: "1 / -1", md: "1 / -1" } }} />
<TextField disabled={disabled} label={t("profileCvContactWebsite")} value={value.website ?? ""} onChange={(e) => set({ website: e.target.value || undefined })} fullWidth />
<TextField disabled={disabled} label={t("profileCvContactLinkedIn")} value={value.linkedIn ?? ""} onChange={(e) => set({ linkedIn: e.target.value || undefined })} fullWidth sx={{ gridColumn: { xs: "1 / -1", md: "1 / -1" } }} />
<TextField disabled={disabled} label="GitHub" value={value.gitHub ?? ""} onChange={(e) => set({ gitHub: e.target.value || undefined })} fullWidth sx={{ gridColumn: { xs: "1 / -1", md: "1 / -1" } }} />
<Box sx={{ gridColumn: "1 / -1" }}>
<Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ mb: 1 }}><Typography component="h4" variant="subtitle2">{t("profileCvOtherLinks")}</Typography><Button size="small" startIcon={<AddIcon />} onClick={() => set({ links: [...(value.links ?? []), { label: "", url: "" }] })}>{t("profileCvAddLink")}</Button></Stack>
<Stack spacing={1}>{(value.links ?? []).map((link, index) => <Stack key={index} direction={{ xs: "column", sm: "row" }} spacing={1} alignItems={{ sm: "center" }}><TextField size="small" label={t("profileCvLinkLabel")} value={link.label ?? ""} onChange={(event) => set({ links: (value.links ?? []).map((item, itemIndex) => itemIndex === index ? { ...item, label: event.target.value || undefined } : item) })} sx={{ flex: "0 1 180px" }} /><TextField size="small" label={t("profileCvLinkUrl")} value={link.url ?? ""} onChange={(event) => set({ links: (value.links ?? []).map((item, itemIndex) => itemIndex === index ? { ...item, url: event.target.value || undefined } : item) })} fullWidth /><IconButton aria-label={t("profileCvDeleteCustomLink", { index: index + 1 })} onClick={() => set({ links: (value.links ?? []).filter((_, itemIndex) => itemIndex !== index) })}><DeleteOutlineIcon fontSize="small" /></IconButton></Stack>)}</Stack>
<Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ mb: 1 }}><Typography component="h4" variant="subtitle2">{t("profileCvOtherLinks")}</Typography><Button disabled={disabled} size="small" startIcon={<AddIcon />} onClick={() => set({ links: [...(value.links ?? []), { label: "", url: "" }] })}>{t("profileCvAddLink")}</Button></Stack>
<Stack spacing={1}>{(value.links ?? []).map((link, index) => <Stack key={index} direction={{ xs: "column", sm: "row" }} spacing={1} alignItems={{ sm: "center" }}><TextField disabled={disabled} size="small" label={t("profileCvLinkLabel")} value={link.label ?? ""} onChange={(event) => set({ links: (value.links ?? []).map((item, itemIndex) => itemIndex === index ? { ...item, label: event.target.value || undefined } : item) })} sx={{ flex: { xs: "0 1 auto", sm: "0 1 180px" } }} /><TextField disabled={disabled} size="small" label={t("profileCvLinkUrl")} value={link.url ?? ""} onChange={(event) => set({ links: (value.links ?? []).map((item, itemIndex) => itemIndex === index ? { ...item, url: event.target.value || undefined } : item) })} fullWidth /><IconButton disabled={disabled} aria-label={t("profileCvDeleteCustomLink", { index: index + 1 })} onClick={() => set({ links: (value.links ?? []).filter((_, itemIndex) => itemIndex !== index) })}><DeleteOutlineIcon fontSize="small" /></IconButton></Stack>)}</Stack>
</Box>
</Box>
</PageSection>
);
}
// Shared free-text list editor (one item per line) for Summary / Skills / Interests.
function LinesField({ label, value, onChange, metadata, minRows }: { label: string; value: string[]; onChange: (next: string[]) => void; metadata?: StructuredCvFieldMetadata; minRows: number }) {
function LinesField({ label, value, onChange, metadata, minRows, disabled }: { label: string; value: string[]; onChange: (next: string[]) => void; metadata?: StructuredCvFieldMetadata; minRows: number; disabled: boolean }) {
const { t } = useI18n();
return (
<Box>
<TextField label={label} value={joinLines(value)} onChange={(e) => onChange(splitLines(e.target.value))} helperText={t("profileCvStructuredListHelp")} multiline minRows={minRows} fullWidth />
<TextField disabled={disabled} label={label} value={joinLines(value)} onChange={(e) => onChange(splitLines(e.target.value))} helperText={t("profileCvStructuredListHelp")} multiline minRows={minRows} fullWidth />
<FieldReviewNote metadata={metadata} />
</Box>
);
}
export function ProfessionalSummarySection({ value, onChange, getMetadata }: { value: string[]; onChange: (next: string[]) => void; getMetadata: MetadataLookup }) {
export function ProfessionalSummarySection({ value, onChange, getMetadata, disabled }: { value: string[]; onChange: (next: string[]) => void; getMetadata: MetadataLookup; disabled: boolean }) {
const { t } = useI18n();
return (
<Box>
<RichTextField label={t("profileCvStructuredSummary")} value={joinLines(value)} onChange={(text) => onChange(splitLines(text))} minRows={5} />
<RichTextField disabled={disabled} label={t("profileCvStructuredSummary")} value={joinLines(value)} onChange={(text) => onChange(splitLines(text))} minRows={5} />
<FieldReviewNote metadata={getMetadata("summary")} />
</Box>
);
}
export function SkillsSection({ value, onChange, getMetadata }: { value: string[]; onChange: (next: string[]) => void; getMetadata: MetadataLookup }) {
export function SkillsSection({ value, onChange, getMetadata, disabled }: { value: string[]; onChange: (next: string[]) => void; getMetadata: MetadataLookup; disabled: boolean }) {
const { t } = useI18n();
return <LinesField label={t("profileCvStructuredSkills")} value={value} onChange={onChange} metadata={getMetadata("skills")} minRows={5} />;
return <LinesField label={t("profileCvStructuredSkills")} value={value} onChange={onChange} metadata={getMetadata("skills")} minRows={5} disabled={disabled} />;
}
export function InterestsSection({ value, onChange, getMetadata }: { value: string[]; onChange: (next: string[]) => void; getMetadata: MetadataLookup }) {
export function InterestsSection({ value, onChange, getMetadata, disabled }: { value: string[]; onChange: (next: string[]) => void; getMetadata: MetadataLookup; disabled: boolean }) {
const { t } = useI18n();
return <LinesField label={t("profileCvStructuredInterests")} value={value} onChange={onChange} metadata={getMetadata("interests")} minRows={4} />;
return <LinesField label={t("profileCvStructuredInterests")} value={value} onChange={onChange} metadata={getMetadata("interests")} minRows={4} disabled={disabled} />;
}
export function LongTailSections({ values, onChange }: { values: Record<"awards" | "publications" | "organisations" | "references", string[]>; onChange: (key: keyof typeof values, next: string[]) => void }) {
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: "1fr", md: "1fr 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} />)}</Box>;
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>;
}
export function LanguagesSection({ value, onChange, getMetadata }: { value: StructuredCvLanguage[]; onChange: (next: StructuredCvLanguage[]) => void; getMetadata: MetadataLookup }) {
export function LanguagesSection({ value, onChange, getMetadata, disabled }: { value: StructuredCvLanguage[]; onChange: (next: StructuredCvLanguage[]) => void; getMetadata: MetadataLookup; disabled: boolean }) {
const { t } = useI18n();
const update = (index: number, patch: Partial<StructuredCvLanguage>) => onChange(value.map((entry, i) => (i === index ? { ...entry, ...patch } : entry)));
return (
<Box sx={{ mt: 2 }}>
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 1, alignItems: "center", mb: 1 }}>
<Typography component="h4" variant="subtitle2" sx={{ fontWeight: 800 }}>{t("profileCvStructuredLanguages")}</Typography>
<Button variant="outlined" size="small" onClick={() => onChange([...value, { name: "", level: "", notes: "" }])}>{t("profileCvStructuredAddLanguage")}</Button>
</Box>
<PageSection title={t("profileCvStructuredLanguages")} level="h3" actions={<Button disabled={disabled} variant="outlined" size="small" onClick={() => onChange([...value, { name: "", level: "", notes: "" }])}>{t("profileCvStructuredAddLanguage")}</Button>}>
<FieldReviewNote metadata={getMetadata("languages")} />
{value.length === 0 ? <Typography variant="body2" sx={{ color: "text.secondary" }}>{t("profileCvStructuredEmpty")}</Typography> : null}
{value.map((language, index) => (
<Box key={`language-${index}`} sx={{ p: 1.25, mb: 1, borderRadius: 2.5, border: "1px solid", borderColor: "divider", backgroundColor: "background.default" }}>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr 1fr auto" }, gap: 1 }}>
<TextField label={t("profileCvLanguageName")} value={language.name ?? ""} onChange={(e) => update(index, { name: e.target.value || undefined })} fullWidth />
<TextField label={t("profileCvLanguageLevel")} value={language.level ?? ""} onChange={(e) => update(index, { level: e.target.value || undefined })} fullWidth />
<TextField label={t("profileCvLanguageNotes")} value={language.notes ?? ""} onChange={(e) => update(index, { notes: e.target.value || undefined })} fullWidth />
<Button color="inherit" onClick={() => onChange(value.filter((_, i) => i !== index))}>{t("profileCvStructuredRemove")}</Button>
<Box key={`language-${index}`} sx={{ p: "var(--app-card-padding)", mt: 1.5, borderRadius: 1, border: "1px solid", borderColor: "divider", backgroundColor: "background.default" }}>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "minmax(0, 1fr)", md: "minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1fr) auto" }, gap: 1 }}>
<TextField disabled={disabled} label={t("profileCvLanguageName")} value={language.name ?? ""} onChange={(e) => update(index, { name: e.target.value || undefined })} fullWidth />
<TextField disabled={disabled} label={t("profileCvLanguageLevel")} value={language.level ?? ""} onChange={(e) => update(index, { level: e.target.value || undefined })} fullWidth />
<TextField disabled={disabled} label={t("profileCvLanguageNotes")} value={language.notes ?? ""} onChange={(e) => update(index, { notes: e.target.value || undefined })} fullWidth />
<Button disabled={disabled} color="inherit" onClick={() => onChange(value.filter((_, i) => i !== index))} aria-label={t("careerProfileRemoveLanguage", { index: index + 1 })}>{t("profileCvStructuredRemove")}</Button>
</Box>
</Box>
))}
</Box>
</PageSection>
);
}
export function WorkExperienceSection({ value, onChange }: { value: StructuredCvJob[]; onChange: (next: StructuredCvJob[]) => void }) {
export function WorkExperienceSection({ value, onChange, disabled }: { value: StructuredCvJob[]; onChange: (next: StructuredCvJob[]) => void; disabled: boolean }) {
const { t } = useI18n();
const update = (index: number, patch: Partial<StructuredCvJob>) => onChange(value.map((entry, i) => (i === index ? { ...entry, ...patch } : entry)));
return (
<Box sx={{ mt: 2 }}>
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 1, alignItems: "center", mb: 1 }}>
<Typography component="h4" variant="subtitle2" sx={{ fontWeight: 800 }}>{t("profileCvStructuredJobs")}</Typography>
<Button variant="outlined" size="small" onClick={() => onChange([...value, { title: "", company: "", location: "", start: "", end: "", isCurrent: false, bullets: [], skills: [] }])}>{t("profileCvStructuredAddJob")}</Button>
</Box>
<PageSection title={t("profileCvStructuredJobs")} level="h3" actions={<Button disabled={disabled} variant="outlined" size="small" onClick={() => onChange([...value, { title: "", company: "", location: "", start: "", end: "", isCurrent: false, bullets: [], skills: [] }])}>{t("profileCvStructuredAddJob")}</Button>}>
{value.length === 0 ? <Typography variant="body2" sx={{ color: "text.secondary" }}>{t("profileCvStructuredEmpty")}</Typography> : null}
{value.map((job, index) => (
<Box key={`job-${index}`} sx={{ p: 1.25, mb: 1, borderRadius: 2.5, border: "1px solid", borderColor: "divider", backgroundColor: "background.default" }}>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 1 }}>
<TextField label={t("profileCvJobTitle")} value={job.title ?? ""} onChange={(e) => update(index, { title: e.target.value || undefined })} fullWidth />
<TextField label={t("profileCvJobCompany")} value={job.company ?? ""} onChange={(e) => update(index, { company: e.target.value || undefined })} fullWidth />
<TextField label={t("profileCvJobLocation")} value={job.location ?? ""} onChange={(e) => update(index, { location: e.target.value || undefined })} fullWidth />
<Box sx={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 1 }}>
<TextField label={t("profileCvJobStart")} value={job.start ?? ""} onChange={(e) => update(index, { start: e.target.value || undefined })} fullWidth />
<TextField label={t("profileCvJobEnd")} value={job.end ?? ""} onChange={(e) => update(index, { end: e.target.value || undefined, isCurrent: /present|current/i.test(e.target.value) || job.isCurrent })} fullWidth />
<Box key={`job-${index}`} sx={{ p: "var(--app-card-padding)", mt: 1.5, borderRadius: 1, border: "1px solid", borderColor: "divider", backgroundColor: "background.default" }}>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "minmax(0, 1fr)", md: "minmax(0, 1fr) minmax(0, 1fr)" }, gap: 1 }}>
<TextField disabled={disabled} label={t("profileCvJobTitle")} value={job.title ?? ""} onChange={(e) => update(index, { title: e.target.value || undefined })} fullWidth />
<TextField disabled={disabled} label={t("profileCvJobCompany")} value={job.company ?? ""} onChange={(e) => update(index, { company: e.target.value || undefined })} fullWidth />
<TextField disabled={disabled} label={t("profileCvJobLocation")} value={job.location ?? ""} onChange={(e) => update(index, { location: e.target.value || undefined })} fullWidth />
<Box sx={{ display: "grid", gridTemplateColumns: "minmax(0, 1fr) minmax(0, 1fr)", gap: 1 }}>
<TextField disabled={disabled} label={t("profileCvJobStart")} value={job.start ?? ""} onChange={(e) => update(index, { start: e.target.value || undefined })} fullWidth />
<TextField disabled={disabled} label={t("profileCvJobEnd")} value={job.end ?? ""} onChange={(e) => update(index, { end: e.target.value || undefined, isCurrent: /present|current/i.test(e.target.value) || job.isCurrent })} fullWidth />
</Box>
<Box sx={{ gridColumn: "1 / -1" }}><RichTextField label={t("profileCvJobBullets")} value={joinLines(job.bullets)} onChange={(text) => update(index, { bullets: splitLines(text) })} minRows={5} /></Box>
<TextField label={t("profileCvJobSkills")} value={joinLines(job.skills)} onChange={(e) => update(index, { skills: splitLines(e.target.value) })} helperText={t("profileCvStructuredListHelp")} multiline minRows={3} fullWidth sx={{ gridColumn: { xs: "1 / -1", md: "1 / -1" } }} />
<Box sx={{ gridColumn: "1 / -1" }}><RichTextField disabled={disabled} label={t("profileCvJobBullets")} value={joinLines(job.bullets)} onChange={(text) => update(index, { bullets: splitLines(text) })} minRows={5} /></Box>
<TextField disabled={disabled} label={t("profileCvJobSkills")} value={joinLines(job.skills)} onChange={(e) => update(index, { skills: splitLines(e.target.value) })} helperText={t("profileCvStructuredListHelp")} multiline minRows={3} fullWidth sx={{ gridColumn: { xs: "1 / -1", md: "1 / -1" } }} />
<Box sx={{ gridColumn: { xs: "1 / -1", md: "1 / -1" }, display: "flex", justifyContent: "flex-end" }}>
<Button color="inherit" onClick={() => onChange(value.filter((_, i) => i !== index))}>{t("profileCvStructuredRemove")}</Button>
<Button disabled={disabled} color="inherit" onClick={() => onChange(value.filter((_, i) => i !== index))} aria-label={t("careerProfileRemoveJob", { index: index + 1 })}>{t("profileCvStructuredRemove")}</Button>
</Box>
</Box>
</Box>
))}
</Box>
</PageSection>
);
}
export function EducationSection({ value, onChange }: { value: StructuredCvEducation[]; onChange: (next: StructuredCvEducation[]) => void }) {
export function EducationSection({ value, onChange, disabled }: { value: StructuredCvEducation[]; onChange: (next: StructuredCvEducation[]) => void; disabled: boolean }) {
const { t } = useI18n();
const update = (index: number, patch: Partial<StructuredCvEducation>) => onChange(value.map((entry, i) => (i === index ? { ...entry, ...patch } : entry)));
return (
<Box sx={{ mt: 2 }}>
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 1, alignItems: "center", mb: 1 }}>
<Typography component="h4" variant="subtitle2" sx={{ fontWeight: 800 }}>{t("profileCvStructuredEducation")}</Typography>
<Button variant="outlined" size="small" onClick={() => onChange([...value, { qualification: "", institution: "", location: "", start: "", end: "", details: [] }])}>{t("profileCvStructuredAddEducation")}</Button>
</Box>
<PageSection title={t("profileCvStructuredEducation")} level="h3" actions={<Button disabled={disabled} variant="outlined" size="small" onClick={() => onChange([...value, { qualification: "", institution: "", location: "", start: "", end: "", details: [] }])}>{t("profileCvStructuredAddEducation")}</Button>}>
{value.length === 0 ? <Typography variant="body2" sx={{ color: "text.secondary" }}>{t("profileCvStructuredEmpty")}</Typography> : null}
{value.map((education, index) => (
<Box key={`education-${index}`} sx={{ p: 1.25, mb: 1, borderRadius: 2.5, border: "1px solid", borderColor: "divider", backgroundColor: "background.default" }}>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 1 }}>
<TextField label={t("profileCvEducationQualification")} value={education.qualification ?? ""} onChange={(e) => update(index, { qualification: e.target.value || undefined })} fullWidth />
<TextField label={t("profileCvEducationInstitution")} value={education.institution ?? ""} onChange={(e) => update(index, { institution: e.target.value || undefined })} fullWidth />
<TextField label={t("profileCvEducationLocation")} value={education.location ?? ""} onChange={(e) => update(index, { location: e.target.value || undefined })} fullWidth />
<Box sx={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 1 }}>
<TextField label={t("profileCvEducationStart")} value={education.start ?? ""} onChange={(e) => update(index, { start: e.target.value || undefined })} fullWidth />
<TextField label={t("profileCvEducationEnd")} value={education.end ?? ""} onChange={(e) => update(index, { end: e.target.value || undefined })} fullWidth />
<Box key={`education-${index}`} sx={{ p: "var(--app-card-padding)", mt: 1.5, borderRadius: 1, border: "1px solid", borderColor: "divider", backgroundColor: "background.default" }}>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "minmax(0, 1fr)", md: "minmax(0, 1fr) minmax(0, 1fr)" }, gap: 1 }}>
<TextField disabled={disabled} label={t("profileCvEducationQualification")} value={education.qualification ?? ""} onChange={(e) => update(index, { qualification: e.target.value || undefined })} fullWidth />
<TextField disabled={disabled} label={t("profileCvEducationInstitution")} value={education.institution ?? ""} onChange={(e) => update(index, { institution: e.target.value || undefined })} fullWidth />
<TextField disabled={disabled} label={t("profileCvEducationLocation")} value={education.location ?? ""} onChange={(e) => update(index, { location: e.target.value || undefined })} fullWidth />
<Box sx={{ display: "grid", gridTemplateColumns: "minmax(0, 1fr) minmax(0, 1fr)", gap: 1 }}>
<TextField disabled={disabled} label={t("profileCvEducationStart")} value={education.start ?? ""} onChange={(e) => update(index, { start: e.target.value || undefined })} fullWidth />
<TextField disabled={disabled} label={t("profileCvEducationEnd")} value={education.end ?? ""} onChange={(e) => update(index, { end: e.target.value || undefined })} fullWidth />
</Box>
<Box sx={{ gridColumn: "1 / -1" }}><RichTextField label={t("profileCvEducationDetails")} value={joinLines(education.details)} onChange={(text) => update(index, { details: splitLines(text) })} minRows={4} /></Box>
<Box sx={{ gridColumn: "1 / -1" }}><RichTextField disabled={disabled} label={t("profileCvEducationDetails")} value={joinLines(education.details)} onChange={(text) => update(index, { details: splitLines(text) })} minRows={4} /></Box>
<Box sx={{ gridColumn: { xs: "1 / -1", md: "1 / -1" }, display: "flex", justifyContent: "flex-end" }}>
<Button color="inherit" onClick={() => onChange(value.filter((_, i) => i !== index))}>{t("profileCvStructuredRemove")}</Button>
<Button disabled={disabled} color="inherit" onClick={() => onChange(value.filter((_, i) => i !== index))} aria-label={t("careerProfileRemoveEducation", { index: index + 1 })}>{t("profileCvStructuredRemove")}</Button>
</Box>
</Box>
</Box>
))}
</Box>
</PageSection>
);
}
export function OtherSectionsSection({ value, onChange }: { value: StructuredCvOtherSection[]; onChange: (next: StructuredCvOtherSection[]) => void }) {
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)));
return (
<Box sx={{ mt: 2 }}>
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 1, alignItems: "center", mb: 1 }}>
<Typography component="h4" variant="subtitle2" sx={{ fontWeight: 800 }}>{t("profileCvStructuredOtherSections")}</Typography>
<Button variant="outlined" size="small" onClick={() => onChange([...value, { title: "", items: [] }])}>{t("profileCvStructuredAddOtherSection")}</Button>
</Box>
<PageSection title={t("profileCvStructuredOtherSections")} level="h3" actions={<Button disabled={disabled} variant="outlined" size="small" onClick={() => onChange([...value, { title: "", items: [] }])}>{t("profileCvStructuredAddOtherSection")}</Button>}>
{value.length === 0 ? <Typography variant="body2" sx={{ color: "text.secondary" }}>{t("profileCvStructuredEmpty")}</Typography> : null}
{value.map((section, index) => (
<Box key={`other-${index}`} sx={{ p: 1.25, mb: 1, borderRadius: 2.5, border: "1px solid", borderColor: "divider", backgroundColor: "background.default" }}>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr auto" }, gap: 1 }}>
<TextField label={t("profileCvOtherSectionTitle")} value={section.title ?? ""} onChange={(e) => update(index, { title: e.target.value || undefined })} fullWidth />
<Button color="inherit" onClick={() => onChange(value.filter((_, i) => i !== index))}>{t("profileCvStructuredRemove")}</Button>
<Box sx={{ gridColumn: "1 / -1" }}><RichTextField label={t("profileCvOtherSectionItems")} value={joinLines(section.items)} onChange={(text) => update(index, { items: splitLines(text) })} minRows={4} /></Box>
<Box key={`other-${index}`} sx={{ p: "var(--app-card-padding)", mt: 1.5, borderRadius: 1, border: "1px solid", borderColor: "divider", backgroundColor: "background.default" }}>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "minmax(0, 1fr)", md: "minmax(0, 1fr) auto" }, gap: 1 }}>
<TextField disabled={disabled} label={t("profileCvOtherSectionTitle")} value={section.title ?? ""} onChange={(e) => update(index, { title: e.target.value || undefined })} fullWidth />
<Button disabled={disabled} color="inherit" onClick={() => onChange(value.filter((_, i) => i !== index))} aria-label={t("careerProfileRemoveOtherSection", { index: index + 1 })}>{t("profileCvStructuredRemove")}</Button>
<Box sx={{ gridColumn: "1 / -1" }}><RichTextField disabled={disabled} label={t("profileCvOtherSectionItems")} value={joinLines(section.items)} onChange={(text) => update(index, { items: splitLines(text) })} minRows={4} /></Box>
</Box>
</Box>
))}
</Box>
</PageSection>
);
}