From 663023a088573e44abea8da1c1858da934af77e9 Mon Sep 17 00:00:00 2001 From: cesnimda Date: Tue, 27 Oct 2026 07:37:03 +0100 Subject: [PATCH] fix: preserve blank CVs and reliable editor undo --- .../CvBlankPersistenceTests.cs | 146 +++++++++++++++ .../ApplicationIntelligenceTests.cs | 14 ++ JobTrackerApi.Tests/CvAiReadinessTests.cs | 14 ++ JobTrackerApi.Tests/CvBuilderTests.cs | 66 +++++++ JobTrackerApi.Tests/CvExportRevisionTests.cs | 21 +++ .../Controllers/CvVariantController.cs | 1 + JobTrackerApi/Models/CvVariantSettings.cs | 4 + .../ApplicationIntelligenceService.cs | 3 +- .../Services/CvAiSuggestionService.cs | 1 + JobTrackerApi/Services/CvRenderModel.cs | 38 ++-- JobTrackerApi/Services/ThemedCvRenderer.cs | 28 ++- docs/pre-phase10-ux/A_ACCEPTANCE.md | 38 ++++ .../pre-phase10-ux/CV_BUILDER_REPRODUCTION.md | 29 +++ docs/pre-phase10-ux/FEEDBACK_REGISTER.md | 4 +- docs/pre-phase10-ux/PLAN.md | 4 +- job-tracker-ui/e2e/pre-p10-cv-editor.spec.ts | 148 +++++++++++++++ .../src/cv-builder-deep-link.test.tsx | 157 +++++++++++++++- job-tracker-ui/src/cv-builder-page.test.tsx | 73 +++++++- job-tracker-ui/src/cvBuilder.ts | 13 ++ job-tracker-ui/src/i18n/translations.ts | 32 +++- job-tracker-ui/src/views/CvBuilderEditor.tsx | 169 ++++++++++++------ job-tracker-ui/src/views/CvBuilderPage.tsx | 48 ++++- 22 files changed, 950 insertions(+), 101 deletions(-) create mode 100644 JobTrackerApi.MariaDb.Tests/CvBlankPersistenceTests.cs create mode 100644 docs/pre-phase10-ux/A_ACCEPTANCE.md create mode 100644 docs/pre-phase10-ux/CV_BUILDER_REPRODUCTION.md create mode 100644 job-tracker-ui/e2e/pre-p10-cv-editor.spec.ts diff --git a/JobTrackerApi.MariaDb.Tests/CvBlankPersistenceTests.cs b/JobTrackerApi.MariaDb.Tests/CvBlankPersistenceTests.cs new file mode 100644 index 0000000..3eb3c44 --- /dev/null +++ b/JobTrackerApi.MariaDb.Tests/CvBlankPersistenceTests.cs @@ -0,0 +1,146 @@ +using JobTrackerApi.Data; +using JobTrackerApi.Models; +using JobTrackerApi.Services; +using Microsoft.EntityFrameworkCore; +using Xunit; + +namespace JobTrackerApi.MariaDb.Tests; + +public sealed class CvBlankPersistenceTests +{ + private const string Owner = "rd21-owner"; + private static CvVariantService Service(JobTrackerContext db) => new(db, new CareerProfileService(db), new ThemedCvRenderer()); + private static CvVariantSettings Blank() => new() { SectionsExplicit = true, IncludeProfileContact = false }; + + private static async Task SeedProfile(JobTrackerContext db) + { + db.Users.Add(new() { Id = Owner, UserName = "synthetic-cv-blank" }); + await db.SaveChangesAsync(); + await new CareerProfileService(db).SaveVersionAsync(Owner, new() + { + Contact = new() { FullName = "Canonical Person", Headline = "Canonical headline", Email = "synthetic@example.test" }, + Summary = { "Canonical summary" }, + Jobs = { new() { Id = "canonical-role", Title = "Canonical role", Company = "Synthetic company", Bullets = { "Canonical evidence" } } }, + Skills = { "Canonical skill" }, + }, "manual", 0, default); + } + + [Fact] + public async Task Blank_json_readback_duplicate_design_and_restore_preserve_canonical_profile_on_mariadb() + { + await using var database = await MariaDbDatabase.CreateAsync(); + int id; + string initialJson; + string canonicalJson; + string? identityJson; + await using (var seed = database.Context()) + { + await SeedProfile(seed); + canonicalJson = (await seed.CareerProfiles.SingleAsync()).ProfileJson; + identityJson = (await seed.Users.SingleAsync()).ProfileCvStructureJson; + var created = await Service(seed).CreateAsync(Owner, "Blank", null, Blank(), default); + id = created.Id; + initialJson = created.SettingsJson; + } + + int copyId; + await using (var edit = database.Context()) + { + var service = Service(edit); + var read = (await service.GetAsync(Owner, id, default))!; + Assert.Equal(initialJson, read.SettingsJson); + var settings = CvVariantSettingsJson.Deserialize(read.SettingsJson); + Assert.True(settings.SectionsExplicit); + Assert.False(settings.IncludeProfileContact); + Assert.Empty(settings.Sections); + var rendered = await service.RenderAsync(Owner, id, new("Fallback identity", "data:image/png;base64,AA=="), default, 1); + Assert.False(rendered!.HasContent); + Assert.DoesNotContain("Canonical", rendered.Html); + Assert.DoesNotContain("Fallback identity", rendered.Html); + + var copy = (await service.DuplicateAsync(Owner, id, "Blank copy", default))!; + copyId = copy.Id; + Assert.Equal(initialJson, copy.SettingsJson); + settings.ThemeId = "minimal"; + settings.AccentColor = "#123456"; + await service.SaveAsync(Owner, id, null, settings, "manual", 1, default); + } + + await using (var reopen = database.Context()) + { + var service = Service(reopen); + var settings = CvVariantSettingsJson.Deserialize((await service.GetAsync(Owner, id, default))!.SettingsJson); + Assert.Equal("minimal", settings.ThemeId); + Assert.True(settings.SectionsExplicit); + Assert.False(settings.IncludeProfileContact); + Assert.False((await service.RenderAsync(Owner, id, new("Fallback", null), default, 2))!.HasContent); + settings.Headline = "Authored CV-only headline"; + await service.SaveAsync(Owner, id, null, settings, "manual", 2, default); + var restored = (await service.RestoreVersionAsync(Owner, id, 1, 3, default))!; + Assert.Equal(4, restored.Version); + Assert.Equal(initialJson, restored.SettingsJson); + } + + await using var verify = database.Context(); + Assert.Equal(initialJson, (await verify.CvVariants.SingleAsync(cv => cv.Id == id)).SettingsJson); + Assert.Equal(initialJson, (await verify.CvVariants.SingleAsync(cv => cv.Id == copyId)).SettingsJson); + Assert.Equal(new[] { 1, 2, 3, 4 }, await verify.CvVariantVersions.Where(row => row.CvVariantId == id).OrderBy(row => row.Version).Select(row => row.Version).ToArrayAsync()); + Assert.Single(await verify.CvVariantVersions.Where(row => row.CvVariantId == copyId).ToListAsync()); + Assert.Equal(canonicalJson, (await verify.CareerProfiles.SingleAsync()).ProfileJson); + Assert.Equal(identityJson, (await verify.Users.SingleAsync()).ProfileCvStructureJson); + Assert.Equal(1, (await verify.CareerProfiles.SingleAsync()).Version); + Assert.Single(await verify.CareerProfileVersions.ToListAsync()); + Assert.Equal("Canonical role", (await verify.CareerExperiences.SingleAsync()).Title); + var profile = await new CareerProfileService(verify).LoadStructuredAsync(Owner, default); + Assert.Equal("Canonical summary", Assert.Single(profile.Summary)); + Assert.Equal("Canonical skill", Assert.Single(profile.Skills)); + Assert.Equal("Canonical evidence", Assert.Single(Assert.Single(profile.Jobs).Bullets)); + } + + [Fact] + public async Task Competing_blank_edits_and_foreign_access_cannot_overwrite_winner_or_append_history_on_mariadb() + { + await using var database = await MariaDbDatabase.CreateAsync(); + int id; + await using (var seed = database.Context()) + { + await SeedProfile(seed); + id = (await Service(seed).CreateAsync(Owner, "Blank", null, Blank(), default)).Id; + } + await using var winner = database.Context(); + await using var staleSave = database.Context(); + await using var staleRestore = database.Context(); + Assert.Equal(1, (await Service(staleSave).GetAsync(Owner, id, default))!.Version); + Assert.Equal(1, (await Service(staleRestore).GetAsync(Owner, id, default))!.Version); + var settings = Blank(); + settings.ThemeId = "minimal"; + settings.Headline = "Acknowledged CV-only winner"; + await Service(winner).SaveAsync(Owner, id, "Winner", settings, "manual", 1, default); + await Assert.ThrowsAsync(() => Service(staleSave).SaveAsync(Owner, id, "Stale name", Blank(), "autosave", 1, default)); + await Assert.ThrowsAsync(() => Service(staleRestore).RestoreVersionAsync(Owner, id, 1, 1, default)); + + await using (var foreign = database.Context("foreign")) + { + var service = Service(foreign); + Assert.Null(await service.GetAsync("foreign", id, default)); + Assert.Null(await service.SaveAsync("foreign", id, "Foreign", Blank(), "manual", 2, default)); + Assert.Null(await service.DuplicateAsync("foreign", id, null, default)); + Assert.Null(await service.RestoreVersionAsync("foreign", id, 1, 2, default)); + Assert.Null(await service.RenderAsync("foreign", id, new("Foreign", null), default, 2)); + Assert.Empty(await service.ListVersionsAsync("foreign", id, default)); + } + + await using var verify = database.Context(); + var saved = await verify.CvVariants.SingleAsync(); + Assert.Equal(2, saved.Version); + Assert.Equal("Winner", saved.Name); + Assert.Equal(CvVariantSettingsJson.Serialize(settings), saved.SettingsJson); + var history = await verify.CvVariantVersions.OrderBy(row => row.Version).ToListAsync(); + Assert.Equal(new[] { 1, 2 }, history.Select(row => row.Version)); + Assert.Equal(CvVariantSettingsJson.Serialize(Blank()), history[0].SettingsJson); + Assert.Equal(saved.SettingsJson, history[1].SettingsJson); + Assert.Equal(1, (await verify.CareerProfiles.SingleAsync()).Version); + Assert.Single(await verify.CareerProfileVersions.ToListAsync()); + Assert.Equal("Canonical role", (await verify.CareerExperiences.SingleAsync()).Title); + } +} diff --git a/JobTrackerApi.Tests/ApplicationIntelligenceTests.cs b/JobTrackerApi.Tests/ApplicationIntelligenceTests.cs index 61bfade..5efcc4d 100644 --- a/JobTrackerApi.Tests/ApplicationIntelligenceTests.cs +++ b/JobTrackerApi.Tests/ApplicationIntelligenceTests.cs @@ -364,6 +364,20 @@ public sealed class ApplicationIntelligenceTests Assert.Empty(match.RelevantExperience); } + [Fact] + public async Task Match_does_not_inherit_experience_from_an_explicit_blank_cv() + { + var (db, intelligence, _) = New("user-1"); + await using var dispose = db; + var job = await SeedJobAsync(db, "user-1"); + await SeedProfileAsync(db, "user-1"); + await AttachCvAsync(db, "user-1", job.Id, CvVariantSettingsJson.Deserialize("{\"sectionsExplicit\":true,\"includeProfileContact\":false,\"sections\":[]}")); + var match = await intelligence.MatchAsync("user-1", job.Id, default); + Assert.True(match!.HasSelectedCv); + Assert.Empty(match.RelevantExperience); + Assert.Empty(match.RelevantProjects); + } + [Fact] public async Task Match_is_not_readable_for_another_users_application() { diff --git a/JobTrackerApi.Tests/CvAiReadinessTests.cs b/JobTrackerApi.Tests/CvAiReadinessTests.cs index 8c17545..18b5347 100644 --- a/JobTrackerApi.Tests/CvAiReadinessTests.cs +++ b/JobTrackerApi.Tests/CvAiReadinessTests.cs @@ -19,6 +19,20 @@ public sealed class CvAiReadinessTests { private const string Source = "Built and maintained internal web applications."; private const string Paraphrase = "Developed and maintained internal web applications."; + + [Fact] + public async Task Explicit_blank_document_AI_does_not_send_unselected_profile_facts() + { + await using var fixture = new Fixture(); + await fixture.Open(); + await fixture.Variants.SaveAsync("owner", fixture.Cv.Id, null, + CvVariantSettingsJson.Deserialize("{\"sectionsExplicit\":true,\"includeProfileContact\":false,\"sections\":[]}"), "manual", 1, default); + var error = await Assert.ThrowsAsync(() => fixture.Suggestions.GenerateAsync( + "owner", fixture.Cv.Id, 2, null, "document-improve", null, null, "Rewrite", default)); + Assert.Equal("cv_ai_source_empty", error.Code); + fixture.Ai.VerifyNoOtherCalls(); + } + private sealed class Fixture : IAsyncDisposable { public readonly SqliteConnection Connection = new("Data Source=:memory:"); diff --git a/JobTrackerApi.Tests/CvBuilderTests.cs b/JobTrackerApi.Tests/CvBuilderTests.cs index 72e2592..96a55b1 100644 --- a/JobTrackerApi.Tests/CvBuilderTests.cs +++ b/JobTrackerApi.Tests/CvBuilderTests.cs @@ -28,6 +28,72 @@ public sealed class CvBuilderTests // ---- Resolver (the lens over the master profile) ---- + [Fact] + public void Explicit_blank_roundtrips_without_inheriting_profile_content_or_header() + { + var settings = CvVariantSettingsJson.Deserialize("{\"sectionsExplicit\":true,\"includeProfileContact\":false,\"sections\":[],\"showPhoto\":true}"); + var json = CvVariantSettingsJson.Serialize(settings); + Assert.Contains("\"sectionsExplicit\":true", json); + Assert.Contains("\"includeProfileContact\":false", json); + var model = CvVariantResolver.Build(Rich(), CvVariantSettingsJson.Deserialize(json), "Fallback", "data:image/png;base64,AA=="); + Assert.Empty(model.Sections); + Assert.Empty(model.FullName); + Assert.Null(model.Headline); + Assert.Empty(model.Contact); + Assert.Null(model.PhotoDataUrl); + foreach (var theme in CvThemeCatalog.Themes) + { + var html = new ThemedCvRenderer().Render(model, theme, settings).Html; + Assert.DoesNotContain("(); + current.SetupGet(x => x.UserId).Returns("owner"); + await using var db = new JobTrackerContext(new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()).Options, current.Object); + var variants = new CvVariantService(db, new CareerProfileService(db), new ThemedCvRenderer()); + var settings = CvVariantSettingsJson.Deserialize("{\"sectionsExplicit\":true,\"includeProfileContact\":false,\"sections\":[]}"); + var cv = await variants.CreateAsync("owner", "Blank", null, settings, default); + var user = new ApplicationUser { Id = "owner", FirstName = "Inherited name" }; + var users = TestHostFactory.CreateUserManager(user); + users.Setup(x => x.GetUserAsync(It.IsAny())).ReturnsAsync(user); + var pdf = new Mock(MockBehavior.Strict); + var controller = new CvVariantController(users.Object, variants, pdf.Object, Mock.Of()) + { ControllerContext = new() { HttpContext = new DefaultHttpContext() } }; + var result = Assert.IsType(await controller.ExportPdf(cv.Id, default, 1)); + Assert.Equal("cv_empty_content", JsonSerializer.SerializeToElement(result.Value).GetProperty("code").GetString()); + pdf.VerifyNoOtherCalls(); + } + [Theory] [InlineData(1, "owner", 409)] [InlineData(0, "owner", 409)] diff --git a/JobTrackerApi/Controllers/CvVariantController.cs b/JobTrackerApi/Controllers/CvVariantController.cs index 06feab4..32a529d 100644 --- a/JobTrackerApi/Controllers/CvVariantController.cs +++ b/JobTrackerApi/Controllers/CvVariantController.cs @@ -240,6 +240,7 @@ public sealed class CvVariantController : ControllerBase { var render = await _variants.RenderAsync(user.Id, id, Person(user), ct, expectedVersion); if (render is null) return NotFound(); + if (!render.HasContent) return BadRequest(new { code = "cv_empty_content" }); var artifact = await _pdf.ExportAsync(user.Id, new TailoredCvRenderResult(render.ThemeId, render.SuggestedFileName, render.Html), ct); return File(artifact.Bytes, "application/pdf", artifact.FileName); } diff --git a/JobTrackerApi/Models/CvVariantSettings.cs b/JobTrackerApi/Models/CvVariantSettings.cs index 194a574..8363160 100644 --- a/JobTrackerApi/Models/CvVariantSettings.cs +++ b/JobTrackerApi/Models/CvVariantSettings.cs @@ -44,6 +44,10 @@ public sealed class CvVariantSettings public bool ShowPageNumbers { get; set; } public bool ShowIcons { get; set; } = true; + // Missing flags preserve legacy profile inheritance, including serialized history snapshots. + public bool? SectionsExplicit { get; set; } + public bool? IncludeProfileContact { get; set; } + // Section order + visibility + optional renamed heading. Empty => default order, all visible. public List Sections { get; set; } = new(); diff --git a/JobTrackerApi/Services/ApplicationIntelligenceService.cs b/JobTrackerApi/Services/ApplicationIntelligenceService.cs index 4958bd4..483d808 100644 --- a/JobTrackerApi/Services/ApplicationIntelligenceService.cs +++ b/JobTrackerApi/Services/ApplicationIntelligenceService.cs @@ -377,7 +377,8 @@ public sealed class ApplicationIntelligenceService : IApplicationIntelligenceSer settings.Overrides.TryGetValue(itemKey, out var itemOverride) ? itemOverride : new CvItemOverride(); private static bool SectionVisible(CvVariantSettings settings, string key) => - settings.Sections.FirstOrDefault(section => string.Equals(section.Key, key, StringComparison.OrdinalIgnoreCase))?.Hidden != true; + settings.Sections.FirstOrDefault(section => string.Equals(section.Key, key, StringComparison.OrdinalIgnoreCase)) is { } section + ? !section.Hidden : settings.SectionsExplicit != true; private static string ReadJsonArray(string? json) { diff --git a/JobTrackerApi/Services/CvAiSuggestionService.cs b/JobTrackerApi/Services/CvAiSuggestionService.cs index cfb4fa9..49c90ad 100644 --- a/JobTrackerApi/Services/CvAiSuggestionService.cs +++ b/JobTrackerApi/Services/CvAiSuggestionService.cs @@ -46,6 +46,7 @@ public sealed class CvAiSuggestionService( if (document != (targetId is null)) throw Error(400, "cv_ai_scope_invalid"); var snapshot = await SnapshotAsync(owner, id, ct); RequireCurrent(snapshot, version, null); + if (document && snapshot.Blocks.Count == 0) throw Error(400, "cv_ai_source_empty"); var source = Source(targetId is null ? snapshot.Blocks : snapshot.EditableBlocks, targetId); if (inputText is not null && !string.Equals(ContentLines(inputText), ContentLines(source.Text), StringComparison.Ordinal)) throw Error(409, "cv_ai_source_changed"); diff --git a/JobTrackerApi/Services/CvRenderModel.cs b/JobTrackerApi/Services/CvRenderModel.cs index 9c362fc..aa1d19a 100644 --- a/JobTrackerApi/Services/CvRenderModel.cs +++ b/JobTrackerApi/Services/CvRenderModel.cs @@ -68,22 +68,25 @@ public static class CvVariantResolver settings = CvVariantSettingsJson.Normalize(settings); var model = new CvRenderModel { - FullName = Trim(profile.Contact.FullName) ?? fallbackName, - Headline = Trim(settings.Headline) ?? Trim(profile.Contact.Headline), - PhotoDataUrl = settings.ShowPhoto ? Trim(photoDataUrl) : null, + FullName = settings.IncludeProfileContact == false ? string.Empty : Trim(profile.Contact.FullName) ?? fallbackName, + Headline = Trim(settings.Headline) ?? (settings.IncludeProfileContact == false ? null : Trim(profile.Contact.Headline)), + PhotoDataUrl = settings.ShowPhoto && settings.IncludeProfileContact != false ? Trim(photoDataUrl) : null, }; - AddContact(model.Contact, "email", profile.Contact.Email, v => $"mailto:{v}"); - AddContact(model.Contact, "phone", profile.Contact.Phone, v => $"tel:{new string(v.Where(character => char.IsDigit(character) || character == '+').ToArray())}"); - AddContact(model.Contact, "location", profile.Contact.Location, null); - AddContact(model.Contact, "web", profile.Contact.Website, AsUrl); - AddContact(model.Contact, "linkedin", profile.Contact.LinkedIn, AsUrl); - AddContact(model.Contact, "web", profile.Contact.GitHub, AsUrl); - foreach (var link in profile.Contact.Links) + if (settings.IncludeProfileContact != false) { - var url = Trim(link.Url); - if (url is null) continue; - model.Contact.Add(new CvContactItem { Icon = "web", Value = Trim(link.Label) ?? url, Href = AsUrl(url) }); + AddContact(model.Contact, "email", profile.Contact.Email, v => $"mailto:{v}"); + AddContact(model.Contact, "phone", profile.Contact.Phone, v => $"tel:{new string(v.Where(character => char.IsDigit(character) || character == '+').ToArray())}"); + AddContact(model.Contact, "location", profile.Contact.Location, null); + AddContact(model.Contact, "web", profile.Contact.Website, AsUrl); + AddContact(model.Contact, "linkedin", profile.Contact.LinkedIn, AsUrl); + AddContact(model.Contact, "web", profile.Contact.GitHub, AsUrl); + foreach (var link in profile.Contact.Links) + { + var url = Trim(link.Url); + if (url is null) continue; + model.Contact.Add(new CvContactItem { Icon = "web", Value = Trim(link.Label) ?? url, Href = AsUrl(url) }); + } } var built = new Dictionary(StringComparer.OrdinalIgnoreCase) @@ -138,10 +141,13 @@ public static class CvVariantResolver settingByKey[section.Key] = section; if (!ordered.Contains(section.Key, StringComparer.OrdinalIgnoreCase)) ordered.Add(section.Key); } - if (ordered.Count == 0) ordered.AddRange(DefaultOrder); - foreach (var key in built.Keys) + if (settings.SectionsExplicit != true) { - if (!ordered.Contains(key, StringComparer.OrdinalIgnoreCase)) ordered.Add(key); + if (ordered.Count == 0) ordered.AddRange(DefaultOrder); + foreach (var key in built.Keys) + { + if (!ordered.Contains(key, StringComparer.OrdinalIgnoreCase)) ordered.Add(key); + } } foreach (var key in ordered) diff --git a/JobTrackerApi/Services/ThemedCvRenderer.cs b/JobTrackerApi/Services/ThemedCvRenderer.cs index ee9dc23..f612428 100644 --- a/JobTrackerApi/Services/ThemedCvRenderer.cs +++ b/JobTrackerApi/Services/ThemedCvRenderer.cs @@ -6,7 +6,10 @@ using JobTrackerApi.Models; namespace JobTrackerApi.Services; -public sealed record ThemedCvRenderResult(string ThemeId, string SuggestedFileName, string Html); +public sealed record ThemedCvRenderResult(string ThemeId, string SuggestedFileName, string Html) +{ + public bool HasContent { get; init; } = true; +} public interface IThemedCvRenderer { @@ -45,11 +48,11 @@ public sealed class ThemedCvRenderer : IThemedCvRenderer var css = CvDocumentFonts.Embed(headingFont, bodyFont, theme.UtilityFont) + BuildCss(theme, settings, accent, headerInk, ink, muted, paper, headingColor, CvDocumentFonts.Stack(headingFont), CvDocumentFonts.Stack(bodyFont), headingStyle, density, pageDims, layout, twoColumn); - var header = RenderHeader(model, theme, showIcons, twoColumn, headerStyle); + var header = RenderHeader(model, theme, showIcons, twoColumn, headerStyle, settings.IncludeProfileContact == false); var body = layout switch { - "sidebar-left" => $@"
{Sidebar(model, sidebarHtml, theme, showIcons, sidebarSections, headerStyle)}
{mainHtml}
", - "sidebar-right" => $@"
{mainHtml}
{Sidebar(model, sidebarHtml, theme, showIcons, sidebarSections, headerStyle)}
", + "sidebar-left" => $@"
{Sidebar(model, sidebarHtml, theme, showIcons, sidebarSections, headerStyle, settings.IncludeProfileContact == false)}
{mainHtml}
", + "sidebar-right" => $@"
{mainHtml}
{Sidebar(model, sidebarHtml, theme, showIcons, sidebarSections, headerStyle, settings.IncludeProfileContact == false)}
", _ => $@"{header}
{mainHtml}
", }; var html = $@" @@ -65,7 +68,12 @@ public sealed class ThemedCvRenderer : IThemedCvRenderer "; var fileName = Slug($"{model.FullName}-{theme.Id}") + ".pdf"; - return new ThemedCvRenderResult(theme.Id, fileName, html); + return new ThemedCvRenderResult(theme.Id, fileName, html) + { + HasContent = model.Sections.Any(section => !section.IsEmpty) + || !string.IsNullOrWhiteSpace(model.FullName) || !string.IsNullOrWhiteSpace(model.Headline) + || model.Contact.Count > 0, + }; } private static (string sidebar, string main) SplitColumns(CvRenderModel model, CvTheme theme, IReadOnlyCollection sidebarKeys, CvVariantSettings settings) @@ -82,20 +90,22 @@ public sealed class ThemedCvRenderer : IThemedCvRenderer return (sidebar.ToString(), RenderSections(mainSections, theme, settings)); } - private static string Sidebar(CvRenderModel model, string sectionsHtml, CvTheme theme, bool showIcons, IReadOnlyCollection sidebarSections, string headerStyle) + private static string Sidebar(CvRenderModel model, string sectionsHtml, CvTheme theme, bool showIcons, IReadOnlyCollection sidebarSections, string headerStyle, bool hideEmptyHeader) { - var header = RenderHeader(model, theme, showIcons, twoColumn: true, headerStyle); + var header = RenderHeader(model, theme, showIcons, twoColumn: true, headerStyle, hideEmptyHeader); var contact = sidebarSections.Contains("contact", StringComparer.OrdinalIgnoreCase) ? RenderContactBlock(model.Contact, showIcons, sidebar: true) : string.Empty; return $@""; } - private static string RenderHeader(CvRenderModel model, CvTheme theme, bool showIcons, bool twoColumn, string headerStyle) + private static string RenderHeader(CvRenderModel model, CvTheme theme, bool showIcons, bool twoColumn, string headerStyle, bool hideEmptyHeader) { + if (hideEmptyHeader && string.IsNullOrWhiteSpace(model.FullName) && string.IsNullOrWhiteSpace(model.Headline) + && model.Contact.Count == 0 && string.IsNullOrWhiteSpace(model.PhotoDataUrl)) return string.Empty; var photo = RenderPhoto(model.PhotoDataUrl, theme.PhotoShape); var kicker = headerStyle == "kicker" ? @"
Curriculum Vitae
" : string.Empty; - var name = $@"

{Enc(model.FullName)}

"; + var name = hideEmptyHeader && string.IsNullOrWhiteSpace(model.FullName) ? string.Empty : $@"

{Enc(model.FullName)}

"; var headline = string.IsNullOrWhiteSpace(model.Headline) ? string.Empty : $@"
{Enc(model.Headline)}
"; var contact = twoColumn ? string.Empty : RenderContactBlock(model.Contact, showIcons, sidebar: false); var headerClass = twoColumn ? "hero" : $"header header-{headerStyle}"; diff --git a/docs/pre-phase10-ux/A_ACCEPTANCE.md b/docs/pre-phase10-ux/A_ACCEPTANCE.md new file mode 100644 index 0000000..978ecd1 --- /dev/null +++ b/docs/pre-phase10-ux/A_ACCEPTANCE.md @@ -0,0 +1,38 @@ +# A: blank CV and editor history acceptance + +27 October 2026. Corrective branch: `codex/cv-builder-blank-undo`, from completed CV-QA `4a8c907`. Audit/plan checkpoint: `5d4b8f2`. This is development qualification; it does not reopen production or live inference. + +## Implemented + +- New Blank creation contains zero sections and no inherited identity/contact/photo/headline. Profile creation requires explicit section/contact selection. Existing settings without nullable inclusion flags retain legacy behaviour. +- Editor, shared render resolver, document AI and application matcher agree on selected content. AI rejects an empty source before provider access. No live inference was performed. +- Empty resolved export returns `cv_empty_content` before allocating a PDF worker, with English/Bokmål recovery copy. Design changes, save/reload, duplicate and historical restore preserve explicit selection. +- Session undo/redo includes name and settings, groups focused typing bursts, records structural/design actions, invalidates redo after new edits, preserves history across autosave and rejects history actions during revision conflict. Native text undo remains native; document shortcuts support Ctrl/Cmd+Z, Ctrl+Y and Ctrl/Cmd+Shift+Z outside editable controls. +- Document loads clear old controls and previews. Late save, preview, restore and history responses are scoped to their editor session. Explicitly selected profile `other:n` sections are reachable through Add content. + +## Evidence + +Evidence root: `.artifacts/pre-phase10-ux/`. Original failing runs are retained; overlapping runs are never added together. + +| Lane | Result | Evidence / scope | +|---|---|---| +| Actual Next development reproduction | Both reported defects reproduced | `runtime-red/`; blank inherited four canonical markers; document Ctrl+Z failed for non-text section reorder | +| Focused backend | 108 passed, 0 failed/skipped | `blank-green/blank-green.trx`; resolver, legacy serialization, save/duplicate/restore, empty export allocation, bound AI and matching | +| Frontend | 98 passed in 4 suites, 0 failed/skipped | `a-ui-qualified.json`; includes Strict Mode typing, naming, branch redo, conflicts, document switch, restore isolation and explicit additional profile sections | +| Development browser | 4 passed, 0 failed/skipped/flaky | `a-browser/results-final.json`; EN1440/NB390 blank save/reload/template Apply, UI+API empty-export rejection, unchanged canonical source, persisted section-order shortcuts and native text undo | +| Accessibility automation | Four scoped scans: 0 violations | Each retains one serious contrast **incomplete**; this does not pass manual contrast/AT acceptance | +| TypeScript | Exit 0 | `tsc --noEmit --incremental false`, current source | +| Focused ESLint | Exit 0 | Editor/library/types/tests/translations; full lint remains final integration work | +| MariaDB + RD21 | 82 passed, 0 failed/skipped | `a-mariadb-final/mariadb.trx`; full repository helper, generated loopback MariaDB12.2.2 schemas, locked restore and cleanup exit0. Includes2 new blank persistence/design/restore, stale-writer and ownership cases | + +Browser evidence precedes the final additional-section title fallback by one localized catalogue-line change; that addition has its own failing-then-passing frontend test and is also included in the final integrated browser source. No claim of human testing follows from automated widths or screenshot inspection. + +Fresh review identified late restore/history isolation and additional profile-section reachability. Both received new RED tests before correction. The catalogue finding was narrowed to actual nonstandard `other:n` keys: the standard list already includes awards/publications/organisations/references. No standard section was missing. A previous assertion that renaming creates no undo state was updated to verify the new requested transaction and that rejected AI adds no extra history step. + +## Decisions and limits + +Blank creation and undo/redo: development GO. CV integration preservation: conditional; focused legacy and revision paths pass, full regression/PDF qualification remains scheduled on the combined integration branch. B may start after this checkpoint is committed/pushed/remote-verified. + +The initial MariaDB run also passed82, but its test binary preceded the fixture correction from an unknown design ID to actual `minimal`. The final run above rebuilt that source and is authoritative; these runs are not additive. No provider fallback or production connection was used. + +Session history is bounded to60 steps and is not restored after refresh; the editor explains this. Historical settings revisions remain available separately. Human accessibility, physical devices, native-language review, contrast and printing remain open. CV-model compatibility/accuracy remain BLOCKED. Production remains NO-GO. Phase10 cleanup has not begun. diff --git a/docs/pre-phase10-ux/CV_BUILDER_REPRODUCTION.md b/docs/pre-phase10-ux/CV_BUILDER_REPRODUCTION.md new file mode 100644 index 0000000..99e808f --- /dev/null +++ b/docs/pre-phase10-ux/CV_BUILDER_REPRODUCTION.md @@ -0,0 +1,29 @@ +# CV Builder reproduction and correction + +Date: 2026-10-27. Baseline: accepted CV-QA `4a8c907`; corrective branch `codex/cv-builder-blank-undo`. Audit checkpoint `5d4b8f2` is pushed. Production and live-model gates remain closed. + +## Observed failures before application changes + +Actual development browser used the repository Playwright configuration: Next development server (`next dev --webpack`), local Release API, isolated synthetic SQLite data, disabled AI/background workers, and loopback-only browser requests. No production resources or personal documents were used. + +- Creating a new CV inherited four independent canonical markers: name, headline, summary and employment role. The creation dialog had no explicit Blank choice. Career Profile itself remained unchanged. +- After a section reorder was saved and read back, Ctrl+Z with the Undo toolbar button focused left the sections reordered. Expected document undo did not occur. +- An initial headline-only keyboard check passed through native browser text undo. This is retained as an observation, not used to prove document shortcuts worked. The authoritative failure uses a non-text reorder. + +Raw runtime evidence: `.artifacts/pre-phase10-ux/runtime-red/results.json` (original one failure and one native-text pass); `reorder-results.json` (one focused failure), with screenshots and Playwright traces in their result directories. These are separate runs, not additive suite totals. + +Backend RED: six requirement failures and one legacy-preservation pass in `blank-red/blank-red.trx`. Library RED: two failures in `blank-ui-red.json`. The initial four-suite frontend run included those two newly added failures: 87 passed/2 failed, so it is not a fully green starting baseline. + +Editor RED reproduced typing-burst history, missing document shortcuts/name history, enabled Undo after revision conflict, previous document controls remaining visible while the next document loads, and automatic section hydration despite explicit empty settings. A subsequent historical-restore test reproduced a late response setting a conflict on another document. Harness syntax/navigation setup failures are retained but do not count as product reproductions. + +## Correction contract + +New creation defaults to Blank: explicit empty section selection and excluded profile contact. From Career Profile offers actual available sections and optional contact, initially unselected. Existing documents without the new nullable flags retain historical inheritance. Design selection changes presentation; duplication and restore preserve the stored selection. Empty export fails with a translated explanation before allocating a PDF worker. + +Interactive history records immutable name/settings snapshots, bounded to 60 undo steps within the current editor session. Changes in one focused text field within one second form a typing burst; focus changes and separate controls end that burst. Structural actions are individual steps. New edits clear redo. Autosave acknowledgements preserve history; reload, historical restore and opening another document reset it. History is not a frozen document revision. + +Outside editable controls, Ctrl/Cmd+Z undoes, Ctrl+Y or Ctrl/Cmd+Shift+Z redoes. Native input/textarea/contenteditable shortcuts remain native. Revision conflicts disable document history and retain the draft for explicit recovery. Save success still requires the established version-aware server acknowledgement; no history operation bypasses RD21. + +## Qualification in progress + +Focused backend: 108 passed, zero failed/skipped (`blank-green/blank-green.trx`). TypeScript: exit zero. This does not substitute for real MariaDB, RD21, the browser result, full regression or human/device acceptance. Final checkpoint evidence and independent verdicts will be recorded after those applicable lanes complete. diff --git a/docs/pre-phase10-ux/FEEDBACK_REGISTER.md b/docs/pre-phase10-ux/FEEDBACK_REGISTER.md index 6beec80..890dcf5 100644 --- a/docs/pre-phase10-ux/FEEDBACK_REGISTER.md +++ b/docs/pre-phase10-ux/FEEDBACK_REGISTER.md @@ -4,8 +4,8 @@ | ID | Owner requirement | Entry evidence / intended verification | State | |---|---|---|---| -| A1 | Truly blank CV, zero content sections/header facts, no fictional defaults | Both resolver/editor inherit empty arrays; contact always hydrates. Tests must cover persistence/design/copy/restore and no AI/matching leakage. | Source defect confirmed; runtime reproduction pending | -| A2 | Trustworthy undo/redo and native shortcuts | Settings-only stacks mutate inside state updater callbacks; Strict Mode and autosave/document-switch tests required. | Investigation | +| A1 | Truly blank CV, zero content sections/header facts, no fictional defaults | Actual development RED followed by explicit inclusion, shared resolver/AI/matcher, design/copy/restore/provider and empty-export checks. See A_ACCEPTANCE.md. | Development GO; integrated preservation pending | +| A2 | Trustworthy undo/redo and native shortcuts | Actual non-text shortcut RED, Strict Mode grouping, naming, autosave/conflict/session isolation, persisted EN/NB shortcuts and RD21 provider checks. | Development GO; human/device and combined regression pending | | B1 | Audit every template and fix spacing/alignment/page flow | Nine current IDs; sidebar palette ignored, narrow identity geometry and conservative packing identified. Actual per-template rendering pending. | Audit prepared | | B2 | Six–eight distinct quality styles or justified count | Retain9 existing IDs for compatibility; improve identities rather than add duplicates. Need actual visual proof. | Engineering count decision; qualification pending | | B3 | Optional secure portrait/upload/crop/preview/remove/PDF | Existing shared avatar/crop/storage pipeline; keyboard crop controls absent. | Reuse identified; new acceptance pending | diff --git a/docs/pre-phase10-ux/PLAN.md b/docs/pre-phase10-ux/PLAN.md index 581e56b..9d2b763 100644 --- a/docs/pre-phase10-ux/PLAN.md +++ b/docs/pre-phase10-ux/PLAN.md @@ -25,8 +25,8 @@ No main merge, production service/database/storage/model/cache/deployment operat ## Checkpoints - [x] P10-UX-00: read owner specification, verify latest CV-QA ancestry/remote/entry, isolate managed worktree, audit four owners and choose compatibility-first design. Create feedback register and branch plan. -- [ ] A-01: reproduce blank hydration/header/export and Strict Mode history failures in focused tests plus actual local development browser. Save RED evidence; no application fix before reproduction. -- [ ] A-02: nullable explicit inclusion flags across resolver/editor/AI/matcher, deliberate creation/profile selection, empty export validation; pure immutable grouped session history/shortcuts/epoch guards; focused unit/provider/browser and RD21 checks, review, docs, commit/push/verify. +- [x] A-01: reproduce blank hydration/header/export and Strict Mode history failures in focused tests plus actual local development browser. Save RED evidence; no application fix before reproduction. +- [x] A-02: nullable explicit inclusion flags across resolver/editor/AI/matcher, deliberate creation/profile selection, empty export validation; pure immutable grouped session history/shortcuts/epoch guards; focused unit/provider/browser and RD21 checks, review, docs, commit/push/verify. Evidence: A_ACCEPTANCE.md, backend108/frontend98/browser4/MariaDB82, TypeScript/lint0; fresh review fixes have RED/GREEN tests. Commit is the source-bearing checkpoint immediately following5d4b8f2. - [ ] B-00: branch from accepted A; inventory9 IDs, render equivalent short/medium/long/dense samples, establish measured spacing/pagination defects and legacy source/PDF proof. - [ ] B-01: opt-in revised design semantics and template-specific spacing/palette/identity fixes; portrait controls reuse secure existing storage/crop with keyboard alternatives; tests and per-template Windows/Linux visual evidence; commit/push/verify. - [ ] C-00: branch from accepted B; verify official grid API/licence/compatibility/bundle, define v2 JSON and v1 migration tests. diff --git a/job-tracker-ui/e2e/pre-p10-cv-editor.spec.ts b/job-tracker-ui/e2e/pre-p10-cv-editor.spec.ts new file mode 100644 index 0000000..870fedb --- /dev/null +++ b/job-tracker-ui/e2e/pre-p10-cv-editor.spec.ts @@ -0,0 +1,148 @@ +import { expect, test, type APIRequestContext, type Page } from '@playwright/test'; +import { translations } from '../src/i18n/translations'; +import { emptyStructuredCv } from '../src/profileCv'; +import AxeBuilder from '@axe-core/playwright'; + +const api = 'http://localhost:5302/api'; +const password = 'E2ePassword123!'; + +async function login(request: APIRequestContext, email: string) { + expect((await request.post(`${api}/auth/login`, { data: { email, password } })).status()).toBe(200); + const me = await (await request.get(`${api}/auth/me`)).json(); + return { 'X-CSRF-TOKEN': (await request.storageState()).cookies.find(cookie => cookie.name === 'XSRF-TOKEN')!.value, + 'X-Session-Context': me.sessionContext as string }; +} + +async function owner(page: Page, admin: APIRequestContext, language: 'en' | 'nb' = 'en') { + const adminHeaders = await login(admin, 'e2e@example.test'); + const email = `pre-p10-${Date.now()}-${Math.random().toString(36).slice(2)}@example.test`; + expect((await admin.post(`${api}/users`, { headers: adminHeaders, data: { email, password, roles: [] } })).status()).toBe(200); + const headers = await login(page.request, email); + const profile = { ...emptyStructuredCv(), contact: { ...emptyStructuredCv().contact, + fullName: 'Canonical Identity Marker', headline: 'Canonical Headline Marker', email: 'canonical-contact@example.test' }, + summary: ['Canonical Summary Marker'], jobs: [{ id: 'pre-p10-job', title: 'Canonical Role Marker', company: 'Synthetic Employer', bullets: ['Canonical Detail Marker'] }] }; + const before = await (await page.request.get(`${api}/career/profile`)).json(); + expect((await page.request.put(`${api}/career/profile`, { headers, data: { expectedVersion: before.revision, profile } })).status()).toBe(200); + expect((await page.request.put(`${api}/auth/preferences/language`, { headers, data: { language: language === 'nb' ? 'nb-NO' : 'en' } })).status()).toBe(204); + await page.addInitScript(language => { localStorage.setItem('uiLanguage', language); localStorage.setItem('jobtracker.themeMode', 'light'); }, language); + await page.setViewportSize({ width: language === 'nb' ? 390 : 1440, height: 1000 }); + return headers; +} + +test.beforeEach(async ({ context }) => { + await context.route('**/*', route => { + const url = new URL(route.request().url()); + return ['localhost', '127.0.0.1'].includes(url.hostname) || ['data:', 'blob:', 'about:'].includes(url.protocol) + ? route.continue() : route.abort(); + }); +}); + +for (const language of ['en', 'nb'] as const) { +test(`[PRE-P10 A] ${language} blank CV survives rename, reload, template apply and empty export validation`, async ({ page, playwright }, info) => { + test.setTimeout(180_000); + const t = translations[language]; + const admin = await playwright.request.newContext(); + let headers: Record; + try { headers = await owner(page, admin, language); } finally { await admin.dispose(); } + const sourceBefore = await (await page.request.get(`${api}/career/profile`)).json(); + await page.goto('/career/builder'); + await page.getByRole('button', { name: t.cvDashboardNewCv, exact: true }).click(); + const dialog = page.getByRole('dialog', { name: t.cvDashboardCreateTitle, exact: true }); + await expect(dialog).toBeVisible(); + await dialog.getByRole('radio', { name: t.cvDashboardStartBlank, exact: true }).check(); + await expect(dialog.getByRole('radio', { name: t.cvDashboardStartBlank, exact: true })).toBeChecked(); + const dialogAxe = await new AxeBuilder({ page }).include('[role="dialog"]').withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa']).analyze(); + await info.attach('create-dialog-axe', { body: JSON.stringify({ violations: dialogAxe.violations, incomplete: dialogAxe.incomplete }), contentType: 'application/json' }); + expect(dialogAxe.violations).toEqual([]); + await dialog.getByLabel(t.cvEditorName, { exact: false }).fill('New blank CV'); + const createdResponse = page.waitForResponse(response => response.url() === `${api}/cv/variants` && response.request().method() === 'POST'); + await dialog.getByRole('button', { name: t.cvDashboardCreateCv, exact: true }).click(); + const response = await createdResponse; + expect(response.status()).toBe(200); + const created = await response.json(); + await expect(page).toHaveURL(new RegExp(`/career/builder/${created.id}$`)); + const read = async () => (await page.request.get(`${api}/cv/variants/${created.id}`)).json(); + const assertBlank = async () => { + const saved = await read(); + expect(saved.settings).toMatchObject({ sectionsExplicit: true, includeProfileContact: false, sections: [] }); + const preview = await page.request.get(`${api}/cv/variants/${created.id}/preview`); + expect(preview.status()).toBe(200); + const rendered = await preview.json(); + for (const marker of ['Canonical Identity Marker', 'Canonical Headline Marker', 'Canonical Summary Marker', 'Canonical Role Marker', 'canonical-contact@example.test']) { + expect(rendered.html.includes(marker), `Blank CV must not inherit ${marker}`).toBe(false); + } + return saved; + }; + await expect(page.getByText(t.cvEditorEmptyBody, { exact: true })).toBeVisible(); + await assertBlank(); + await page.getByLabel(t.cvEditorName, { exact: true }).fill('Saved empty CV'); + await expect.poll(async () => (await read()).name).toBe('Saved empty CV'); + await page.reload(); + await expect(page.getByLabel(t.cvEditorName, { exact: true })).toHaveValue('Saved empty CV'); + await expect(page.getByRole('checkbox', { name: t.cvEditorIncludeProfileContact, exact: true })).not.toBeChecked(); + await assertBlank(); + await page.getByRole('tab', { name: t.cvEditorTemplate, exact: true }).click(); + await page.getByRole('button', { name: t.cvTemplateMinimalName, exact: true }).click(); + await page.getByRole('button', { name: t.cvEditorApplyTemplate, exact: true }).click(); + await expect.poll(async () => (await read()).settings.themeId).toBe('minimal'); + await assertBlank(); + await page.getByRole('tab', { name: t.cvEditorContent, exact: true }).click(); + const editorAxe = await new AxeBuilder({ page }).include('#cv-studio-panel-1').withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa']).analyze(); + await info.attach('blank-content-axe', { body: JSON.stringify({ violations: editorAxe.violations, incomplete: editorAxe.incomplete }), contentType: 'application/json' }); + expect(editorAxe.violations).toEqual([]); + const exportResponse = page.waitForResponse(response => new URL(response.url()).pathname === `/api/cv/variants/${created.id}/export-pdf` && response.request().method() === 'POST'); + await page.getByRole('button', { name: t.cvEditorDownloadPdf, exact: true }).click(); + const exported = await exportResponse; + expect(exported.status()).toBe(400); + expect(await exported.json()).toEqual({ code: 'cv_empty_content' }); + await expect(page.getByText(t.cvEmptyContent, { exact: true })).toBeVisible(); + const saved = await assertBlank(); + const independent = await page.request.post(`${api}/cv/variants/${created.id}/export-pdf?expectedVersion=${saved.version}`, { headers }); + expect(independent.status()).toBe(400); + expect(await independent.json()).toEqual({ code: 'cv_empty_content' }); + expect(await (await page.request.get(`${api}/career/profile`)).json()).toEqual(sourceBefore); + expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1)).toBe(true); + await info.attach('blank-roundtrip', { body: JSON.stringify({ language, created, saved, exportStatus: exported.status(), sourceUnchanged: true }), contentType: 'application/json' }); + await page.screenshot({ path: info.outputPath('blank-roundtrip.png'), fullPage: true }); +}); + +test(`[PRE-P10 A] ${language} document undo and both redo shortcuts preserve native text undo`, async ({ page, playwright }, info) => { + test.setTimeout(180_000); + const t = translations[language]; + const admin = await playwright.request.newContext(); + let headers: Record; + try { headers = await owner(page, admin, language); } finally { await admin.dispose(); } + const response = await page.request.post(`${api}/cv/variants`, { headers, data: { name: 'Keyboard undo runtime reproduction', settings: { themeId: 'minimal', sections: [{ key: 'summary' }, { key: 'experience' }] } } }); + expect(response.status()).toBe(200); + const cv = await response.json(); + await page.goto(`/career/builder/${cv.id}`); + const headline = page.getByLabel(t.cvEditorHeadlineOverride, { exact: true }); + await expect(headline).toBeVisible(); + const readOrder = async () => (await (await page.request.get(`${api}/cv/variants/${cv.id}`)).json()).settings.sections.slice(0, 2).map((section: { key: string }) => section.key); + await page.getByRole('button', { name: t.cvEditorMoveSectionDown.replace('{section}', t.cvSectionSummary), exact: true }).click(); + const undo = page.getByRole('button', { name: t.cvEditorUndo, exact: true }); + const redo = page.getByRole('button', { name: t.cvEditorRedo, exact: true }); + await expect(undo).toBeEnabled(); + await expect.poll(readOrder).toEqual(['experience', 'summary']); + for (const shortcut of ['Control+z', 'Control+y', 'Control+z', 'Control+Shift+z']) { + const reverting = shortcut === 'Control+z'; + await (reverting ? undo : redo).focus(); + await page.keyboard.press(shortcut); + await expect.poll(readOrder, { message: shortcut }).toEqual(reverting ? ['summary', 'experience'] : ['experience', 'summary']); + } + await headline.focus(); + await headline.pressSequentially('Native input marker'); + await expect(headline).toHaveValue('Native input marker'); + await page.evaluate(() => { document.addEventListener('keydown', event => { + if (event.ctrlKey && event.key.toLowerCase() === 'z') document.documentElement.dataset.nativeUndoPrevented = String(event.defaultPrevented); + }); }); + await page.keyboard.press('Control+z'); + await expect(headline).not.toHaveValue('Native input marker'); + await expect(headline).toBeFocused(); + expect(await page.evaluate(() => document.documentElement.dataset.nativeUndoPrevented)).toBe('false'); + expect(await readOrder()).toEqual(['experience', 'summary']); + expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1)).toBe(true); + await info.attach('shortcut-roundtrip', { body: JSON.stringify({ language, finalOrder: await readOrder(), nativeInputValue: await headline.inputValue(), nativeUndoPrevented: false }), contentType: 'application/json' }); + await page.screenshot({ path: info.outputPath('shortcut-roundtrip.png'), fullPage: true }); +}); +} diff --git a/job-tracker-ui/src/cv-builder-deep-link.test.tsx b/job-tracker-ui/src/cv-builder-deep-link.test.tsx index 75c2a40..aa66603 100644 --- a/job-tracker-ui/src/cv-builder-deep-link.test.tsx +++ b/job-tracker-ui/src/cv-builder-deep-link.test.tsx @@ -26,6 +26,8 @@ jest.mock('./api', () => ({ })); const mockedApi = api as jest.Mocked; +const originalRequest = global.Request; +const originalAbortController = global.AbortController; const variant = { id: 3, @@ -38,12 +40,12 @@ const variant = { updatedAtUtc: '2026-07-19T10:00:00Z', }; -function renderAt(id: number) { +function renderAt(id: number, strict = false) { const router = createMemoryRouter([ { path: '/career/builder/:id', element: }, { path: '/career/builder', element:
CV list destination
}, ], { initialEntries: [`/career/builder/${id}`] }); - return render( + const tree = ( @@ -52,8 +54,9 @@ function renderAt(id: number) { - , + ); + return { ...render(strict ? {tree} : tree), router }; } beforeEach(() => { @@ -65,7 +68,11 @@ beforeEach(() => { mockedApi.post.mockResolvedValue({ data: { themeId: 'nordic', html: '

cv

', suggestedFileName: 'cv.pdf' } } as any); }); -afterEach(() => window.localStorage.removeItem('uiLanguage')); +afterEach(() => { + window.localStorage.removeItem('uiLanguage'); + global.Request = originalRequest; + global.AbortController = originalAbortController; +}); function routeGet(onVariant: () => Promise, outline: any = { sections: [] }, history: any[] = []) { mockedApi.get.mockImplementation((url: string) => { @@ -82,6 +89,143 @@ async function openContentTab() { fireEvent.click(screen.getByRole('tab', { name: 'Content' })); } +test('Strict Mode groups a typing burst into one undo step and preserves redo through autosave', async () => { + routeGet(() => Promise.resolve({ data: variant })); + renderAt(3, true); + await openContentTab(); + const headline = screen.getByLabelText('Headline override'); + act(() => headline.focus()); + fireEvent.change(headline, { target: { value: 'D' } }); + fireEvent.change(headline, { target: { value: 'Developer' } }); + act(() => headline.blur()); + fireEvent.click(screen.getByRole('button', { name: 'Save now' })); + await screen.findByText('Saved'); + fireEvent.click(screen.getByRole('button', { name: 'Undo' })); + expect(headline).toHaveValue(''); + expect(screen.getByRole('button', { name: 'Undo' })).toBeDisabled(); + fireEvent.click(screen.getByRole('button', { name: 'Redo' })); + expect(headline).toHaveValue('Developer'); +}); + +test('document shortcuts undo and redo renaming, preserve native input undo and discard branched redo', async () => { + routeGet(() => Promise.resolve({ data: variant })); + renderAt(3, true); + await openContentTab(); + const name = screen.getByDisplayValue('Backend CV'); + act(() => name.focus()); + fireEvent.change(name, { target: { value: 'Frontend CV' } }); + fireEvent.keyDown(name, { key: 'z', ctrlKey: true }); + expect(name).toHaveValue('Frontend CV'); + act(() => name.blur()); + fireEvent.keyDown(document, { key: 'z', ctrlKey: true }); + expect(name).toHaveValue('Backend CV'); + fireEvent.keyDown(document, { key: 'y', ctrlKey: true }); + expect(name).toHaveValue('Frontend CV'); + fireEvent.keyDown(document, { key: 'z', ctrlKey: true }); + fireEvent.keyDown(document, { key: 'Z', ctrlKey: true, shiftKey: true }); + expect(name).toHaveValue('Frontend CV'); + fireEvent.keyDown(document, { key: 'z', ctrlKey: true }); + fireEvent.change(screen.getByLabelText('Headline override'), { target: { value: 'New branch' } }); + expect(screen.getByRole('button', { name: 'Redo' })).toBeDisabled(); +}); + +test('a revision conflict disables document history without silently writing an undo', async () => { + mockedApi.put.mockRejectedValue({ response: { status: 409, data: { code: 'cv_revision_conflict' } } }); + routeGet(() => Promise.resolve({ data: variant })); + renderAt(3); + await openContentTab(); + fireEvent.change(screen.getByLabelText('Headline override'), { target: { value: 'Retained local edit' } }); + fireEvent.click(screen.getByRole('button', { name: 'Save now' })); + await screen.findByRole('button', { name: 'Reload saved CV' }); + expect(screen.getByRole('button', { name: 'Undo' })).toBeDisabled(); + fireEvent.keyDown(document, { key: 'z', ctrlKey: true }); + expect(screen.getByLabelText('Headline override')).toHaveValue('Retained local edit'); + expect(mockedApi.put).toHaveBeenCalledTimes(1); +}); + +test('switching documents hides the previous draft while loading and starts fresh history', async () => { + // JSDOM omits Node's fetch globals, which React Router needs for navigation. + global.Request = jest.requireActual('node:vm').runInThisContext('Request'); + global.AbortController = jest.requireActual('node:vm').runInThisContext('AbortController'); + let loadNext!: (value: any) => void; + routeGet(() => Promise.resolve({ data: variant })); + const previousGet = mockedApi.get.getMockImplementation()!; + mockedApi.get.mockImplementation((url: string, ...args: any[]) => url === '/cv/variants/4' + ? new Promise(resolve => { loadNext = resolve; }) : previousGet(url, ...args)); + const { router } = renderAt(3); + await openContentTab(); + fireEvent.change(screen.getByLabelText('Headline override'), { target: { value: 'Only first document' } }); + fireEvent.click(screen.getByRole('button', { name: 'Save now' })); + await screen.findByText('Saved'); + await act(async () => { await router.navigate('/career/builder/4'); }); + expect(screen.queryByDisplayValue('Backend CV')).not.toBeInTheDocument(); + expect(screen.queryByDisplayValue('Only first document')).not.toBeInTheDocument(); + await act(async () => loadNext({ data: { ...variant, id: 4, name: 'Second CV' } })); + await screen.findByDisplayValue('Second CV'); + expect(screen.getByRole('button', { name: 'Undo' })).toBeDisabled(); + fireEvent.keyDown(document, { key: 'z', ctrlKey: true }); + expect(screen.getByLabelText('Headline override')).toHaveValue(''); +}); + +test('explicit blank editing excludes master content and includes a profile section only after an Add action', async () => { + routeGet(() => Promise.resolve({ data: { ...variant, settings: { ...variant.settings, + sectionsExplicit: true, includeProfileContact: false, + } } }), { headline: 'Private master title', sections: [ + { key: 'summary', kind: 'bullets', title: 'Professional Summary', bullets: ['Private master summary'] }, + ] }); + renderAt(3); + await openContentTab(); + expect(screen.queryByRole('button', { name: 'Expand Professional Summary' })).not.toBeInTheDocument(); + expect(screen.getByRole('combobox', { name: 'Go to section' })).toHaveAttribute('aria-disabled', 'true'); + fireEvent.click(screen.getByRole('button', { name: 'Add content' })); + const card = screen.getByText('Professional Summary', { selector: 'h6' }).parentElement!; + fireEvent.click(within(card).getByRole('button', { name: 'Add' })); + expect(await screen.findByRole('button', { name: 'Expand Professional Summary' })).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Undo' })); + expect(screen.queryByRole('button', { name: 'Expand Professional Summary' })).not.toBeInTheDocument(); +}); + +test('a blank CV can deliberately add an additional Career Profile section', async () => { + routeGet(() => Promise.resolve({ data: { ...variant, settings: { ...variant.settings, + sectionsExplicit: true, includeProfileContact: false, + } } }), { sections: [ + { key: 'other:0', kind: 'bullets', title: 'Community service', bullets: ['Explicitly sourced volunteering'] }, + ] }); + renderAt(3); + await openContentTab(); + expect(screen.queryByRole('button', { name: 'Expand Community service' })).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Add content' })); + const card = screen.getByText('Community service', { selector: 'h6' }).parentElement!; + fireEvent.click(within(card).getByRole('button', { name: 'Add' })); + expect(await screen.findByRole('button', { name: 'Expand Community service' })).toBeInTheDocument(); +}); + +test('a historical restore response from the previous document cannot change the newly opened CV', async () => { + global.Request = jest.requireActual('node:vm').runInThisContext('Request'); + global.AbortController = jest.requireActual('node:vm').runInThisContext('AbortController'); + let finishRestore!: (value: any) => void; + routeGet(() => Promise.resolve({ data: variant }), { sections: [] }, [ + { version: 3, source: 'manual', createdAtUtc: variant.updatedAtUtc, isCurrent: false }, + ]); + const previousGet = mockedApi.get.getMockImplementation()!; + mockedApi.get.mockImplementation((url: string, ...args: any[]) => url === '/cv/variants/4' + ? Promise.resolve({ data: { ...variant, id: 4, name: 'Second CV' } } as any) : previousGet(url, ...args)); + mockedApi.post.mockImplementation((url: string) => url.endsWith('/restore') + ? new Promise(resolve => { finishRestore = resolve; }) + : Promise.resolve({ data: { html: '

CV

' } } as any)); + const { router } = renderAt(3); + await openContentTab(); + fireEvent.click(screen.getByRole('tab', { name: 'History' })); + fireEvent.click(await screen.findByRole('button', { name: 'Restore version 3' })); + fireEvent.click(within(await screen.findByRole('dialog')).getByRole('button', { name: 'Restore version' })); + await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith('/cv/variants/3/versions/3/restore', { expectedVersion: 4 })); + await act(async () => { await router.navigate('/career/builder/4'); }); + await screen.findByDisplayValue('Second CV'); + await act(async () => finishRestore({ data: { ...variant, name: 'Old restored name', version: 5 } })); + expect(screen.getByDisplayValue('Second CV')).toBeInTheDocument(); + expect(screen.queryByText('CV changed elsewhere')).not.toBeInTheDocument(); +}); + test('content sections follow the editing-region heading without mutating CV settings', async () => { routeGet(() => Promise.resolve({ data: variant })); renderAt(3); @@ -999,6 +1143,11 @@ test('a newer draft is flushed before stale proposal acceptance fails without re expect(screen.getByDisplayValue('Newer retained name')).toBeInTheDocument(); fireEvent.click(screen.getByRole('tab', { name: 'Content' })); expect(screen.getByLabelText('Headline override')).toHaveValue(''); + // Renaming is now an editor transaction; the rejected proposal must add no history step. + expect(screen.getByRole('button', { name: 'Undo' })).toBeEnabled(); + fireEvent.click(screen.getByRole('button', { name: 'Undo' })); + expect(screen.getByDisplayValue('Backend CV')).toBeInTheDocument(); + expect(screen.getByLabelText('Headline override')).toHaveValue(''); expect(screen.getByRole('button', { name: 'Undo' })).toBeDisabled(); }); diff --git a/job-tracker-ui/src/cv-builder-page.test.tsx b/job-tracker-ui/src/cv-builder-page.test.tsx index 641d9d7..0513ab2 100644 --- a/job-tracker-ui/src/cv-builder-page.test.tsx +++ b/job-tracker-ui/src/cv-builder-page.test.tsx @@ -9,7 +9,7 @@ import { ToastProvider } from './toast'; import { api } from './api'; import { ConfirmProvider } from './confirm'; import { PromptProvider } from './prompt'; -import { emptyCvVariantSettings } from './cvBuilder'; +import { emptyCvVariantSettings, getCvExportFailureKey } from './cvBuilder'; const mockNavigate = jest.fn(); jest.mock('react-router-dom', () => ({ @@ -82,6 +82,73 @@ async function selectAction(name: string) { fireEvent.click(item); } +test('blank creation explicitly excludes all profile sections and contact even after template selection', async () => { + mockedApi.post.mockResolvedValueOnce({ data: { id: 42 } } as any); + renderPage(); + await screen.findByText('No CVs yet'); + fireEvent.click(screen.getAllByRole('button', { name: 'New CV' })[0]); + const dialog = await screen.findByRole('dialog', { name: 'Create a CV' }); + fireEvent.click(within(dialog).getByRole('button', { name: 'Clean' })); + fireEvent.click(within(dialog).getByRole('button', { name: 'Create CV' })); + await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith('/cv/variants', expect.objectContaining({ + settings: expect.objectContaining({ themeId: 'clean', sectionsExplicit: true, includeProfileContact: false, sections: [], customSections: [], overrides: {} }), + }))); +}); + +test('profile creation includes only deliberately selected available sections and contact', async () => { + mockedApi.get.mockImplementation(async (url) => ({ data: url === '/cv/themes' ? themes : url === '/cv/outline' + ? { fullName: 'Synthetic Person', sections: [ + { key: 'skills', title: 'Skills', kind: 'tags', tags: ['TypeScript'], bullets: [], entries: [] }, + { key: 'experience', title: 'Experience', kind: 'entries', tags: [], bullets: [], entries: [{ key: 'job-1', title: 'Engineer', bullets: [], tags: [] }] }, + ] } : [] }) as any); + mockedApi.post.mockResolvedValueOnce({ data: { id: 42 } } as any); + renderPage(); + await screen.findByText('No CVs yet'); + fireEvent.click(screen.getAllByRole('button', { name: 'New CV' })[0]); + const dialog = await screen.findByRole('dialog', { name: 'Create a CV' }); + fireEvent.click(within(dialog).getByRole('radio', { name: 'From Career Profile' })); + const skills = await within(dialog).findByRole('checkbox', { name: 'Skills' }); + expect(skills).not.toBeChecked(); + expect(within(dialog).getByRole('checkbox', { name: 'Experience' })).not.toBeChecked(); + const contact = within(dialog).getByRole('checkbox', { name: 'Include Career Profile contact details' }); + expect(contact).not.toBeChecked(); + fireEvent.click(skills); + fireEvent.click(contact); + fireEvent.click(within(dialog).getByRole('button', { name: 'Create CV' })); + await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith('/cv/variants', expect.objectContaining({ + settings: expect.objectContaining({ sectionsExplicit: true, includeProfileContact: true, sections: [{ key: 'skills' }] }), + }))); +}); + +test('unavailable profile sections block profile creation and allow an explicit blank fallback', async () => { + mockedApi.get.mockImplementation(async (url) => { + if (url === '/cv/outline') throw new Error('Synthetic profile read failure'); + return { data: url === '/cv/themes' ? themes : [] } as any; + }); + mockedApi.post.mockResolvedValueOnce({ data: { id: 42 } } as any); + renderPage(); + await screen.findByText('No CVs yet'); + fireEvent.click(screen.getAllByRole('button', { name: 'New CV' })[0]); + const dialog = await screen.findByRole('dialog', { name: 'Create a CV' }); + fireEvent.click(within(dialog).getByRole('radio', { name: 'From Career Profile' })); + expect(await within(dialog).findByRole('alert')).toHaveTextContent('Could not load Career Profile sections.'); + expect(within(dialog).getByRole('button', { name: 'Create CV' })).toBeDisabled(); + fireEvent.click(within(dialog).getByRole('radio', { name: 'Start blank' })); + fireEvent.click(within(dialog).getByRole('button', { name: 'Create CV' })); + await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith('/cv/variants', expect.objectContaining({ + settings: expect.objectContaining({ sections: [], includeProfileContact: false }), + }))); +}); + +test('export validation decodes structured and blob errors without relabelling unknown failures', async () => { + expect(await getCvExportFailureKey({ response: { status: 400, data: { code: 'cv_empty_content' } } })).toBe('cvEmptyContent'); + const body = new Blob(); + Object.defineProperty(body, 'text', { value: async () => '{"code":"cv_empty_content"}' }); + expect(await getCvExportFailureKey({ response: { status: 400, data: body } })).toBe('cvEmptyContent'); + expect(await getCvExportFailureKey({ response: { status: 400, data: { code: 'unrelated' } } })).toBeUndefined(); + expect(await getCvExportFailureKey(new Error('network'))).toBeUndefined(); +}); + test('the CV library has a section heading and announces its held initial read without showing an empty result', async () => { const read = deferred(); mockedApi.get.mockImplementation((url) => url === '/cv/variants' ? read.promise : Promise.resolve({ data: themes }) as any); @@ -168,7 +235,7 @@ test('held CV creation locks the draft and gallery, then keeps a failed draft fo expect(mockNavigate).not.toHaveBeenCalled(); fireEvent.click(within(dialog).getByRole('button', { name: 'Create CV' })); - await waitFor(() => expect(mockedApi.post).toHaveBeenLastCalledWith('/cv/variants', { name: 'Application-specific CV', settings: emptyCvVariantSettings('clean') })); + await waitFor(() => expect(mockedApi.post).toHaveBeenLastCalledWith('/cv/variants', { name: 'Application-specific CV', settings: { ...emptyCvVariantSettings('clean'), sectionsExplicit: true, includeProfileContact: false } })); await waitFor(() => expect(mockNavigate).toHaveBeenCalledWith('/career/builder/42')); }); @@ -185,7 +252,7 @@ test('a locked CV template remains identifiable and cannot change the creation r fireEvent.keyDown(premium, { key: 'Enter' }); expect(within(dialog).getByRole('button', { name: 'Modern' })).toHaveAttribute('aria-pressed', 'true'); fireEvent.click(within(dialog).getByRole('button', { name: 'Create CV' })); - await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith('/cv/variants', expect.objectContaining({ settings: emptyCvVariantSettings('modern') }))); + await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith('/cv/variants', expect.objectContaining({ settings: { ...emptyCvVariantSettings('modern'), sectionsExplicit: true, includeProfileContact: false } }))); }); test('a held duplicate blocks another action for the same CV and shows the persisted refreshed copy', async () => { diff --git a/job-tracker-ui/src/cvBuilder.ts b/job-tracker-ui/src/cvBuilder.ts index f8995fa..8cd5a31 100644 --- a/job-tracker-ui/src/cvBuilder.ts +++ b/job-tracker-ui/src/cvBuilder.ts @@ -51,6 +51,8 @@ export type CvVariantSettings = { showPhoto: boolean; showPageNumbers: boolean; showIcons: boolean; + sectionsExplicit?: boolean | null; + includeProfileContact?: boolean | null; sections: CvSectionSetting[]; overrides: Record; customSections: CvCustomSectionSetting[]; @@ -241,6 +243,17 @@ export function emptyCvVariantSettings(themeId = "modern"): CvVariantSettings { }; } +export async function getCvExportFailureKey(error: unknown): Promise<"cvEmptyContent" | "cvPdfBusy" | "cvPdfTimedOut" | undefined> { + const response = (error as { response?: { status?: number; data?: unknown } })?.response; + if (response?.status === 503) return "cvPdfBusy"; + if (response?.status === 504) return "cvPdfTimedOut"; + if (response?.status !== 400) return undefined; + try { + const data = response.data instanceof Blob ? JSON.parse(await response.data.text()) : response.data; + return (data as { code?: string })?.code === "cv_empty_content" ? "cvEmptyContent" : undefined; + } catch { return undefined; } +} + // --- API --- // --- Pure helpers (unit-tested) --- diff --git a/job-tracker-ui/src/i18n/translations.ts b/job-tracker-ui/src/i18n/translations.ts index 29babeb..c42af8c 100644 --- a/job-tracker-ui/src/i18n/translations.ts +++ b/job-tracker-ui/src/i18n/translations.ts @@ -887,7 +887,7 @@ export const translations = { 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 your Career Profile headline. Changes here affect only this CV.", + cvEditorHeadlineHelp: "An empty field uses your Career Profile headline only when profile contact is included. Changes here affect only this CV.", cvEditorSections: "Sections", cvEditorSectionsHelp: "Drag to reorder, or use the arrows. Omitted content stays editable.", cvEditorUsingSource: "Using Career Profile", @@ -1109,7 +1109,19 @@ export const translations = { cvDashboardOpen: "Open", cvDashboardDelete: "Delete", cvDashboardCreateTitle: "Create a CV", - cvDashboardCreateHelp: "Start with your saved career profile, choose a visual direction, then tailor what appears.", + cvDashboardCreateHelp: "Choose your content source and a template. Templates change presentation only.", + cvDashboardContentSource: "CV content source", + cvDashboardStartBlank: "Start blank", + cvDashboardFromProfile: "From Career Profile", + cvDashboardBlankHelp: "Start with no sections or inherited contact details. Add your own content in the editor.", + cvDashboardProfileSelectionHelp: "Select only the Career Profile sections and contact details you want to include. Nothing is selected automatically.", + cvDashboardProfileLoadFailed: "Could not load Career Profile sections. Try again or start blank.", + cvDashboardProfileEmpty: "Your Career Profile has no sections yet. You can add content in the editor.", + cvEditorUndoSessionHelp: "Undo and redo apply to this editing session. Saved versions remain available in History.", + cvEditorEmptyBody: "Your CV has no sections yet. Add a section to begin.", + cvEditorProfileContentHint: "Adding a Career Profile section includes its saved content. Custom sections start empty.", + cvEditorIncludeProfileContact: "Include Career Profile contact details", + cvEmptyContent: "Add content to your CV before exporting a PDF.", cvDashboardEnterName: "Enter a name.", cvDashboardNameExample: "For example: Backend Engineer — Acme", cvDashboardChooseTemplate: "Choose a template", @@ -3887,7 +3899,7 @@ export const translations = { 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 Karriereprofil. Endringer her gjelder bare denne CV-en.", + cvEditorHeadlineHelp: "Et tomt felt bruker overskriften fra Karriereprofil bare når profilens kontaktinformasjon er inkludert. Endringer her gjelder bare denne CV-en.", cvEditorSections: "Seksjoner", cvEditorSectionsHelp: "Dra for å endre rekkefølgen, eller bruk pilene. Utelatt innhold kan fortsatt redigeres.", cvEditorUsingSource: "Bruker Karriereprofil", @@ -4109,7 +4121,19 @@ export const translations = { cvDashboardOpen: "Åpne", cvDashboardDelete: "Slett", cvDashboardCreateTitle: "Opprett en CV", - cvDashboardCreateHelp: "Start med den lagrede karriereprofilen, velg en visuell retning og tilpass deretter hva som skal vises.", + cvDashboardCreateHelp: "Velg innholdskilde og mal. Maler endrer bare utformingen.", + cvDashboardContentSource: "Innholdskilde for CV", + cvDashboardStartBlank: "Start med en tom CV", + cvDashboardFromProfile: "Fra karriereprofilen", + cvDashboardBlankHelp: "Start uten seksjoner eller kontaktopplysninger fra profilen. Legg til eget innhold i redigeringsverktøyet.", + cvDashboardProfileSelectionHelp: "Velg seksjonene og kontaktopplysningene fra karriereprofilen som du vil ta med. Ingenting velges automatisk.", + cvDashboardProfileLoadFailed: "Kunne ikke laste seksjonene fra karriereprofilen. Prøv igjen eller start med en tom CV.", + cvDashboardProfileEmpty: "Karriereprofilen har ingen seksjoner ennå. Du kan legge til innhold i redigeringsverktøyet.", + cvEditorUndoSessionHelp: "Angre og gjør om gjelder denne redigeringsøkten. Lagrede versjoner er fortsatt tilgjengelige i historikken.", + cvEditorEmptyBody: "CV-en har ingen seksjoner ennå. Legg til en seksjon for å begynne.", + cvEditorProfileContentHint: "Når du legger til en seksjon fra karriereprofilen, tas det lagrede innholdet med. Egendefinerte seksjoner starter tomme.", + cvEditorIncludeProfileContact: "Ta med kontaktopplysninger fra karriereprofilen", + cvEmptyContent: "Legg til innhold i CV-en før du eksporterer en PDF.", cvDashboardEnterName: "Skriv inn et navn.", cvDashboardNameExample: "For eksempel: Backendutvikler — Acme", cvDashboardChooseTemplate: "Velg en mal", diff --git a/job-tracker-ui/src/views/CvBuilderEditor.tsx b/job-tracker-ui/src/views/CvBuilderEditor.tsx index a141acb..5656f31 100644 --- a/job-tracker-ui/src/views/CvBuilderEditor.tsx +++ b/job-tracker-ui/src/views/CvBuilderEditor.tsx @@ -2,7 +2,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from "react" import { Link as RouterLink, useBlocker, useNavigate, useParams } from "react-router-dom"; import { - Alert, Box, Button, Chip, Collapse, Dialog, DialogActions, DialogContent, DialogTitle, IconButton, + Alert, Box, Button, Checkbox, Chip, Collapse, Dialog, DialogActions, DialogContent, DialogTitle, FormControlLabel, IconButton, Menu, MenuItem, Paper, Skeleton, Stack, Tab, Tabs, TextField, Tooltip, Typography, } from "@mui/material"; import useMediaQuery from "@mui/material/useMediaQuery"; @@ -36,7 +36,7 @@ import { useDragReorder } from "../hooks/useDragReorder"; import { CUSTOM_SECTION_PRESETS, CvAiCallbacks, CvAiInput, CvAiProposal, CvCustomSectionSetting, CvItemOverride, CvOutline, CvOutlineSection, CvSectionSetting, CvTheme, CvVariant, CvVariantSettings, CvVariantVersionInfo, DEFAULT_SECTION_ORDER, SECTION_LABELS, - cvBuilderApi, getCvPageMetrics, getCvPreviewFitZoom, moveItem, normalizeCvLanguage, + cvBuilderApi, getCvExportFailureKey, getCvPageMetrics, getCvPreviewFitZoom, moveItem, normalizeCvLanguage, } from "../cvBuilder"; import { previewDocumentHtml } from "../cvPreview"; import { useDialogActions } from "../dialogs"; @@ -46,6 +46,11 @@ import ViewStateNotice from "../components/ViewStateNotice"; const MIN_PREVIEW_ZOOM = 0.2; type SaveState = "idle" | "unsaved" | "saving" | "saved" | "error" | "conflict"; +type EditorSnapshot = { name: string; settings: CvVariantSettings }; + +function isTextInput(element: Element | null) { + return !!element?.matches('textarea,input:not([type=checkbox]):not([type=radio]):not([type=range]),[contenteditable=true]'); +} export default function CvBuilderEditor() { const { id } = useParams(); @@ -101,15 +106,27 @@ export default function CvBuilderEditor() { const loadRegion = useRef(null); const retryFocus = useRef(false); const blockerPromptOpen = useRef(false); - const undoStack = useRef([]); - const redoStack = useRef([]); + const undoStack = useRef([]); + const redoStack = useRef([]); + const typingGroup = useRef<{ element: Element; time: number } | null>(null); + const loadedVariantId = useRef(null); const pageMetrics = useMemo(() => getCvPageMetrics(settings?.pageSize), [settings?.pageSize]); - const editorReady = settings !== null; + const editorReady = settings !== null && loadedVariantId.current === variantId && !opening; const previewBusy = previewing || (!previewError && (!html || measuredHtml !== html)); const previewReady = !!html && measuredHtml === html && !previewBusy && !previewError; useEffect(() => { let alive = true; + loadedVariantId.current = null; + latestSettings.current = null; + if (saveTimer.current) clearTimeout(saveTimer.current); + if (previewTimer.current) clearTimeout(previewTimer.current); + previewRequest.current += 1; + setSettings(null); + setHtml(""); + setMeasuredHtml(null); + setVersions([]); + setSaveState("idle"); setOpening(true); (async () => { try { @@ -132,6 +149,8 @@ export default function CvBuilderEditor() { return () => { alive = false; editorEpoch.current += 1; + previewRequest.current += 1; + if (saveTimer.current) clearTimeout(saveTimer.current); }; }, [variantId, loadAttempt]); @@ -148,6 +167,7 @@ export default function CvBuilderEditor() { editorEpoch.current += 1; serverVersion.current = variant.version; revisionConflict.current = false; + loadedVariantId.current = variant.id; setName(variant.name); setSettings(variant.settings); latestName.current = variant.name; @@ -158,12 +178,13 @@ export default function CvBuilderEditor() { setSaveState("saved"); undoStack.current = []; redoStack.current = []; + typingGroup.current = null; setHistoryRevision((value) => value + 1); }; // Debounced live preview. useEffect(() => { - if (!settings) return; + if (!editorReady || !settings) return; const request = ++previewRequest.current; setPreviewing(true); if (previewTimer.current) clearTimeout(previewTimer.current); @@ -183,7 +204,7 @@ export default function CvBuilderEditor() { return () => { if (previewTimer.current) clearTimeout(previewTimer.current); }; - }, [settings, previewRevision]); + }, [settings, previewRevision, editorReady]); const performSave = useCallback(async (next: CvVariantSettings, nextName: string, revision: number) => { const epoch = editorEpoch.current; @@ -260,7 +281,7 @@ export default function CvBuilderEditor() { const acceptAi = async (proposal: CvAiProposal, result?: string) => { const { epoch, revision } = await flushAiSource(); - const previous = latestSettings.current!; + const previous = { name: latestName.current, settings: latestSettings.current! }; const apply = async () => { if (epoch !== editorEpoch.current || revision !== saveRevision.current || revisionConflict.current) throw new Error(t("cvAiFailed")); try { @@ -274,6 +295,7 @@ export default function CvBuilderEditor() { } undoStack.current = [...undoStack.current.slice(-59), previous]; redoStack.current = []; + typingGroup.current = null; saveRevision.current += 1; persistedRevision.current = saveRevision.current; latestSettings.current = saved.settings; @@ -299,46 +321,67 @@ export default function CvBuilderEditor() { await queued; }; + const editDocument = (next: CvVariantSettings, nextName = latestName.current) => { + const previous = latestSettings.current; + if (!editorReady || !previous || (nextName === latestName.current && JSON.stringify(next) === JSON.stringify(previous))) return; + const element = document.activeElement; + const time = Date.now(); + const grouped = isTextInput(element) && typingGroup.current?.element === element && time - typingGroup.current.time < 1000; + if (!grouped) undoStack.current = [...undoStack.current.slice(-59), { name: latestName.current, settings: previous }]; + typingGroup.current = isTextInput(element) ? { element: element!, time } : null; + redoStack.current = []; + setSettings(next); + setName(nextName); + scheduleSave(next, nextName); + setHistoryRevision(value => value + 1); + }; + const update = (patch: Partial) => { - setSettings((prev) => { - if (!prev) return prev; - const next = { ...prev, ...patch }; - undoStack.current = [...undoStack.current.slice(-59), prev]; - redoStack.current = []; - setHistoryRevision((value) => value + 1); - scheduleSave(next); - return next; - }); + if (latestSettings.current) editDocument({ ...latestSettings.current, ...patch }); }; const undo = () => { - setSettings((current) => { - const previous = undoStack.current.pop(); - if (!current || !previous) return current; - redoStack.current.push(current); - setHistoryRevision((value) => value + 1); - scheduleSave(previous); - return previous; - }); + if (!editorReady || revisionConflict.current || !latestSettings.current) return; + const previous = undoStack.current.pop(); + if (!previous) return; + redoStack.current.push({ name: latestName.current, settings: latestSettings.current }); + typingGroup.current = null; + setSettings(previous.settings); + setName(previous.name); + scheduleSave(previous.settings, previous.name); + setHistoryRevision(value => value + 1); }; const redo = () => { - setSettings((current) => { - const next = redoStack.current.pop(); - if (!current || !next) return current; - undoStack.current.push(current); - setHistoryRevision((value) => value + 1); - scheduleSave(next); - return next; - }); + if (!editorReady || revisionConflict.current || !latestSettings.current) return; + const next = redoStack.current.pop(); + if (!next) return; + undoStack.current.push({ name: latestName.current, settings: latestSettings.current }); + typingGroup.current = null; + setSettings(next.settings); + setName(next.name); + scheduleSave(next.settings, next.name); + setHistoryRevision(value => value + 1); }; const renameVariant = (value: string) => { - setName(value); - latestName.current = value; - if (settings) scheduleSave(settings, value); + if (latestSettings.current) editDocument(latestSettings.current, value); }; + useEffect(() => { + const shortcut = (event: KeyboardEvent) => { + const target = event.target instanceof Element ? event.target : document.activeElement; + if (event.defaultPrevented || event.isComposing || event.altKey || !(event.ctrlKey || event.metaKey) + || target?.closest('input,textarea,select,[contenteditable=true]') || !editorReady || revisionConflict.current) return; + const key = event.key.toLowerCase(); + const wantsRedo = key === "y" || (key === "z" && event.shiftKey); + if (wantsRedo && redoStack.current.length) { event.preventDefault(); redo(); } + else if (key === "z" && !event.shiftKey && undoStack.current.length) { event.preventDefault(); undo(); } + }; + document.addEventListener("keydown", shortcut); + return () => document.removeEventListener("keydown", shortcut); + }); + const hasUnsavedChanges = saveState === "unsaved" || saveState === "saving" || saveState === "error" || saveState === "conflict"; const downloadUnsavedCopy = () => { @@ -353,10 +396,11 @@ export default function CvBuilderEditor() { const reloadSavedVariant = async () => { if (!(await confirmAction(t("cvEditorConflictReloadMessage"), { title: t("cvEditorConflictReload"), confirmLabel: t("cvEditorConflictReload"), destructive: true }))) return; const revision = saveRevision.current; + const epoch = editorEpoch.current; await saveQueue.current; try { const saved = await cvBuilderApi.get(variantId); - if (saveRevision.current === revision) applyVariant(saved); + if (editorEpoch.current === epoch && saveRevision.current === revision) applyVariant(saved); } catch (error) { toast(getApiErrorMessage(error, t("cvEditorOpenFailed")), "error"); } }; @@ -438,8 +482,8 @@ export default function CvBuilderEditor() { a.click(); URL.revokeObjectURL(url); } catch (err) { - const status = (err as { response?: { status?: number } })?.response?.status; - toast(status === 503 ? t("cvPdfBusy") : status === 504 ? t("cvPdfTimedOut") : getApiErrorMessage(err, t("cvEditorPdfFailed")), "error"); + const key = await getCvExportFailureKey(err); + toast(key ? t(key) : getApiErrorMessage(err, t("cvEditorPdfFailed")), "error"); } finally { exportInFlight.current = false; setExporting(false); @@ -461,26 +505,32 @@ export default function CvBuilderEditor() { }; const loadVersions = async () => { + const epoch = editorEpoch.current; try { - setVersions(await cvBuilderApi.versions(variantId)); + const result = await cvBuilderApi.versions(variantId); + if (epoch === editorEpoch.current) setVersions(result); } catch (err) { - toast(getApiErrorMessage(err, t("cvEditorHistoryFailed")), "error"); + if (epoch === editorEpoch.current) toast(getApiErrorMessage(err, t("cvEditorHistoryFailed")), "error"); } }; const restore = async (version: number) => { + const epoch = editorEpoch.current; if (!(await confirmAction(t("cvEditorRestoreMessage", { version }), { title: t("cvEditorRestoreTitle", { version }), confirmLabel: t("cvEditorRestoreVersion"), }))) return; + if (epoch !== editorEpoch.current) return; if (hasUnsavedChanges && !(await retrySave())) { toast(t("cvEditorSaveBeforeRestore"), "error"); return; } try { await saveQueue.current; + if (epoch !== editorEpoch.current) return; const revision = saveRevision.current; const updated = await cvBuilderApi.restore(variantId, version, serverVersion.current); + if (epoch !== editorEpoch.current) return; if (saveRevision.current === revision) applyVariant(updated); else { // Edits made while restore was in flight remain recoverable; never replace them on response. @@ -490,6 +540,7 @@ export default function CvBuilderEditor() { await loadVersions(); toast(t("cvEditorRestoredVersion", { version }), "success"); } catch (err) { + if (epoch !== editorEpoch.current) return; const code = (err as { response?: { data?: { code?: string } } })?.response?.data?.code; if (code === "cv_revision_conflict" || code === "cv_revision_required") { revisionConflict.current = true; @@ -550,13 +601,14 @@ export default function CvBuilderEditor() { ); } - if (!settings) return ; + if (!settings || !editorReady) return ; - const canUndo = historyRevision >= 0 && undoStack.current.length > 0; - const canRedo = historyRevision >= 0 && redoStack.current.length > 0; + const canUndo = !revisionConflict.current && historyRevision >= 0 && undoStack.current.length > 0; + const canRedo = !revisionConflict.current && historyRevision >= 0 && redoStack.current.length > 0; return ( - { typingGroup.current = null; }} + onPointerDownCapture={event => { if ((event.target as Element).closest('button,[role=button]')) typingGroup.current = null; }} sx={{ minWidth: 0, "--cv-pane-top": "calc(var(--app-shell-header-height, 76px) + 12px)", "& [tabindex], & button, & input, & textarea, & [role=combobox]": { scrollMarginTop: "var(--cv-pane-top)" }, @@ -612,6 +664,7 @@ export default function CvBuilderEditor() { + {t("cvEditorUndoSessionHelp")} { setTab(v); if (v === 4) loadVersions(); }} variant="scrollable" scrollButtons="auto" allowScrollButtonsMobile sx={{ borderBottom: "1px solid", borderColor: "divider" }}> {[1, 0, 2, 3, 4].map((value) => )} @@ -755,12 +808,13 @@ type DocumentAiBlock = { function documentAiBlocks(settings: CvVariantSettings, outline: CvOutline | null): DocumentAiBlock[] { const blocks: DocumentAiBlock[] = []; const configured = new Map(settings.sections.map((section) => [section.key, section])); - if ((settings.headline ?? outline?.headline)?.trim()) { - blocks.push({ id: "headline", items: [(settings.headline ?? outline?.headline ?? "").trim()] }); + const headline = settings.headline ?? (settings.includeProfileContact !== false ? outline?.headline : null); + if (headline?.trim()) { + blocks.push({ id: "headline", items: [headline.trim()] }); } for (const section of outline?.sections ?? []) { const sectionSetting = configured.get(section.key); - if (sectionSetting?.hidden) continue; + if (sectionSetting?.hidden || (settings.sectionsExplicit && !sectionSetting)) continue; const title = sectionSetting?.title ?? section.title; if (section.kind === "entries") { const entryKeys = Array.from(new Set(section.entries.map((entry, index) => entry.key ?? `${index}`))); @@ -786,6 +840,7 @@ function documentAiBlocks(settings: CvVariantSettings, outline: CvOutline | null } } for (const section of settings.customSections ?? []) { + if (settings.sectionsExplicit && !configured.has(`custom:${section.key}`)) continue; if (!(configured.get(`custom:${section.key}`)?.hidden ?? section.hidden)) blocks.push({ id: `custom:${section.key}`, title: section.title, items: section.items }); } return blocks.filter((block) => block.title?.trim() || block.subtitle?.trim() || block.items?.some((item) => item.trim())); @@ -982,8 +1037,9 @@ function ContentTab({ settings, update, outline, showPreviewEntry, onFocusPrevie const [expandedSections, setExpandedSections] = useState>(() => new Set()); const [reorderMessage, setReorderMessage] = useState(""); const sectionButtons = useRef>({}); - // Full section list = configured order (once touched) else default, always including every known key. + // Legacy documents inherit profile sections; new explicit documents include only chosen keys. const sectionRows: CvSectionSetting[] = useMemo(() => { + if (settings.sectionsExplicit) return settings.sections; const populated = new Set((outline?.sections ?? []).map((section) => section.key)); const base: CvSectionSetting[] = settings.sections.length ? [...settings.sections] : DEFAULT_SECTION_ORDER.map((key) => ({ key, hidden: !populated.has(key) })); const have = new Set(base.map((s) => s.key)); @@ -1000,7 +1056,7 @@ function ContentTab({ settings, update, outline, showPreviewEntry, onFocusPrevie } } return base; - }, [outline?.sections, settings.customSections, settings.sections]); + }, [outline?.sections, settings.customSections, settings.sections, settings.sectionsExplicit]); const writeSections = (rows: CvSectionSetting[]) => update({ sections: rows }); const reorderSections = (from: number, to: number, retainFocus = false) => { @@ -1060,7 +1116,8 @@ function ContentTab({ settings, update, outline, showPreviewEntry, onFocusPrevie setAddContentOpen(false); }; const addBuiltIn = (key: string) => { - patchSection(key, { hidden: false }); + if (sectionRows.some(row => row.key === key)) patchSection(key, { hidden: false }); + else writeSections([...sectionRows, { key, title: outlineByKey[key]?.title }]); setAddContentOpen(false); }; const updateCustom = (key: string, patch: Partial) => @@ -1081,7 +1138,7 @@ function ContentTab({ settings, update, outline, showPreviewEntry, onFocusPrevie - row.key === activeSection) ? activeSection : ""} onChange={(event) => setActiveSection(event.target.value)}> {sectionRows.map((row) => goToSection(row.key)}> @@ -1094,6 +1151,8 @@ function ContentTab({ settings, update, outline, showPreviewEntry, onFocusPrevie {t("cvEditorEditMasterProfile")}}> {t("cvEditorMasterProfileHelp")} + update({ includeProfileContact: checked })} />} label={t("cvEditorIncludeProfileContact")} /> update({ headline: e.target.value || null })} helperText={t("cvEditorHeadlineHelp")} /> @@ -1102,6 +1161,7 @@ function ContentTab({ settings, update, outline, showPreviewEntry, onFocusPrevie {t("cvEditorSections")} {t("cvEditorSectionsHelp")} {reorderMessage} + {!sectionRows.length && {t("cvEditorEmptyBody")}} {sectionRows.map((row, i) => ( {t("cvEditorAddContent")} {t("cvEditorAddContentHelp")} + {settings.sectionsExplicit && {t("cvEditorProfileContentHint")}} - {DEFAULT_SECTION_ORDER.map((key) => { + {Array.from(new Set([...DEFAULT_SECTION_ORDER, ...(outline?.sections ?? []).map(section => section.key)])).map((key) => { const row = sectionRows.find((section) => section.key === key); const added = !!row && !row.hidden; - const label = sectionName(key, SECTION_LABELS[key] ?? key, t); + const label = sectionName(key, outlineByKey[key]?.title ?? SECTION_LABELS[key] ?? key, t); return addBuiltIn(key)} />; })} {CUSTOM_SECTION_PRESETS.map((preset) => { diff --git a/job-tracker-ui/src/views/CvBuilderPage.tsx b/job-tracker-ui/src/views/CvBuilderPage.tsx index a1302db..01fb6bb 100644 --- a/job-tracker-ui/src/views/CvBuilderPage.tsx +++ b/job-tracker-ui/src/views/CvBuilderPage.tsx @@ -2,7 +2,7 @@ import React, { useEffect, useRef, useState } from "react"; import { useNavigate } from "react-router-dom"; import { - Alert, Box, Button, Chip, Dialog, DialogActions, DialogContent, DialogTitle, IconButton, Menu, MenuItem, Paper, Stack, TextField, Typography, + Alert, Box, Button, Checkbox, Chip, Dialog, DialogActions, DialogContent, DialogTitle, FormControlLabel, IconButton, Menu, MenuItem, Paper, Radio, RadioGroup, Stack, TextField, Typography, } from "@mui/material"; import AddIcon from "@mui/icons-material/Add"; import MoreVertIcon from "@mui/icons-material/MoreVert"; @@ -10,7 +10,7 @@ import PublicIcon from "@mui/icons-material/Public"; import { api, getApiErrorMessage } from "../api"; import { useToast } from "../toast"; -import { CvTheme, CvVariantSummary, cvBuilderApi, emptyCvVariantSettings } from "../cvBuilder"; +import { CvOutline, CvTheme, CvVariantSummary, cvBuilderApi, emptyCvVariantSettings, getCvExportFailureKey } from "../cvBuilder"; import { useDialogActions } from "../dialogs"; import CvTemplateGallery from "../components/CvTemplateGallery"; import CvTemplateThumbnail from "../components/CvTemplateThumbnail"; @@ -45,6 +45,20 @@ export default function CvBuilderPage() { const [newName, setNewName] = useState(t("cvDashboardUntitled")); const [newTheme, setNewTheme] = useState("modern"); const [creating, setCreating] = useState(false); + const [contentSource, setContentSource] = useState("blank"); + const [profileOutline, setProfileOutline] = useState(null); + const [profileLoading, setProfileLoading] = useState(false); + const [profileFailed, setProfileFailed] = useState(false); + const [selectedSections, setSelectedSections] = useState([]); + const [includeContact, setIncludeContact] = useState(false); + + const loadProfile = async () => { + setProfileLoading(true); + setProfileFailed(false); + try { setProfileOutline(await cvBuilderApi.outline()); } + catch { setProfileFailed(true); } + finally { setProfileLoading(false); } + }; useEffect(() => { if (pending) contentRef.current?.focus(); @@ -77,7 +91,12 @@ export default function CvBuilderPage() { setCreateError(null); setCreating(true); try { - const variant = await cvBuilderApi.create({ name: newName.trim(), settings: emptyCvVariantSettings(newTheme) }); + const variant = await cvBuilderApi.create({ name: newName.trim(), settings: { + ...emptyCvVariantSettings(newTheme), + sectionsExplicit: true, + includeProfileContact: contentSource === "profile" && includeContact, + sections: contentSource === "profile" ? selectedSections.map((key) => ({ key })) : [], + } }); setCreateOpen(false); navigate(`/career/builder/${variant.id}`); } catch (err) { @@ -144,8 +163,8 @@ export default function CvBuilderPage() { link.click(); URL.revokeObjectURL(url); } catch (err) { - const status = (err as { response?: { status?: number } })?.response?.status; - setActionError({ error: status === 503 || status === 504 ? undefined : err, key: status === 503 ? "cvPdfBusy" : status === 504 ? "cvPdfTimedOut" : "cvDashboardDownloadFailed" }); + const key = await getCvExportFailureKey(err); + setActionError({ error: key ? undefined : err, key: key ?? "cvDashboardDownloadFailed" }); } finally { finishAction(); } }; @@ -216,10 +235,27 @@ export default function CvBuilderPage() { {t("cvDashboardCreateHelp")} {createError && {getApiErrorMessage(createError.error, t("cvDashboardCreateFailed"))}} setNewName(event.target.value)} error={!newName.trim()} helperText={!newName.trim() ? t("cvDashboardEnterName") : t("cvDashboardNameExample")} sx={{ mb: 2, "& .MuiFormHelperText-root.Mui-disabled": { color: "text.secondary" } }} /> + { + setContentSource(event.target.value); + if (event.target.value === "profile" && !profileOutline && !profileLoading) void loadProfile(); + }}> + } label={t("cvDashboardStartBlank")} /> + } label={t("cvDashboardFromProfile")} /> + + {t(contentSource === "profile" ? "cvDashboardProfileSelectionHelp" : "cvDashboardBlankHelp")} + {contentSource === "profile" && + {profileLoading && {t("loading")}} + {profileFailed && void loadProfile()}>{t("retry")}}>{t("cvDashboardProfileLoadFailed")}} + {profileOutline && <> + setIncludeContact(event.target.checked)} />} label={t("cvEditorIncludeProfileContact")} /> + {profileOutline.sections.length === 0 && {t("cvDashboardProfileEmpty")}} + {profileOutline.sections.map((section) => setSelectedSections((previous) => event.target.checked ? [...previous, section.key] : previous.filter((key) => key !== section.key))} />} />)} + } + } {t("cvDashboardChooseTemplate")} { if (!actionBusy.current) setNewTheme(theme.id); }} previewHeight={125} showDescriptions={false} /> - + );