feat(cv): clarify section editing scope (P5-10)

This commit is contained in:
cesnimda
2026-10-07 13:53:54 +02:00
parent 44b944e5ed
commit fc9598be8e
45 changed files with 20686 additions and 42 deletions
@@ -20,6 +20,7 @@ export default function RichTextField({
placeholder,
minRows = 2,
ariaLabel,
helperText,
disabled = false,
}: {
value: string;
@@ -28,6 +29,7 @@ export default function RichTextField({
placeholder?: string;
minRows?: number;
ariaLabel?: string;
helperText?: React.ReactNode;
disabled?: boolean;
}) {
const { t } = useI18n();
@@ -63,6 +65,7 @@ export default function RichTextField({
<TextField
inputRef={ref}
label={label}
helperText={helperText}
placeholder={placeholder}
multiline
minRows={minRows}
@@ -462,6 +462,7 @@ test('custom entries can be added, edited, reordered and deleted with confirmati
const title = screen.getByLabelText(/Section name for custom:/);
fireEvent.change(title, { target: { value: 'Selected projects' } });
fireEvent.click(screen.getByRole('button', { name: 'Expand Selected projects' }));
expect(within(screen.getByRole('group', { name: 'Selected projects' })).getByText('CV-only content')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Add entry' }));
fireEvent.change(screen.getByLabelText('Entry 1'), { target: { value: 'First project' } });
fireEvent.click(screen.getByRole('button', { name: 'Add entry' }));
@@ -471,16 +472,20 @@ test('custom entries can be added, edited, reordered and deleted with confirmati
expect(screen.getByLabelText('Entry 1')).toHaveValue('Second project');
expect(screen.getByLabelText('Entry 2')).toHaveValue('First project');
fireEvent.click(screen.getByRole('button', { name: 'Delete custom entry 1' }));
const deleteEntry = screen.getByRole('button', { name: 'Delete custom entry 1' });
deleteEntry.focus();
fireEvent.click(deleteEntry);
let dialog = await screen.findByRole('dialog', { name: 'Delete custom entry' });
fireEvent.click(within(dialog).getByRole('button', { name: 'Cancel' }));
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
expect(deleteEntry).toHaveFocus();
expect(screen.getAllByLabelText(/^Entry /)).toHaveLength(2);
fireEvent.click(screen.getByRole('button', { name: 'Delete custom entry 1' }));
dialog = await screen.findByRole('dialog', { name: 'Delete custom entry' });
fireEvent.click(within(dialog).getByRole('button', { name: 'Delete entry' }));
await waitFor(() => expect(screen.getAllByLabelText(/^Entry /)).toHaveLength(1));
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
expect(screen.getByLabelText('Entry 1')).toHaveFocus();
await waitFor(() => expect(mockedApi.put).toHaveBeenLastCalledWith('/cv/variants/3', expect.objectContaining({
settings: expect.objectContaining({
@@ -500,6 +505,120 @@ test('custom entries can be added, edited, reordered and deleted with confirmati
await waitFor(() => expect(screen.queryByLabelText(/Section name for custom:/)).not.toBeInTheDocument());
});
const contentOutline = {
sections: [{
key: 'experience', title: 'Experience', kind: 'entries', bullets: [], tags: [], entries: [
{ key: 'job-1', title: 'Engineer', subtitle: 'First Co', meta: '2021-01 – Present', bullets: ['Built APIs'], tags: [] },
{ key: 'job-2', title: 'Lead', subtitle: 'Second Co', bullets: ['Led teams'], tags: [] },
],
}],
};
test('[P5-10 PRES-C01 C07] source-backed entry edits identify their scope and save only this CV', async () => {
routeGet(() => Promise.resolve({ data: variant }), contentOutline);
renderAt(3);
await openContentTab();
fireEvent.click(screen.getByRole('button', { name: 'Expand Experience' }));
const entry = screen.getByRole('group', { name: 'Engineer' });
expect(entry).not.toHaveTextContent('2021-01 – Present');
expect(within(entry).getAllByText('Using Career Profile')).toHaveLength(3);
fireEvent.change(within(entry).getByLabelText('Title'), { target: { value: ' ' } });
expect(screen.getByRole('group', { name: 'Engineer' })).toBe(entry);
expect(within(entry).getByText('Blank uses Career Profile')).toBeInTheDocument();
fireEvent.change(within(entry).getByLabelText('Title'), { target: { value: 'Platform Engineer' } });
fireEvent.change(within(entry).getByLabelText('Subtitle'), { target: { value: 'CV-specific employer label' } });
fireEvent.change(within(entry).getByLabelText('Bullet points (one per line)'), { target: { value: 'Reviewed CV bullet\nSecond CV bullet' } });
expect(within(entry).getAllByText('Customized for this CV')).toHaveLength(3);
fireEvent.click(screen.getByRole('button', { name: 'Save now' }));
await screen.findByText('Saved');
expect(mockedApi.put).toHaveBeenLastCalledWith('/cv/variants/3', expect.objectContaining({
expectedVersion: 4, settings: expect.objectContaining({ overrides: {
'job-1': { title: 'Platform Engineer', subtitle: 'CV-specific employer label', bullets: ['Reviewed CV bullet', 'Second CV bullet'] },
} }),
}));
expect(mockedApi.put.mock.calls.every(([url]) => url === '/cv/variants/3')).toBe(true);
expect(contentOutline.sections[0].entries[0]).toEqual({ key: 'job-1', title: 'Engineer', subtitle: 'First Co', meta: '2021-01 – Present', bullets: ['Built APIs'], tags: [] });
});
test('[P5-10 PRES-C07 C10] resetting one entry field preserves omission, sibling override and order and supports undo', async () => {
const settings = { ...variant.settings,
sections: [{ key: 'experience', itemOrder: ['job-2', 'job-1'] }],
overrides: {
'job-1': { title: 'CV Engineer', subtitle: 'CV employer', bullets: ['CV bullet'], hidden: true },
'job-2': { title: 'CV Lead', bullets: ['Sibling item override'] },
},
};
routeGet(() => Promise.resolve({ data: { ...variant, settings } }), contentOutline);
renderAt(3);
await openContentTab();
fireEvent.click(screen.getByRole('button', { name: 'Expand Experience' }));
const entry = screen.getByRole('group', { name: 'CV Engineer' });
fireEvent.click(within(entry).getByRole('button', { name: 'Use Career Profile: Title' }));
expect(within(entry).getByLabelText('Title')).toHaveValue('Engineer');
expect(within(entry).getByLabelText('Subtitle')).toHaveValue('CV employer');
expect(within(entry).getByLabelText('Bullet points (one per line)')).toHaveValue('CV bullet');
fireEvent.click(within(entry).getByRole('button', { name: 'Use Career Profile: Subtitle' }));
expect(within(entry).getByLabelText('Subtitle')).toHaveValue('First Co');
fireEvent.click(within(entry).getByRole('button', { name: 'Use Career Profile: Bullet points (one per line)' }));
expect(within(entry).getByLabelText('Bullet points (one per line)')).toHaveValue('Built APIs');
expect(within(entry).getAllByText('Using Career Profile')).toHaveLength(3);
expect(within(entry).getByRole('button', { name: 'Show Engineer entry' })).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Undo' }));
expect(within(entry).getByLabelText('Title')).toHaveValue('Engineer');
expect(within(entry).getByLabelText('Subtitle')).toHaveValue('First Co');
expect(within(entry).getByLabelText('Bullet points (one per line)')).toHaveValue('CV bullet');
fireEvent.click(screen.getByRole('button', { name: 'Redo' }));
fireEvent.click(screen.getByRole('button', { name: 'Save now' }));
await screen.findByText('Saved');
const saved = mockedApi.put.mock.calls[mockedApi.put.mock.calls.length - 1][1] as any;
expect(saved.settings.overrides).toEqual({ 'job-1': { hidden: true }, 'job-2': settings.overrides['job-2'] });
expect(saved.settings.sections[0].itemOrder).toEqual(['job-2', 'job-1']);
});
test('[P5-10 PRES-C07 C10 E08] omitted entries remain editable and retain their draft after a failed save', async () => {
routeGet(() => Promise.resolve({ data: { ...variant, settings: { ...variant.settings, overrides: { 'job-1': { hidden: true } } } } }), contentOutline);
mockedApi.put.mockRejectedValueOnce(new Error('offline')).mockResolvedValueOnce({ data: { ...variant, version: 5 } } as any);
renderAt(3);
await openContentTab();
fireEvent.click(screen.getByRole('button', { name: 'Expand Experience' }));
const entry = screen.getByRole('group', { name: 'Engineer' });
expect(within(entry).getByText('Omitted', { exact: true })).toBeInTheDocument();
expect(within(entry).getByLabelText('Title')).toBeEnabled();
expect(within(entry).getByLabelText('Subtitle')).toBeEnabled();
const bullets = within(entry).getByLabelText('Bullet points (one per line)');
fireEvent.change(bullets, { target: { value: 'Retained omitted-entry draft' } });
fireEvent.click(screen.getByRole('button', { name: 'Save now' }));
await screen.findByText('Save failed');
expect(bullets).toHaveValue('Retained omitted-entry draft');
expect(within(entry).getByRole('button', { name: 'Show Engineer entry' })).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Save now' }));
await screen.findByText('Saved');
expect(mockedApi.put).toHaveBeenLastCalledWith('/cv/variants/3', expect.objectContaining({
settings: expect.objectContaining({ overrides: { 'job-1': { hidden: true, bullets: ['Retained omitted-entry draft'] } } }),
}));
});
test('[P5-10 PRES-C01 C07] section content reset inherits source without changing omission or presentation', async () => {
routeGet(() => Promise.resolve({ data: { ...variant, settings: { ...variant.settings,
sections: [{ key: 'skills', items: ['CV-specific skill'], hidden: true, presentation: 'bubble' }],
} } }), { sections: [{ key: 'skills', title: 'Skills', kind: 'tags', tags: ['C#', 'Norsk'], bullets: [], entries: [] }] });
renderAt(3);
await openContentTab();
fireEvent.click(screen.getByRole('button', { name: 'Expand Skills' }));
const section = screen.getByRole('group', { name: 'Skills' });
expect(within(section).getByLabelText('Items (one per line)')).toHaveValue('CV-specific skill');
fireEvent.click(within(section).getByRole('button', { name: 'Use Career Profile content' }));
expect(within(section).getByLabelText('Items (one per line)')).toHaveValue('C#\nNorsk');
expect(within(section).getByRole('button', { name: 'Show Skills section' })).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Save now' }));
await screen.findByText('Saved');
const saved = mockedApi.put.mock.calls[mockedApi.put.mock.calls.length - 1][1] as any;
const row = saved.settings.sections.find((item: any) => item.key === 'skills');
expect(row).toEqual(expect.objectContaining({ key: 'skills', hidden: true, presentation: 'bubble' }));
expect(row.items).toBeUndefined();
expect(mockedApi.put.mock.calls.every(([url]) => url === '/cv/variants/3')).toBe(true);
});
test('profile-backed sections expand, reorder, hide and persist variant-only overrides', async () => {
routeGet(() => Promise.resolve({ data: variant } as any), {
sections: [{
+24 -10
View File
@@ -590,12 +590,19 @@ export const translations = {
cvEditorDeleteCustomMessage: "Delete the custom section ‘{title}’ and all of its entries?",
cvEditorDeleteCustomTitle: "Delete custom section",
cvEditorDeleteSection: "Delete section",
cvEditorEditMasterProfile: "Edit master profile",
cvEditorMasterProfileHelp: "Contact details and career history are shared from your master profile. CV-specific headings, wording, order and visibility stay in this version.",
cvEditorEditMasterProfile: "Open Career Profile",
cvEditorMasterProfileHelp: "Career Profile is your source. Editing wording, order or inclusion here changes only this CV.",
cvEditorHeadlineOverride: "Headline override",
cvEditorHeadlineHelp: "Blank uses the headline from your master profile.",
cvEditorHeadlineHelp: "Blank uses your Career Profile headline. Changes here affect only this CV.",
cvEditorSections: "Sections",
cvEditorSectionsHelp: "Drag to reorder, or use the arrows. Content comes from your master profile.",
cvEditorSectionsHelp: "Drag to reorder, or use the arrows. Omitted content stays editable.",
cvEditorUsingSource: "Using Career Profile",
cvEditorCustomized: "Customized for this CV",
cvEditorCvOnly: "CV-only content",
cvEditorEntryScopeHelp: "Changes here affect only this CV. Career Profile stays unchanged.",
cvEditorBlankUsesSource: "Blank uses Career Profile",
cvEditorResetField: "Use Career Profile: {field}",
cvEditorResetFieldHelp: "Replaces this CV field with the current Career Profile value. Other fields, order and inclusion stay unchanged.",
cvEditorAddSection: "Add a section",
cvEditorAddSectionButton: "Add section",
cvEditorAddSectionHelp: "Preset sections use the same editable, reorderable card as every other section.",
@@ -642,7 +649,7 @@ export const translations = {
cvEditorBulletsHelp: "Bullet points (one per line)",
cvEditorItemsHelp: "Items (one per line)",
cvEditorContentItemsHelp: "Content (one item per line)",
cvEditorResetMasterProfile: "Reset to master profile",
cvEditorResetMasterProfile: "Use Career Profile content",
cvEditorMoveNamedEntryUp: "Move {entry} entry up",
cvEditorMoveNamedEntryDown: "Move {entry} entry down",
cvEditorShowEntry: "Show {entry} entry",
@@ -3137,12 +3144,19 @@ export const translations = {
cvEditorDeleteCustomMessage: "Slette den egendefinerte seksjonen «{title}» og alle oppføringene?",
cvEditorDeleteCustomTitle: "Slett egendefinert seksjon",
cvEditorDeleteSection: "Slett seksjon",
cvEditorEditMasterProfile: "Rediger hovedprofil",
cvEditorMasterProfileHelp: "Kontaktopplysninger og karrierehistorikk deles fra hovedprofilen. CV-spesifikke overskrifter, formuleringer, rekkefølge og synlighet beholdes i denne versjonen.",
cvEditorEditMasterProfile: "Åpne Karriereprofil",
cvEditorMasterProfileHelp: "Karriereprofil er kilden din. Endringer i tekst, rekkefølge og synlighet her gjelder bare denne CV-en.",
cvEditorHeadlineOverride: "Egen overskrift",
cvEditorHeadlineHelp: "Tomt felt bruker overskriften fra hovedprofilen.",
cvEditorHeadlineHelp: "Tomt felt bruker overskriften fra Karriereprofil. Endringer her gjelder bare denne CV-en.",
cvEditorSections: "Seksjoner",
cvEditorSectionsHelp: "Dra for å endre rekkefølgen, eller bruk pilene. Innholdet kommer fra hovedprofilen.",
cvEditorSectionsHelp: "Dra for å endre rekkefølgen, eller bruk pilene. Utelatt innhold kan fortsatt redigeres.",
cvEditorUsingSource: "Bruker Karriereprofil",
cvEditorCustomized: "Tilpasset denne CV-en",
cvEditorCvOnly: "Innhold bare i denne CV-en",
cvEditorEntryScopeHelp: "Endringer her gjelder bare denne CV-en. Karriereprofil endres ikke.",
cvEditorBlankUsesSource: "Tomt felt bruker Karriereprofil",
cvEditorResetField: "Bruk Karriereprofil: {field}",
cvEditorResetFieldHelp: "Erstatter dette CV-feltet med gjeldende verdi fra Karriereprofil. Andre felt, rekkefølge og synlighet endres ikke.",
cvEditorAddSection: "Legg til seksjon",
cvEditorAddSectionButton: "Legg til seksjon",
cvEditorAddSectionHelp: "Forhåndsdefinerte seksjoner bruker det samme redigerbare kortet som alle andre seksjoner.",
@@ -3189,7 +3203,7 @@ export const translations = {
cvEditorBulletsHelp: "Punkter (ett per linje)",
cvEditorItemsHelp: "Elementer (ett per linje)",
cvEditorContentItemsHelp: "Innhold (ett element per linje)",
cvEditorResetMasterProfile: "Tilbakestill til hovedprofil",
cvEditorResetMasterProfile: "Bruk innhold fra Karriereprofil",
cvEditorMoveNamedEntryUp: "Flytt oppføringen {entry} opp",
cvEditorMoveNamedEntryDown: "Flytt oppføringen {entry} ned",
cvEditorShowEntry: "Vis oppføringen {entry}",
+66 -30
View File
@@ -818,6 +818,7 @@ function ContentTab({ settings, update, outline, showPreviewEntry, onFocusPrevie
const { t } = useI18n();
const { confirmAction } = useDialogActions();
const [addContentOpen, setAddContentOpen] = useState(false);
const addContentButton = useRef<HTMLButtonElement | null>(null);
const [activeSection, setActiveSection] = useState<string | null>(null);
const [expandedSections, setExpandedSections] = useState<Set<string>>(() => new Set());
const sectionButtons = useRef<Record<string, HTMLButtonElement | null>>({});
@@ -902,6 +903,7 @@ function ContentTab({ settings, update, outline, showPreviewEntry, onFocusPrevie
customSections: settings.customSections.filter((c) => c.key !== section.key),
sections: sectionRows.filter((row) => row.key !== `custom:${section.key}`),
});
requestAnimationFrame(() => addContentButton.current?.focus());
};
return (
<Stack spacing={2}>
@@ -964,7 +966,7 @@ function ContentTab({ settings, update, outline, showPreviewEntry, onFocusPrevie
</Stack>
</Box>
<Button variant="contained" startIcon={<AddIcon />} onClick={() => setAddContentOpen(true)} sx={{ alignSelf: "center", minWidth: 190 }}>{t("cvEditorAddContent")}</Button>
<Button ref={addContentButton} variant="contained" startIcon={<AddIcon />} onClick={() => setAddContentOpen(true)} sx={{ alignSelf: "center", minWidth: 190 }}>{t("cvEditorAddContent")}</Button>
<Dialog open={addContentOpen} onClose={() => setAddContentOpen(false)} fullWidth maxWidth="md" aria-labelledby="cv-add-content-title">
<DialogTitle id="cv-add-content-title" sx={{ fontWeight: 850 }}>{t("cvEditorAddContent")}</DialogTitle>
<DialogContent dividers>
@@ -1029,6 +1031,12 @@ function SectionRow({
const { t } = useI18n();
const { confirmAction } = useDialogActions();
const sectionLabel = customSection?.title ?? row.title ?? sectionName(row.key, SECTION_LABELS[row.key] ?? row.key, t);
const customInputs = useRef<Record<number, HTMLTextAreaElement | null>>({});
const addEntryButton = useRef<HTMLButtonElement | null>(null);
const contentRef = useRef<HTMLDivElement | null>(null);
const focusCustomEntry = (index: number) => requestAnimationFrame(() => {
(customInputs.current[index] ?? addEntryButton.current)?.focus();
});
const sourceItems = outlineSection?.kind === "tags" ? outlineSection.tags : outlineSection?.bullets ?? [];
const editableItems = row.items ?? sourceItems;
const customText = customSection?.items.join("\n\n") ?? "";
@@ -1044,7 +1052,7 @@ function SectionRow({
opacity: dragging ? 0.4 : 1,
borderTop: over ? "2px solid" : undefined, borderTopColor: over ? "primary.main" : undefined,
transition: "opacity 150ms ease, border-color 120ms ease, box-shadow 150ms ease",
overflow: "hidden", "&:hover": { boxShadow: 1 },
minWidth: 0,
}}
>
<Stack direction="row" alignItems="center" gap={0.5} sx={{ px: 0.75, py: 0.5 }}>
@@ -1053,10 +1061,9 @@ function SectionRow({
<IconButton size="small" aria-label={t("cvEditorMoveSectionUp", { section: sectionLabel })} disabled={index === 0} onClick={() => onMove(-1)}><ArrowUpwardIcon sx={{ fontSize: 15 }} /></IconButton>
<IconButton size="small" aria-label={t("cvEditorMoveSectionDown", { section: sectionLabel })} disabled={index === total - 1} onClick={() => onMove(1)}><ArrowDownwardIcon sx={{ fontSize: 15 }} /></IconButton>
</Stack>
<TextField variant="standard" fullWidth value={sectionLabel}
<TextField variant="standard" fullWidth value={sectionLabel} sx={{ minWidth: 0 }}
onChange={(e) => customSection ? onRenameCustom(e.target.value) : onPatch({ title: e.target.value })}
slotProps={{ input: { disableUnderline: true }, htmlInput: { "aria-label": t("cvEditorSectionName", { section: row.key }) } }} />
{customSection && <Chip size="small" label={t("cvEditorCustom")} variant="outlined" sx={{ height: 20 }} />}
<IconButton size="small" aria-label={t(row.hidden ? "cvEditorShowSection" : "cvEditorHideSection", { section: sectionLabel })} onClick={() => onPatch({ hidden: !row.hidden })}>
{row.hidden ? <VisibilityOffIcon fontSize="small" /> : <VisibilityIcon fontSize="small" />}
</IconButton>
@@ -1070,8 +1077,11 @@ function SectionRow({
</IconButton>
</Stack>
<Collapse in={expanded} unmountOnExit>
<Box sx={{ px: 1.5, py: 1.25, borderTop: "1px solid", borderColor: "divider", bgcolor: "action.hover" }}>
<Paper variant="outlined" sx={{ p: 1, mb: 1.25, bgcolor: "background.paper" }}>
<Box ref={contentRef} sx={{ px: 1.5, py: 1.25, borderTop: "1px solid", borderColor: "divider", bgcolor: "action.hover" }}>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1.5 }}>
<span>{customSection ? t("cvEditorCvOnly") : t("cvEditorEntryScopeHelp")}</span> · <span>{t(row.hidden ? "cvEditorOmitted" : "cvEditorIncluded")}</span>
</Typography>
<Box sx={{ mb: 1.5 }}>
<Stack direction={{ xs: "column", sm: "row" }} spacing={1} alignItems={{ sm: "center" }}>
<Box sx={{ flex: 1 }}>
<Typography variant="caption" sx={{ fontWeight: 800 }}>{t("cvEditorSectionLayout")}</Typography>
@@ -1087,20 +1097,22 @@ function SectionRow({
{[1, 2].map((columns) => <Button key={columns} size="small" variant={(row.columns ?? 1) === columns ? "contained" : "outlined"} onClick={() => onPatch({ columns: columns as 1 | 2 })}>{columns}</Button>)}
</Stack>}
</Stack>
</Paper>
</Box>
{customSection ? (
<Stack spacing={1}>
<TextField select size="small" label={t("cvEditorContentFormat")} value={customSection.contentType ?? "bullets"} onChange={(event) => onUpdateCustom({ contentType: event.target.value as CvCustomSectionSetting["contentType"] })}>
<MenuItem value="paragraphs">{t("cvEditorParagraphs")}</MenuItem><MenuItem value="bullets">{t("cvEditorBulletList")}</MenuItem><MenuItem value="entries">{t("cvEditorMultipleEntries")}</MenuItem>
</TextField>
{customSection.contentType === "entries" ? <Stack spacing={1}>
{customSection.items.map((item, itemIndex) => <Stack key={`${customSection.key}-${itemIndex}`} direction="row" alignItems="flex-start" spacing={0.5}>
<TextField fullWidth size="small" multiline minRows={2} label={t("cvEditorEntry", { number: itemIndex + 1 })} value={item} onChange={(event) => onUpdateCustom({ items: customSection.items.map((current, index) => index === itemIndex ? event.target.value : current) })} />
<IconButton size="small" aria-label={t("cvEditorMoveEntryUp", { number: itemIndex + 1 })} disabled={itemIndex === 0} onClick={() => onUpdateCustom({ items: moveItem(customSection.items, itemIndex, itemIndex - 1) })}><ArrowUpwardIcon fontSize="small" /></IconButton>
<IconButton size="small" aria-label={t("cvEditorMoveEntryDown", { number: itemIndex + 1 })} disabled={itemIndex === customSection.items.length - 1} onClick={() => onUpdateCustom({ items: moveItem(customSection.items, itemIndex, itemIndex + 1) })}><ArrowDownwardIcon fontSize="small" /></IconButton>
<IconButton size="small" aria-label={t("cvEditorDeleteEntryNumber", { number: itemIndex + 1 })} onClick={() => void confirmAction(t("cvEditorDeleteEntryMessage"), { title: t("cvEditorDeleteEntryTitle"), confirmLabel: t("cvEditorDeleteEntry"), destructive: true }).then((confirmed) => { if (confirmed) onUpdateCustom({ items: customSection.items.filter((_, index) => index !== itemIndex) }); })}><DeleteOutlineIcon fontSize="small" /></IconButton>
{customSection.items.map((item, itemIndex) => <Stack key={`${customSection.key}-${itemIndex}`} spacing={0.5}>
<TextField inputRef={(input) => { customInputs.current[itemIndex] = input; }} fullWidth size="small" multiline minRows={2} label={t("cvEditorEntry", { number: itemIndex + 1 })} value={item} onChange={(event) => onUpdateCustom({ items: customSection.items.map((current, index) => index === itemIndex ? event.target.value : current) })} />
<Stack direction="row" gap={0.5}>
<IconButton size="small" aria-label={t("cvEditorMoveEntryUp", { number: itemIndex + 1 })} disabled={itemIndex === 0} onClick={() => { onUpdateCustom({ items: moveItem(customSection.items, itemIndex, itemIndex - 1) }); focusCustomEntry(itemIndex - 1); }}><ArrowUpwardIcon fontSize="small" /></IconButton>
<IconButton size="small" aria-label={t("cvEditorMoveEntryDown", { number: itemIndex + 1 })} disabled={itemIndex === customSection.items.length - 1} onClick={() => { onUpdateCustom({ items: moveItem(customSection.items, itemIndex, itemIndex + 1) }); focusCustomEntry(itemIndex + 1); }}><ArrowDownwardIcon fontSize="small" /></IconButton>
<IconButton size="small" aria-label={t("cvEditorDeleteEntryNumber", { number: itemIndex + 1 })} onClick={() => void confirmAction(t("cvEditorDeleteEntryMessage"), { title: t("cvEditorDeleteEntryTitle"), confirmLabel: t("cvEditorDeleteEntry"), destructive: true }).then((confirmed) => { if (confirmed) { onUpdateCustom({ items: customSection.items.filter((_, index) => index !== itemIndex) }); focusCustomEntry(Math.max(0, Math.min(itemIndex, customSection.items.length - 2))); } })}><DeleteOutlineIcon fontSize="small" /></IconButton>
</Stack>
</Stack>)}
<Button size="small" startIcon={<AddIcon />} sx={{ alignSelf: "flex-start" }} onClick={() => onUpdateCustom({ items: [...customSection.items, ""] })}>{t("cvEditorAddEntry")}</Button>
<Button ref={addEntryButton} size="small" startIcon={<AddIcon />} sx={{ alignSelf: "flex-start" }} onClick={() => { onUpdateCustom({ items: [...customSection.items, ""] }); focusCustomEntry(customSection.items.length); }}>{t("cvEditorAddEntry")}</Button>
</Stack> : <RichTextField label={customSection.contentType === "paragraphs" ? t("cvEditorParagraphsHelp") : t("cvEditorBulletsHelp")} minRows={4} value={customSection.contentType === "paragraphs" ? customText : customSection.items.join("\n")}
onChange={(value) => onUpdateCustom({ items: customSection.contentType === "paragraphs" ? value.split(/\n\s*\n/) : value.split("\n") })} />}
<AiSectionAssistant sectionName={sectionLabel} text={customText} documentLanguage={settings.language} context="This is a custom CV section." onApply={(value) => onUpdateCustom({ items: customSection.contentType === "paragraphs" ? value.split(/\n\s*\n/) : value.split("\n") })} />
@@ -1109,8 +1121,14 @@ function SectionRow({
<EntryEditor section={outlineSection} row={row} settings={settings} onPatch={onPatch} onUpdateSettings={onUpdateSettings} />
) : (
<Stack spacing={1}>
<RichTextField label={outlineSection?.kind === "tags" ? t("cvEditorItemsHelp") : t("cvEditorContentItemsHelp")} minRows={3} value={editableItems.join("\n")} onChange={(value) => onPatch({ items: value.split("\n") })} />
{row.items && <Button size="small" sx={{ alignSelf: "flex-start" }} onClick={() => onPatch({ items: undefined })}>{t("cvEditorResetMasterProfile")}</Button>}
<RichTextField label={outlineSection?.kind === "tags" ? t("cvEditorItemsHelp") : t("cvEditorContentItemsHelp")} helperText={t(row.items != null ? "cvEditorCustomized" : "cvEditorUsingSource")} minRows={3} value={editableItems.join("\n")} onChange={(value) => onPatch({ items: value.split("\n") })} />
{row.items && <Stack spacing={0.5}>
<Typography variant="caption" color="text.secondary">{t("cvEditorResetFieldHelp")}</Typography>
<Button size="small" sx={{ alignSelf: "flex-start" }} onClick={() => {
const field = contentRef.current?.querySelector<HTMLTextAreaElement>("textarea:not([aria-hidden])");
onPatch({ items: undefined }); requestAnimationFrame(() => field?.focus());
}}>{t("cvEditorResetMasterProfile")}</Button>
</Stack>}
<AiSectionAssistant sectionName={sectionLabel} text={editableItems.join("\n")} documentLanguage={settings.language} context="Return one item per line when suggesting a list." onApply={(value) => onPatch({ items: value.split("\n") })} />
</Stack>
)}
@@ -1128,6 +1146,7 @@ function EntryEditor({ section, row, settings, onPatch, onUpdateSettings }: {
onUpdateSettings: (p: Partial<CvVariantSettings>) => void;
}) {
const { t } = useI18n();
const fieldRefs = useRef<Record<string, HTMLInputElement | HTMLTextAreaElement | null>>({});
// Entry order: itemOrder if set, else master order; always covering every entry key.
const orderedKeys: string[] = useMemo(() => {
const master = Array.from(new Set(section.entries.map((e) => e.key ?? "").filter(Boolean)));
@@ -1149,6 +1168,10 @@ function EntryEditor({ section, row, settings, onPatch, onUpdateSettings }: {
const overrides = { ...settings.overrides, [key]: { ...settings.overrides[key], ...patch } };
onUpdateSettings({ overrides });
};
const resetField = (key: string, field: "title" | "subtitle" | "bullets") => {
setOverride(key, { [field]: undefined });
requestAnimationFrame(() => fieldRefs.current[`${key}:${field}`]?.focus());
};
return (
<Stack spacing={0.75}>
@@ -1157,39 +1180,52 @@ function EntryEditor({ section, row, settings, onPatch, onUpdateSettings }: {
if (!entry) return null;
const ov = settings.overrides[key] ?? {};
const hidden = !!ov.hidden;
const entryLabel = ov.title || entry.title || t("cvEditorUntitled");
const entryLabel = ov.title?.trim() || entry.title || t("cvEditorUntitled");
return (
<Paper key={key} variant="outlined" {...drag.getItemProps(i)}
<Box key={key} {...drag.getItemProps(i)} role="group" aria-label={entryLabel}
sx={{
p: 0.75, opacity: drag.dragIndex === i ? 0.4 : 1, cursor: "grab",
py: 1.25, minWidth: 0, opacity: drag.dragIndex === i ? 0.4 : 1,
borderBottom: "1px solid", borderBottomColor: "divider",
borderTop: drag.overIndex === i && drag.dragIndex !== i ? "2px solid" : undefined,
borderTopColor: "primary.main", transition: "opacity 150ms ease",
}}>
<Stack direction="row" alignItems="center" gap={0.5}>
<Stack direction="row" alignItems="center" gap={0.5} flexWrap="wrap">
<DragIndicatorIcon sx={{ fontSize: 16, color: "text.disabled" }} aria-hidden />
<Typography variant="body2" sx={{ fontWeight: 700, flex: 1 }}>{entryLabel}</Typography>
<Box sx={{ flex: "1 1 10rem", minWidth: 0 }}>
<Typography component="p" variant="subtitle2" sx={{ overflowWrap: "anywhere" }}>{entryLabel}</Typography>
<Typography variant="caption" color="text.secondary">{t(hidden ? "cvEditorOmitted" : "cvEditorIncluded")}</Typography>
</Box>
<Stack direction="row" gap={0.5}>
<IconButton size="small" aria-label={t("cvEditorMoveNamedEntryUp", { entry: entryLabel })} disabled={i === 0} onClick={() => onPatch({ itemOrder: moveItem(orderedKeys, i, i - 1) })}><ArrowUpwardIcon sx={{ fontSize: 14 }} /></IconButton>
<IconButton size="small" aria-label={t("cvEditorMoveNamedEntryDown", { entry: entryLabel })} disabled={i === orderedKeys.length - 1} onClick={() => onPatch({ itemOrder: moveItem(orderedKeys, i, i + 1) })}><ArrowDownwardIcon sx={{ fontSize: 14 }} /></IconButton>
<IconButton size="small" aria-label={t(hidden ? "cvEditorShowEntry" : "cvEditorHideEntry", { entry: entryLabel })} onClick={() => setOverride(key, { hidden: !hidden })}>
{hidden ? <VisibilityOffIcon sx={{ fontSize: 16 }} /> : <VisibilityIcon sx={{ fontSize: 16 }} />}
</IconButton>
</Stack>
</Stack>
{!hidden && (
<Stack spacing={0.75} sx={{ mt: 0.75 }}>
<TextField size="small" fullWidth label={t("cvEditorEntryTitle")} value={ov.title ?? entry.title ?? ""}
onChange={(e) => setOverride(key, { title: e.target.value })} />
<TextField size="small" fullWidth label={t("cvEditorEntrySubtitle")} value={ov.subtitle ?? entry.subtitle ?? ""}
onChange={(e) => setOverride(key, { subtitle: e.target.value })} />
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", sm: "repeat(2, minmax(0, 1fr))" }, gap: 1 }}>
{(["title", "subtitle"] as const).map((field) => {
const fieldLabel = t(field === "title" ? "cvEditorEntryTitle" : "cvEditorEntrySubtitle");
return <Box key={field} sx={{ minWidth: 0 }}>
<TextField inputRef={(input) => { fieldRefs.current[`${key}:${field}`] = input; }} size="small" fullWidth label={fieldLabel} value={ov[field] ?? entry[field] ?? ""}
placeholder={entry[field]} helperText={t(ov[field]?.trim() ? "cvEditorCustomized" : ov[field] != null ? "cvEditorBlankUsesSource" : "cvEditorUsingSource")}
onChange={(e) => setOverride(key, { [field]: e.target.value })} />
{ov[field] != null && <Button size="small" onClick={() => resetField(key, field)}>{t("cvEditorResetField", { field: fieldLabel })}</Button>}
</Box>;
})}
</Box>
<Box ref={(box: HTMLDivElement | null) => { fieldRefs.current[`${key}:bullets`] = box?.querySelector<HTMLTextAreaElement>("textarea:not([aria-hidden])") ?? null; }}>
<RichTextField label={t("cvEditorBulletsHelp")} minRows={2}
helperText={t(ov.bullets != null ? "cvEditorCustomized" : "cvEditorUsingSource")}
value={(ov.bullets ?? entry.bullets).join("\n")}
onChange={(v) => setOverride(key, { bullets: v.split("\n") })} />
</Box>
{ov.bullets != null && <Button size="small" sx={{ alignSelf: "flex-start" }} onClick={() => resetField(key, "bullets")}>{t("cvEditorResetField", { field: t("cvEditorBulletsHelp") })}</Button>}
{(ov.title != null || ov.subtitle != null || ov.bullets != null) && <Typography variant="caption" color="text.secondary">{t("cvEditorResetFieldHelp")}</Typography>}
<AiSectionAssistant sectionName={`${section.title}: ${entryLabel}`} text={(ov.bullets ?? entry.bullets).join("\n")} documentLanguage={settings.language} context={`Entry title: ${ov.title ?? entry.title ?? ""}. Subtitle: ${ov.subtitle ?? entry.subtitle ?? ""}. Return one bullet per line.`} onApply={(value) => setOverride(key, { bullets: value.split("\n") })} />
{ov.bullets && (
<Button size="small" onClick={() => setOverride(key, { bullets: undefined })}>{t("cvEditorResetMasterBullets")}</Button>
)}
</Stack>
)}
</Paper>
</Box>
);
})}
</Stack>