fix(cv): make profile saves atomic

This commit is contained in:
cesnimda
2026-08-29 23:12:33 +02:00
parent 487c5f59a9
commit 8288923e5d
8 changed files with 127 additions and 27 deletions
+10 -1
View File
@@ -1,4 +1,4 @@
import { getCvPageCount, getCvPageMetrics, moveItem, wrapSelection } from "./cvBuilder";
import { getCvPageCount, getCvPageMetrics, moveItem, normalizeCvLanguage, wrapSelection } from "./cvBuilder";
describe("moveItem", () => {
test("moves an item forward", () => {
@@ -51,3 +51,12 @@ describe("CV page measurement", () => {
expect(getCvPageCount(heightPx * 2 + 20, heightPx)).toBe(3);
});
});
describe("CV language compatibility", () => {
test("maps legacy Norwegian values to the supported Bokmål locale", () => {
expect(normalizeCvLanguage("no")).toBe("nb-NO");
expect(normalizeCvLanguage("nb")).toBe("nb-NO");
expect(normalizeCvLanguage("nb-NO")).toBe("nb-NO");
expect(normalizeCvLanguage("en")).toBe("en");
});
});
+5
View File
@@ -176,6 +176,11 @@ export function getCvPageCount(contentHeightPx: number, pageHeightPx: number): n
return Math.max(1, Math.ceil(Math.max(0, contentHeightPx - 2) / pageHeightPx));
}
export function normalizeCvLanguage(language?: string | null): string {
const normalized = language?.trim().toLowerCase();
return normalized === "no" || normalized === "nb" || normalized === "nb-no" ? "nb-NO" : normalized || "en";
}
export function emptyCvVariantSettings(themeId = "modern"): CvVariantSettings {
return {
themeId,
+13 -10
View File
@@ -37,7 +37,7 @@ import { useDragReorder } from "../hooks/useDragReorder";
import {
CUSTOM_SECTION_PRESETS, CvCustomSectionSetting, CvItemOverride, CvOutline, CvOutlineSection, CvSectionSetting,
CvTheme, CvVariant, CvVariantSettings, CvVariantVersionInfo, DEFAULT_SECTION_ORDER, SECTION_LABELS,
cvBuilderApi, getCvPageCount, getCvPageMetrics, moveItem,
cvBuilderApi, getCvPageCount, getCvPageMetrics, moveItem, normalizeCvLanguage,
} from "../cvBuilder";
import { useDialogActions } from "../dialogs";
import { useI18n } from "../i18n/I18nProvider";
@@ -575,9 +575,11 @@ function documentAiBlocks(settings: CvVariantSettings, outline: CvOutline | null
if (sectionSetting?.hidden) continue;
const title = sectionSetting?.title ?? section.title;
if (section.kind === "entries") {
const orderedKeys = sectionSetting?.itemOrder?.length
? [...sectionSetting.itemOrder, ...section.entries.map((entry, index) => entry.key ?? `${index}`).filter((key) => !sectionSetting.itemOrder?.includes(key))]
: section.entries.map((entry, index) => entry.key ?? `${index}`);
const entryKeys = Array.from(new Set(section.entries.map((entry, index) => entry.key ?? `${index}`)));
const storedOrder = Array.from(new Set(sectionSetting?.itemOrder ?? []));
const orderedKeys = storedOrder.length
? [...storedOrder.filter((key) => entryKeys.includes(key)), ...entryKeys.filter((key) => !storedOrder.includes(key))]
: entryKeys;
const byKey = new Map(section.entries.map((entry, index) => [entry.key ?? `${index}`, entry]));
for (const key of orderedKeys) {
const entry = byKey.get(key);
@@ -659,11 +661,11 @@ function DocumentAiTab({ settings, outline, update }: {
const { toast } = useToast();
const { canUseAi } = useAccountPlan();
const blocks = useMemo(() => documentAiBlocks(settings, outline), [settings, outline]);
const [targetLanguage, setTargetLanguage] = useState(settings.language === "no" || settings.language === "nb" ? "nb-NO" : settings.language ?? "en");
const [targetLanguage, setTargetLanguage] = useState(normalizeCvLanguage(settings.language));
const [busy, setBusy] = useState<string | null>(null);
const [suggestion, setSuggestion] = useState<DocumentAiBlock[] | null>(null);
useEffect(() => setTargetLanguage(settings.language === "no" || settings.language === "nb" ? "nb-NO" : settings.language ?? "en"), [settings.language]);
useEffect(() => setTargetLanguage(normalizeCvLanguage(settings.language)), [settings.language]);
const run = async (action: string) => {
if (!blocks.length) return;
@@ -1047,10 +1049,11 @@ function EntryEditor({ section, row, settings, onPatch, onUpdateSettings }: {
const { t } = useI18n();
// Entry order: itemOrder if set, else master order; always covering every entry key.
const orderedKeys: string[] = useMemo(() => {
const master = section.entries.map((e) => e.key ?? "").filter(Boolean);
const master = Array.from(new Set(section.entries.map((e) => e.key ?? "").filter(Boolean)));
if (!row.itemOrder?.length) return master;
const set = new Set(row.itemOrder);
return [...row.itemOrder.filter((k) => master.includes(k)), ...master.filter((k) => !set.has(k))];
const storedOrder = Array.from(new Set(row.itemOrder));
const set = new Set(storedOrder);
return [...storedOrder.filter((k) => master.includes(k)), ...master.filter((k) => !set.has(k))];
}, [row.itemOrder, section.entries]);
const entriesByKey = useMemo(() => {
@@ -1190,7 +1193,7 @@ function CustomizeTab({ mode, settings, update, themes }: {
<FormControl size="small" fullWidth><InputLabel>{t("cvEditorPageSize")}</InputLabel><Select inputProps={{ "aria-label": t("cvEditorPageSize") }} label={t("cvEditorPageSize")} value={settings.pageSize ?? "a4"} onChange={(e) => update({ pageSize: e.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={(e) => update({ density: e.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={settings.language === "no" ? "nb-NO" : settings.language ?? "en"} onChange={(e) => update({ language: e.target.value })}><MenuItem value="en">{t("languageEnglish")}</MenuItem><MenuItem value="nb-NO">{t("languageNorwegianBokmal")}</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={(e) => update({ language: e.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={(e) => update({ dateFormat: e.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={(e) => update({ layout: (e.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>}