Compare commits

...

2 Commits

Author SHA1 Message Date
cesnimda 663023a088 fix: preserve blank CVs and reliable editor undo 2026-10-27 07:37:03 +01:00
cesnimda 5d4b8f23ce docs: audit pre-phase10 UX architecture and gates 2026-10-27 07:19:58 +01:00
23 changed files with 1053 additions and 97 deletions
@@ -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<DbUpdateConcurrencyException>(() => Service(staleSave).SaveAsync(Owner, id, "Stale name", Blank(), "autosave", 1, default));
await Assert.ThrowsAsync<DbUpdateConcurrencyException>(() => 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);
}
}
@@ -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()
{
+14
View File
@@ -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<CvAiSuggestionException>(() => 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:");
+66
View File
@@ -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("<header", html);
Assert.DoesNotContain("Ada Lovelace", html);
Assert.DoesNotContain("Fallback", html);
}
}
[Fact]
public void Explicit_sections_include_only_selected_content_and_allow_authored_headline()
{
var settings = CvVariantSettingsJson.Deserialize("{\"sectionsExplicit\":true,\"includeProfileContact\":false,\"headline\":\"Authored headline\",\"sections\":[{\"key\":\"skills\"}]}");
var model = CvVariantResolver.Build(Rich(), settings, "Fallback", null);
Assert.Equal("skills", Assert.Single(model.Sections).Key);
Assert.Equal("Authored headline", model.Headline);
Assert.Empty(model.FullName);
Assert.Empty(model.Contact);
}
[Fact]
public void Legacy_inclusion_flags_remain_absent_and_preserve_profile_inheritance()
{
var settings = CvVariantSettingsJson.Deserialize("{\"sections\":[]}");
var json = CvVariantSettingsJson.Serialize(settings);
Assert.DoesNotContain("sectionsExplicit", json);
Assert.DoesNotContain("includeProfileContact", json);
var model = CvVariantResolver.Build(Rich(), settings, "Fallback", null);
Assert.Equal("Ada Lovelace", model.FullName);
Assert.NotEmpty(model.Sections);
Assert.NotEmpty(model.Contact);
}
[Fact]
public async Task Explicit_blank_survives_save_duplicate_restore_and_design_change()
{
var (db, service) = NewService("user-1");
await using var dispose = db;
var settings = CvVariantSettingsJson.Deserialize("{\"sectionsExplicit\":true,\"includeProfileContact\":false,\"sections\":[]}");
var created = await service.CreateAsync("user-1", "Blank", null, settings, default);
settings.ThemeId = "clean";
await service.SaveAsync("user-1", created.Id, null, settings, "manual", 1, default);
var copy = await service.DuplicateAsync("user-1", created.Id, null, default);
var restored = await service.RestoreVersionAsync("user-1", created.Id, 1, 2, default);
foreach (var document in new[] { copy!, restored! })
{
var stored = CvVariantSettingsJson.Deserialize(document.SettingsJson);
Assert.Contains("\"sectionsExplicit\":true", document.SettingsJson);
Assert.Contains("\"includeProfileContact\":false", document.SettingsJson);
Assert.Empty(CvVariantResolver.Build(Rich(), stored, "Fallback", null).Sections);
}
}
[Fact]
public void Resolver_defaults_include_every_populated_section()
{
@@ -16,6 +16,27 @@ namespace JobTrackerApi.Tests;
public sealed class CvExportRevisionTests
{
[Fact]
public async Task Explicit_blank_export_is_rejected_before_pdf_allocation()
{
var current = new Mock<ICurrentUserService>();
current.SetupGet(x => x.UserId).Returns("owner");
await using var db = new JobTrackerContext(new DbContextOptionsBuilder<JobTrackerContext>()
.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<ClaimsPrincipal>())).ReturnsAsync(user);
var pdf = new Mock<ICvPdfExporter>(MockBehavior.Strict);
var controller = new CvVariantController(users.Object, variants, pdf.Object, Mock.Of<ISummarizerService>())
{ ControllerContext = new() { HttpContext = new DefaultHttpContext() } };
var result = Assert.IsType<BadRequestObjectResult>(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)]
@@ -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);
}
@@ -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<CvSectionSetting> Sections { get; set; } = new();
@@ -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)
{
@@ -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");
+22 -16
View File
@@ -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<string, CvRenderSection>(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)
+19 -9
View File
@@ -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" => $@"<div class=""cols"">{Sidebar(model, sidebarHtml, theme, showIcons, sidebarSections, headerStyle)}<section class=""main"">{mainHtml}</section></div>",
"sidebar-right" => $@"<div class=""cols""><section class=""main"">{mainHtml}</section>{Sidebar(model, sidebarHtml, theme, showIcons, sidebarSections, headerStyle)}</div>",
"sidebar-left" => $@"<div class=""cols"">{Sidebar(model, sidebarHtml, theme, showIcons, sidebarSections, headerStyle, settings.IncludeProfileContact == false)}<section class=""main"">{mainHtml}</section></div>",
"sidebar-right" => $@"<div class=""cols""><section class=""main"">{mainHtml}</section>{Sidebar(model, sidebarHtml, theme, showIcons, sidebarSections, headerStyle, settings.IncludeProfileContact == false)}</div>",
_ => $@"{header}<section class=""main"">{mainHtml}</section>",
};
var html = $@"<!DOCTYPE html>
@@ -65,7 +68,12 @@ public sealed class ThemedCvRenderer : IThemedCvRenderer
</html>";
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<string> 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<string> sidebarSections, string headerStyle)
private static string Sidebar(CvRenderModel model, string sectionsHtml, CvTheme theme, bool showIcons, IReadOnlyCollection<string> 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 $@"<aside class=""sidebar"">{header}{contact}{sectionsHtml}</aside>";
}
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" ? @"<div class=""kicker"">Curriculum Vitae</div>" : string.Empty;
var name = $@"<h1 class=""name"">{Enc(model.FullName)}</h1>";
var name = hideEmptyHeader && string.IsNullOrWhiteSpace(model.FullName) ? string.Empty : $@"<h1 class=""name"">{Enc(model.FullName)}</h1>";
var headline = string.IsNullOrWhiteSpace(model.Headline) ? string.Empty : $@"<div class=""headline"">{Enc(model.Headline)}</div>";
var contact = twoColumn ? string.Empty : RenderContactBlock(model.Contact, showIcons, sidebar: false);
var headerClass = twoColumn ? "hero" : $"header header-{headerStyle}";
+38
View File
@@ -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.
@@ -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.
+45
View File
@@ -0,0 +1,45 @@
# PRE-P10-UX design and scope
27 October 2026. Owner specification: CV-QA Addendum/PRE-P10-UX and appended administrator attribution/failure-diagnostics requirements, supplied in the attachment `03bb825f-4d45-40ea-8574-548831f24611/Pasted text.txt`.
The owner explicitly authorises autonomous safe implementation, validation, commits and pushes in the stated sequence. Routine design choices below are engineering decisions under that authorisation, not a claim that the owner separately reviewed this document. No additional design approval loop is required. Production, main merge, live-model activation and original Phase10 cleanup remain excluded.
## Verified entry
The completed CV-QA baseline is `4a8c907d70cbed235728f7d690b5409e72b93abe`, with remote equality verified. Its conditional acceptance permits development/QA; extraction, human/device/print and production qualifications remain limited. It contains `ef425c2` and CV-UX `4a0af62`. An attached managed worktree isolates unrelated local Docker/tooling/security work. Corrective branch: `codex/cv-builder-blank-undo`.
## A: blank creation and interactive history
Root causes found in source: `sections=[]` means inherit defaults on both server resolver and editor hydration; contact is always inherited; document AI/application matching also treat absent rows as visible. Undo mutates history inside React state updater callbacks, contains settings but not the document name, and has no document-level keyboard handling. Runtime reproduction and failing tests must precede fixes.
Use nullable settings-JSON flags `sectionsExplicit` and `includeProfileContact`. Missing flags retain legacy behavior and serialization/no-op compatibility. Blank explicitly selects zero sections and suppresses inherited contact/header/photo. Manual custom sections remain CV-specific content; adding existing Career Profile content is a deliberate action. From Career Profile exposes actual available-section/contact choices. A template changes presentation; duplicate preserves content and flags. Every resolver/context consumer must use the same inclusion semantics. Empty resolved content receives clear export validation before PDF allocation; no fictional placeholders are inserted.
Alternatives rejected: changing the meaning of every existing empty list would rewrite old CVs; hiding seeded defaults only in the UI would leave render/AI/matching leaks. A new master-profile or revision store is unnecessary.
Interactive undo/redo uses immutable document snapshots, pure state updates and bounded session-only stacks. Text editing groups by focus/edit burst; structural/design actions are individual transactions. New edits discard redo. Autosave uses the existing queue and expected server revision; success is acknowledged only after durable save. Conflict reload/restore/new-document hydration deliberately resets session history, while ordinary saves preserve it. Native input/contenteditable undo remains native; document shortcuts operate outside editable controls. History cannot cross document IDs, and delayed saves/previews must not rehydrate another document.
## B: versioned template quality and optional photographs
Keep all nine existing IDs for compatibility; this is the justified alternative to a numerical6–8 target. The catalogue already spans the requested layout families. Improve actual distinctness/spacing rather than add near-duplicates. Audit short/medium/long/dense equivalents across all nine, render PDF page images and inspect them.
Known source defects/limits: sidebar CSS ignores configured sidebar palette; Technical's usable identity width is30mm after padding, causing aggressive word wrapping; conservative section keep-together rules can produce sparse pages. Measure before changing shared pagination. Introduce an opt-in design revision if new styling would alter legacy lens renders; missing revision stays legacy. New creation/explicit template Apply may opt into revised styling. Duplicate/restore retain the chosen revision; frozen stored HTML/bytes remain untouched. Reuse licensed embedded fonts and existing photo upload/storage/crop pipeline; add accessible crop alternatives and explicit CV entry points where needed. No generated portraits or second image store.
## C: interactive dashboard
Replace form-heavy arrangement with an edit mode on the actual widgets: drag handles, resizing, accessible visibility/catalogue controls, save/discard/reset, keyboard movement/resizing and status announcements. Keep widget data/actions and the existing preference hook/owner/revision contract. Assess the current official React-Grid-Layout API/licence/React compatibility/bundle cost before selecting it; avoid reimplementing a collision engine without evidence.
Schema v2 stays in existing preference JSON/revision columns. Deterministic migration preserves v1 stored order, size and visibility; no automatic reset or write on viewport change. Persist bounded geometry with responsive behavior and per-widget minima; retain hidden geometry and future-widget rules. Old-schema clients cannot overwrite newer stored layout data. Phone/touch interactions preserve scrolling and functional widget controls; drag is never the only authoring method. No dashboard SQL migration is needed.
## D: administrator processing monitor
Reuse real `UserOperation`, queue/leases, CV runs and actual task-specific job-subject decoding. Current rows provide latest state, not historical transitions. Add a bounded owned operation-event table with monotonic cursor, UTC time, attempt and allowlisted event/status/stage/error codes. Append only successful transitions atomically; do not save unrelated tracked entities or log every heartbeat. Do not invent history for old rows. Include additive migration, retention, account erasure/export and actual MariaDB tests.
Dedicated server-admin-authorised list/detail/event APIs project safe metadata. Existing admin audit permits cross-owner identity/job metadata; ordinary APIs remain owner-scoped. Every association is checked against the operation owner. Display original user ID plus current permitted name/email; CV imports show no linked application, safe retained file metadata and known page count only. Deleted/mismatched relationships remain unavailable. No existing user-detail route is assumed.
Use bounded cursor polling with pause/resume/reconnect, filters, pagination and finite retention; SSE is optional, not required infrastructure. No raw document/prompt/completion/request/exception/stack/connection/lease data enters events or responses. Sanitisation occurs before storage/transmission. Unknown failures remain unknown. Operation GUID is the correlation key; propagate safe correlation across actual worker/backend/sidecar boundaries. Read-only monitoring avoids new privileged cancellation/retry paths unless separately proven safe. Audit any sensitive administrative action through existing owners. Disabled/disconnected model assistance is shown honestly; no live inference is started.
## Publication, preservation and acceptance
After validated A, create `feature/cv-template-library-review` from accepted A; then `feature/dashboard-interactive-grid` from accepted B; then `feature/admin-processing-monitor` from accepted C. Consolidate on `test/pre-phase10-ux-acceptance` from accepted D. No disconnected branch omits earlier work. Each checkpoint has focused RED/GREEN evidence, review, documentation, commit/push and remote check. Final regression serialises native build/browser/provider lanes and retains independent counts/source/runtime identities.
Preserve all57 capability groups/33 routes, canonical Career Profile, overrides, document/history/letter/application revisions, ownership/authentication, v1 dashboard data and licensed physical rendering. Keep old evidence/goldens; if intentional new design output differs, use separate versioned specimens rather than conceal regressions. Human/device/native-language/print/live-model/deployed acceptance is only recorded when actually performed. Final independent verdicts cover all requested areas, with owner actions and Phase10-vs-production scopes. Stop after the new Phase10 handoff; do not start cleanup.
+19
View File
@@ -0,0 +1,19 @@
# Owner feedback and acceptance register
27 October 2026. Baseline4a8c907. Entries are requested work, not passing claims.
| ID | Owner requirement | Entry evidence / intended verification | State |
|---|---|---|---|
| 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 |
| C1 | Direct draggable/resizable dashboard with catalogue/reset/view/edit | Existing v1 order/standard/full preferences and revision-safe hook; no arbitrary geometry. | Audit prepared |
| C2 | Preserve responsive/hidden/old/concurrent preferences | v2 migration and no viewport-triggered writes/reset; real pointer/keyboard/provider tests required. | Pending |
| D1 | Admin-only actual processing task/status/stage/stats/filter view | Existing UserOperation latest rows; admin identity precedent; no user detail route. | Audit prepared |
| D2 | Safe live event viewer, bounded retained actual history | No durable operation history; retries wipe current-state diagnostics. Add atomic typed events and cursor polling, never raw logs. | Pending |
| D3 | User attribution, actual job/import linkage, failure detail/correlation | Operation GUID/owner immutable; subject decoders differ; CV run has no job association. Safe identity/file/error policies and missing/deleted cases required. | Pending |
| INT | Full preservation and independent acceptance | Inherit57 groups/33 routes and all prior qualifications. No production/main/live-model/cleanup work. | Pending |
Original CV-QA/CV-UX acceptance records remain historical. Current final verdicts will be issued only from actual evidence; no owner-approved exception to A1/A2 has been requested or inferred.
+43
View File
@@ -0,0 +1,43 @@
# PRE-P10-UX implementation plan
> **For agentic workers:** Use superpowers:executing-plans or subagent-driven-development. Root owns integration, Editor history, serial native evidence and Git. Scoped workers own assigned files only; preserve other edits. Steps use checkbox syntax.
**Goal:** Deliver correct blank/history authoring, qualified distinct template designs, direct dashboard arrangement and a safe administrator processing/event monitor before cleanup.
**Architecture:** Extend existing settings/renderer, canonical preference/revision and durable operation owners. No duplicate master profile, image store or queue.
**Tech Stack:** Existing .NET9/EF/Pomelo/MariaDB, React19/MUI7/TypeScript, Playwright, pinned model-free sidecar/Docling and licensed font/PDF infrastructure.
**Spec:** Owner addendum and DESIGN.md.
## Global constraints
No main merge, production service/database/storage/model/cache/deployment operation, destructive migration, live inference, FINN/NAV/LinkedIn initiative or Phase10 cleanup. All feature branches cumulatively contain4a8c907. Use safe synthetic fixtures, isolated resources and exact independent counts. Commit/push/verify every validated checkpoint. No false human/device/model acceptance.
## Review focus
- Explicit empty must survive normalize/save/reopen/design/apply/duplicate/restore and cannot leak master facts into AI/matching.
- Strict Mode, native typing undo, burst grouping, in-flight autosave, stale revisions and document switches cannot corrupt history or save another document.
- New template spacing must not alter legacy/frozen output; genuine content completeness and actual physical pages outrank compact page counts.
- v1 preferences, hidden/future widgets and breakpoint changes must survive v2 geometry without automatic resets or lost concurrent updates.
- Admin joins, failure data and event transactions must fail closed without storing sensitive content, exposing foreign ordinary-user APIs or manufacturing missing history/model jobs.
## 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.
- [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.
- [ ] C-01: interactive real widget grid/edit/catalogue with geometry, min sizes, keyboard/touch alternatives, conflict-safe persistence; unit/provider/drag/resize/reflow/browser coverage; commit/push/verify.
- [ ] D-00: branch from accepted C; define exact safe diagnostic/event schema and admin identity/association policies; tests first.
- [ ] D-01: atomic structured events/correlation/retention and account rights, safe admin list/stats/detail/events, polling filters/reconnect/pause, EN/Bokmål and responsive UI; security/provider/browser validation; commit/push/verify.
- [ ] UX-INT-00: branch `test/pre-phase10-ux-acceptance` from accepted D. Fresh full backend/frontend/ordinary and added browsers/real MariaDB/RD21/Python/Docling/import/contracts/security/type/lint/build/Windows/Linux PDF/preservation; exact source/discovery/native exits, retain failures and fix regressions.
- [ ] UX-INT-01: independent feature/overall/Phase10/production verdicts, all57/33 preservation carry-forward, accepted branch commit register, human/operator debt and cleanup-only handoff. Commit/push/remote equality/own-work clean; preserve unrelated primary checkout work; stop.
## Execution commands and ownership
Use repository SDK `C:/Users/Connor/.dotnet/dotnet.exe`, existing npm/Jest/Playwright configs, `scripts/mariadb-integration.py`, unchanged original PDF proof helper and model-free sidecar. Native build/browser/provider lanes remain serial; unit-only Node may run independently. Graph-first where a local graph exists, followed by AST-only updates after code changes. New worktree dependencies are installed offline from cached lock files, without touching primary node_modules.
Root owns `CvBuilderEditor.tsx` and Editor history tests; A worker may own settings/resolver/creation/matching/export and library/types/tests/translation additions after explicit assignment. Template and dashboard/admin audits are read-only until preceding feature entry gates pass. Later file ownership will be recorded at dispatch.
@@ -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<string, string>;
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<string, string>;
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 });
});
}
@@ -26,6 +26,8 @@ jest.mock('./api', () => ({
}));
const mockedApi = api as jest.Mocked<typeof api>;
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: <CvBuilderEditor /> },
{ path: '/career/builder', element: <div>CV list destination</div> },
], { initialEntries: [`/career/builder/${id}`] });
return render(
const tree = (
<I18nProvider>
<ToastProvider>
<ConfirmProvider>
@@ -52,8 +54,9 @@ function renderAt(id: number) {
</PromptProvider>
</ConfirmProvider>
</ToastProvider>
</I18nProvider>,
</I18nProvider>
);
return { ...render(strict ? <React.StrictMode>{tree}</React.StrictMode> : tree), router };
}
beforeEach(() => {
@@ -65,7 +68,11 @@ beforeEach(() => {
mockedApi.post.mockResolvedValue({ data: { themeId: 'nordic', html: '<p>cv</p>', 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<any>, 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: '<p>CV</p>' } } 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();
});
+70 -3
View File
@@ -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<any>();
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 () => {
+13
View File
@@ -51,6 +51,8 @@ export type CvVariantSettings = {
showPhoto: boolean;
showPageNumbers: boolean;
showIcons: boolean;
sectionsExplicit?: boolean | null;
includeProfileContact?: boolean | null;
sections: CvSectionSetting[];
overrides: Record<string, CvItemOverride>;
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) ---
+28 -4
View File
@@ -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",
+115 -54
View File
@@ -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<HTMLDivElement | null>(null);
const retryFocus = useRef(false);
const blockerPromptOpen = useRef(false);
const undoStack = useRef<CvVariantSettings[]>([]);
const redoStack = useRef<CvVariantSettings[]>([]);
const undoStack = useRef<EditorSnapshot[]>([]);
const redoStack = useRef<EditorSnapshot[]>([]);
const typingGroup = useRef<{ element: Element; time: number } | null>(null);
const loadedVariantId = useRef<number | null>(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<CvVariantSettings>) => {
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() {
</Box>
);
}
if (!settings) return <EditorSkeleton />;
if (!settings || !editorReady) return <EditorSkeleton />;
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 (
<Stack spacing={1.5} data-content-density="comfortable" sx={{
<Stack spacing={1.5} data-content-density="comfortable" onBlurCapture={() => { 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() {
<Tooltip describeChild title={t("cvEditorRedo")}><span><IconButton size="small" aria-label={t("cvEditorRedo")} disabled={!canRedo} onClick={redo}><RedoIcon fontSize="small" /></IconButton></span></Tooltip>
</Stack>
</Stack>
<Typography variant="caption" color="text.secondary" sx={{ px: 1.5 }}>{t("cvEditorUndoSessionHelp")}</Typography>
<Tabs aria-label={t("cvEditorStudioNavigation")} value={tab} onChange={(_, v) => { setTab(v); if (v === 4) loadVersions(); }} variant="scrollable" scrollButtons="auto" allowScrollButtonsMobile sx={{ borderBottom: "1px solid", borderColor: "divider" }}>
{[1, 0, 2, 3, 4].map((value) => <Tab key={value} value={value} id={`cv-studio-tab-${value}`} aria-controls={`cv-studio-panel-${value}`} label={t((["cvEditorTemplate", "cvEditorContent", "cvEditorCustomize", "cvEditorAiTools", "cvEditorHistory"] as const)[value])} />)}
</Tabs>
@@ -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<Set<string>>(() => new Set());
const [reorderMessage, setReorderMessage] = useState("");
const sectionButtons = useRef<Record<string, HTMLButtonElement | null>>({});
// 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<CvCustomSectionSetting>) =>
@@ -1081,7 +1138,7 @@ function ContentTab({ settings, update, outline, showPreviewEntry, onFocusPrevie
<Stack spacing={2}>
<Stack direction="row" gap={1} alignItems="center" flexWrap="wrap">
<Box component="nav" aria-label={t("cvEditorSectionNavigation")} sx={{ flex: "1 1 14rem", minWidth: 0 }}>
<TextField select fullWidth size="small" label={t("cvEditorGoToSection")}
<TextField select fullWidth size="small" disabled={!sectionRows.length} label={t("cvEditorGoToSection")}
value={sectionRows.some((row) => row.key === activeSection) ? activeSection : ""}
onChange={(event) => setActiveSection(event.target.value)}>
{sectionRows.map((row) => <MenuItem key={row.key} value={row.key} onClick={() => goToSection(row.key)}>
@@ -1094,6 +1151,8 @@ function ContentTab({ settings, update, outline, showPreviewEntry, onFocusPrevie
<Alert severity="info" sx={{ flexWrap: "wrap", "& .MuiAlert-message": { flex: "1 1 12rem", minWidth: 0, overflowWrap: "anywhere" }, "& .MuiAlert-action": { ml: "auto" } }} action={<Button component={RouterLink} to="/career?section=profile" size="small">{t("cvEditorEditMasterProfile")}</Button>}>
{t("cvEditorMasterProfileHelp")}
</Alert>
<FormControlLabel control={<Checkbox checked={settings.includeProfileContact !== false}
onChange={(_, checked) => update({ includeProfileContact: checked })} />} label={t("cvEditorIncludeProfileContact")} />
<TextField label={t("cvEditorHeadlineOverride")} size="small" fullWidth value={settings.headline ?? ""}
onChange={(e) => update({ headline: e.target.value || null })}
helperText={t("cvEditorHeadlineHelp")} />
@@ -1102,6 +1161,7 @@ function ContentTab({ settings, update, outline, showPreviewEntry, onFocusPrevie
<Typography component="h3" variant="subtitle2" sx={{ fontWeight: 800, mb: 0.5 }}>{t("cvEditorSections")}</Typography>
<Typography variant="caption" color="text.secondary">{t("cvEditorSectionsHelp")}</Typography>
<Typography variant="caption" color="text.secondary" role="status" aria-label={t("cvEditorSectionOrderStatus")} sx={{ display: "block", minHeight: "1.5em" }}>{reorderMessage}</Typography>
{!sectionRows.length && <Alert severity="info">{t("cvEditorEmptyBody")}</Alert>}
<Stack spacing={0.5} sx={{ mt: 1 }}>
{sectionRows.map((row, i) => (
<SectionRow
@@ -1146,11 +1206,12 @@ function ContentTab({ settings, update, outline, showPreviewEntry, onFocusPrevie
<DialogTitle id="cv-add-content-title" sx={{ fontWeight: 850 }}>{t("cvEditorAddContent")}</DialogTitle>
<DialogContent dividers>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>{t("cvEditorAddContentHelp")}</Typography>
{settings.sectionsExplicit && <Alert severity="info" sx={{ mb: 2 }}>{t("cvEditorProfileContentHint")}</Alert>}
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", sm: "repeat(2, minmax(0, 1fr))", md: "repeat(3, minmax(0, 1fr))" }, gap: 1 }}>
{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 <AddContentCard key={key} title={label} description={t("cvEditorAddBuiltInHelp", { section: label })} added={added} onAdd={() => addBuiltIn(key)} />;
})}
{CUSTOM_SECTION_PRESETS.map((preset) => {
+42 -6
View File
@@ -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<CvOutline | null>(null);
const [profileLoading, setProfileLoading] = useState(false);
const [profileFailed, setProfileFailed] = useState(false);
const [selectedSections, setSelectedSections] = useState<string[]>([]);
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() {
<Typography id="create-cv-help" color="text.secondary" sx={{ mb: 2 }}>{t("cvDashboardCreateHelp")}</Typography>
{createError && <Alert id="create-cv-error" severity="error" sx={{ mb: 2 }}>{getApiErrorMessage(createError.error, t("cvDashboardCreateFailed"))}</Alert>}
<TextField autoFocus fullWidth disabled={creating} required label={t("cvEditorName")} value={newName} onChange={(event) => setNewName(event.target.value)} error={!newName.trim()} helperText={!newName.trim() ? t("cvDashboardEnterName") : t("cvDashboardNameExample")} sx={{ mb: 2, "& .MuiFormHelperText-root.Mui-disabled": { color: "text.secondary" } }} />
<RadioGroup aria-label={t("cvDashboardContentSource")} value={contentSource} onChange={(event) => {
setContentSource(event.target.value);
if (event.target.value === "profile" && !profileOutline && !profileLoading) void loadProfile();
}}>
<FormControlLabel value="blank" control={<Radio disabled={creating} />} label={t("cvDashboardStartBlank")} />
<FormControlLabel value="profile" control={<Radio disabled={creating} />} label={t("cvDashboardFromProfile")} />
</RadioGroup>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>{t(contentSource === "profile" ? "cvDashboardProfileSelectionHelp" : "cvDashboardBlankHelp")}</Typography>
{contentSource === "profile" && <Stack sx={{ mb: 2 }}>
{profileLoading && <Typography role="status">{t("loading")}</Typography>}
{profileFailed && <Alert severity="error" action={<Button onClick={() => void loadProfile()}>{t("retry")}</Button>}>{t("cvDashboardProfileLoadFailed")}</Alert>}
{profileOutline && <>
<FormControlLabel control={<Checkbox disabled={creating} checked={includeContact} onChange={(event) => setIncludeContact(event.target.checked)} />} label={t("cvEditorIncludeProfileContact")} />
{profileOutline.sections.length === 0 && <Typography>{t("cvDashboardProfileEmpty")}</Typography>}
{profileOutline.sections.map((section) => <FormControlLabel key={section.key} label={section.title} control={<Checkbox disabled={creating} checked={selectedSections.includes(section.key)} onChange={(event) => setSelectedSections((previous) => event.target.checked ? [...previous, section.key] : previous.filter((key) => key !== section.key))} />} />)}
</>}
</Stack>}
<Typography component="h3" variant="sectionTitle" sx={{ mb: 1 }}>{t("cvDashboardChooseTemplate")}</Typography>
<CvTemplateGallery disabled={creating} themes={themes.length ? themes : [{ id: "modern", name: t("cvDashboardDefaultTemplate"), category: t("cvEditorTemplate"), description: "", layout: "header-band", accent: "#3157d5", photoShape: "none", supportsIcons: false, atsFriendly: true, requiresPro: false, available: true, swatches: ["#3157d5", "#eef1f4", "#fff"] }]} selectedId={newTheme} onSelect={(theme) => { if (!actionBusy.current) setNewTheme(theme.id); }} previewHeight={125} showDescriptions={false} />
</DialogContent>
<DialogActions><Button onClick={() => setCreateOpen(false)} disabled={creating}>{t("cancel")}</Button><Button variant="contained" disabled={creating || !newName.trim()} onClick={() => void confirmCreate()}>{creating ? t("cvDashboardCreating") : t("cvDashboardCreateCv")}</Button></DialogActions>
<DialogActions><Button onClick={() => setCreateOpen(false)} disabled={creating}>{t("cancel")}</Button><Button variant="contained" disabled={creating || !newName.trim() || (contentSource === "profile" && (!profileOutline || profileLoading))} onClick={() => void confirmCreate()}>{creating ? t("cvDashboardCreating") : t("cvDashboardCreateCv")}</Button></DialogActions>
</Dialog>
</Box>
);