Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0bd290e788 |
@@ -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<OkObjectResult>(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<ConflictObjectResult>(await Controller(verify, Geometry).UpdateDashboardPreference(default));
|
||||
Assert.IsType<ConflictObjectResult>(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<OkObjectResult>());
|
||||
Assert.Single(results.OfType<ConflictObjectResult>());
|
||||
await using var readback = database.Context();
|
||||
Assert.Equal(1, (await readback.Users.SingleAsync()).DashboardLayoutRevision);
|
||||
}
|
||||
}
|
||||
@@ -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<JsonElement>(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<JsonElement>(Route)).GetRawText());
|
||||
Assert.Equal(0, (await other.GetFromJsonAsync<JsonElement>(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<JsonElement>(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<JsonElement>(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<JsonElement>(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)]
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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<DashboardWidget> Widgets)
|
||||
{
|
||||
public const int MaxBytes = 16 * 1024;
|
||||
@@ -14,7 +19,7 @@ public sealed record DashboardLayout(int Version, long Revision, IReadOnlyList<D
|
||||
|
||||
public static DashboardLayout? Parse(JsonElement root, bool reading = false, long revision = 0)
|
||||
{
|
||||
if (root.ValueKind != JsonValueKind.Object || !root.TryGetProperty("version", out var version) || version.ValueKind != JsonValueKind.Number || !version.TryGetInt32(out var v) || v != 1
|
||||
if (root.ValueKind != JsonValueKind.Object || !root.TryGetProperty("version", out var version) || version.ValueKind != JsonValueKind.Number || !version.TryGetInt32(out var v) || v is not (1 or 2)
|
||||
|| !root.TryGetProperty("widgets", out var widgets) || widgets.ValueKind != JsonValueKind.Array || widgets.GetArrayLength() > 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<DashboardWidget>();
|
||||
@@ -35,9 +40,35 @@ public sealed record DashboardLayout(int Version, long Revision, IReadOnlyList<D
|
||||
if (!reading) return null;
|
||||
size = id == "cards" ? "full" : "standard";
|
||||
}
|
||||
result.Add(new DashboardWidget(id, visible.GetBoolean(), size));
|
||||
if (v == 1) result.Add(new DashboardWidget(id, visible.GetBoolean(), size));
|
||||
else
|
||||
{
|
||||
static bool Integer(JsonElement item, string key, out int number)
|
||||
{
|
||||
number = 0;
|
||||
return item.TryGetProperty(key, out var field) && field.ValueKind == JsonValueKind.Number && field.TryGetInt32(out number);
|
||||
}
|
||||
if (!Integer(widget, "x", out var x) || !Integer(widget, "y", out var y) || !Integer(widget, "w", out var w) || !Integer(widget, "h", out var h)
|
||||
|| x is < 0 or > 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<D
|
||||
using var document = JsonDocument.Parse(json);
|
||||
if (document.RootElement.ValueKind == JsonValueKind.Object
|
||||
&& document.RootElement.TryGetProperty("version", out var version) && version.ValueKind == JsonValueKind.Number
|
||||
&& version.TryGetInt32(out var futureVersion) && futureVersion > 1)
|
||||
&& version.TryGetInt32(out var futureVersion) && futureVersion > 2)
|
||||
return new(futureVersion, revision, []);
|
||||
return Parse(document.RootElement, reading: true, revision) ?? new(1, revision, []);
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -23,8 +23,15 @@ async function readLayout(api: APIRequestContext): Promise<DashboardLayout> {
|
||||
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);
|
||||
});
|
||||
|
||||
Generated
+59
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 <div>{props.children}</div>; },
|
||||
useContainerWidth: () => ({ width: mockMeasuredWidth, containerRef: { current: null } }),
|
||||
}));
|
||||
beforeEach(() => { mockMeasuredWidth = 1200; mockGridProps = null; });
|
||||
|
||||
test('library collision handling resolves resize overlaps but leaves deliberate gaps unchanged', () => {
|
||||
render(<I18nProvider><DashboardGrid layout={defaultDashboardLayout()} editing disabled={false} onChange={jest.fn()} childrenFor={() => <p>Report</p>} /></I18nProvider>);
|
||||
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(<I18nProvider><DashboardGrid layout={layout} editing disabled={false} onChange={change} childrenFor={widget => <p>{widget.id} content</p>} /></I18nProvider>);
|
||||
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(<I18nProvider><DashboardGrid layout={layout} editing disabled={false} onChange={change} childrenFor={widget => <p>{widget.id} content</p>} /></I18nProvider>);
|
||||
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(<I18nProvider><DashboardGrid layout={layout} editing disabled={false} onChange={change} childrenFor={widget => <p>{widget.id} content</p>} /></I18nProvider>);
|
||||
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(<I18nProvider><DashboardGrid layout={defaultDashboardLayout()} editing disabled onChange={change} childrenFor={() => <p>Report</p>} /></I18nProvider>);
|
||||
expect(mockGridProps.dragConfig.enabled).toBe(false);
|
||||
expect(mockGridProps.resizeConfig.enabled).toBe(false);
|
||||
mockGridProps.onDragStop(mockGridProps.layout);
|
||||
expect(change).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -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<HTMLDivElement>(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<DashboardWidget>) => {
|
||||
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<HTMLButtonElement>(`[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 <Box key={widget.id} data-dashboard-widget={widget.id} data-widget-size={widget.size}
|
||||
sx={{ minWidth: 0, display: 'flex', flexDirection: 'column', border: editing ? '1px solid' : undefined, borderColor: 'divider', borderRadius: 2, bgcolor: 'background.paper', overflow: 'hidden' }}>
|
||||
{editing ? <Stack direction="row" useFlexGap flexWrap="wrap" spacing={0.5} sx={{ p: 1, flexShrink: 0, maxHeight: '65%', overflow: 'auto' }}>
|
||||
<Button className="dashboard-grid-handle" disabled={disabled || !wide} aria-label={t('dashboardDrag', { name })} aria-describedby="dashboard-grid-help" sx={{ cursor: 'grab', minWidth: 44, minHeight: 44 }}><DragIndicatorIcon /></Button>
|
||||
<Box component="details" sx={{ flex: 1, minWidth: 0 }}>
|
||||
<Box component="summary" sx={{ cursor: 'pointer', p: 1, minHeight: 44 }}>{name}</Box>
|
||||
<Stack direction="row" useFlexGap flexWrap="wrap" spacing={1}>
|
||||
{[-1, 1].map(direction => <Button key={direction} data-move={direction} disabled={disabled || index + direction < 0 || index + direction >= layout.widgets.length}
|
||||
aria-label={t(direction < 0 ? 'dashboardMoveEarlierNamed' : 'dashboardMoveLaterNamed', { name })} onClick={() => move(widget.id, direction)}>{t(direction < 0 ? 'dashboardEarlier' : 'dashboardLater')}</Button>)}
|
||||
<TextField select label={t('dashboardSize')} value={widget.size} disabled={disabled || widget.id === 'cards'}
|
||||
slotProps={{ select: { native: true }, htmlInput: { 'aria-label': t('dashboardSizeFor', { name }) } }}
|
||||
onChange={event => update(widget.id, { size: event.target.value as 'standard' | 'full', w: event.target.value === 'full' ? 12 : 6 })}>
|
||||
<option value="standard">{t('dashboardSizeStandard')}</option><option value="full">{t('dashboardSizeFull')}</option>
|
||||
</TextField>
|
||||
<TextField type="number" label={t('dashboardHeightFor', { name })} value={widget.h} disabled={disabled} sx={{ width: 110 }}
|
||||
slotProps={{ htmlInput: { min: 4, max: 24, step: 1 } }} onChange={event => { const h = Number(event.target.value); if (Number.isInteger(h) && h >= 4 && h <= 24) update(widget.id, { h }); }} />
|
||||
<Button disabled={disabled} onClick={() => {
|
||||
update(widget.id, { visible: false });
|
||||
announce(t('dashboardWidgetHidden', { name }));
|
||||
requestAnimationFrame(() => focus.current?.focus());
|
||||
}}>{t('dashboardHideWidget')}</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Stack> : null}
|
||||
<Box tabIndex={wide ? 0 : undefined} role={wide ? 'group' : undefined} aria-label={wide ? name : undefined}
|
||||
sx={{ minHeight: 0, minWidth: 0, flex: 1, overflow: wide ? 'auto' : 'visible', p: editing ? 1 : 0 }}>{contents.get(widget.id)}</Box>
|
||||
</Box>;
|
||||
});
|
||||
return <Box ref={containerRef} sx={{ minWidth: 0 }}>
|
||||
{editing ? <Typography id="dashboard-grid-help" variant="body2">{t('dashboardGridHelp')}</Typography> : null}
|
||||
<Typography role="status" aria-live="polite" sx={{ position: 'absolute', width: '1px', height: '1px', overflow: 'hidden', clipPath: 'inset(50%)' }}>{announcement}</Typography>
|
||||
<Box ref={focus} tabIndex={editing ? -1 : undefined} role={editing ? 'region' : undefined} aria-label={editing ? t('dashboardPreview') : undefined}
|
||||
sx={{ '& .react-resizable-handle': { width: 44, height: 44 }, '@media (prefers-reduced-motion: reduce)': { '& .react-grid-item': { transition: 'none' } } }}>
|
||||
{wide ? <GridLayout width={width} compactor={preserveGaps} layout={visible.map(widget => ({ 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}</GridLayout>
|
||||
: <Stack spacing={3}>{tiles}</Stack>}
|
||||
</Box>
|
||||
</Box>;
|
||||
}
|
||||
@@ -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<HTMLDivElement>(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<HTMLButtonElement>(`button[data-move="${target.direction}"]:not(:disabled)`);
|
||||
(preferred ?? row?.querySelector<HTMLButtonElement>("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<DashboardLayout["widgets"][number]>) => 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 ? <Alert severity="error" sx={{ mb: 2 }}>{t(conflict ? "dashboardConflict" : "dashboardSaveFailed")}
|
||||
{conflict ? <Button disabled={saving} onClick={() => void reload()}>{t("dashboardReload")}</Button> : null}
|
||||
</Alert> : null}
|
||||
<Typography role="status" aria-live="polite" variant="body2" sx={{ mb: 1 }}>{announcement || t(dirty ? "assetsUnsavedChanges" : "dashboardDraftSaved")}</Typography>
|
||||
<Box component="ol" aria-label={t("dashboardManageWidgets")} sx={{ listStyle: "none", m: 0, p: 0, display: "grid", gap: 1 }}>
|
||||
{draft.widgets.map((widget, index) => {
|
||||
<Typography role="status" aria-live="polite" variant="body2" sx={{ mb: 1 }}>{t(dirty ? "assetsUnsavedChanges" : "dashboardDraftSaved")}</Typography>
|
||||
<Box component="details">
|
||||
<Box component="summary" sx={{ p: 1, cursor: "pointer", minHeight: 44 }}>{t("dashboardManageWidgets")}</Box>
|
||||
<Stack direction="row" useFlexGap flexWrap="wrap" role="group" aria-label={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 <Box component="li" key={widget.id} data-layout-row={widget.id} onDragOver={saving ? undefined : gesture.onDragOver} onDrop={saving ? undefined : gesture.onDrop}
|
||||
sx={{ display: "flex", alignItems: "center", flexWrap: "wrap", gap: 1, p: 1, border: "1px solid", borderColor: drag.overIndex === index ? "primary.main" : "divider", borderRadius: 1, bgcolor: drag.overIndex === index ? "action.hover" : "background.paper", opacity: drag.dragIndex === index ? 0.6 : 1, minWidth: 0 }}>
|
||||
<Button {...gesture} disabled={saving} draggable={!saving} aria-label={t("dashboardDrag", { name })} aria-describedby="dashboard-drag-help" sx={{ minWidth: 44, minHeight: 44, cursor: "grab" }}><DragIndicatorIcon /></Button>
|
||||
<FormControlLabel sx={{ flex: "1 1 14rem", minWidth: 0, mr: 0, overflowWrap: "anywhere" }} control={<Checkbox checked={widget.visible} disabled={saving} onChange={event => change(widget.id, { visible: event.target.checked })} />} label={name} />
|
||||
<Stack direction="row" spacing={0.5} useFlexGap flexWrap="wrap">
|
||||
{([-1, 1] as const).map(direction => <Button key={direction} data-move={direction} disabled={saving || index + direction < 0 || index + direction >= draft.widgets.length}
|
||||
aria-label={t(direction === -1 ? "dashboardMoveEarlierNamed" : "dashboardMoveLaterNamed", { name })}
|
||||
onClick={() => { pendingFocus.current = { id: widget.id, direction }; reorder(index, index + direction); }} sx={{ minHeight: 44 }}>{t(direction === -1 ? "dashboardEarlier" : "dashboardLater")}</Button>)}
|
||||
</Stack>
|
||||
<TextField select size="small" label={t("dashboardSize")} value={widget.size} disabled={saving || entry.sizes.length === 1}
|
||||
onChange={event => 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 => <option key={size} value={size}>{t(size === "full" ? "dashboardSizeFull" : "dashboardSizeStandard")}</option>)}
|
||||
</TextField>
|
||||
</Box>;
|
||||
return <FormControlLabel key={widget.id} sx={{ minWidth: 0, overflowWrap: "anywhere" }} label={name}
|
||||
control={<Checkbox checked={widget.visible} disabled={saving || conflict} onChange={event => onChange({ ...draft,
|
||||
widgets: arrangeDashboardWidgets(draft.widgets.map(item => item.id === widget.id ? { ...item, visible: event.target.checked } : item)) })} />} />;
|
||||
})}
|
||||
</Stack>
|
||||
</Box>
|
||||
<Typography id="dashboard-drag-help" variant="body2" color="text.secondary" sx={{ mt: 2 }}>{t("dashboardReorderHelp")}</Typography>
|
||||
</PageSection>
|
||||
</Box>;
|
||||
}
|
||||
|
||||
@@ -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 ? <Box sx={grid}>{reports.stageAge}{reports.sources}</Box> : null}
|
||||
{reports.salary}
|
||||
<Box sx={grid}>{reports.companies}{reports.skills}</Box>
|
||||
</> : <Box role={layoutState.editing ? "region" : undefined} aria-label={layoutState.editing ? t("dashboardPreview") : undefined} sx={{ display: "grid", gridTemplateColumns: { xs: "minmax(0, 1fr)", md: "repeat(2, minmax(0, 1fr))" }, gap: 3, alignItems: "start" }}>
|
||||
{activeLayout.widgets.filter(widget => widget.visible).map(widget => {
|
||||
</> : <DashboardGrid layout={activeLayout} editing={layoutState.editing} disabled={layoutState.saving || layoutState.conflict || layoutState.loading}
|
||||
onChange={layoutState.setDraft} childrenFor={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 <Box key={widget.id} data-dashboard-widget={widget.id} data-widget-size={widget.size} sx={{ minWidth: 0, gridColumn: { xs: "auto", md: widget.size === "full" ? "1 / -1" : "auto" } }}>
|
||||
{content ?? <PageSection title={t(WIDGETS.find(item => item.id === widget.id)!.titleKey)}><Typography color="text.secondary">{t("dashboardNoSample")}</Typography></PageSection>}
|
||||
</Box>;
|
||||
})}
|
||||
</Box>}
|
||||
return content ?? <PageSection title={t(WIDGETS.find(item => item.id === widget.id)!.titleKey)}><Typography color="text.secondary">{t("dashboardNoSample")}</Typography></PageSection>;
|
||||
}} />}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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());
|
||||
});
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
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<WidgetId>();
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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<DashboardLayout> | 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;
|
||||
|
||||
@@ -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.",
|
||||
|
||||
Reference in New Issue
Block a user