From 0bd290e788fc525157fe59ca830d39d5748fd192 Mon Sep 17 00:00:00 2001 From: cesnimda Date: Wed, 28 Oct 2026 15:32:47 +0100 Subject: [PATCH] feat(dashboard): add revision-safe interactive widget grid --- .../DashboardGeometryPersistenceTests.cs | 65 ++++++++++ .../DashboardPreferencesTests.cs | 63 +++++++++- JobTrackerApi/Controllers/AuthController.cs | 5 +- JobTrackerApi/Models/DashboardLayout.cs | 41 ++++++- docs/pre-phase10-ux/C_ACCEPTANCE.md | 40 ++++++ docs/pre-phase10-ux/C_IMPLEMENTATION.md | 47 ++++++++ docs/pre-phase10-ux/PLAN.md | 4 +- .../e2e/dashboard-presentation.spec.ts | 5 +- .../phase8-dashboard-customisation.spec.ts | 114 ++++++++++++++---- job-tracker-ui/package-lock.json | 59 +++++++++ job-tracker-ui/package.json | 1 + .../src/components/DashboardGrid.test.tsx | 75 ++++++++++++ .../src/components/DashboardGrid.tsx | 105 ++++++++++++++++ .../src/components/DashboardLayoutEditor.tsx | 58 ++------- .../src/components/DashboardView.tsx | 12 +- .../src/dashboard-customisation.test.tsx | 25 +++- job-tracker-ui/src/dashboardLayout.test.ts | 35 +++++- job-tracker-ui/src/dashboardLayout.ts | 33 +++-- .../src/hooks/useDashboardLayout.test.tsx | 2 +- .../src/hooks/useDashboardLayout.ts | 7 +- job-tracker-ui/src/i18n/translations.ts | 8 ++ 21 files changed, 696 insertions(+), 108 deletions(-) create mode 100644 JobTrackerApi.MariaDb.Tests/DashboardGeometryPersistenceTests.cs create mode 100644 docs/pre-phase10-ux/C_ACCEPTANCE.md create mode 100644 docs/pre-phase10-ux/C_IMPLEMENTATION.md create mode 100644 job-tracker-ui/src/components/DashboardGrid.test.tsx create mode 100644 job-tracker-ui/src/components/DashboardGrid.tsx diff --git a/JobTrackerApi.MariaDb.Tests/DashboardGeometryPersistenceTests.cs b/JobTrackerApi.MariaDb.Tests/DashboardGeometryPersistenceTests.cs new file mode 100644 index 0000000..55590e0 --- /dev/null +++ b/JobTrackerApi.MariaDb.Tests/DashboardGeometryPersistenceTests.cs @@ -0,0 +1,65 @@ +using System.Security.Claims; +using System.Text; +using JobTrackerApi.Controllers; +using JobTrackerApi.Data; +using JobTrackerApi.Models; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Xunit; + +namespace JobTrackerApi.MariaDb.Tests; + +public sealed class DashboardGeometryPersistenceTests +{ + private const string Geometry = "{\"version\":2,\"revision\":0,\"widgets\":[{\"id\":\"attention\",\"visible\":false,\"size\":\"standard\",\"x\":4,\"y\":17,\"w\":8,\"h\":10}]}"; + private static AuthController Controller(JobTrackerContext db, string body, string owner = "rd21-owner") + { + var http = new DefaultHttpContext { User = new ClaimsPrincipal(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, owner) }, "test")) }; + http.Request.Body = new MemoryStream(Encoding.UTF8.GetBytes(body)); + return new(new ConfigurationBuilder().Build(), null!, null!, null!, null!, null!, null!, null!, db) + { ControllerContext = new() { HttpContext = http } }; + } + + [Fact] + public async Task Geometry_is_durable_owner_scoped_and_stale_or_legacy_writes_preserve_other_preferences() + { + await using var database = await MariaDbDatabase.CreateAsync(); + await using (var seed = database.Context()) + { + seed.Users.AddRange(new ApplicationUser { Id = "rd21-owner", UserName = "geometry", UiLanguage = "nb-NO" }, new ApplicationUser { Id = "other", UserName = "untouched" }); + await seed.SaveChangesAsync(); + } + await using (var write = database.Context()) + Assert.IsType(await Controller(write, Geometry).UpdateDashboardPreference(default)); + await using var verify = database.Context(); + var owner = await verify.Users.AsNoTracking().SingleAsync(u => u.Id == "rd21-owner"); + var saved = DashboardLayout.Read(owner.DashboardLayoutJson, owner.DashboardLayoutRevision); + Assert.Equal(1, saved.Revision); + Assert.Equal(new DashboardWidget("attention", false, "standard", 4, 17, 8, 10), Assert.Single(saved.Widgets)); + Assert.Equal("nb-NO", owner.UiLanguage); + Assert.Null((await verify.Users.AsNoTracking().SingleAsync(u => u.Id == "other")).DashboardLayoutJson); + Assert.IsType(await Controller(verify, Geometry).UpdateDashboardPreference(default)); + Assert.IsType(await Controller(verify, "{\"version\":1,\"revision\":1,\"widgets\":[]}").UpdateDashboardPreference(default)); + Assert.Equal(owner.DashboardLayoutJson, (await verify.Users.AsNoTracking().SingleAsync(u => u.Id == owner.Id)).DashboardLayoutJson); + } + + [Fact] + public async Task Concurrent_geometry_saves_have_one_winner_and_one_conflict() + { + await using var database = await MariaDbDatabase.CreateAsync(); + await using (var seed = database.Context()) + { + seed.Users.Add(new() { Id = "rd21-owner", UserName = "concurrent-geometry" }); + await seed.SaveChangesAsync(); + } + await using var first = database.Context(); + await using var second = database.Context(); + var results = await Task.WhenAll(Controller(first, Geometry).UpdateDashboardPreference(default), Controller(second, Geometry.Replace("\"h\":10", "\"h\":12")).UpdateDashboardPreference(default)); + Assert.Single(results.OfType()); + Assert.Single(results.OfType()); + await using var readback = database.Context(); + Assert.Equal(1, (await readback.Users.SingleAsync()).DashboardLayoutRevision); + } +} diff --git a/JobTrackerApi.Tests/DashboardPreferencesTests.cs b/JobTrackerApi.Tests/DashboardPreferencesTests.cs index 28d0993..ef403e1 100644 --- a/JobTrackerApi.Tests/DashboardPreferencesTests.cs +++ b/JobTrackerApi.Tests/DashboardPreferencesTests.cs @@ -28,6 +28,61 @@ public sealed class DashboardPreferencesTests private const string Route = "/api/auth/preferences/dashboard"; private const string Layout = "{\"version\":1,\"revision\":0,\"widgets\":[{\"id\":\"cards\",\"visible\":true,\"size\":\"full\"}]}"; + [Fact] + public async Task Geometry_roundtrip_preserves_hidden_positions_and_blocks_legacy_downgrade() + { + await using var host = await Host.Start(); + using var owner = host.Client("owner"); + using var other = host.Client("other"); + const string geometry = "{\"version\":2,\"revision\":0,\"widgets\":[{\"id\":\"attention\",\"visible\":false,\"size\":\"standard\",\"x\":4,\"y\":17,\"w\":8,\"h\":10}]}"; + Assert.Equal(HttpStatusCode.OK, (await owner.PutAsync(Route, Json(geometry))).StatusCode); + var actual = await owner.GetFromJsonAsync(Route); + Assert.Equal(2, actual.GetProperty("version").GetInt32()); + Assert.Equal(1, actual.GetProperty("revision").GetInt64()); + var widget = actual.GetProperty("widgets")[0]; + Assert.False(widget.GetProperty("visible").GetBoolean()); + Assert.Equal(4, widget.GetProperty("x").GetInt32()); + Assert.Equal(17, widget.GetProperty("y").GetInt32()); + Assert.Equal(8, widget.GetProperty("w").GetInt32()); + Assert.Equal(10, widget.GetProperty("h").GetInt32()); + Assert.Equal(HttpStatusCode.Conflict, (await owner.PutAsync(Route, Json(Layout.Replace("\"revision\":0", "\"revision\":1")))).StatusCode); + Assert.Equal(actual.GetRawText(), (await owner.GetFromJsonAsync(Route)).GetRawText()); + Assert.Equal(0, (await other.GetFromJsonAsync(Route)).GetProperty("revision").GetInt64()); + } + + [Theory] + [InlineData(true, 4, HttpStatusCode.BadRequest)] + [InlineData(true, 6, HttpStatusCode.OK)] + [InlineData(false, 4, HttpStatusCode.OK)] + public async Task Visible_geometry_cannot_overlap_but_hidden_positions_and_adjacent_edges_are_supported(bool visible, int x, HttpStatusCode expected) + { + await using var host = await Host.Start(); + using var owner = host.Client("owner"); + var request = new { version = 2, revision = 0, widgets = new[] { + new { id = "attention", visible = true, size = "standard", x = 0, y = 0, w = 6, h = 8 }, + new { id = "funnel", visible, size = "standard", x, y = 0, w = 6, h = 8 }, + } }; + Assert.Equal(expected, (await owner.PutAsJsonAsync(Route, request)).StatusCode); + Assert.Equal(expected == HttpStatusCode.OK ? 1 : 0, (await owner.GetFromJsonAsync(Route)).GetProperty("revision").GetInt64()); + } + + [Theory] + [InlineData(-1, 0, 6, 8)] + [InlineData(8, 0, 6, 8)] + [InlineData(0, 250, 6, 8)] + [InlineData(0, 0, 3, 8)] + [InlineData(0, 0, 6, 3)] + [InlineData(0, 0, 6, 25)] + [InlineData(0.5, 0, 6, 8)] + public async Task Unbounded_or_fractional_geometry_is_rejected_before_persistence(double x, double y, double w, double h) + { + await using var host = await Host.Start(); + using var owner = host.Client("owner"); + var request = new { version = 2, revision = 0, widgets = new[] { new { id = "attention", visible = true, size = "standard", x, y, w, h } } }; + Assert.Equal(HttpStatusCode.BadRequest, (await owner.PutAsJsonAsync(Route, request)).StatusCode); + Assert.Equal(0, (await owner.GetFromJsonAsync(Route)).GetProperty("revision").GetInt64()); + } + [Fact] public async Task Http_roundtrip_is_owner_scoped_and_preserves_other_preferences() { @@ -131,10 +186,10 @@ public sealed class DashboardPreferencesTests await using var host = await Host.Start(); using var client = host.Client("owner"); await using var db = host.Db(); - const string future = "{\"version\":2,\"widgets\":[{\"id\":\"future-widget\",\"custom\":\"preserve\"}]}"; + const string future = "{\"version\":3,\"widgets\":[{\"id\":\"future-widget\",\"custom\":\"preserve\"}]}"; await db.Database.ExecuteSqlInterpolatedAsync($"UPDATE AspNetUsers SET DashboardLayoutJson={future}, DashboardLayoutRevision=4 WHERE Id='owner'"); var response = await client.GetFromJsonAsync(Route); - Assert.Equal(2, response.GetProperty("version").GetInt32()); + Assert.Equal(3, response.GetProperty("version").GetInt32()); Assert.Equal(4, response.GetProperty("revision").GetInt64()); Assert.Empty(response.GetProperty("widgets").EnumerateArray()); Assert.Equal(HttpStatusCode.Conflict, (await client.PutAsync(Route, Json("{\"version\":1,\"revision\":4,\"widgets\":[]}"))).StatusCode); @@ -157,7 +212,7 @@ public sealed class DashboardPreferencesTests { await preRead.Task.WaitAsync(TimeSpan.FromSeconds(10)); await using var db = host.Db(); - const string future = "{\"version\":2,\"widgets\":[{\"id\":\"future-widget\",\"custom\":\"preserve\"}]}"; + const string future = "{\"version\":3,\"widgets\":[{\"id\":\"future-widget\",\"custom\":\"preserve\"}]}"; await db.Database.ExecuteSqlInterpolatedAsync($"UPDATE AspNetUsers SET DashboardLayoutJson={future}, DashboardLayoutRevision=1 WHERE Id='owner'"); releaseBody.TrySetResult(); Assert.Equal(HttpStatusCode.Conflict, (await pending).StatusCode); @@ -190,7 +245,7 @@ public sealed class DashboardPreferencesTests [InlineData("{\"version\":1,\"revision\":\"0\",\"widgets\":[]}", 400)] [InlineData("{\"version\":1,\"revision\":9223372036854775807,\"widgets\":[]}", 400)] [InlineData("{\"version\":1,\"revision\":9007199254740991,\"widgets\":[]}", 400)] - [InlineData("{\"version\":2,\"revision\":0,\"widgets\":[]}", 400)] + [InlineData("{\"version\":3,\"revision\":0,\"widgets\":[]}", 400)] [InlineData("{\"version\":1,\"revision\":-1,\"widgets\":[]}", 400)] [InlineData("{\"version\":1,\"revision\":0,\"widgets\":null}", 400)] [InlineData("{\"version\":1,\"revision\":0,\"widgets\":[{\"id\":\"unknown\",\"visible\":true,\"size\":\"full\"}]}", 400)] diff --git a/JobTrackerApi/Controllers/AuthController.cs b/JobTrackerApi/Controllers/AuthController.cs index 87efcd3..00b5da9 100644 --- a/JobTrackerApi/Controllers/AuthController.cs +++ b/JobTrackerApi/Controllers/AuthController.cs @@ -565,7 +565,8 @@ public sealed class AuthController : ControllerBase var existing = await _db.Users.AsNoTracking().Where(x => x.Id == userId) .Select(x => new { x.DashboardLayoutJson, x.DashboardLayoutRevision }).SingleOrDefaultAsync(cancellationToken); if (existing is null) return Unauthorized(); - if (DashboardLayout.Read(existing.DashboardLayoutJson, 0).Version > 1) + var storedVersion = DashboardLayout.Read(existing.DashboardLayoutJson, 0).Version; + if (storedVersion > 2) return Conflict(new { error = "dashboard_version_unsupported" }); if (Request.ContentLength > DashboardLayout.MaxBytes) return StatusCode(StatusCodes.Status413PayloadTooLarge); // Bound actual bytes as well as Content-Length, including chunked bodies. @@ -586,6 +587,8 @@ public sealed class AuthController : ControllerBase } catch (JsonException) { return BadRequest("Invalid dashboard layout JSON."); } if (layout is null) return BadRequest("Invalid dashboard layout version, revision or widgets."); + if (layout.Version < storedVersion) + return Conflict(new { error = "dashboard_version_unsupported" }); if (layout.Revision != existing.DashboardLayoutRevision) return Conflict(new { error = "dashboard_revision_conflict" }); var json = JsonSerializer.Serialize(layout, new JsonSerializerOptions(JsonSerializerDefaults.Web)); diff --git a/JobTrackerApi/Models/DashboardLayout.cs b/JobTrackerApi/Models/DashboardLayout.cs index 85be34c..9fee99b 100644 --- a/JobTrackerApi/Models/DashboardLayout.cs +++ b/JobTrackerApi/Models/DashboardLayout.cs @@ -1,9 +1,14 @@ using System.Text; using System.Text.Json; +using System.Text.Json.Serialization; namespace JobTrackerApi.Models; -public sealed record DashboardWidget(string Id, bool Visible, string Size); +public sealed record DashboardWidget(string Id, bool Visible, string Size, + [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] int? X = null, + [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] int? Y = null, + [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] int? W = null, + [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] int? H = null); public sealed record DashboardLayout(int Version, long Revision, IReadOnlyList Widgets) { public const int MaxBytes = 16 * 1024; @@ -14,7 +19,7 @@ public sealed record DashboardLayout(int Version, long Revision, IReadOnlyList 32) return null; if (!reading && (!root.TryGetProperty("revision", out var rev) || rev.ValueKind != JsonValueKind.Number || !rev.TryGetInt64(out revision) || revision < 0 || revision >= MaxRevision)) return null; var result = new List(); @@ -35,9 +40,35 @@ public sealed record DashboardLayout(int Version, long Revision, IReadOnlyList 12 || y is < 0 or > 256 || w is < 4 or > 12 || h is < 4 or > 24 || x + w > 12 || y + h > 256 + || (id == "cards" && w != 12) || (!reading && size != (w == 12 ? "full" : "standard"))) + { + if (reading) continue; + return null; + } + result.Add(new DashboardWidget(id, visible.GetBoolean(), w == 12 ? "full" : "standard", x, y, w, h)); + } } - return new DashboardLayout(1, revision, result); + if (!reading && v == 2) + { + var visibleWidgets = result.Where(widget => widget.Visible).ToArray(); + for (var first = 0; first < visibleWidgets.Length; first++) + for (var second = first + 1; second < visibleWidgets.Length; second++) + { + var a = visibleWidgets[first]; var b = visibleWidgets[second]; + if (a.X < b.X + b.W && a.X + a.W > b.X && a.Y < b.Y + b.H && a.Y + a.H > b.Y) return null; + } + } + return new DashboardLayout(v, revision, result); } public static DashboardLayout Read(string? json, long revision) @@ -49,7 +80,7 @@ public sealed record DashboardLayout(int Version, long Revision, IReadOnlyList 1) + && version.TryGetInt32(out var futureVersion) && futureVersion > 2) return new(futureVersion, revision, []); return Parse(document.RootElement, reading: true, revision) ?? new(1, revision, []); } diff --git a/docs/pre-phase10-ux/C_ACCEPTANCE.md b/docs/pre-phase10-ux/C_ACCEPTANCE.md new file mode 100644 index 0000000..7ef9d60 --- /dev/null +++ b/docs/pre-phase10-ux/C_ACCEPTANCE.md @@ -0,0 +1,40 @@ +# C: interactive dashboard acceptance + +28 October2026. Branch `feature/dashboard-interactive-grid`, cumulative base `aea7c29` includes accepted B, A, CV-QA and CV-UX history. Source checkpoint is the commit containing this report. **CONDITIONAL GO for continued development and D entry; production NO-GO.** + +The dashboard now arranges the actual reports using drag handles and corner resizing on wide containers. A compact visibility catalogue and per-widget keyboard movement/width/height/hide controls replace the former nine-row arrangement form. Save, discard, reset, conflict recovery and navigation guards retain the existing preference owner. Analytics retains its original layout. + +Schema2 uses bounded12-column geometry, preserving legacy order/visibility/revision through an in-memory migration. Loading, mounting or crossing a breakpoint never saves. Hidden geometry survives. The existing authenticated, byte-bounded, owner-filtered revision compare-and-swap remains the only persistence path. Old clients cannot downgrade a stored schema2 layout. Visible overlapping rectangles are rejected; hidden positions may overlap. Collision resolution uses the installed grid library, preserving gaps when no collision exists. No database migration or extra preference store. + +## Evidence + +Artifacts are under `.artifacts/pre-phase10-ux/`; these are independent lanes, not additive totals. + +| Lane | Result | Evidence | +| --- | --- | --- | +| Full backend | **1,405 passed**, zero failed/skipped | `c-backend-02/c-backend.trx` | +| Full frontend | **1,019 passed/111 suites** before the final extra collision case | `c-frontend-full-01.json` | +| Final focused frontend | **33 passed/5 suites**, including the extra collision case and final implementation | `c-focused-final-03.json` | +| Real MariaDB12.2.2 | **86 passed**, zero failed/skipped; generated resources cleaned | `c-mariadb-01/{mariadb.trx,run.json}` | +| Dashboard browsers | **12 passed**, zero failed/skipped/retries | `c-browser-full-03.json` | +| Static build/typecheck | Native exits0; final build includes TypeScript | `c-build-05.log`, `c-typecheck-final.log` | +| Full lint | Native exit0 | `c-lint-final.log` | +| Production npm audit | Native exit0, zero advisories | `c-npm-production-audit.json` | +| Full npm audit | Native exit1, five low dev-tool entries in existing Jest/deepmerge dependencies | `c-npm-audit.json` | + +The full frontend total does not include the subsequently added collision case; do not add overlapping focused reruns to that total. The final integrated regression must rediscover and run the entire resulting suite. + +The twelve browser cases retain native report values/actions, fresh browser and logout/login readback, draft/cancel/reset, offline/503 recovery, delayed acknowledgements, two-session conflicts, ordinary/admin/anonymous isolation, legacy hidden flags, all-hidden recovery, actual pointer drag/resize, keyboard focus/status, emulated touch, stored gaps/hidden geometry and zero viewport-induced writes. Reflow covers EN/Bokmål, light/dark, widths320/390/768/1024/1440/1920 across the presentation scenarios; automated axe checks pass in their exercised scopes. The original data and persistence assertions remain. Screenshots/traces are attached to the latest browser results. + +Initial RED migration and HTTP cases are retained. Browser failures exposed a real announcement-element horizontal overflow and a resize overlap in the library's no-compaction mode. Both are fixed, with unchanged outer-overflow assertions, a new pure collision regression and actual browser save/readback. The server's rejection prevented persisting the overlap. Earlier failed JSON/log artifacts remain; they are not counted as passing evidence. + +All exported JS artifacts increase from3,499,170 to3,572,883 raw bytes, or1,032,319 to1,055,955 individually gzip-compressed bytes: **+73,713 raw/+23,636 gzip**. `c-bundle-final.json` measures the complete static artifact set, not a route download or runtime speed. `react-grid-layout` is pinned2.3.0/MIT; no Jest/deepmerge dependency was changed by its installation. A proposed audit fix downgrading Jest25 was not applied; the five tooling entries remain acceptance debt. + +## Decisions and limits + +- Interactive arrangement, preference compatibility, authenticated persistence and automated responsive/accessibility scope: **PASS in the evidence above**. +- C development/D readiness: **CONDITIONAL GO**. Human screen-reader, physical-touch, software-keyboard and subjective usability acceptance remain open. No physical-device or human language acceptance is claimed. +- Full integrated browser/contracts/Python/Docling/security/preservation/PDF lanes: **pending the final integration checkpoint**; C did not modify parser, Career Profile, CV/letter rendering or their ownership contracts. Existing B PDF/name-oracle/model debt remains open and is not waived. +- Existing local runtime still serves qualified B source3f4db8c; this C checkpoint has **not** been activated there. Production, main merge and Phase10 cleanup remain **NO-GO/not performed**. + +See [implementation contract](C_IMPLEMENTATION.md) for architecture, limits and reproduction evidence. Proceed to the administrator processing monitor from this cumulative checkpoint. diff --git a/docs/pre-phase10-ux/C_IMPLEMENTATION.md b/docs/pre-phase10-ux/C_IMPLEMENTATION.md new file mode 100644 index 0000000..afcbbc0 --- /dev/null +++ b/docs/pre-phase10-ux/C_IMPLEMENTATION.md @@ -0,0 +1,47 @@ +# C: interactive dashboard implementation contract + +28 October 2026. Entry: accepted cumulative B history through `aea7c29` (source3f4db8c), verified pushed. Branch `feature/dashboard-interactive-grid`. Existing local runtime continues to serve B until C is separately qualified. + +Keep the existing widget registry, reports, preference JSON/revision columns, authentication boundaries, draft/save/cancel hook and navigation guard. Analytics/reporting retains its current layout. No dashboard database migration or parallel preference store is needed. + +Use pinned `react-grid-layout@2.3.0`, modern typed API. Official package metadata declares MIT and React/ReactDOM>=16.3; installed exports and declarations were inspected. Its grouped grid/drag/resize configuration and existing compactor avoid a new collision engine. Actual React19 pointer/resize compatibility, accessibility alternatives and bundle impact must be measured, not inferred from peer ranges. Sources: [upstream package](https://github.com/react-grid-layout/react-grid-layout/blob/master/package.json), [modern API](https://github.com/react-grid-layout/react-grid-layout#whats-new-in-v2). + +## Geometry and compatibility + +- Canonical schema2 stores bounded integer `x,y,w,h` per known widget on12 columns. Retain legacy `size` as a consistent full/standard alias. Bounds: x>=0,y>=0,w4–12,h4–24,x+w<=12,y+h<=256; summary cards remain12 columns. Hidden geometry remains stored. +- Deterministically migrate schema1 order/visibility/full-or-standard widths in memory. New missing widgets remain hidden for existing accounts. Loading or changing viewport never persists a reset or migration. +- Preserve valid schema2 geometry/revision. Unsupported future schemas remain blocked. Schema1 clients cannot overwrite stored schema2, even with a current revision. Retain request-byte bounds and atomic owner/revision compare-and-swap. +- Visible rectangles cannot overlap on write; adjacent edges and overlapping hidden rectangles are allowed. The grid retains gaps using the library's `noCompactor` when there are no collisions, and delegates actual overlap resolution to `verticalCompactor` using `getAllCollisions`. A RED resize test exposed that `noCompactor` alone permits overlapping rectangles during resizing; the server rejected the write. Completed pointer operations order visible records by row/column, retaining hidden slots and all geometry. +- Desktop uses measured container width and real drag/resize handles. Mobile/tablet narrow containers use a linear content layout, retaining desktop geometry without writes. Content remains scrollable/readable at bounded sizes. + +## Interaction + +Edit the actual report widgets. The existing toolbar retains explicit Save, Discard, Reset and conflict/reload behaviour; a compact catalogue replaces the large per-widget configuration form. Drag handles and widget options supply keyboard movement, width/height and visibility alternatives. Keep focus after moves, announce changes, respect reduced motion and preserve ordinary widget links/inputs and touch scrolling. + +Only actual drag/resize completion or explicit controls change the draft. Ignore mount/compaction/breakpoint callbacks as persistence events. No timers or automatic saves. A save success follows confirmed durable acknowledgement; conflicts retain the draft and require deliberate recovery. + +## Qualification + +1. RED/GREEN pure migration/geometry and backend HTTP schema/stale-write tests, retaining existing owner/revision/corruption/request-bound checks. Future-schema fixtures advance to3 because2 is now supported. +2. Real widget pointer drag/resize, keyboard/menu/catalogue, save/reload, discard/reset, failed save and two-tab conflict; existing report actions/data and hidden preferences survive. +3. EN/Bokmål, light/dark, desktop/tablet/mobile, reduced motion and accessibility automation. Physical device/assistive-technology acceptance remains separate. +4. Real MariaDB revision/ownership/preservation coverage, focused/full appropriate regressions, measured aggregate compiled bundle impact and browser screenshots. Commit/push/remote verification before D entry. + +No production deployment, main merge, Phase10 cleanup or unrelated integrations. + +## Implementation and evidence, 28 October + +Implemented in `DashboardGrid.tsx`, the existing editor/catalogue, `dashboardLayout.ts`, preference hook and existing AuthController/JSON model. No preference table, queue, schema migration or new data owner. Actual report components and their links/tables remain intact. Narrow containers use content height and desktop geometry survives without writes. Scrollable desktop content has keyboard focus; controls include native width/height inputs, movement, visibility, announcements and focus recovery. The reduced-motion rule disables item transitions. + +Evidence under `.artifacts/pre-phase10-ux/`: + +- Initial migration RED: `c-layout-red.log`, 2 failed/6 passed. Initial HTTP geometry RED: `c-layout-red/c-layout-red.trx`, 1 failed/31 passed. GREEN foundation: 8 pure tests and32 API tests. Added visible-overlap/adjacency/hidden cases subsequently pass in the full backend suite. +- Full backend `c-backend-02/c-backend.trx`: **1,405 passed, zero failed/skipped**, including35 dashboard HTTP cases, existing revision/authentication/preservation tests. +- Real MariaDB12.2.2 `c-mariadb-01/`: **86 passed, zero failed/skipped**, including new durable geometry/owner/legacy/stale protection and concurrent one-winner tests. Generated schema/container cleanup exited0. No production database access. +- Final focused frontend `c-focused-final-03.json`: **33 passed/5 suites**; includes4 grid collision/callback/narrow viewport/conflict cases. Test-double callbacks qualify surrounding logic only; actual pointer qualification uses Playwright. Collision RED `c-collision-red.json`:1 failed/3 passed; GREEN `c-collision-green.json`:4 passed. +- Browser qualification and full frontend totals are recorded in C_ACCEPTANCE.md when complete. Failed runs are retained. A screen-reader announcement used MUI numeric width1 (100%), causing outer overflow; corrected to explicit1px and the existing reflow assertion passes. Resize simulation now starts at the actual handle centre, avoiding a clipped rounded corner. Tests now open the compact catalogue and compare pointer-induced order to actual geometry; no original ownership, persistence, failure or accessibility assertions were removed. +- Final static build `c-build-05.log` exits0; exact React19 runtime tested by actual browser cases. Full lint/typecheck native exits are recorded alongside final acceptance. +- `c-bundle-final.json`: all exported JS artifacts, B57 files3,499,170 raw/1,032,319 gzip bytes; C57 files3,572,883 raw/1,055,955 gzip. Delta **73,713 raw/23,636 gzip bytes**. This measures aggregate artifacts, not route download or runtime performance. CSS is separate. Dependency is pinned2.3.0/MIT; official peer range permits React>=16.3, actual React19 functionality requires the browser evidence. +- `c-npm-production-audit.json`: exit0, zero production advisories. `c-npm-audit.json`: exit1, five low dev-tool dependency entries around existing Jest/deepmerge; no package-lock changes to those existing packages. No new grid dependency is flagged. A suggested downgrade to Jest25 is not applied. Carry these tooling advisories to integrated security qualification; this is not a clean full audit. + +No human screen-reader, physical-touch or subjective design acceptance is inferred from automation. Existing B rendering/model qualification debt remains open. C has not been deployed to the local B runtime or production. diff --git a/docs/pre-phase10-ux/PLAN.md b/docs/pre-phase10-ux/PLAN.md index 90bbd07..2b5e0ff 100644 --- a/docs/pre-phase10-ux/PLAN.md +++ b/docs/pre-phase10-ux/PLAN.md @@ -29,8 +29,8 @@ No main merge, production service/database/storage/model/cache/deployment operat - [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. - [x] 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. See TEMPLATE_AUDIT.md. - [x] B-01: versioned designs, actual samples, matching letters, secure optional photographs, queue repair and local runtime. Backend1394/frontend1014/MariaDB84/interactive browser6/real Docling2; revised CV36+letter18 per platform and original Linux24+4. Conditional development acceptance and retained large-name oracle failures: B_ACCEPTANCE.md. Source3f4db8c committed/pushed with remote equality verified; qualified source images activated only locally on28 October. -- [ ] 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. +- [x] C-00: branch from accepted B; pinned2.3.0/MIT, installed modern API/React19 pointer proof, measured+23,636 aggregate gzip bytes; schema2 and legacy RED/GREEN migration tests. Combined with C-01 source checkpoint. +- [x] C-01: actual widgets drag/resize, compact catalogue and keyboard controls, bounded non-overlapping geometry, no automatic writes, stale-client/CAS protection. Backend1405/full frontend1019 plus final focused33/MariaDB86/browser12; C_ACCEPTANCE.md records conditional development acceptance and independent evidence. Commit/push/remote equality form the publication step of this checkpoint. - [ ] 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. diff --git a/job-tracker-ui/e2e/dashboard-presentation.spec.ts b/job-tracker-ui/e2e/dashboard-presentation.spec.ts index 57d8c40..adc887b 100644 --- a/job-tracker-ui/e2e/dashboard-presentation.spec.ts +++ b/job-tracker-ui/e2e/dashboard-presentation.spec.ts @@ -92,7 +92,8 @@ test('[PRES-S01 S02 E07 E08 E09] Dashboard native data, visibility and responsiv const editor = page.getByRole('region', { name: t.dashboardEditing, exact: true }); await expect(editor).toBeVisible(); await expect(editor.locator('..')).toBeFocused(); - await expect(editor.getByRole('list', { name: t.dashboardManageWidgets })).toBeVisible(); + await editor.locator('summary').click(); + await expect(editor.getByRole('group', { name: t.dashboardManageWidgets })).toBeVisible(); await expect(editor.getByRole('checkbox')).toHaveCount(9); if (language === 'en') { const editorScan = await new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa']).analyze(); @@ -117,6 +118,7 @@ test('[PRES-S01 S02 E07 E08 E09] Dashboard native data, visibility and responsiv expect((await page.request.put(`${apiUrl}/auth/preferences/language`, { headers, data: { language: 'en' } })).status()).toBe(204); await page.evaluate(() => localStorage.setItem('uiLanguage', 'en')); await page.reload(); await page.getByRole('button', { name: 'Customize dashboard' }).click(); + await page.getByRole('region', { name: 'Editing dashboard', exact: true }).locator('summary').click(); for (const item of await page.getByRole('region', { name: 'Editing dashboard', exact: true }).getByRole('checkbox').all()) await item.uncheck(); await page.getByRole('button', { name: 'Save layout', exact: true }).click(); await expect(page.getByRole('region', { name: 'Editing dashboard', exact: true })).toHaveCount(0); @@ -141,6 +143,7 @@ test('[PRES-S01 S02 E07 E08 E09] Dashboard native data, visibility and responsiv await capture(page, info, 'dashboard-new-account'); await page.getByRole('button', { name: 'Customize dashboard' }).click(); const newAccountEditor = page.getByRole('region', { name: 'Editing dashboard', exact: true }); + await newAccountEditor.locator('summary').click(); await expect(newAccountEditor.getByRole('checkbox')).toHaveCount(9); for (const item of await newAccountEditor.getByRole('checkbox').all()) await expect(item).toBeChecked(); await page.keyboard.press('Escape'); diff --git a/job-tracker-ui/e2e/phase8-dashboard-customisation.spec.ts b/job-tracker-ui/e2e/phase8-dashboard-customisation.spec.ts index 00ef177..27d775a 100644 --- a/job-tracker-ui/e2e/phase8-dashboard-customisation.spec.ts +++ b/job-tracker-ui/e2e/phase8-dashboard-customisation.spec.ts @@ -23,8 +23,15 @@ async function readLayout(api: APIRequestContext): Promise { return response.json(); } const editor = (page: Page) => page.getByRole('region', { name: 'Editing dashboard', exact: true }); +const preview = (page: Page) => page.getByRole('region', { name: 'Dashboard preview', exact: true }); +async function options(page: Page, id: string) { + const widget = page.locator(`[data-dashboard-widget="${id}"]`); + await widget.locator('summary').first().click(); + return widget; +} async function openEditor(page: Page) { await page.getByRole('button', { name: 'Customize dashboard', exact: true }).click(); + await editor(page).locator('summary').click(); await expect(editor(page).getByRole('checkbox')).toHaveCount(9); } async function save(page: Page) { @@ -76,14 +83,15 @@ const test = base.extend<{ account: Account }>({ test('[P8] Saved order, size and visibility survive HTTP readback, a fresh browser and logout/login', async ({ page, browser, account }, info) => { await openEditor(page); - await editor(page).getByRole('button', { name: 'Move Current pipeline earlier', exact: true }).click(); - await editor(page).getByRole('combobox', { name: 'Size for Current pipeline', exact: true }).selectOption('full'); + await options(page, 'funnel'); + await preview(page).getByRole('button', { name: 'Move Current pipeline earlier', exact: true }).click(); + await preview(page).getByRole('combobox', { name: 'Size for Current pipeline', exact: true }).selectOption('full'); await editor(page).getByRole('checkbox', { name: 'Application activity', exact: true }).uncheck(); await save(page); const saved = await readLayout(page.request); - expect(saved.version).toBe(1); expect(saved.revision).toBe(1); + expect(saved.version).toBe(2); expect(saved.revision).toBe(1); expect(saved.widgets.map(widget => widget.id)).toEqual(['cards', 'funnel', 'attention', 'activity', 'stageAge', 'sources', 'salary', 'companies', 'skills']); - expect(saved.widgets.find(widget => widget.id === 'funnel')).toEqual({ id: 'funnel', visible: true, size: 'full' }); + expect(saved.widgets.find(widget => widget.id === 'funnel')).toMatchObject({ id: 'funnel', visible: true, size: 'full', w: 12 }); expect(saved.widgets.find(widget => widget.id === 'activity')?.visible).toBe(false); const assertSaved = async (target: Page) => { await expect(target.locator('[data-dashboard-widget="activity"]')).toHaveCount(0); @@ -153,7 +161,8 @@ test('[P8] HTTP 503 and offline transport failure retain draft and support an ac const before = await readLayout(page.request); await openEditor(page); await editor(page).getByRole('checkbox', { name: 'Job tags', exact: true }).uncheck(); - await editor(page).getByRole('combobox', { name: 'Size for Current pipeline', exact: true }).selectOption('full'); + await options(page, 'funnel'); + await preview(page).getByRole('combobox', { name: 'Size for Current pipeline', exact: true }).selectOption('full'); let failure: '503' | 'offline' | null = '503'; await page.route(layoutUrl, route => route.request().method() !== 'PUT' || !failure ? route.continue() : failure === '503' ? route.fulfill({ status: 503, json: { message: 'Controlled unavailable' } }) : route.abort('internetdisconnected')); @@ -167,7 +176,7 @@ test('[P8] HTTP 503 and offline transport failure retain draft and support an ac await expect(editor(page).getByRole('alert')).toContainText('Your layout could not be saved'); await expect(editor(page).getByRole('button', { name: 'Save layout', exact: true })).toBeEnabled(); await expect(editor(page).getByRole('checkbox', { name: 'Job tags', exact: true })).not.toBeChecked(); - await expect(editor(page).getByRole('combobox', { name: 'Size for Current pipeline', exact: true })).toHaveValue('full'); + await expect(preview(page).getByRole('combobox', { name: 'Size for Current pipeline', exact: true })).toHaveValue('full'); expect(await readLayout(page.request)).toEqual(before); await capture(page, info, `draft-retained-${mode}`); } @@ -183,7 +192,7 @@ test('[P8] HTTP 503 and offline transport failure retain draft and support an ac await expect(editor(page).getByRole('button', { name: 'Saving layout…', exact: true })).toBeDisabled(); await expect(editor(page).getByRole('button', { name: 'Cancel', exact: true })).toBeDisabled(); for (const checkbox of await editor(page).getByRole('checkbox').all()) await expect(checkbox).toBeDisabled(); - for (const select of await editor(page).getByRole('combobox').all()) await expect(select).toBeDisabled(); + for (const select of await preview(page).getByRole('combobox').all()) await expect(select).toBeDisabled(); expect(await readLayout(page.request)).toEqual(before); } finally { release(); } await saving; @@ -283,14 +292,18 @@ test('[P8] Editor reflows across EN/NB themes and desktop/tablet/mobile with nat await page.reload(); await page.getByRole('button', { name: t.dashboardCustomize, exact: true }).click(); const panel = page.getByRole('region', { name: t.dashboardEditing, exact: true }); - await expect(panel.getByRole('list', { name: t.dashboardManageWidgets })).toBeVisible(); + await panel.locator('summary').click(); + await expect(panel.getByRole('group', { name: t.dashboardManageWidgets })).toBeVisible(); await expect(panel.getByRole('checkbox')).toHaveCount(9); - await expect(panel.getByRole('combobox')).toHaveCount(9); - await expect(panel.getByRole('combobox').first()).toBeDisabled(); - expect(await panel.getByRole('combobox').evaluateAll(nodes => nodes.every(node => node.tagName === 'SELECT'))).toBe(true); + const widgets = page.getByRole('region', { name: t.dashboardPreview, exact: true }); + await widgets.locator('[data-dashboard-widget="cards"] summary').click(); + await expect(widgets.getByRole('combobox', { name: t.dashboardSizeFor.replace('{name}', t.dashboardSummaryCards) })).toBeDisabled(); + expect(await widgets.getByRole('combobox').evaluateAll(nodes => nodes.every(node => node.tagName === 'SELECT'))).toBe(true); for (const [width, height] of [[1440, 900], [768, 1024], [390, 844], [320, 740]]) { await page.setViewportSize({ width, height }); - await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1)).toBe(true); + await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1 ? [] : + [...document.querySelectorAll('body *')].map(element => ({ tag: element.tagName, class: element.className?.toString().slice(0, 100), right: element.getBoundingClientRect().right })) + .filter(element => element.right > innerWidth + 1).slice(0, 8))).toEqual([]); await capture(page, info, `editor-${language}-${mode}-${width}`); } const axe = await new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa']).analyze(); @@ -305,26 +318,83 @@ test('[P8] Editor reflows across EN/NB themes and desktop/tablet/mobile with nat test('[P8] Keyboard move focus/live status, pointer drag and emulated touch buttons preserve DOM order', async ({ page, browser, account }, info) => { await page.setViewportSize({ width: 1440, height: 1000 }); await openEditor(page); - const earlier = editor(page).getByRole('button', { name: 'Move Current pipeline earlier', exact: true }); + await options(page, 'funnel'); + const earlier = preview(page).getByRole('button', { name: 'Move Current pipeline earlier', exact: true }); await earlier.focus(); await page.keyboard.press('Enter'); await expect(earlier).toBeFocused(); - await expect(editor(page).getByRole('status')).toHaveText('Current pipeline moved to position 2 of 9.'); - await expect.poll(() => editor(page).locator('[data-layout-row]').evaluateAll(nodes => nodes.map(node => node.getAttribute('data-layout-row')))).toEqual(['cards', 'funnel', 'attention', 'activity', 'stageAge', 'sources', 'salary', 'companies', 'skills']); - await editor(page).getByRole('button', { name: 'Drag Current pipeline to reorder', exact: true }).dragTo(editor(page).locator('[data-layout-row="cards"]')); - await expect(editor(page).getByRole('status')).toHaveText('Current pipeline moved to position 1 of 9.'); - await expect(editor(page).getByRole('button', { name: 'Move Current pipeline earlier', exact: true })).toBeDisabled(); + await expect(page.getByRole('status').filter({ hasText: 'Current pipeline moved to position 2 of 9.' })).toBeVisible(); + await expect.poll(() => preview(page).locator('[data-dashboard-widget]').evaluateAll(nodes => nodes.map(node => node.getAttribute('data-dashboard-widget')))).toEqual(['cards', 'funnel', 'attention', 'activity', 'stageAge', 'sources', 'salary', 'companies', 'skills']); + const widget = page.locator('[data-dashboard-widget="funnel"]'); + await widget.locator('summary').first().click(); + const handle = widget.locator('.dashboard-grid-handle'); + await handle.scrollIntoViewIfNeeded(); + const start = (await handle.boundingBox())!; + const before = (await widget.boundingBox())!; + await page.mouse.move(start.x + start.width / 2, start.y + start.height / 2); + await page.mouse.down(); await page.mouse.move(start.x + start.width / 2 + before.width, start.y + start.height / 2, { steps: 15 }); await page.mouse.up(); + await expect.poll(async () => Math.round((await widget.boundingBox())!.x)).not.toBe(Math.round(before.x)); + const resize = widget.locator('.react-resizable-handle-se'); + await resize.scrollIntoViewIfNeeded(); + const corner = (await resize.boundingBox())!; + const oldHeight = (await widget.boundingBox())!.height; + await resize.hover(); + await page.mouse.down(); + await page.mouse.move(corner.x + corner.width / 2, corner.y + corner.height / 2 + 112, { steps: 12 }); + await expect(widget).toHaveClass(/resizing/); + await page.mouse.up(); + await expect.poll(async () => (await widget.boundingBox())!.height).toBeGreaterThan(oldHeight + 50); await save(page); - expect((await readLayout(page.request)).widgets[0].id).toBe('funnel'); + const saved = await readLayout(page.request); + expect(saved.widgets.find(widget => widget.id === 'funnel')!.h).toBeGreaterThan(8); + expect(saved.widgets.find(widget => widget.id === 'funnel')!.x).toBeGreaterThan(0); + const movedIndex = saved.widgets.findIndex(widget => widget.id === 'funnel'); + const expectedOrder = saved.widgets.map(widget => widget.id); + const [moved] = expectedOrder.splice(movedIndex, 1); expectedOrder.splice(movedIndex + 1, 0, moved); + await page.reload(); + expect(await readLayout(page.request)).toEqual(saved); const touch = await browser.newContext({ baseURL: 'http://localhost:3300', viewport: { width: 390, height: 844 }, hasTouch: true, isMobile: true }); try { await localOnly(touch); await login(touch.request, account.email); const mobile = await touch.newPage(); await mobile.goto('/dashboard'); await mobile.getByRole('button', { name: 'Customize dashboard', exact: true }).tap(); - await editor(mobile).getByRole('button', { name: 'Move Current pipeline later', exact: true }).tap(); - await expect(editor(mobile).getByRole('status')).toHaveText('Current pipeline moved to position 2 of 9.'); + expect(await readLayout(touch.request)).toEqual(saved); + await options(mobile, 'funnel'); + await preview(mobile).getByRole('button', { name: 'Move Current pipeline later', exact: true }).tap(); + await expect(mobile.getByRole('status').filter({ hasText: `Current pipeline moved to position ${movedIndex + 2} of 9.` })).toBeVisible(); await save(mobile); - expect((await readLayout(touch.request)).widgets.slice(0, 2).map(widget => widget.id)).toEqual(['cards', 'funnel']); + expect((await readLayout(touch.request)).widgets.map(widget => widget.id)).toEqual(expectedOrder); await capture(mobile, info, 'emulated-touch-button-order'); await info.attach('device-evidence-limit', { body: 'Chromium desktop pointer drag and emulated touch button input only. Physical touch, software keyboards and human screen reader acceptance remain untested. Axe incomplete items require review.', contentType: 'text/plain' }); } finally { await touch.close(); } }); + +test('[C] Stored gaps and hidden geometry survive viewport changes without automatic writes', async ({ page, account }) => { + const stored = { + version: 2, revision: 0, widgets: [ + { id: 'attention', visible: true, size: 'standard', x: 4, y: 17, w: 8, h: 10 }, + { id: 'funnel', visible: false, size: 'standard', x: 0, y: 40, w: 6, h: 12 }, + ], + }; + expect((await page.request.put(layoutUrl, { headers: account.headers, data: stored })).status()).toBe(200); + const saved = await readLayout(page.request); + let writes = 0; + page.on('request', request => { if (request.url() === layoutUrl && request.method() === 'PUT') writes++; }); + await page.setViewportSize({ width: 1440, height: 900 }); + await page.reload(); + const widget = page.locator('[data-dashboard-widget="attention"]'); + await expect(widget).toBeAttached(); + await expect.poll(() => widget.evaluate(element => { + const grid = element.closest('.react-grid-layout')!; + return Math.round(element.getBoundingClientRect().top - grid.getBoundingClientRect().top); + })).toBe(17 * 56); + await openEditor(page); + for (const width of [768, 390, 1440]) { + await page.setViewportSize({ width, height: 900 }); + await expect.poll(() => page.locator('.react-grid-layout').count()).toBe(width === 1440 ? 1 : 0); + expect(await readLayout(page.request)).toEqual(saved); + } + await expect(editor(page).getByRole('status')).toHaveText('Your saved layout. Changes are only applied when you save.'); + expect(writes).toBe(0); + await editor(page).getByRole('button', { name: 'Cancel', exact: true }).click(); + expect(await readLayout(page.request)).toEqual(saved); +}); diff --git a/job-tracker-ui/package-lock.json b/job-tracker-ui/package-lock.json index 3ac4d6b..93d82e8 100644 --- a/job-tracker-ui/package-lock.json +++ b/job-tracker-ui/package-lock.json @@ -29,6 +29,7 @@ "next": "16.4.1", "react": "^19.2.4", "react-dom": "^19.2.4", + "react-grid-layout": "2.3.0", "react-router-dom": "^7.18.2", "typescript": "^5.9.3", "web-vitals": "^2.1.4" @@ -7579,6 +7580,12 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-equals": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-4.0.3.tgz", + "integrity": "sha512-G3BSX9cfKttjr+2o1O22tYMLq0DPluZnYtq1rXumE1SpL/F/SLIfHx08WYQoWSIpeMYf8sRbJ8++71+v6Pnxfg==", + "license": "MIT" + }, "node_modules/fast-glob": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.1.tgz", @@ -11058,6 +11065,38 @@ "react": "^19.2.4" } }, + "node_modules/react-draggable": { + "version": "4.7.2", + "resolved": "https://registry.npmjs.org/react-draggable/-/react-draggable-4.7.2.tgz", + "integrity": "sha512-pmqDx72EqhMGoJe/x42ZO2wJ9TIz7qJ5OPnFu6lVWWznEuJumlSjR+J0V4tNyDFhUo6dVvv56FeiTqN+arTgnw==", + "license": "MIT", + "dependencies": { + "clsx": "^2.1.1", + "prop-types": "^15.8.1" + }, + "peerDependencies": { + "react": ">= 16.3.0", + "react-dom": ">= 16.3.0" + } + }, + "node_modules/react-grid-layout": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/react-grid-layout/-/react-grid-layout-2.3.0.tgz", + "integrity": "sha512-4ofzuR/MlYchp+ixDUVBxH6ybaw+JH51M+rpf675ik/++4Dn0Kax/Vf2Gv/7Z++mV1ZC7mY9UV2sRRycjGjKXQ==", + "license": "MIT", + "dependencies": { + "clsx": "^2.1.1", + "fast-equals": "^4.0.3", + "prop-types": "^15.8.1", + "react-draggable": "^4.4.6", + "react-resizable": "^3.2.0", + "resize-observer-polyfill": "^1.5.1" + }, + "peerDependencies": { + "react": ">= 16.3.0", + "react-dom": ">= 16.3.0" + } + }, "node_modules/react-is": { "version": "19.2.4", "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.4.tgz", @@ -11080,6 +11119,20 @@ "dev": true, "license": "MIT" }, + "node_modules/react-resizable": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/react-resizable/-/react-resizable-3.2.0.tgz", + "integrity": "sha512-3NKQ0SLZV7rs3LQHeXlOzDSRQfFrkX6TVet77/Qk03zqiZyee37b7N8/gwDJAA8UUjRz7PdWCCy49hcso45SMQ==", + "license": "MIT", + "dependencies": { + "prop-types": "15.x", + "react-draggable": "^4.5.0" + }, + "peerDependencies": { + "react": ">= 16.3", + "react-dom": ">= 16.3" + } + }, "node_modules/react-router": { "version": "7.18.2", "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.2.tgz", @@ -11265,6 +11318,12 @@ "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", "license": "MIT" }, + "node_modules/resize-observer-polyfill": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz", + "integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==", + "license": "MIT" + }, "node_modules/resolve": { "version": "1.22.11", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", diff --git a/job-tracker-ui/package.json b/job-tracker-ui/package.json index ca6fa42..f50e3d3 100644 --- a/job-tracker-ui/package.json +++ b/job-tracker-ui/package.json @@ -24,6 +24,7 @@ "next": "16.4.1", "react": "^19.2.4", "react-dom": "^19.2.4", + "react-grid-layout": "2.3.0", "react-router-dom": "^7.18.2", "typescript": "^5.9.3", "web-vitals": "^2.1.4" diff --git a/job-tracker-ui/src/components/DashboardGrid.test.tsx b/job-tracker-ui/src/components/DashboardGrid.test.tsx new file mode 100644 index 0000000..139b041 --- /dev/null +++ b/job-tracker-ui/src/components/DashboardGrid.test.tsx @@ -0,0 +1,75 @@ +import React from 'react'; +import { fireEvent, render, screen, within } from '@testing-library/react'; +import DashboardGrid from './DashboardGrid'; +import { defaultDashboardLayout } from '../dashboardLayout'; +import { I18nProvider } from '../i18n/I18nProvider'; + +let mockGridProps: any; +let mockMeasuredWidth = 1200; +jest.mock('react-grid-layout', () => ({ + ...jest.requireActual('react-grid-layout'), + __esModule: true, + default: (props: any) => { mockGridProps = props; return
{props.children}
; }, + useContainerWidth: () => ({ width: mockMeasuredWidth, containerRef: { current: null } }), +})); +beforeEach(() => { mockMeasuredWidth = 1200; mockGridProps = null; }); + +test('library collision handling resolves resize overlaps but leaves deliberate gaps unchanged', () => { + render(

Report

} />
); + const items = [ + { i: 'a', x: 0, y: 17, w: 6, h: 10 }, + { i: 'b', x: 0, y: 25, w: 6, h: 8 }, + { i: 'c', x: 6, y: 25, w: 6, h: 8 }, + ]; + const original = JSON.stringify(items); + const resolved = mockGridProps.compactor.compact(items, 12); + const { getAllCollisions } = jest.requireActual('react-grid-layout'); + expect(resolved.every((item: any) => getAllCollisions(resolved, item).length === 0)).toBe(true); + expect(JSON.stringify(items)).toBe(original); + const spaced = [{ i: 'a', x: 4, y: 17, w: 8, h: 10 }]; + expect(mockGridProps.compactor.compact(spaced, 12)).toMatchObject(spaced); +}); + +test('mount and compaction do not save; completed resize keeps hidden geometry and uses the draft owner revision', () => { + const layout = defaultDashboardLayout(); layout.revision = 8; + layout.widgets[2] = { ...layout.widgets[2], visible: false, x: 4, y: 17, w: 8, h: 10 }; + const change = jest.fn(); + render(

{widget.id} content

} />
); + expect(change).not.toHaveBeenCalled(); + expect(mockGridProps.onLayoutChange).toBeUndefined(); + const next = mockGridProps.layout.map((row: any) => row.i === 'attention' ? { ...row, w: 8, h: 12 } : row); + mockGridProps.onResizeStop(next); + expect(change).toHaveBeenCalledTimes(1); + const saved = change.mock.calls[0][0]; + expect(saved.revision).toBe(8); + expect(saved.widgets[1]).toMatchObject({ w: 8, h: 12, size: 'standard' }); + expect(saved.widgets[2]).toEqual(layout.widgets[2]); +}); + +test('narrow viewport retains desktop geometry and keyboard height changes are bounded', () => { + mockMeasuredWidth = 390; + const layout = defaultDashboardLayout(); + const change = jest.fn(); + const view = render(

{widget.id} content

} />
); + expect(mockGridProps).toBeNull(); + expect(change).not.toHaveBeenCalled(); + const preview = screen.getByRole('region', { name: 'Dashboard preview' }); + fireEvent.click(within(preview).getByText('Today')); + fireEvent.change(screen.getByLabelText('Height for Today'), { target: { value: '25' } }); + expect(change).not.toHaveBeenCalled(); + fireEvent.change(screen.getByLabelText('Height for Today'), { target: { value: '12' } }); + expect(change.mock.calls[0][0].widgets[1].h).toBe(12); + mockMeasuredWidth = 1200; + view.rerender(

{widget.id} content

} />
); + expect(change).toHaveBeenCalledTimes(1); + expect(mockGridProps.layout[1]).toMatchObject({ x: layout.widgets[1].x, y: layout.widgets[1].y }); +}); + +test('conflict disables pointer operations and ignores a late completion callback', () => { + const change = jest.fn(); + render(

Report

} />
); + expect(mockGridProps.dragConfig.enabled).toBe(false); + expect(mockGridProps.resizeConfig.enabled).toBe(false); + mockGridProps.onDragStop(mockGridProps.layout); + expect(change).not.toHaveBeenCalled(); +}); diff --git a/job-tracker-ui/src/components/DashboardGrid.tsx b/job-tracker-ui/src/components/DashboardGrid.tsx new file mode 100644 index 0000000..24e6481 --- /dev/null +++ b/job-tracker-ui/src/components/DashboardGrid.tsx @@ -0,0 +1,105 @@ +import React, { useRef, useState } from 'react'; +import { Box, Button, Stack, TextField, Typography } from '@mui/material'; +import DragIndicatorIcon from '@mui/icons-material/DragIndicator'; +import GridLayout, { getAllCollisions, noCompactor, verticalCompactor, useContainerWidth, type Compactor, type Layout } from 'react-grid-layout'; +import { arrangeDashboardWidgets, type DashboardLayout, type DashboardWidget, WIDGETS } from '../dashboardLayout'; +import { useI18n } from '../i18n/I18nProvider'; +import 'react-grid-layout/css/styles.css'; +import 'react-resizable/css/styles.css'; + +const preserveGaps: Compactor = { + ...verticalCompactor, + compact(layout, cols) { + // The library resolves collisions; untouched gaps survive mount and reload. + return layout.some(item => getAllCollisions(layout, item).length > 0) + ? verticalCompactor.compact(layout, cols) : noCompactor.compact(layout, cols); + }, +}; + +type Props = { + layout: DashboardLayout; + editing: boolean; + disabled: boolean; + onChange: (layout: DashboardLayout) => void; + childrenFor: (widget: DashboardWidget) => React.ReactNode; +}; + +export default function DashboardGrid({ layout, editing, disabled, onChange, childrenFor }: Props) { + const { t } = useI18n(); + const { width, containerRef } = useContainerWidth({ initialWidth: 0 }); + const wide = width >= 800; + const [announcement, announce] = useState(''); + const focus = useRef(null); + const contents = new Map(layout.widgets.filter(widget => widget.visible).map(widget => [widget.id, childrenFor(widget)])); + const visible = layout.widgets.filter(widget => widget.visible && contents.get(widget.id) != null); + const update = (id: string, patch: Partial) => { + if (disabled) return; + const widgets = layout.widgets.map(widget => widget.id === id ? { ...widget, ...patch } : widget); + onChange({ ...layout, widgets: arrangeDashboardWidgets(widgets) }); + }; + const move = (id: string, direction: number) => { + const index = layout.widgets.findIndex(widget => widget.id === id); + const target = index + direction; + if (disabled || target < 0 || target >= layout.widgets.length) return; + const widgets = [...layout.widgets]; + const [moved] = widgets.splice(index, 1); widgets.splice(target, 0, moved); + onChange({ ...layout, widgets: arrangeDashboardWidgets(widgets) }); + announce(t('dashboardMoved', { name: t(WIDGETS.find(widget => widget.id === id)!.titleKey), position: target + 1, total: widgets.length })); + requestAnimationFrame(() => focus.current?.querySelector(`[data-dashboard-widget="${id}"] button[data-move]:not(:disabled)`)?.focus()); + }; + const finish = (next: Layout) => { + if (!editing || disabled) return; + // Mount, compaction and viewport changes never write preferences. + if (next.some(item => item.x < 0 || item.y < 0 || item.x + item.w > 12 || item.y + item.h > 256)) return; + const widgets = layout.widgets.map(widget => { + const item = next.find(item => item.i === widget.id); + return item ? { ...widget, x: item.x, y: item.y, w: item.w, h: item.h, size: item.w === 12 ? 'full' as const : 'standard' as const } : widget; + }); + const ordered = widgets.filter(widget => widget.visible).sort((a, b) => a.y - b.y || a.x - b.x); + onChange({ ...layout, widgets: widgets.map(widget => widget.visible ? ordered.shift()! : widget) }); + announce(t('assetsUnsavedChanges')); + }; + const tiles = visible.map(widget => { + const name = t(WIDGETS.find(item => item.id === widget.id)!.titleKey); + const index = layout.widgets.indexOf(widget); + return + {editing ? + + + {name} + + {[-1, 1].map(direction => )} + update(widget.id, { size: event.target.value as 'standard' | 'full', w: event.target.value === 'full' ? 12 : 6 })}> + + + { const h = Number(event.target.value); if (Number.isInteger(h) && h >= 4 && h <= 24) update(widget.id, { h }); }} /> + + + + : null} + {contents.get(widget.id)} + ; + }); + return + {editing ? {t('dashboardGridHelp')} : null} + {announcement} + + {wide ? ({ i: widget.id, x: widget.x, y: widget.y, w: widget.w, h: widget.h, minW: widget.id === 'cards' ? 12 : 4, maxW: 12, minH: 4, maxH: 24 }))} + gridConfig={{ cols: 12, rowHeight: 40, margin: [16, 16], containerPadding: [0, 0], maxRows: 256 }} + dragConfig={{ enabled: editing && !disabled, handle: '.dashboard-grid-handle', allowMobileScroll: true }} + resizeConfig={{ enabled: editing && !disabled, handles: ['se'] }} onDragStop={finish} onResizeStop={finish}>{tiles} + : {tiles}} + + ; +} diff --git a/job-tracker-ui/src/components/DashboardLayoutEditor.tsx b/job-tracker-ui/src/components/DashboardLayoutEditor.tsx index 48770dd..bf35b59 100644 --- a/job-tracker-ui/src/components/DashboardLayoutEditor.tsx +++ b/job-tracker-ui/src/components/DashboardLayoutEditor.tsx @@ -1,9 +1,7 @@ -import React, { useEffect, useLayoutEffect, useRef, useState } from "react"; +import React, { useEffect, useRef, useState } from "react"; import { useBlocker, useBeforeUnload } from "react-router-dom"; -import { Alert, Box, Button, Checkbox, FormControlLabel, Stack, TextField, Typography } from "@mui/material"; -import DragIndicatorIcon from "@mui/icons-material/DragIndicator"; -import { DashboardLayout, defaultDashboardLayout, WIDGETS } from "../dashboardLayout"; -import { useDragReorder } from "../hooks/useDragReorder"; +import { Alert, Box, Button, Checkbox, FormControlLabel, Stack, Typography } from "@mui/material"; +import { arrangeDashboardWidgets, DashboardLayout, defaultDashboardLayout, WIDGETS } from "../dashboardLayout"; import { useI18n } from "../i18n/I18nProvider"; import { useConfirm } from "../confirm"; import { PageSection } from "../layout/PageLayout"; @@ -45,31 +43,10 @@ export default function DashboardLayoutEditor({ draft, dirty, saving, saveError, const { t } = useI18n(); const { confirm } = useConfirm(); const panel = useRef(null); - const pendingFocus = useRef<{ id: string; direction: number } | null>(null); - const [announcement, announce] = useState(""); useEffect(() => { panel.current?.focus({ preventScroll: true }); panel.current?.scrollIntoView?.({ block: "start" }); }, []); - useLayoutEffect(() => { - const target = pendingFocus.current; - if (!target) return; - pendingFocus.current = null; - const row = panel.current?.querySelector(`[data-layout-row="${target.id}"]`); - const preferred = row?.querySelector(`button[data-move="${target.direction}"]:not(:disabled)`); - (preferred ?? row?.querySelector("button[data-move]:not(:disabled)"))?.focus(); - }, [draft]); - - const reorder = (from: number, to: number) => { - if (saving || from === to || to < 0 || to >= draft.widgets.length) return; - const widgets = [...draft.widgets]; - const [moved] = widgets.splice(from, 1); widgets.splice(to, 0, moved); - onChange({ ...draft, widgets }); - const name = t(WIDGETS.find(widget => widget.id === moved.id)!.titleKey); - announce(t("dashboardMoved", { name, position: to + 1, total: widgets.length })); - }; - const drag = useDragReorder(reorder); - const change = (id: string, patch: Partial) => onChange({ ...draft, widgets: draft.widgets.map(widget => widget.id === id ? { ...widget, ...patch } : widget) }); const cancel = async () => { if (!dirty || await confirm({ title: t("dashboardDiscardTitle"), message: t("dashboardDiscardBody"), confirmLabel: t("dashboardDiscard"), destructive: true })) onCancel(); }; @@ -91,30 +68,19 @@ export default function DashboardLayoutEditor({ draft, dirty, saving, saveError, {saveError ? {t(conflict ? "dashboardConflict" : "dashboardSaveFailed")} {conflict ? : null} : null} - {announcement || t(dirty ? "assetsUnsavedChanges" : "dashboardDraftSaved")} - - {draft.widgets.map((widget, index) => { + {t(dirty ? "assetsUnsavedChanges" : "dashboardDraftSaved")} + + {t("dashboardManageWidgets")} + + {draft.widgets.map(widget => { const entry = WIDGETS.find(item => item.id === widget.id)!; const name = t(entry.titleKey); - const gesture = drag.getItemProps(index); - return - - change(widget.id, { visible: event.target.checked })} />} label={name} /> - - {([-1, 1] as const).map(direction => )} - - change(widget.id, { size: event.target.value as "standard" | "full" })} - slotProps={{ select: { native: true }, htmlInput: { "aria-label": t("dashboardSizeFor", { name }) } }} sx={{ width: { xs: "100%", sm: 190 }, minWidth: 0, "& select": { minHeight: 28 } }}> - {entry.sizes.map(size => )} - - ; + return onChange({ ...draft, + widgets: arrangeDashboardWidgets(draft.widgets.map(item => item.id === widget.id ? { ...item, visible: event.target.checked } : item)) })} />} />; })} + - {t("dashboardReorderHelp")} ; } diff --git a/job-tracker-ui/src/components/DashboardView.tsx b/job-tracker-ui/src/components/DashboardView.tsx index 2f1fffd..9ed698d 100644 --- a/job-tracker-ui/src/components/DashboardView.tsx +++ b/job-tracker-ui/src/components/DashboardView.tsx @@ -19,6 +19,7 @@ import OnboardingChecklist from "./OnboardingChecklist"; import { WIDGETS, defaultDashboardLayout, WidgetId } from "../dashboardLayout"; import { useDashboardLayout } from "../hooks/useDashboardLayout"; import DashboardLayoutEditor, { DashboardNavigationGuard } from "./DashboardLayoutEditor"; +import DashboardGrid from "./DashboardGrid"; import { useOptionalToast } from "../toast"; import { useI18n } from "../i18n/I18nProvider"; import { statusLabel, statusTone } from "../pipeline"; @@ -363,16 +364,13 @@ export default function DashboardView({ mode = "dashboard" }: { mode?: "dashboar {summaryReady && hasJobs ? {reports.stageAge}{reports.sources} : null} {reports.salary} {reports.companies}{reports.skills} - : - {activeLayout.widgets.filter(widget => widget.visible).map(widget => { + : { const content = reports[widget.id]; const ready = widget.id === "activity" || widget.id === "skills" ? trendsReady : summaryReady; if (!content && !(ready && (hasJobs || layoutState.editing))) return null; - return - {content ?? item.id === widget.id)!.titleKey)}>{t("dashboardNoSample")}} - ; - })} - } + return content ?? item.id === widget.id)!.titleKey)}>{t("dashboardNoSample")}; + }} />} ); } diff --git a/job-tracker-ui/src/dashboard-customisation.test.tsx b/job-tracker-ui/src/dashboard-customisation.test.tsx index 1e9f173..75e9496 100644 --- a/job-tracker-ui/src/dashboard-customisation.test.tsx +++ b/job-tracker-ui/src/dashboard-customisation.test.tsx @@ -26,7 +26,18 @@ beforeEach(() => { mocked.get.mockImplementation((url: string) => Promise.resolve({ data: response(url) }) as any); mocked.put.mockReset(); mocked.put.mockImplementation((_url: string, body: any) => { stored = { ...body, revision: body.revision + 1 }; return Promise.resolve({ data: stored }) as any; }); }); -async function edit() { fireEvent.click(await screen.findByRole('button', { name: 'Customize dashboard' })); return screen.findByRole('region', { name: 'Editing dashboard' }); } +async function edit() { + fireEvent.click(await screen.findByRole('button', { name: 'Customize dashboard' })); + const editor = await screen.findByRole('region', { name: 'Editing dashboard' }); + fireEvent.click(within(editor).getByText('Manage dashboard widgets')); + return editor; +} + +function widgetOptions(name: string) { + const preview = screen.getByRole('region', { name: 'Dashboard preview' }); + fireEvent.click(within(preview).getAllByText(name).find(element => element.tagName === 'SUMMARY')!); + return preview; +} test('visibility is a preview; cancel restores the saved layout without a write', async () => { open(); await screen.findByText('Tracked jobs'); @@ -42,15 +53,16 @@ test('visibility is a preview; cancel restores the saved layout without a write' test('failed save keeps the draft and retry persists visibility, order and size', async () => { open(); const editor = await edit(); fireEvent.click(within(editor).getByRole('checkbox', { name: 'Summary cards' })); - fireEvent.click(within(editor).getByRole('button', { name: 'Move Today earlier' })); - fireEvent.change(within(editor).getByLabelText('Size for Today'), { target: { value: 'full' } }); + const preview = widgetOptions('Today'); + fireEvent.click(within(preview).getByRole('button', { name: 'Move Today earlier' })); + fireEvent.change(within(preview).getByLabelText('Size for Today'), { target: { value: 'full' } }); mocked.put.mockRejectedValueOnce(new Error('offline')); fireEvent.click(within(editor).getByRole('button', { name: 'Save layout' })); await screen.findByText('Your layout could not be saved. Your changes are still here. Try again.'); expect(within(editor).getByRole('checkbox', { name: 'Summary cards' })).not.toBeChecked(); fireEvent.click(within(editor).getByRole('button', { name: 'Save layout' })); await waitFor(() => expect(screen.queryByRole('region', { name: 'Editing dashboard' })).not.toBeInTheDocument()); - expect(stored.widgets[0]).toEqual({ id: 'attention', visible: true, size: 'full' }); + expect(stored.widgets[0]).toMatchObject({ id: 'attention', visible: true, size: 'full', x: 0, y: 0, w: 12 }); expect(stored.widgets.find((widget: any) => widget.id === 'cards').visible).toBe(false); expect(screen.queryByText('Tracked jobs')).not.toBeInTheDocument(); }); @@ -67,8 +79,9 @@ test('all hidden widgets leave an accessible recovery and reset remains cancella test('keyboard ordering keeps focus and announces the resulting position without refetching reports', async () => { open(); const editor = await edit(); const before = mocked.get.mock.calls.length; - const move = within(editor).getByRole('button', { name: 'Move Today earlier' }); move.focus(); fireEvent.click(move); - await waitFor(() => expect(within(editor).getByRole('button', { name: 'Move Today later' })).toHaveFocus()); + const preview = widgetOptions('Today'); + const move = within(preview).getByRole('button', { name: 'Move Today earlier' }); move.focus(); fireEvent.click(move); + await waitFor(() => expect(within(preview).getByRole('button', { name: 'Move Today later' })).toHaveFocus()); expect(screen.getByText('Today moved to position 1 of 9.')).toBeInTheDocument(); expect(mocked.get.mock.calls).toHaveLength(before); }); diff --git a/job-tracker-ui/src/dashboardLayout.test.ts b/job-tracker-ui/src/dashboardLayout.test.ts index e57caae..d1d33e9 100644 --- a/job-tracker-ui/src/dashboardLayout.test.ts +++ b/job-tracker-ui/src/dashboardLayout.test.ts @@ -1,5 +1,34 @@ import { defaultDashboardLayout, normalizeDashboardLayout, WIDGETS } from './dashboardLayout'; +test('legacy preferences migrate to bounded geometry while preserving order, visibility and revision', () => { + const input = { version: 1, revision: 7, widgets: [ + { id: 'skills', visible: false, size: 'full' }, + { id: 'attention', visible: true, size: 'standard' }, + { id: 'cards', visible: true, size: 'full' }, + ] }; + const original = JSON.stringify(input); + const result = normalizeDashboardLayout(input); + expect(result.version).toBe(2); + expect(result.revision).toBe(7); + expect(result.widgets.map(row => row.id).slice(0, 3)).toEqual(['skills', 'attention', 'cards']); + expect(result.widgets[0]).toMatchObject({ visible: false, size: 'full', w: 12 }); + expect(result.widgets[1]).toMatchObject({ visible: true, size: 'standard', x: 0, y: 0, w: 6 }); + expect(result.widgets[2]).toMatchObject({ visible: true, size: 'full', x: 0, w: 12 }); + expect(result.widgets.slice(3).every(row => !row.visible)).toBe(true); + expect(normalizeDashboardLayout(input)).toEqual(result); + expect(JSON.stringify(input)).toBe(original); +}); + +test('schema two preserves chosen hidden geometry without resetting the owner revision', () => { + const result = normalizeDashboardLayout({ version: 2, revision: 12, widgets: [ + { id: 'attention', visible: false, size: 'standard', x: 4, y: 17, w: 8, h: 10 }, + ] }); + expect(result.version).toBe(2); + expect(result.revision).toBe(12); + expect(result.widgets[0]).toEqual({ id: 'attention', visible: false, size: 'standard', x: 4, y: 17, w: 8, h: 10 }); + expect(result.widgets.slice(1).every(row => !row.visible)).toBe(true); +}); + test('default catalogue preserves every section and supported widths', () => { const layout = defaultDashboardLayout(); expect(layout.widgets.map(w => w.id)).toEqual(['cards', 'attention', 'funnel', 'activity', 'stageAge', 'sources', 'salary', 'companies', 'skills']); @@ -17,12 +46,12 @@ test('saved layouts retain known order, reject duplicates and append missing sec { id: 'cards', visible: 'yes', size: 'standard' }, ] }); expect(result.revision).toBe(7); - expect(result.widgets[0]).toEqual({ id: 'skills', visible: false, size: 'full' }); - expect(result.widgets[1]).toEqual({ id: 'cards', visible: false, size: 'full' }); + expect(result.widgets[0]).toMatchObject({ id: 'skills', visible: false, size: 'full' }); + expect(result.widgets[1]).toMatchObject({ id: 'cards', visible: false, size: 'full' }); expect(result.widgets.slice(2).every(w => !w.visible)).toBe(true); expect(result.widgets).toHaveLength(9); }); -test.each([null, {}, { version: 2, revision: 8, widgets: [] }, { version: 1, revision: -1, widgets: [] }])('invalid input has safe defaults: %j', input => { +test.each([null, {}, { version: 3, revision: 8, widgets: [] }, { version: 1, revision: -1, widgets: [] }])('invalid input has safe defaults: %j', input => { expect(normalizeDashboardLayout(input)).toEqual(defaultDashboardLayout()); }); diff --git a/job-tracker-ui/src/dashboardLayout.ts b/job-tracker-ui/src/dashboardLayout.ts index 854e963..8522a6b 100644 --- a/job-tracker-ui/src/dashboardLayout.ts +++ b/job-tracker-ui/src/dashboardLayout.ts @@ -13,17 +13,30 @@ export const WIDGETS = [ ] as const; export type WidgetId = typeof WIDGETS[number]['id']; -export type DashboardWidget = { id: WidgetId; visible: boolean; size: WidgetSize }; -export type DashboardLayout = { version: 1; revision: number; widgets: DashboardWidget[] }; +export type DashboardWidget = { id: WidgetId; visible: boolean; size: WidgetSize; x: number; y: number; w: number; h: number }; +export type DashboardLayout = { version: 2; revision: number; widgets: DashboardWidget[] }; + +export function arrangeDashboardWidgets(widgets: DashboardWidget[]): DashboardWidget[] { + let x = 0, y = 0, rowHeight = 0; + return widgets.map(widget => { + if (!widget.visible) return { ...widget }; + if (x + widget.w > 12) { x = 0; y += rowHeight; rowHeight = 0; } + const placed = { ...widget, x, y }; + x += widget.w; + rowHeight = Math.max(rowHeight, widget.h); + return placed; + }); +} export function defaultDashboardLayout(): DashboardLayout { - return { version: 1, revision: 0, widgets: WIDGETS.map(w => ({ id: w.id, visible: true, size: w.defaultSize })) }; + return { version: 2, revision: 0, widgets: arrangeDashboardWidgets(WIDGETS.map(w => ({ id: w.id, visible: true, size: w.defaultSize, + x: 0, y: 0, w: w.defaultSize === 'full' ? 12 : 6, h: w.id === 'cards' ? 5 : 8 }))) }; } export function normalizeDashboardLayout(input: unknown): DashboardLayout { if (!input || typeof input !== 'object') return defaultDashboardLayout(); const value = input as Record; - if (value.version !== 1 || !Number.isSafeInteger(value.revision) || (value.revision as number) < 0 || !Array.isArray(value.widgets)) return defaultDashboardLayout(); + if ((value.version !== 1 && value.version !== 2) || !Number.isSafeInteger(value.revision) || (value.revision as number) < 0 || !Array.isArray(value.widgets)) return defaultDashboardLayout(); const revision = value.revision as number; const widgets: DashboardWidget[] = []; const seen = new Set(); @@ -32,11 +45,17 @@ export function normalizeDashboardLayout(input: unknown): DashboardLayout { const definition = WIDGETS.find(w => w.id === item.id); if (!definition || seen.has(definition.id)) continue; seen.add(definition.id); + const size: WidgetSize = (definition.sizes as readonly string[]).includes(item.size) ? item.size : definition.defaultSize; + const valid = value.version === 2 && [item.x, item.y, item.w, item.h].every(Number.isInteger) + && item.x >= 0 && item.y >= 0 && item.w >= 4 && item.w <= 12 && item.h >= 4 && item.h <= 24 + && item.x + item.w <= 12 && item.y + item.h <= 256 && (definition.id !== 'cards' || item.w === 12); + const w = valid ? item.w : size === 'full' ? 12 : 6; widgets.push({ id: definition.id, visible: typeof item.visible === 'boolean' ? item.visible : revision === 0, - size: (definition.sizes as readonly string[]).includes(item.size) ? item.size : definition.defaultSize }); + size: w === 12 ? 'full' : 'standard', x: valid ? item.x : 0, y: valid ? item.y : 0, w, + h: valid ? item.h : definition.id === 'cards' ? 5 : 8 }); } for (const definition of WIDGETS) { - if (!seen.has(definition.id)) widgets.push({ id: definition.id, visible: revision === 0, size: definition.defaultSize }); + if (!seen.has(definition.id)) widgets.push({ ...defaultDashboardLayout().widgets.find(w => w.id === definition.id)!, visible: revision === 0 }); } - return { version: 1, revision, widgets }; + return { version: 2, revision, widgets: value.version === 1 ? arrangeDashboardWidgets(widgets) : widgets }; } diff --git a/job-tracker-ui/src/hooks/useDashboardLayout.test.tsx b/job-tracker-ui/src/hooks/useDashboardLayout.test.tsx index 41ae9f4..b953bf3 100644 --- a/job-tracker-ui/src/hooks/useDashboardLayout.test.tsx +++ b/job-tracker-ui/src/hooks/useDashboardLayout.test.tsx @@ -58,7 +58,7 @@ test('failed or future-version reads cannot enable editing or saves', async () = await waitFor(() => expect(result.current.loadError).toBe('request')); act(() => result.current.startEditing()); expect(result.current.editing).toBe(false); - get.mockResolvedValueOnce({ data: { version: 2, revision: 5, widgets: [] } }); + get.mockResolvedValueOnce({ data: { version: 3, revision: 5, widgets: [] } }); await act(async () => result.current.reload()); expect(result.current.loadError).toBe('unsupportedVersion'); act(() => result.current.startEditing()); diff --git a/job-tracker-ui/src/hooks/useDashboardLayout.ts b/job-tracker-ui/src/hooks/useDashboardLayout.ts index 598de15..2e6d671 100644 --- a/job-tracker-ui/src/hooks/useDashboardLayout.ts +++ b/job-tracker-ui/src/hooks/useDashboardLayout.ts @@ -2,7 +2,7 @@ import { useCallback, useEffect, useRef, useState, type Dispatch, type SetStateA import { api } from '../api'; import { AUTH_USER_CHANGED_EVENT, getAuthUserKey } from '../auth'; import { getUserKeyFromToken } from '../themePrefs'; -import { defaultDashboardLayout, normalizeDashboardLayout, type DashboardLayout } from '../dashboardLayout'; +import { arrangeDashboardWidgets, defaultDashboardLayout, normalizeDashboardLayout, type DashboardLayout } from '../dashboardLayout'; type LayoutError = 'request' | 'unsupportedVersion' | null; type LayoutState = { @@ -16,8 +16,8 @@ function initialState(owner: string, loading: boolean): LayoutState { } function readLayout(input: unknown): DashboardLayout { - const value = input as Partial | null; - if (value && value.version !== 1) throw new Error('unsupportedVersion'); + const value = input as { version?: unknown; revision?: unknown; widgets?: unknown } | null; + if (value && value.version !== 1 && value.version !== 2) throw new Error('unsupportedVersion'); if (!value || !Number.isSafeInteger(value.revision) || (value.revision as number) < 0 || !Array.isArray(value.widgets)) throw new Error('request'); const layout = normalizeDashboardLayout(input); if (layout.revision === 0 && layout.widgets.length && value.widgets.length === 0 && getUserKeyFromToken() !== 'anon') { @@ -26,6 +26,7 @@ function readLayout(input: unknown): DashboardLayout { for (const widget of layout.widgets) { if (['cards', 'activity', 'funnel', 'companies', 'skills'].includes(widget.id) && typeof legacy?.[widget.id] === 'boolean') widget.visible = legacy[widget.id]; } + layout.widgets = arrangeDashboardWidgets(layout.widgets); } catch { /* Corrupt or unavailable local preferences leave defaults intact. */ } } return layout; diff --git a/job-tracker-ui/src/i18n/translations.ts b/job-tracker-ui/src/i18n/translations.ts index 6c3f9fa..7625c58 100644 --- a/job-tracker-ui/src/i18n/translations.ts +++ b/job-tracker-ui/src/i18n/translations.ts @@ -1921,6 +1921,10 @@ export const translations = { dashboardMoved: "{name} moved to position {position} of {total}.", dashboardSizeFor: "Size for {name}", dashboardSize: "Size", + dashboardHeightFor: "Height for {name}", + dashboardHideWidget: "Hide widget", + dashboardWidgetHidden: "{name} hidden. Use Manage dashboard widgets to show it again.", + dashboardGridHelp: "Drag a widget handle or resize its lower corner. Open the widget name for keyboard movement, size and visibility controls. On smaller screens widgets fill the width; desktop positions are preserved.", dashboardSizeStandard: "Standard", dashboardSizeFull: "Full width", dashboardReorderHelp: "Drag a handle to reorder, or use Earlier and Later with keyboard or touch. Hidden widgets keep their position and size. On small screens every widget fills the width.", @@ -4947,6 +4951,10 @@ export const translations = { dashboardMoved: "{name} er flyttet til plass {position} av {total}.", dashboardSizeFor: "Størrelse for {name}", dashboardSize: "Størrelse", + dashboardHeightFor: "Høyde for {name}", + dashboardHideWidget: "Skjul widget", + dashboardWidgetHidden: "{name} er skjult. Bruk Administrer dashboard-widgeter for å vise den igjen.", + dashboardGridHelp: "Dra i håndtaket eller endre størrelse fra nedre hjørne. Åpne widgetnavnet for å flytte, endre størrelse eller skjule med tastaturet. På mindre skjermer fyller widgetene bredden; plasseringen på større skjermer beholdes.", dashboardSizeStandard: "Standard", dashboardSizeFull: "Full bredde", dashboardReorderHelp: "Dra i håndtaket for å endre rekkefølgen, eller bruk Tidligere og Senere med tastatur eller berøring. Skjulte widgeter beholder plass og størrelse. På små skjermer fyller alle widgeter bredden.",