feat(cv): organize template and document controls
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import React, { useMemo, useState } from "react";
|
||||
import React, { useId, useMemo, useState } from "react";
|
||||
import CheckCircleIcon from "@mui/icons-material/CheckCircle";
|
||||
import { Box, Button, Chip, Paper, Stack, Typography } from "@mui/material";
|
||||
|
||||
@@ -16,14 +16,15 @@ type Props = {
|
||||
|
||||
export default function CvTemplateGallery({ themes, selectedId, onSelect, previewHeight = 170, showDescriptions = true }: Props) {
|
||||
const { t } = useI18n();
|
||||
const id = useId();
|
||||
const [atsOnly, setAtsOnly] = useState(true);
|
||||
const visibleThemes = useMemo(() => getVisibleCvThemes(themes, atsOnly, selectedId), [atsOnly, selectedId, themes]);
|
||||
|
||||
return (
|
||||
<Stack spacing={1.25}>
|
||||
<Stack direction="row" spacing={0.75} alignItems="center" flexWrap="wrap" useFlexGap>
|
||||
<Button size="small" variant={atsOnly ? "contained" : "outlined"} onClick={() => setAtsOnly(true)}>{t("cvEditorAtsTemplates")}</Button>
|
||||
<Button size="small" variant={!atsOnly ? "contained" : "outlined"} onClick={() => setAtsOnly(false)}>{t("cvEditorAllTemplates")}</Button>
|
||||
<Button size="small" aria-pressed={atsOnly} variant={atsOnly ? "contained" : "outlined"} onClick={() => setAtsOnly(true)}>{t("cvEditorAtsTemplates")}</Button>
|
||||
<Button size="small" aria-pressed={!atsOnly} variant={!atsOnly ? "contained" : "outlined"} onClick={() => setAtsOnly(false)}>{t("cvEditorAllTemplates")}</Button>
|
||||
{atsOnly && <Typography variant="caption" color="text.secondary">{t("cvEditorAtsTemplateHelp")}</Typography>}
|
||||
</Stack>
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(150px, 1fr))", gap: 1.25 }}>
|
||||
@@ -31,16 +32,16 @@ export default function CvTemplateGallery({ themes, selectedId, onSelect, previe
|
||||
const active = theme.id === selectedId;
|
||||
const locked = theme.available === false;
|
||||
return (
|
||||
<Paper key={theme.id} variant="outlined" role="button" tabIndex={locked ? -1 : 0} aria-disabled={locked} aria-pressed={active}
|
||||
<Paper key={theme.id} variant="outlined" role="button" tabIndex={locked ? -1 : 0} aria-label={theme.name} aria-describedby={`${id}-${theme.id}-category ${showDescriptions ? `${id}-${theme.id}-description ` : ""}${id}-${theme.id}-features`} aria-disabled={locked} aria-pressed={active}
|
||||
onClick={() => { if (!locked) onSelect(theme); }}
|
||||
onKeyDown={(event) => { if (!locked && (event.key === "Enter" || event.key === " ")) { event.preventDefault(); onSelect(theme); } }}
|
||||
sx={{ p: 1, cursor: locked ? "not-allowed" : "pointer", opacity: locked ? 0.6 : 1, outline: "none", position: "relative", borderColor: active ? "primary.main" : "divider", borderWidth: active ? 2 : 1, transition: "border-color 120ms ease, transform 120ms ease", "&:hover": locked ? undefined : { borderColor: "primary.main", transform: "translateY(-1px)" }, "&:focus-visible": { outline: "3px solid", outlineColor: "primary.main", outlineOffset: 2 } }}>
|
||||
sx={{ p: 1, cursor: locked ? "not-allowed" : "pointer", position: "relative", borderColor: active ? "primary.main" : "divider", borderWidth: active ? 2 : 1, "&:hover": locked ? undefined : { borderColor: "primary.main" } }}>
|
||||
{active && <CheckCircleIcon aria-label={t("cvEditorSelectedTemplate")} color="primary" sx={{ position: "absolute", zIndex: 2, top: 6, right: 6, bgcolor: "background.paper", borderRadius: "50%" }} />}
|
||||
<Box sx={{ mb: 1 }}><CvTemplateThumbnail theme={theme} height={previewHeight} /></Box>
|
||||
<Typography variant="body2" sx={{ fontWeight: 750 }}>{theme.name}</Typography>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: "block" }}>{theme.category}</Typography>
|
||||
{showDescriptions && <Typography variant="caption" color="text.secondary" sx={{ display: "block", mt: 0.25, lineHeight: 1.35 }}>{theme.description}</Typography>}
|
||||
<Stack direction="row" spacing={0.5} alignItems="center" flexWrap="wrap" useFlexGap sx={{ mt: 0.75 }}>
|
||||
<Typography id={`${id}-${theme.id}-category`} variant="caption" color="text.secondary" sx={{ display: "block" }}>{theme.category}</Typography>
|
||||
{showDescriptions && <Typography id={`${id}-${theme.id}-description`} variant="caption" color="text.secondary" sx={{ display: "block", mt: 0.25, lineHeight: 1.35 }}>{theme.description}</Typography>}
|
||||
<Stack id={`${id}-${theme.id}-features`} direction="row" spacing={0.5} alignItems="center" flexWrap="wrap" useFlexGap sx={{ mt: 0.75 }}>
|
||||
{theme.atsFriendly && <Chip size="small" label={t("cvEditorAtsFriendly")} color="success" variant="outlined" sx={{ height: 20, fontSize: 10 }} />}
|
||||
{theme.requiresPro && <Chip size="small" label={locked ? t("cvEditorPro") : t("cvEditorProUnlocked")} color="secondary" variant="outlined" sx={{ height: 20, fontSize: 10 }} />}
|
||||
<Stack direction="row" spacing={0.35} sx={{ ml: "auto" }} aria-hidden>
|
||||
|
||||
@@ -1,21 +1,22 @@
|
||||
import React, { useId } from "react";
|
||||
import React, { useEffect, useId, useState } from "react";
|
||||
import CheckIcon from "@mui/icons-material/Check";
|
||||
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
|
||||
import {
|
||||
Box, Button, Divider, FormControl, FormControlLabel, IconButton, InputLabel, MenuItem, Paper, Select, Slider, Stack, Switch, TextField, Typography,
|
||||
Accordion, AccordionDetails, AccordionSummary, Box, Button, FormControl, FormControlLabel,
|
||||
IconButton, InputAdornment, InputLabel, MenuItem, Select, Stack, Switch, TextField, Typography,
|
||||
} from "@mui/material";
|
||||
|
||||
import { applyCvTheme, CvTheme, CvVariantSettings, normalizeCvLanguage, SECTION_LABELS } from "../../cvBuilder";
|
||||
import { useConfirm } from "../../confirm";
|
||||
import { useI18n } from "../../i18n/I18nProvider";
|
||||
import CvTemplateGallery from "../CvTemplateGallery";
|
||||
|
||||
const FONTS = [
|
||||
"'Segoe UI', Roboto, Arial, sans-serif",
|
||||
"Arial, Helvetica, sans-serif",
|
||||
"Georgia, 'Times New Roman', serif",
|
||||
"'Helvetica Neue', Arial, sans-serif",
|
||||
"'Roboto', Arial, sans-serif",
|
||||
"'Poppins', 'Segoe UI', Arial, sans-serif",
|
||||
"'Segoe UI', Roboto, Arial, sans-serif", "Arial, Helvetica, sans-serif", "Georgia, 'Times New Roman', serif",
|
||||
"'Helvetica Neue', Arial, sans-serif", "'Roboto', Arial, sans-serif", "'Poppins', 'Segoe UI', Arial, sans-serif",
|
||||
];
|
||||
const FONT_LABELS = ["Segoe UI", "Arial", "Georgia (serif)", "Helvetica Neue", "Roboto", "Poppins"];
|
||||
const CONTROL_GRID = { display: "grid", gridTemplateColumns: { xs: "minmax(0, 1fr)", sm: "repeat(2, minmax(0, 1fr))" }, gap: 1.5 };
|
||||
|
||||
type Props = {
|
||||
mode: "template" | "customize";
|
||||
@@ -27,77 +28,141 @@ type Props = {
|
||||
|
||||
export default function CvCustomizeTab({ mode, settings, update, themes, sectionLabel }: Props) {
|
||||
const { t } = useI18n();
|
||||
const labelId = useId();
|
||||
const activeTheme = themes.find((theme) => theme.id === settings.themeId);
|
||||
const { confirm } = useConfirm();
|
||||
const id = useId();
|
||||
const activeTheme = themes.find(theme => theme.id === settings.themeId);
|
||||
const supports = (setting: string) => !activeTheme?.supportedSettings?.length || activeTheme.supportedSettings.includes(setting);
|
||||
const sidebarSections = settings.sidebarSections ?? ["contact", "skills", "languages"];
|
||||
const toggleSidebarSection = (key: string) => update({
|
||||
sidebarSections: sidebarSections.includes(key)
|
||||
? sidebarSections.filter((item) => item !== key)
|
||||
: [...sidebarSections, key],
|
||||
});
|
||||
const layout = settings.layout ?? activeTheme?.layout;
|
||||
const selectTemplate = async (theme: CvTheme) => {
|
||||
const next = applyCvTheme(settings, theme.id);
|
||||
const resetsOverrides = Object.entries(next).some(([key, value]) => value === null && settings[key as keyof CvVariantSettings] != null);
|
||||
if (resetsOverrides && !await confirm({ title: t("cvEditorChangeTemplateTitle"), message: t("cvEditorChangeTemplateMessage"), confirmLabel: t("cvEditorApplyTemplate") })) return;
|
||||
update(next);
|
||||
};
|
||||
const toggleSidebarSection = (key: string) => update({ sidebarSections: sidebarSections.includes(key) ? sidebarSections.filter(item => item !== key) : [...sidebarSections, key] });
|
||||
const defaultOption: [string, string] = ["", t("cvEditorTemplateDefault")];
|
||||
const fonts: [string, string][] = [defaultOption, ...FONTS.map((font, index): [string, string] => [font, FONT_LABELS[index]])];
|
||||
|
||||
return (
|
||||
<Stack spacing={2}>
|
||||
{mode === "template" && <Box>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 800, mb: 1 }}>{t("cvEditorTheme")}</Typography>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: "block", mb: 1.25 }}>{t("cvEditorThemeHelp")}</Typography>
|
||||
<CvTemplateGallery themes={themes} selectedId={settings.themeId} onSelect={(theme) => update(applyCvTheme(settings, theme.id))} />
|
||||
</Box>}
|
||||
if (mode === "template") return <Stack spacing={1.5}>
|
||||
<Typography component="h3" variant="subtitle2">{t("cvEditorTemplate")}</Typography>
|
||||
<Typography variant="body2" color="text.secondary">{t("cvEditorThemeHelp")}</Typography>
|
||||
<CvTemplateGallery themes={themes} selectedId={settings.themeId} onSelect={selectTemplate} />
|
||||
</Stack>;
|
||||
|
||||
{mode === "customize" && <>
|
||||
{supports("accent") && <><Typography variant="subtitle2" sx={{ fontWeight: 800 }}>{t("cvEditorColour")}</Typography>
|
||||
return <Stack spacing={2.5}>
|
||||
<Typography variant="body2" color="text.secondary">{t("cvEditorAppearanceHelp")}</Typography>
|
||||
<Stack component="section" aria-labelledby={id + "-style"} spacing={1.5}>
|
||||
<Typography id={id + "-style"} component="h3" variant="subtitle2">{t("cvEditorStyle")}</Typography>
|
||||
{supports("accent") && <Stack spacing={1}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>{t("cvEditorColour")}</Typography>
|
||||
<Stack direction="row" spacing={0.75} flexWrap="wrap" useFlexGap>
|
||||
{["#3157d5", "#0f766e", "#9f1239", "#7c3aed", "#b45309", "#334155"].map((color) => (
|
||||
<IconButton key={color} aria-label={t("cvEditorUseAccent", { colour: color })} onClick={() => update({ accentColor: color })} sx={{ width: 34, height: 34, bgcolor: color, border: settings.accentColor === color ? "3px solid" : "1px solid", borderColor: settings.accentColor === color ? "text.primary" : "divider", "&:hover": { bgcolor: color } }} />
|
||||
))}
|
||||
<Box component="label" sx={{ width: 34, height: 34, borderRadius: "50%", bgcolor: settings.accentColor ?? "#3157d5", border: "2px dashed", borderColor: "text.secondary", display: "grid", placeItems: "center", cursor: "pointer", overflow: "hidden", position: "relative" }}>
|
||||
<input type="color" aria-label={t("cvEditorCustomAccent")} value={settings.accentColor ?? "#3157d5"} onChange={(event) => update({ accentColor: event.target.value })} style={{ position: "absolute", inset: 0, width: "100%", height: "100%", opacity: 0, cursor: "pointer" }} />
|
||||
</Box>
|
||||
{settings.accentColor && <Button size="small" onClick={() => update({ accentColor: null })}>{t("cvEditorThemeDefault")}</Button>}
|
||||
</Stack></>}
|
||||
|
||||
{supports("typography") && <><Typography variant="subtitle2" sx={{ fontWeight: 800, mt: 0.5 }}>{t("cvEditorTypography")}</Typography>
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 1 }}>
|
||||
<FormControl size="small" fullWidth><InputLabel id={`${labelId}-cvEditorHeadingFont`}>{t("cvEditorHeadingFont")}</InputLabel><Select labelId={`${labelId}-cvEditorHeadingFont`} label={t("cvEditorHeadingFont")} value={settings.headingFont ?? ""} onChange={(event) => update({ headingFont: event.target.value || null })}><MenuItem value="">{t("cvEditorThemeDefault")}</MenuItem>{FONTS.map((font, index) => <MenuItem key={font} value={font}>{FONT_LABELS[index]}</MenuItem>)}</Select></FormControl>
|
||||
<FormControl size="small" fullWidth><InputLabel id={`${labelId}-cvEditorBodyFont`}>{t("cvEditorBodyFont")}</InputLabel><Select labelId={`${labelId}-cvEditorBodyFont`} label={t("cvEditorBodyFont")} value={settings.bodyFont ?? ""} onChange={(event) => update({ bodyFont: event.target.value || null })}><MenuItem value="">{t("cvEditorThemeDefault")}</MenuItem>{FONTS.map((font, index) => <MenuItem key={font} value={font}>{FONT_LABELS[index]}</MenuItem>)}</Select></FormControl>
|
||||
{["#3157d5", "#0f766e", "#9f1239", "#7c3aed", "#b45309", "#334155"].map(color => <IconButton key={color}
|
||||
aria-label={t("cvEditorUseAccent", { colour: color })} aria-pressed={settings.accentColor === color}
|
||||
onClick={() => update({ accentColor: color })} sx={{ width: 40, height: 40, border: "1px solid", borderColor: settings.accentColor === color ? "text.primary" : "divider" }}>
|
||||
<Box sx={{ width: 22, height: 22, borderRadius: "50%", bgcolor: color }} />
|
||||
{settings.accentColor === color && <CheckIcon sx={{ position: "absolute", right: -2, bottom: -2, fontSize: 16, bgcolor: "background.paper", color: "text.primary", borderRadius: "50%" }} />}
|
||||
</IconButton>)}
|
||||
</Stack>
|
||||
<TextField size="small" type="color" label={t("cvEditorCustomAccent")} value={settings.accentColor ?? activeTheme?.accent ?? "#3157d5"}
|
||||
onChange={event => update({ accentColor: event.target.value })} slotProps={{ inputLabel: { shrink: true }, htmlInput: { "aria-label": t("cvEditorCustomAccent"), style: { height: 28, cursor: "pointer" } } }} />
|
||||
<Typography variant="caption" color="text.secondary">{t("cvEditorAccentHelp")}</Typography>
|
||||
{settings.accentColor && <Button size="small" sx={{ alignSelf: "flex-start" }} aria-label={t("cvEditorUseTemplateSetting", { setting: t("cvEditorColour") })} onClick={() => update({ accentColor: null })}>{t("cvEditorTemplateDefault")}</Button>}
|
||||
</Stack>}
|
||||
{supports("typography") && <>
|
||||
<Box sx={CONTROL_GRID}>
|
||||
<ControlSelect label={t("cvEditorHeadingFont")} value={settings.headingFont ?? ""} options={fonts} onChange={value => update({ headingFont: value || null })} />
|
||||
<ControlSelect label={t("cvEditorBodyFont")} value={settings.bodyFont ?? ""} options={fonts} onChange={value => update({ bodyFont: value || null })} />
|
||||
<ControlNumber label={t("cvEditorBodySize")} value={settings.baseFontSizePt} min={7} max={13} step={0.25} unit="pt" onChange={value => update({ baseFontSizePt: value })} />
|
||||
<ControlNumber label={t("cvEditorHeadingSize")} value={settings.headingSizePt} min={9} max={20} step={0.5} unit="pt" onChange={value => update({ headingSizePt: value })} />
|
||||
</Box>
|
||||
<ControlSlider label={t("cvEditorBodySize")} value={settings.baseFontSizePt ?? 10} min={7} max={13} step={0.25} suffix="pt" onChange={(value) => update({ baseFontSizePt: value })} />
|
||||
<ControlSlider label={t("cvEditorHeadingSize")} value={settings.headingSizePt ?? 12} min={9} max={20} step={0.5} suffix="pt" onChange={(value) => update({ headingSizePt: value })} />
|
||||
<ControlSlider label={t("cvEditorLineHeight")} value={settings.lineHeight ?? 1.42} min={1.1} max={1.8} step={0.02} onChange={(value) => update({ lineHeight: value })} /></>}
|
||||
{supports("heading") && <FormControl size="small" fullWidth><InputLabel id={`${labelId}-cvEditorHeadingTreatment`}>{t("cvEditorHeadingTreatment")}</InputLabel><Select labelId={`${labelId}-cvEditorHeadingTreatment`} label={t("cvEditorHeadingTreatment")} value={settings.headingStyle ?? ""} onChange={(event) => update({ headingStyle: (event.target.value || null) as CvVariantSettings["headingStyle"] })}><MenuItem value="">{t("cvEditorTemplateDefault")}</MenuItem><MenuItem value="caps-rule">{t("cvEditorUppercaseDivider")}</MenuItem><MenuItem value="underline">{t("richTextUnderline")}</MenuItem><MenuItem value="plain">{t("cvEditorPlain")}</MenuItem><MenuItem value="bar">{t("cvEditorAccentBar")}</MenuItem></Select></FormControl>}
|
||||
{supports("header") && <FormControl size="small" fullWidth><InputLabel id={`${labelId}-cvEditorHeaderTreatment`}>{t("cvEditorHeaderTreatment")}</InputLabel><Select labelId={`${labelId}-cvEditorHeaderTreatment`} label={t("cvEditorHeaderTreatment")} value={settings.headerStyle ?? ""} onChange={(event) => update({ headerStyle: (event.target.value || null) as CvVariantSettings["headerStyle"] })}><MenuItem value="">{t("cvEditorTemplateDefault")}</MenuItem><MenuItem value="plain">{t("cvEditorPlain")}</MenuItem><MenuItem value="band">{t("cvEditorColourBand")}</MenuItem><MenuItem value="centered">{t("cvEditorCentred")}</MenuItem><MenuItem value="kicker">{t("cvEditorEditorial")}</MenuItem></Select></FormControl>}
|
||||
{supports("skills") && <><FormControl size="small" fullWidth><InputLabel>{t("cvEditorSkillsPresentation")}</InputLabel><Select inputProps={{ "aria-label": t("cvEditorSkillsPresentation") }} label={t("cvEditorSkillsPresentation")} value={settings.skillsStyle ?? "tags"} onChange={(event) => update({ skillsStyle: event.target.value as CvVariantSettings["skillsStyle"] })}><MenuItem value="tags">{t("cvEditorTags")}</MenuItem><MenuItem value="text">{t("cvEditorCompactText")}</MenuItem><MenuItem value="bullets">{t("cvEditorBulletList")}</MenuItem><MenuItem value="grouped">{t("cvEditorGroupedSkills")}</MenuItem></Select></FormControl>
|
||||
{settings.skillsStyle === "grouped" && <TextField label={t("cvEditorSkillGroups")} helperText={t("cvEditorSkillGroupsHelp")} multiline minRows={3} value={(settings.skillGroups ?? []).map((group) => `${group.name}: ${group.items.join(", ")}`).join("\n")} onChange={(event) => update({ skillGroups: event.target.value.split("\n").filter(Boolean).map((line) => { const [name, ...items] = line.split(":"); return { name: name.trim(), items: items.join(":").split(",").map((item) => item.trim()).filter(Boolean) }; }) })} />}</>}
|
||||
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>{t("cvEditorDocument")}</Typography>
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 1 }}>
|
||||
{supports("page") && <>
|
||||
<FormControl size="small" fullWidth><InputLabel>{t("cvEditorPageSize")}</InputLabel><Select inputProps={{ "aria-label": t("cvEditorPageSize") }} label={t("cvEditorPageSize")} value={settings.pageSize ?? "a4"} onChange={(event) => update({ pageSize: event.target.value })}><MenuItem value="a4">A4</MenuItem><MenuItem value="letter">{t("cvEditorUsLetter")}</MenuItem></Select></FormControl>
|
||||
<FormControl size="small" fullWidth><InputLabel>{t("cvEditorDensity")}</InputLabel><Select inputProps={{ "aria-label": t("cvEditorDensity") }} label={t("cvEditorDensity")} value={settings.density ?? "balanced"} onChange={(event) => update({ density: event.target.value })}><MenuItem value="compact">{t("cvEditorCompact")}</MenuItem><MenuItem value="balanced">{t("cvEditorBalanced")}</MenuItem><MenuItem value="roomy">{t("cvEditorRoomy")}</MenuItem></Select></FormControl>
|
||||
</>}
|
||||
<FormControl size="small" fullWidth><InputLabel>{t("cvEditorDocumentLanguage")}</InputLabel><Select inputProps={{ "aria-label": t("cvEditorDocumentLanguage") }} label={t("cvEditorDocumentLanguage")} value={normalizeCvLanguage(settings.language)} onChange={(event) => update({ language: event.target.value })}><MenuItem value="en">{t("languageEnglish")}</MenuItem><MenuItem value="nb-NO">{t("languageNorwegianBokmal")}</MenuItem></Select></FormControl>
|
||||
<FormControl size="small" fullWidth><InputLabel>{t("cvEditorDateFormat")}</InputLabel><Select inputProps={{ "aria-label": t("cvEditorDateFormat") }} label={t("cvEditorDateFormat")} value={settings.dateFormat ?? "short"} onChange={(event) => update({ dateFormat: event.target.value })}><MenuItem value="long">{t("cvEditorDateLongExample")}</MenuItem><MenuItem value="short">{t("cvEditorDateShortExample")}</MenuItem><MenuItem value="numeric">01/2020</MenuItem><MenuItem value="year">2020</MenuItem></Select></FormControl>
|
||||
</Box>
|
||||
{supports("layout") && <FormControl size="small" fullWidth><InputLabel>{t("cvEditorColumns")}</InputLabel><Select inputProps={{ "aria-label": t("cvEditorColumns") }} label={t("cvEditorColumns")} value={settings.layout ?? ""} onChange={(event) => update({ layout: (event.target.value || null) as CvVariantSettings["layout"] })}><MenuItem value="">{t("cvEditorTemplateDefault")}</MenuItem><MenuItem value="single">{t("cvEditorOneColumn")}</MenuItem><MenuItem value="header-band">{t("cvEditorHeaderBandColumn")}</MenuItem><MenuItem value="sidebar-left">{t("cvEditorLeftSidebar")}</MenuItem><MenuItem value="sidebar-right">{t("cvEditorRightSidebar")}</MenuItem></Select></FormControl>}
|
||||
{supports("spacing") && <>
|
||||
<ControlSlider label={t("cvEditorPageMargins")} value={settings.pageMarginMm ?? 16} min={8} max={28} step={1} suffix="mm" onChange={(value) => update({ pageMarginMm: value })} />
|
||||
<ControlSlider label={t("cvEditorSectionSpacing")} value={settings.sectionGapMm ?? 6} min={2} max={14} step={0.5} suffix="mm" onChange={(value) => update({ sectionGapMm: value })} />
|
||||
<ControlSlider label={t("cvEditorEntrySpacing")} value={settings.entryGapMm ?? 4.5} min={1} max={10} step={0.5} suffix="mm" onChange={(value) => update({ entryGapMm: value })} />
|
||||
</>}
|
||||
{supports("sidebar") && (settings.layout === "sidebar-left" || settings.layout === "sidebar-right") && <Paper variant="outlined" sx={{ p: 1.5 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 700 }}>{t("cvEditorSidebarContent")}</Typography>
|
||||
<ControlSlider label={t("cvEditorSidebarWidth")} value={settings.sidebarWidthMm ?? 62} min={45} max={85} step={1} suffix="mm" onChange={(value) => update({ sidebarWidthMm: value })} />
|
||||
<Stack>{["contact", "skills", "languages", "certifications", "projects", "interests"].map((key) => <FormControlLabel key={key} control={<Switch size="small" checked={sidebarSections.includes(key)} onChange={() => toggleSidebarSection(key)} />} label={key === "contact" ? t("cvEditorContactDetails") : sectionLabel(key, SECTION_LABELS[key] ?? key)} />)}</Stack>
|
||||
</Paper>}
|
||||
<Divider />
|
||||
{supports("photo") && <FormControlLabel control={<Switch checked={settings.showPhoto} onChange={(event) => update({ showPhoto: event.target.checked })} />} label={t("cvEditorShowPhoto")} />}
|
||||
{supports("icons") && <FormControlLabel control={<Switch checked={settings.showIcons} onChange={(event) => update({ showIcons: event.target.checked })} />} label={t("cvEditorContactIcons")} />}
|
||||
<Typography variant="caption" color="text.secondary">{t("cvEditorFontHelp")}</Typography>
|
||||
</>}
|
||||
</Stack>
|
||||
);
|
||||
|
||||
<Stack component="section" aria-labelledby={id + "-layout"} spacing={1.5}>
|
||||
<Typography id={id + "-layout"} component="h3" variant="subtitle2">{t("cvEditorLayout")}</Typography>
|
||||
{supports("page") && <ControlSelect label={t("cvEditorPageSize")} value={settings.pageSize ?? "a4"} options={[["a4", "A4"], ["letter", t("cvEditorUsLetter")]]} onChange={pageSize => update({ pageSize })} />}
|
||||
{supports("spacing") && <>
|
||||
<Typography variant="caption" color="text.secondary">{t("cvEditorMarginHelp")}</Typography>
|
||||
<Box sx={CONTROL_GRID}>
|
||||
<ControlNumber label={t("cvEditorPageMargins")} value={settings.pageMarginMm} min={8} max={28} step={1} unit="mm" onChange={value => update({ pageMarginMm: value })} />
|
||||
<ControlNumber label={t("cvEditorSectionSpacing")} value={settings.sectionGapMm} min={2} max={14} step={0.5} unit="mm" onChange={value => update({ sectionGapMm: value })} />
|
||||
<ControlNumber label={t("cvEditorEntrySpacing")} value={settings.entryGapMm} min={1} max={10} step={0.5} unit="mm" onChange={value => update({ entryGapMm: value })} />
|
||||
</Box>
|
||||
</>}
|
||||
</Stack>
|
||||
|
||||
<Accordion disableGutters elevation={0} sx={{ border: "1px solid", borderColor: "divider", "&:before": { display: "none" } }}>
|
||||
<AccordionSummary id={id + "-advanced"} aria-controls={id + "-advanced-content"} expandIcon={<ExpandMoreIcon />}>
|
||||
<Typography component="h3" variant="body2" sx={{ fontWeight: 600 }}>{t("cvEditorAdvancedSettings")}</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails><Stack spacing={2}>
|
||||
<Typography variant="body2" color="text.secondary">{t("cvEditorAdvancedHelp")}</Typography>
|
||||
{activeTheme?.supportedSettings?.length ? <Typography variant="caption" color="text.secondary">{t("cvEditorTemplateCompatibility")}</Typography> : null}
|
||||
<Box sx={CONTROL_GRID}>
|
||||
{supports("typography") && <ControlNumber label={t("cvEditorLineHeight")} value={settings.lineHeight} min={1.1} max={1.8} step={0.02} onChange={value => update({ lineHeight: value })} />}
|
||||
{supports("heading") && <ControlSelect label={t("cvEditorHeadingTreatment")} value={settings.headingStyle ?? ""} options={[defaultOption, ["caps-rule", t("cvEditorUppercaseDivider")], ["underline", t("richTextUnderline")], ["plain", t("cvEditorPlain")], ["bar", t("cvEditorAccentBar")]]} onChange={value => update({ headingStyle: (value || null) as CvVariantSettings["headingStyle"] })} />}
|
||||
{supports("header") && <ControlSelect label={t("cvEditorHeaderTreatment")} value={settings.headerStyle ?? ""} options={[defaultOption, ["plain", t("cvEditorPlain")], ["band", t("cvEditorColourBand")], ["centered", t("cvEditorCentred")], ["kicker", t("cvEditorEditorial")]]} onChange={value => update({ headerStyle: (value || null) as CvVariantSettings["headerStyle"] })} />}
|
||||
{supports("skills") && <ControlSelect label={t("cvEditorSkillsPresentation")} value={settings.skillsStyle ?? ""} options={[defaultOption, ["tags", t("cvEditorTags")], ["text", t("cvEditorCompactText")], ["bullets", t("cvEditorBulletList")], ["grouped", t("cvEditorGroupedSkills")]]} onChange={value => update({ skillsStyle: (value || null) as CvVariantSettings["skillsStyle"] })} />}
|
||||
{supports("page") && <ControlSelect label={t("cvEditorDensity")} value={settings.density ?? "balanced"} options={[["compact", t("cvEditorCompact")], ["balanced", t("cvEditorBalanced")], ["roomy", t("cvEditorRoomy")]]} onChange={density => update({ density })} />}
|
||||
<ControlSelect label={t("cvEditorDocumentLanguage")} value={normalizeCvLanguage(settings.language)} options={[["en", t("languageEnglish")], ["nb-NO", t("languageNorwegianBokmal")]]} onChange={language => update({ language })} />
|
||||
<ControlSelect label={t("cvEditorDateFormat")} value={settings.dateFormat ?? ""} options={[["", t("cvEditorSourceDates")], ["long", t("cvEditorDateLongExample")], ["short", t("cvEditorDateShortExample")], ["numeric", "01/2020"], ["year", "2020"]]} onChange={dateFormat => update({ dateFormat: dateFormat || null })} />
|
||||
{supports("layout") && <ControlSelect label={t("cvEditorColumns")} value={settings.layout ?? ""} options={[defaultOption, ["single", t("cvEditorOneColumn")], ["header-band", t("cvEditorHeaderBandColumn")], ["sidebar-left", t("cvEditorLeftSidebar")], ["sidebar-right", t("cvEditorRightSidebar")]]} onChange={value => update({ layout: (value || null) as CvVariantSettings["layout"] })} />}
|
||||
</Box>
|
||||
{supports("skills") && (settings.skillsStyle === "grouped" || !!settings.skillGroups?.length) && <TextField size="small" label={t("cvEditorSkillGroups")} helperText={t("cvEditorSkillGroupsHelp") + " " + t("cvEditorSkillGroupsOverride")} multiline minRows={3}
|
||||
value={(settings.skillGroups ?? []).map(group => group.name + ": " + group.items.join(", ")).join("\n")}
|
||||
onChange={event => update({ skillGroups: event.target.value.split("\n").filter(Boolean).map(line => { const [name, ...items] = line.split(":"); return { name: name.trim(), items: items.join(":").split(",").map(item => item.trim()).filter(Boolean) }; }) })} />}
|
||||
{supports("sidebar") && (layout === "sidebar-left" || layout === "sidebar-right") && <Stack spacing={1}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>{t("cvEditorSidebarContent")}</Typography>
|
||||
<ControlNumber label={t("cvEditorSidebarWidth")} value={settings.sidebarWidthMm} min={45} max={85} step={1} unit="mm" onChange={value => update({ sidebarWidthMm: value })} />
|
||||
{settings.sidebarSections == null ? <>
|
||||
<Typography variant="caption" color="text.secondary">{t("cvEditorDefaultValue")}. {t("cvEditorSidebarDefaultHelp")}</Typography>
|
||||
<Button size="small" sx={{ alignSelf: "flex-start" }} onClick={() => update({ sidebarSections })}>{t("cvEditorCustomizeSidebar")}</Button>
|
||||
</> : <>
|
||||
<Box sx={CONTROL_GRID}>{["contact", "skills", "languages", "certifications", "projects", "interests"].map(key => <FormControlLabel key={key} control={<Switch size="small" checked={sidebarSections.includes(key)} onChange={() => toggleSidebarSection(key)} />} label={key === "contact" ? t("cvEditorContactDetails") : sectionLabel(key, SECTION_LABELS[key] ?? key)} />)}</Box>
|
||||
<Button size="small" sx={{ alignSelf: "flex-start" }} aria-label={t("cvEditorUseTemplateSetting", { setting: t("cvEditorSidebarContent") })} onClick={() => update({ sidebarSections: null })}>{t("cvEditorTemplateDefault")}</Button>
|
||||
</>}
|
||||
</Stack>}
|
||||
<Stack>
|
||||
{supports("photo") && <FormControlLabel control={<Switch checked={settings.showPhoto} onChange={event => update({ showPhoto: event.target.checked })} />} label={t("cvEditorShowPhoto")} />}
|
||||
{supports("icons") && <FormControlLabel control={<Switch checked={settings.showIcons} onChange={event => update({ showIcons: event.target.checked })} />} label={t("cvEditorContactIcons")} />}
|
||||
</Stack>
|
||||
</Stack></AccordionDetails>
|
||||
</Accordion>
|
||||
</Stack>;
|
||||
}
|
||||
|
||||
function ControlSlider({ label, value, min, max, step, suffix = "", onChange }: { label: string; value: number; min: number; max: number; step: number; suffix?: string; onChange: (value: number) => void }) {
|
||||
return <Box><Stack direction="row" justifyContent="space-between" alignItems="baseline"><Typography variant="body2">{label}</Typography><Typography variant="caption" color="text.secondary">{Number(value.toFixed(2))}{suffix}</Typography></Stack><Slider size="small" aria-label={label} value={value} min={min} max={max} step={step} onChange={(_, next) => onChange(next as number)} /></Box>;
|
||||
function ControlSelect({ label, value, options, onChange }: { label: string; value: string; options: [string, string][]; onChange: (value: string) => void }) {
|
||||
const id = useId();
|
||||
const { t } = useI18n();
|
||||
return <FormControl size="small" fullWidth>
|
||||
<InputLabel id={id} shrink>{label}</InputLabel>
|
||||
<Select displayEmpty labelId={id} label={label} value={value} onChange={event => onChange(event.target.value)}>
|
||||
{!options.some(([key]) => key === value) && <MenuItem value={value}>{t("cvEditorSavedValue", { value })}</MenuItem>}
|
||||
{options.map(([key, name]) => <MenuItem key={key} value={key}>{name}</MenuItem>)}
|
||||
</Select>
|
||||
</FormControl>;
|
||||
}
|
||||
|
||||
function ControlNumber({ label, value, min, max, step, unit = "", onChange }: { label: string; value?: number | null; min: number; max: number; step: number; unit?: string; onChange: (value: number | null) => void }) {
|
||||
const { t } = useI18n();
|
||||
const [draft, setDraft] = useState(value?.toString() ?? "");
|
||||
const [invalid, setInvalid] = useState(false);
|
||||
useEffect(() => { setDraft(value?.toString() ?? ""); setInvalid(false); }, [value]);
|
||||
return <Box sx={{ minWidth: 0 }}>
|
||||
<TextField fullWidth size="small" type="number" label={label} value={draft} placeholder={t("cvEditorTemplateDefault")} error={invalid}
|
||||
helperText={invalid ? t("cvEditorNumberRange", { min, max, step }) : value == null ? t("cvEditorDefaultValue") : " "}
|
||||
slotProps={{ inputLabel: { shrink: true }, htmlInput: { min, max, step }, input: { endAdornment: unit ? <InputAdornment position="end">{unit}</InputAdornment> : undefined } }}
|
||||
onChange={event => {
|
||||
const input = event.target as HTMLInputElement;
|
||||
setDraft(input.value);
|
||||
const number = Number(input.value);
|
||||
const steps = (number - min) / step;
|
||||
const valid = !input.validity.badInput && (input.value === "" || (Number.isFinite(number) && number >= min && number <= max && Math.abs(steps - Math.round(steps)) < 1e-8));
|
||||
setInvalid(!valid);
|
||||
if (valid) onChange(input.value === "" ? null : number);
|
||||
}} />
|
||||
{value != null && <Button size="small" aria-label={t("cvEditorUseTemplateSetting", { setting: label })} onClick={() => onChange(null)}>{t("cvEditorTemplateDefault")}</Button>}
|
||||
</Box>;
|
||||
}
|
||||
|
||||
@@ -355,8 +355,9 @@ test('Content is the primary studio panel and all advanced tabs retain their con
|
||||
expect(screen.getByText('This CV only')).toBeInTheDocument();
|
||||
expect(screen.getByRole('link', { name: 'Linked application' })).toHaveAttribute('href', '/jobs/7');
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Customize' }));
|
||||
expect(screen.getByText('Typography')).toBeInTheDocument();
|
||||
expect(screen.getByRole('region', { name: 'Style' })).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Body size')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Advanced document settings' }));
|
||||
expect(screen.getByLabelText('Skills presentation')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Page size')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Columns')).toBeInTheDocument();
|
||||
@@ -399,6 +400,8 @@ test('choosing a template restores its own structure instead of inheriting the p
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Template' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'All templates' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /Technical/ }));
|
||||
fireEvent.click(within(await screen.findByRole('dialog', { name: 'Apply template defaults?' })).getByRole('button', { name: 'Apply template' }));
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save now' }));
|
||||
|
||||
await waitFor(() => expect(mockedApi.put).toHaveBeenCalledWith('/cv/variants/3', expect.objectContaining({
|
||||
@@ -426,7 +429,8 @@ test('editor sections, design controls and document settings render in Bokmål',
|
||||
expect(screen.getByRole('button', { name: 'Utvid Erfaring' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Legg til innhold' })).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Tilpass' }));
|
||||
expect(screen.getByText('Typografi')).toBeInTheDocument();
|
||||
expect(screen.getByRole('region', { name: 'Stil' })).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Avanserte dokumentinnstillinger' }));
|
||||
expect(screen.getByLabelText('Visning av ferdigheter')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Sidestørrelse')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Dokumentspråk')).toBeInTheDocument();
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import React, { useState } from "react";
|
||||
import "@testing-library/jest-dom";
|
||||
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import CvCustomizeTab from "./components/cv/CvCustomizeTab";
|
||||
import { applyCvTheme, CvTheme, CvVariantSettings, emptyCvVariantSettings } from "./cvBuilder";
|
||||
import { ConfirmProvider } from "./confirm";
|
||||
import { I18nProvider } from "./i18n/I18nProvider";
|
||||
|
||||
const themes: CvTheme[] = [
|
||||
{ id: "modern", name: "Modern", category: "Professional", description: "Default", layout: "header-band", accent: "#3157d5", photoShape: "circle", supportsIcons: true, atsFriendly: true, requiresPro: false, available: true, swatches: [] },
|
||||
{ id: "code", name: "Code", category: "Technical", description: "Text first", layout: "single", accent: "#334155", photoShape: "none", supportsIcons: false, atsFriendly: true, requiresPro: false, available: true, swatches: [], supportedSettings: ["accent", "typography", "spacing", "skills", "page"] },
|
||||
{ id: "technical", name: "Technical", category: "Technical", description: "Sidebar", layout: "sidebar-left", accent: "#0f766e", photoShape: "circle", supportsIcons: true, atsFriendly: true, requiresPro: true, available: false, swatches: [] },
|
||||
];
|
||||
|
||||
beforeEach(() => localStorage.clear());
|
||||
|
||||
function mount(initial = emptyCvVariantSettings(), mode: "customize" | "template" = "customize", availableThemes = themes) {
|
||||
const update = jest.fn();
|
||||
let current = initial;
|
||||
function Harness() {
|
||||
const [settings, setSettings] = useState(initial);
|
||||
current = settings;
|
||||
return <CvCustomizeTab mode={mode} settings={settings} themes={availableThemes} sectionLabel={(_, fallback) => fallback} update={patch => { update(patch); setSettings(previous => ({ ...previous, ...patch })); }} />;
|
||||
}
|
||||
render(<I18nProvider><ConfirmProvider><Harness /></ConfirmProvider></I18nProvider>);
|
||||
return { update, settings: () => current };
|
||||
}
|
||||
|
||||
async function choose(label: string, option: string) {
|
||||
fireEvent.mouseDown(screen.getByRole("combobox", { name: label }));
|
||||
fireEvent.click(await screen.findByRole("option", { name: option }));
|
||||
}
|
||||
|
||||
test("[PRES-C08 C09 E08] common controls and advanced disclosure do not mutate document settings", () => {
|
||||
const { update } = mount();
|
||||
expect(screen.getByRole("region", { name: "Style" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("region", { name: "Layout" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("spinbutton", { name: "Page margins" })).toHaveValue(null);
|
||||
expect(screen.getByRole("combobox", { name: "Page size" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("combobox", { name: "Heading font" })).toHaveTextContent("Template default");
|
||||
const advanced = screen.getByRole("button", { name: "Advanced document settings" });
|
||||
expect(advanced).toHaveAttribute("aria-expanded", "false");
|
||||
fireEvent.click(advanced);
|
||||
expect(advanced).toHaveAttribute("aria-expanded", "true");
|
||||
expect(screen.getByRole("combobox", { name: "Heading treatment" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("switch", { name: "Show profile photo" })).toBeInTheDocument();
|
||||
expect(update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("[PRES-C09 C10] numeric edits reject invalid ranges, keep exact valid values and reset only one setting", () => {
|
||||
const initial = { ...emptyCvVariantSettings(), sectionGapMm: 7, pageMarginMm: 16 };
|
||||
const { update, settings } = mount(initial);
|
||||
const margins = screen.getByRole("spinbutton", { name: "Page margins" });
|
||||
fireEvent.change(margins, { target: { value: "29" } });
|
||||
expect(margins).toHaveAttribute("aria-invalid", "true");
|
||||
expect(update).not.toHaveBeenCalled();
|
||||
expect(settings().pageMarginMm).toBe(16);
|
||||
fireEvent.change(margins, { target: { value: "20" } });
|
||||
expect(update).toHaveBeenLastCalledWith({ pageMarginMm: 20 });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Use template default: Page margins" }));
|
||||
expect(update).toHaveBeenLastCalledWith({ pageMarginMm: null });
|
||||
expect(margins).toHaveValue(null);
|
||||
expect(settings().sectionGapMm).toBe(7);
|
||||
fireEvent.change(margins, { target: { value: "18" } });
|
||||
fireEvent.change(margins, { target: { value: "" } });
|
||||
expect(update).toHaveBeenLastCalledWith({ pageMarginMm: null });
|
||||
});
|
||||
|
||||
test("[PRES-C07 C08 C09] capability filtering preserves dormant saved values and exposes inherited sidebar controls", async () => {
|
||||
const initial = { ...emptyCvVariantSettings(), themeId: "technical", layout: null, headerStyle: "centered" as const, backgroundColor: "#eeeeee", overrides: { experience: { title: "CV only" } } };
|
||||
const { update, settings } = mount(initial);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Advanced document settings" }));
|
||||
expect(screen.getByRole("spinbutton", { name: "Sidebar width" })).toHaveValue(null);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Choose sidebar content" }));
|
||||
expect(update).toHaveBeenLastCalledWith({ sidebarSections: ["contact", "skills", "languages"] });
|
||||
fireEvent.click(screen.getByRole("switch", { name: "Projects" }));
|
||||
expect(update).toHaveBeenLastCalledWith({ sidebarSections: ["contact", "skills", "languages", "projects"] });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Use template default: Sidebar content" }));
|
||||
expect(settings().sidebarSections).toBeNull();
|
||||
await choose("Body font", "Arial");
|
||||
expect(update).toHaveBeenLastCalledWith({ bodyFont: "Arial, Helvetica, sans-serif" });
|
||||
expect(settings().headerStyle).toBe("centered");
|
||||
expect(settings().backgroundColor).toBe("#eeeeee");
|
||||
expect(settings().overrides).toEqual(initial.overrides);
|
||||
await choose("Skills presentation", "Grouped skills");
|
||||
fireEvent.change(screen.getByRole("textbox", { name: "Skill groups" }), { target: { value: "Språk: Norsk, English" } });
|
||||
expect(update).toHaveBeenLastCalledWith({ skillGroups: [{ name: "Språk", items: ["Norsk", "English"] }] });
|
||||
await choose("Skills presentation", "Bullet list");
|
||||
expect(screen.getByRole("textbox", { name: "Skill groups" })).toHaveValue("Språk: Norsk, English");
|
||||
fireEvent.click(screen.getByRole("switch", { name: "Show profile photo" }));
|
||||
expect(update).toHaveBeenLastCalledWith({ showPhoto: true });
|
||||
});
|
||||
|
||||
test("[PRES-C08 C10 E09] locks prevent selection and confirmed template resets preserve content and document metadata", async () => {
|
||||
const initial = { ...emptyCvVariantSettings(), accentColor: "#9f1239", pageMarginMm: 20, pageSize: "letter", language: "nb-NO", overrides: { experience: { title: "CV only" } }, skillGroups: [{ name: "Skills", items: ["C#"] }] };
|
||||
const { update, settings } = mount(initial, "template");
|
||||
const locked = screen.getByRole("button", { name: "Technical" });
|
||||
expect(locked).toHaveAttribute("aria-disabled", "true");
|
||||
expect(locked).toHaveAccessibleDescription("Technical Sidebar ATS-friendly Pro");
|
||||
fireEvent.click(locked);
|
||||
fireEvent.keyDown(locked, { key: "Enter" });
|
||||
expect(update).not.toHaveBeenCalled();
|
||||
fireEvent.keyDown(screen.getByRole("button", { name: "Modern" }), { key: " " });
|
||||
fireEvent.click(within(await screen.findByRole("dialog", { name: "Apply template defaults?" })).getByRole("button", { name: "Cancel" }));
|
||||
await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
|
||||
expect(update).not.toHaveBeenCalled();
|
||||
fireEvent.keyDown(screen.getByRole("button", { name: "Code" }), { key: "Enter" });
|
||||
const dialog = await screen.findByRole("dialog", { name: "Apply template defaults?" });
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "Cancel" }));
|
||||
await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
|
||||
expect(update).not.toHaveBeenCalled();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Code" }));
|
||||
fireEvent.click(within(await screen.findByRole("dialog", { name: "Apply template defaults?" })).getByRole("button", { name: "Apply template" }));
|
||||
await waitFor(() => expect(update).toHaveBeenLastCalledWith(applyCvTheme(initial, "code")));
|
||||
expect(settings().overrides).toEqual(initial.overrides);
|
||||
expect(settings().pageSize).toBe("letter");
|
||||
expect(settings().language).toBe("nb-NO");
|
||||
expect(settings().skillGroups).toEqual(initial.skillGroups);
|
||||
});
|
||||
|
||||
test("[PRES-C08 C09 E08] Bokmål controls expose their scope, values and non-colour selection state", () => {
|
||||
localStorage.setItem("uiLanguage", "nb");
|
||||
const { update } = mount({ ...emptyCvVariantSettings(), accentColor: "#3157d5" });
|
||||
expect(screen.getByRole("region", { name: "Stil" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("region", { name: "Oppsett" })).toBeInTheDocument();
|
||||
const accent = screen.getByRole("button", { name: "Bruk aksentfargen #3157d5" });
|
||||
expect(accent).toHaveAttribute("aria-pressed", "true");
|
||||
expect(screen.getByLabelText("Egendefinert aksentfarge")).toHaveValue("#3157d5");
|
||||
fireEvent.click(screen.getByRole("button", { name: "Avanserte dokumentinnstillinger" }));
|
||||
expect(update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("[PRES-C08 C09] unsupported and legacy saved values survive unrelated edits without implicit normalization", async () => {
|
||||
const initial = { ...emptyCvVariantSettings(), themeId: "code", headerStyle: "centered" as const, layout: "sidebar-left" as const, pageMarginMm: 15.24, pageSize: "legacy-paper", backgroundColor: "#eeeeee" };
|
||||
const { update, settings } = mount(initial);
|
||||
expect(screen.getByRole("spinbutton", { name: "Page margins" })).toHaveValue(15.24);
|
||||
expect(screen.getByRole("combobox", { name: "Page size" })).toHaveTextContent("Saved value: legacy-paper");
|
||||
fireEvent.click(screen.getByRole("button", { name: "Advanced document settings" }));
|
||||
expect(screen.queryByRole("combobox", { name: "Header treatment" })).not.toBeInTheDocument();
|
||||
expect(update).not.toHaveBeenCalled();
|
||||
await choose("Body font", "Arial");
|
||||
expect(update).toHaveBeenCalledTimes(1);
|
||||
expect(settings()).toEqual({ ...initial, bodyFont: "Arial, Helvetica, sans-serif" });
|
||||
});
|
||||
@@ -680,6 +680,25 @@ export const translations = {
|
||||
cvSectionDeclaration: "Declaration",
|
||||
cvEditorTheme: "Theme",
|
||||
cvEditorThemeHelp: "Templates change presentation only. Your content and hidden-section choices stay intact.",
|
||||
cvEditorStyle: "Style",
|
||||
cvEditorAppearanceHelp: "These settings affect this CV only. Unchanged values use the template defaults.",
|
||||
cvEditorAdvancedSettings: "Advanced document settings",
|
||||
cvEditorAdvancedHelp: "Fine-tune headings, document format and template-specific details.",
|
||||
cvEditorDefaultValue: "Uses the template default",
|
||||
cvEditorUseTemplateSetting: "Use template default: {setting}",
|
||||
cvEditorNumberRange: "Enter {min}–{max} in steps of {step}. Invalid values are not applied.",
|
||||
cvEditorMarginHelp: "Values are in millimetres. Document density scales margins and spacing in the preview and PDF.",
|
||||
cvEditorFontHelp: "Use the existing supported fonts. Check the exported PDF if a font is substituted on another device.",
|
||||
cvEditorTemplateCompatibility: "Only supported controls are shown. Other saved settings remain unchanged.",
|
||||
cvEditorAccentHelp: "The document accent is separate from the application theme.",
|
||||
cvEditorChangeTemplateTitle: "Apply template defaults?",
|
||||
cvEditorChangeTemplateMessage: "This resets your visual customizations to the selected template's defaults, including colour, typography, margins and spacing. Content, inclusion choices, page size and document language remain unchanged. You can undo this before saving.",
|
||||
cvEditorApplyTemplate: "Apply template",
|
||||
cvEditorSavedValue: "Saved value: {value}",
|
||||
cvEditorSkillGroupsOverride: "Non-empty skill groups replace the Skills content, even when another presentation is selected. Clear the groups to use the original content.",
|
||||
cvEditorSourceDates: "Keep source date format",
|
||||
cvEditorCustomizeSidebar: "Choose sidebar content",
|
||||
cvEditorSidebarDefaultHelp: "Choosing content starts with Contact details, Skills and Languages; you can then adjust that selection for this CV.",
|
||||
cvEditorAtsFriendly: "ATS-friendly",
|
||||
cvEditorAtsTemplates: "ATS templates",
|
||||
cvEditorAllTemplates: "All templates",
|
||||
@@ -3239,6 +3258,25 @@ export const translations = {
|
||||
cvSectionDeclaration: "Erklæring",
|
||||
cvEditorTheme: "Tema",
|
||||
cvEditorThemeHelp: "Maler endrer bare presentasjonen. Innholdet og valgene for skjulte seksjoner beholdes.",
|
||||
cvEditorStyle: "Stil",
|
||||
cvEditorAppearanceHelp: "Disse innstillingene gjelder bare denne CV-en. Verdier du ikke endrer, bruker malens standarder.",
|
||||
cvEditorAdvancedSettings: "Avanserte dokumentinnstillinger",
|
||||
cvEditorAdvancedHelp: "Juster overskrifter, dokumentformat og detaljer for malen.",
|
||||
cvEditorDefaultValue: "Bruker malens standard",
|
||||
cvEditorUseTemplateSetting: "Bruk malens standard: {setting}",
|
||||
cvEditorNumberRange: "Skriv {min}–{max} i trinn på {step}. Ugyldige verdier brukes ikke.",
|
||||
cvEditorMarginHelp: "Verdiene er i millimeter. Dokumentets tetthet justerer marger og avstand i forhåndsvisningen og PDF-en.",
|
||||
cvEditorFontHelp: "Bruk skriftene som allerede støttes. Sjekk den eksporterte PDF-en hvis en skrift erstattes på en annen enhet.",
|
||||
cvEditorTemplateCompatibility: "Bare støttede kontroller vises. Andre lagrede innstillinger beholdes.",
|
||||
cvEditorAccentHelp: "Dokumentets aksentfarge er uavhengig av appens tema.",
|
||||
cvEditorChangeTemplateTitle: "Bruke malens standarder?",
|
||||
cvEditorChangeTemplateMessage: "Dette tilbakestiller dine visuelle tilpasninger til malens standarder, inkludert farge, skrift, marger og avstand. Innhold, inkluderte deler, sidestørrelse og dokumentspråk beholdes. Du kan angre før du lagrer.",
|
||||
cvEditorApplyTemplate: "Bruk mal",
|
||||
cvEditorSavedValue: "Lagret verdi: {value}",
|
||||
cvEditorSkillGroupsOverride: "Utfylte ferdighetsgrupper erstatter innholdet i Ferdigheter, også når en annen visning er valgt. Tøm gruppene for å bruke det opprinnelige innholdet.",
|
||||
cvEditorSourceDates: "Behold datoformat fra kilden",
|
||||
cvEditorCustomizeSidebar: "Velg innhold i sidekolonnen",
|
||||
cvEditorSidebarDefaultHelp: "Valget starter med kontaktinformasjon, ferdigheter og språk. Du kan deretter endre utvalget for denne CV-en.",
|
||||
cvEditorAtsFriendly: "ATS-vennlig",
|
||||
cvEditorAtsTemplates: "ATS-maler",
|
||||
cvEditorAllTemplates: "Alle maler",
|
||||
|
||||
@@ -6,6 +6,7 @@ import { translations } from "./i18n/translations";
|
||||
import CvCustomizeTab from "./components/cv/CvCustomizeTab";
|
||||
import RichTextField from "./components/RichTextField";
|
||||
import { emptyCvVariantSettings } from "./cvBuilder";
|
||||
import { ConfirmProvider } from "./confirm";
|
||||
|
||||
beforeEach(() => localStorage.clear());
|
||||
|
||||
@@ -13,7 +14,8 @@ test.each(["en", "nb"] as const)("[PRES-C08 PRES-C09] %s CV selects have visible
|
||||
localStorage.setItem("uiLanguage", language);
|
||||
const t = translations[language];
|
||||
const update = jest.fn();
|
||||
render(<I18nProvider><CvCustomizeTab mode="customize" settings={emptyCvVariantSettings()} update={update} themes={[]} sectionLabel={(_, fallback) => fallback} /></I18nProvider>);
|
||||
render(<I18nProvider><ConfirmProvider><CvCustomizeTab mode="customize" settings={emptyCvVariantSettings()} update={update} themes={[]} sectionLabel={(_, fallback) => fallback} /></ConfirmProvider></I18nProvider>);
|
||||
fireEvent.click(screen.getByRole("button", { name: t.cvEditorAdvancedSettings }));
|
||||
for (const [name, option, patch] of [
|
||||
[t.cvEditorHeadingFont, "Arial", { headingFont: "Arial, Helvetica, sans-serif" }],
|
||||
[t.cvEditorBodyFont, "Georgia (serif)", { bodyFont: "Georgia, 'Times New Roman', serif" }],
|
||||
|
||||
Reference in New Issue
Block a user