fix(cv): make profile saves atomic
This commit is contained in:
@@ -34,6 +34,43 @@ public sealed class CareerProfileServiceTests
|
||||
Assert.False(string.IsNullOrWhiteSpace(saved.Jobs[0].Id));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SaveVersionAsync_replaces_duplicate_item_ids_without_dropping_entries()
|
||||
{
|
||||
await using var db = NewContext("user-1");
|
||||
var service = new CareerProfileService(db);
|
||||
var profile = new StructuredCvProfile
|
||||
{
|
||||
Jobs = { new StructuredCvJob { Id = "shared", Title = "Engineer", Company = "Acme" } },
|
||||
Education = { new StructuredCvEducation { Id = "shared", Qualification = "BSc", Institution = "University" } },
|
||||
};
|
||||
|
||||
var saved = await service.SaveVersionAsync("user-1", profile, "manual", default);
|
||||
|
||||
Assert.Equal("shared", saved.Jobs[0].Id);
|
||||
Assert.NotEqual(saved.Jobs[0].Id, saved.Education[0].Id);
|
||||
Assert.Single(await db.CareerExperiences.IgnoreQueryFilters().ToListAsync());
|
||||
Assert.Single(await db.CareerEducations.IgnoreQueryFilters().ToListAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadStructuredAsync_hides_race_affected_duplicate_child_rows()
|
||||
{
|
||||
await using var db = NewContext("user-1");
|
||||
var profile = new CareerProfile { OwnerUserId = "user-1", ProfileJson = "{}", LongTailJson = "{}", Version = 1 };
|
||||
db.CareerProfiles.Add(profile);
|
||||
await db.SaveChangesAsync();
|
||||
db.CareerExperiences.AddRange(
|
||||
new CareerExperience { CareerProfileId = profile.Id, OwnerUserId = "user-1", ItemKey = "duplicate", SortOrder = 0, Title = "Engineer" },
|
||||
new CareerExperience { CareerProfileId = profile.Id, OwnerUserId = "user-1", ItemKey = "duplicate", SortOrder = 0, Title = "Engineer" });
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var loaded = await new CareerProfileService(db).LoadStructuredAsync("user-1", default);
|
||||
|
||||
Assert.Single(loaded.Jobs);
|
||||
Assert.Equal("duplicate", loaded.Jobs[0].Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SaveVersionAsync_preserves_existing_ids_across_saves()
|
||||
{
|
||||
|
||||
@@ -537,4 +537,15 @@ public sealed class CvBuilderTests
|
||||
Assert.Contains("class=\"paragraphs\"><p>I confirm these details.</p>", html);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Settings_normalization_removes_duplicate_entry_order_keys()
|
||||
{
|
||||
var settings = CvVariantSettingsJson.Normalize(new CvVariantSettings
|
||||
{
|
||||
Sections = { new CvSectionSetting { Key = "experience", ItemOrder = new() { "first", "first", "second", " " } } },
|
||||
});
|
||||
|
||||
Assert.Equal(new[] { "first", "second" }, settings.Sections[0].ItemOrder);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -175,6 +175,12 @@ public static class CvVariantSettingsJson
|
||||
{
|
||||
section.Presentation = NormalizeChoice(section.Presentation, "rows", "grid", "compact", "bubble");
|
||||
section.Columns = section.Columns is 1 or 2 ? section.Columns : null;
|
||||
section.ItemOrder = section.ItemOrder?
|
||||
.Where(key => !string.IsNullOrWhiteSpace(key))
|
||||
.Select(key => key.Trim())
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.Take(500)
|
||||
.ToList();
|
||||
}
|
||||
foreach (var section in s.CustomSections)
|
||||
{
|
||||
|
||||
@@ -53,6 +53,13 @@ public sealed class CareerProfileService : ICareerProfileService
|
||||
AssignStableIds(profile);
|
||||
NormalizeDates(profile);
|
||||
|
||||
// Publish the profile row, relational children, and history revision atomically. Without
|
||||
// this boundary a concurrent outline/read can observe the new profile before its children,
|
||||
// enter the legacy backfill path, and insert a second copy of every child row.
|
||||
await using var transaction = _db.Database.IsRelational() && _db.Database.CurrentTransaction is null
|
||||
? await _db.Database.BeginTransactionAsync(cancellationToken)
|
||||
: null;
|
||||
|
||||
var json = StructuredCvProfileJson.SerializePersisted(profile);
|
||||
|
||||
var existing = await _db.CareerProfiles.FirstOrDefaultAsync(x => x.OwnerUserId == ownerUserId, cancellationToken);
|
||||
@@ -90,6 +97,7 @@ public sealed class CareerProfileService : ICareerProfileService
|
||||
CreatedAtUtc = DateTimeOffset.UtcNow,
|
||||
});
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
if (transaction is not null) await transaction.CommitAsync(cancellationToken);
|
||||
|
||||
return profile;
|
||||
}
|
||||
@@ -115,12 +123,12 @@ public sealed class CareerProfileService : ICareerProfileService
|
||||
await SyncRelationalChildrenAsync(profile.Id, ownerUserId, fromBlob, cancellationToken);
|
||||
}
|
||||
|
||||
var experiences = await _db.CareerExperiences.Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ToListAsync(cancellationToken);
|
||||
var education = await _db.CareerEducations.Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ToListAsync(cancellationToken);
|
||||
var skills = await _db.CareerSkills.Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ToListAsync(cancellationToken);
|
||||
var projects = await _db.CareerProjects.Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ToListAsync(cancellationToken);
|
||||
var certifications = await _db.CareerCertifications.Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ToListAsync(cancellationToken);
|
||||
var languages = await _db.CareerLanguages.Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ToListAsync(cancellationToken);
|
||||
var experiences = UniqueChildren(await _db.CareerExperiences.Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ThenBy(x => x.Id).ToListAsync(cancellationToken));
|
||||
var education = UniqueChildren(await _db.CareerEducations.Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ThenBy(x => x.Id).ToListAsync(cancellationToken));
|
||||
var skills = UniqueChildren(await _db.CareerSkills.Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ThenBy(x => x.Id).ToListAsync(cancellationToken));
|
||||
var projects = UniqueChildren(await _db.CareerProjects.Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ThenBy(x => x.Id).ToListAsync(cancellationToken));
|
||||
var certifications = UniqueChildren(await _db.CareerCertifications.Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ThenBy(x => x.Id).ToListAsync(cancellationToken));
|
||||
var languages = UniqueChildren(await _db.CareerLanguages.Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ThenBy(x => x.Id).ToListAsync(cancellationToken));
|
||||
|
||||
return CareerProfileMapper.ToStructured(profile.LongTailJson, experiences, education, skills, projects, certifications, languages);
|
||||
}
|
||||
@@ -130,12 +138,12 @@ public sealed class CareerProfileService : ICareerProfileService
|
||||
var profile = await _db.CareerProfiles.IgnoreQueryFilters().FirstOrDefaultAsync(x => x.OwnerUserId == ownerUserId, cancellationToken);
|
||||
if (profile is null) return new StructuredCvProfile();
|
||||
|
||||
var experiences = await _db.CareerExperiences.IgnoreQueryFilters().Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ToListAsync(cancellationToken);
|
||||
var education = await _db.CareerEducations.IgnoreQueryFilters().Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ToListAsync(cancellationToken);
|
||||
var skills = await _db.CareerSkills.IgnoreQueryFilters().Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ToListAsync(cancellationToken);
|
||||
var projects = await _db.CareerProjects.IgnoreQueryFilters().Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ToListAsync(cancellationToken);
|
||||
var certifications = await _db.CareerCertifications.IgnoreQueryFilters().Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ToListAsync(cancellationToken);
|
||||
var languages = await _db.CareerLanguages.IgnoreQueryFilters().Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ToListAsync(cancellationToken);
|
||||
var experiences = UniqueChildren(await _db.CareerExperiences.IgnoreQueryFilters().Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ThenBy(x => x.Id).ToListAsync(cancellationToken));
|
||||
var education = UniqueChildren(await _db.CareerEducations.IgnoreQueryFilters().Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ThenBy(x => x.Id).ToListAsync(cancellationToken));
|
||||
var skills = UniqueChildren(await _db.CareerSkills.IgnoreQueryFilters().Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ThenBy(x => x.Id).ToListAsync(cancellationToken));
|
||||
var projects = UniqueChildren(await _db.CareerProjects.IgnoreQueryFilters().Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ThenBy(x => x.Id).ToListAsync(cancellationToken));
|
||||
var certifications = UniqueChildren(await _db.CareerCertifications.IgnoreQueryFilters().Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ThenBy(x => x.Id).ToListAsync(cancellationToken));
|
||||
var languages = UniqueChildren(await _db.CareerLanguages.IgnoreQueryFilters().Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ThenBy(x => x.Id).ToListAsync(cancellationToken));
|
||||
|
||||
// No relational rows yet (a pre-Phase-3 profile that has never been re-saved): fall back to
|
||||
// the blob so a public CV still renders. Read-only, so we don't backfill here.
|
||||
@@ -195,26 +203,45 @@ public sealed class CareerProfileService : ICareerProfileService
|
||||
|
||||
private static void AssignStableIds(StructuredCvProfile profile)
|
||||
{
|
||||
var used = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var job in profile.Jobs)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(job.Id)) job.Id = NewItemId();
|
||||
job.Id = UniqueItemId(job.Id, used);
|
||||
}
|
||||
foreach (var education in profile.Education)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(education.Id)) education.Id = NewItemId();
|
||||
education.Id = UniqueItemId(education.Id, used);
|
||||
}
|
||||
foreach (var certification in profile.Certifications)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(certification.Id)) certification.Id = NewItemId();
|
||||
certification.Id = UniqueItemId(certification.Id, used);
|
||||
}
|
||||
foreach (var project in profile.Projects)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(project.Id)) project.Id = NewItemId();
|
||||
project.Id = UniqueItemId(project.Id, used);
|
||||
}
|
||||
}
|
||||
|
||||
private static string NewItemId() => Guid.NewGuid().ToString("N")[..12];
|
||||
|
||||
private static string UniqueItemId(string? candidate, HashSet<string> used)
|
||||
{
|
||||
var normalized = candidate?.Trim();
|
||||
if (!string.IsNullOrWhiteSpace(normalized) && used.Add(normalized)) return normalized;
|
||||
string generated;
|
||||
do generated = NewItemId(); while (!used.Add(generated));
|
||||
return generated;
|
||||
}
|
||||
|
||||
// Old race-affected databases can contain exact duplicate rows with one stable ItemKey. Keep
|
||||
// reads deterministic and avoid duplicate CV output while preserving the stored rows for an
|
||||
// explicit repair migration/backup workflow.
|
||||
private static List<T> UniqueChildren<T>(List<T> items) where T : CareerChildEntity =>
|
||||
items.GroupBy(item => item.ItemKey, StringComparer.Ordinal)
|
||||
.Select(group => group.OrderBy(item => item.SortOrder).ThenBy(item => item.Id).First())
|
||||
.OrderBy(item => item.SortOrder).ThenBy(item => item.Id)
|
||||
.ToList();
|
||||
|
||||
private static void NormalizeDates(StructuredCvProfile profile)
|
||||
{
|
||||
foreach (var job in profile.Jobs)
|
||||
|
||||
@@ -63,6 +63,7 @@ Updated: 2026-08-29
|
||||
- The previous Dashboard rendered its time-in-stage empty state using the unrelated “No tags yet” copy. Empty analytical panels are now suppressed instead of presenting mismatched messages.
|
||||
- The Application Analysis match previously ignored both `JobApplication.DescriptionLanguage`/`TranslatedDescription` and the linked CV's language setting. This could under-score an English CV against a Norwegian advert even when import had already stored a usable English translation.
|
||||
- The bundled portable Node runtime starts Next 16 successfully, but Turbopack repeatedly panicked while emitting `/page`; the webpack development path serves the same route correctly and is now the documented/scripted default.
|
||||
- Playwright exposed duplicate React keys in the long Code-template CV despite passing its assertions. Read-only inspection of the disposable SQLite database proved every relational Career Profile child had been inserted twice: a newly visible profile could be read and legacy-backfilled before its child replacement completed. Profile snapshot/children/history saves are now atomic, malformed duplicate item/order keys are normalized, and race-affected stored rows resolve once on reads without destructive cleanup.
|
||||
|
||||
### Verification
|
||||
|
||||
@@ -92,6 +93,7 @@ Updated: 2026-08-29
|
||||
- Job creation and Gmail workflow localization verification: 4 focused suites, 11/11 passed; TypeScript passed. The add-job stepper, Gmail continuity management, review queue, usage progress labels, errors and status copy now switch between English and Bokmål without translating user/job/email content.
|
||||
- Shared active-surface localization/accessibility verification: Profile 13/13 and settings/auth/landing 18/18 passed; TypeScript passed. Tag entry, Pro notices, disabled registration, profile load recovery, dashboard totals, language selector semantics, job-selection checkboxes and locale-aware correspondence dates were aligned with the global EN/NB system.
|
||||
- Final localization regression gate: all 62 frontend suites and 267/267 tests passed after correcting the isolated Pro-notice provider fallback; the optimized Next production build and integrated TypeScript check passed; the full .NET 9 backend suite passed 713/713.
|
||||
- Career Profile atomicity/CV locale verification: focused backend 55/55 and frontend 10/10 passed; the targeted long Code-template Playwright/PDF flow passed 1/1 with no duplicate-key or out-of-range locale warning. Its fresh disposable database contained exactly 9 experiences, 1 education, 8 skills, 1 project, 1 certification and 2 languages, with zero duplicate experience ItemKeys.
|
||||
- Backend matcher/intelligence focused verification: 35/35 passed, including detection of a manually created Norwegian advert with no saved translation.
|
||||
- Full backend: 712/712 tests passed on .NET 9.
|
||||
- Next optimized production build and TypeScript: passed after the Job Workspace/checklist batch.
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>}
|
||||
|
||||
Reference in New Issue
Block a user