Compare commits
3 Commits
mai/pasteu
...
4cd2f05d33
| Author | SHA1 | Date | |
|---|---|---|---|
| 4cd2f05d33 | |||
| 92d0340d74 | |||
| f8c6206afe |
285
frontend/src/client/dashboard-grid.test.ts
Normal file
285
frontend/src/client/dashboard-grid.test.ts
Normal file
@@ -0,0 +1,285 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
GRID_COLUMNS,
|
||||
clampH,
|
||||
clampW,
|
||||
placeWidgets,
|
||||
type WidgetPlacementInput,
|
||||
} from "./dashboard-grid";
|
||||
|
||||
// Regression suite for m/paliad#70 (t-paliad-228): the post-#69 edit
|
||||
// mode produced overlapping widgets when a 2-col widget sat next to a
|
||||
// 1-col widget on the same row, when a drag swapped widgets of
|
||||
// different widths, and when a resize grew a widget into a sibling. The
|
||||
// fix moved the placement math into ./dashboard-grid + made it
|
||||
// collision-aware. These tests pin the no-overlap invariant.
|
||||
|
||||
function spec(
|
||||
key: string,
|
||||
x: number | undefined,
|
||||
y: number | undefined,
|
||||
w: number,
|
||||
h = 1,
|
||||
visible = true,
|
||||
): WidgetPlacementInput {
|
||||
return { key, visible, x, y, w, h };
|
||||
}
|
||||
|
||||
// hasOverlap returns true if any placed pair shares a cell. O(n²) is
|
||||
// fine — layouts cap at 32 widgets and the tests stay tiny.
|
||||
function hasOverlap(rects: Map<string, { x: number; y: number; w: number; h: number }>): string | null {
|
||||
const list = Array.from(rects.entries());
|
||||
for (let i = 0; i < list.length; i++) {
|
||||
const [ka, a] = list[i];
|
||||
for (let j = i + 1; j < list.length; j++) {
|
||||
const [kb, b] = list[j];
|
||||
const xOverlap = a.x < b.x + b.w && b.x < a.x + a.w;
|
||||
const yOverlap = a.y < b.y + b.h && b.y < a.y + a.h;
|
||||
if (xOverlap && yOverlap) return `${ka} ↔ ${kb} at (${a.x},${a.y},${a.w}x${a.h}) vs (${b.x},${b.y},${b.w}x${b.h})`;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
describe("placeWidgets — basic auto-flow", () => {
|
||||
test("places two 6-wide widgets side by side on row 0", () => {
|
||||
const out = placeWidgets([
|
||||
spec("a", undefined, undefined, 6),
|
||||
spec("b", undefined, undefined, 6),
|
||||
]);
|
||||
expect(out.get("a")).toEqual({ x: 0, y: 0, w: 6, h: 1 });
|
||||
expect(out.get("b")).toEqual({ x: 6, y: 0, w: 6, h: 1 });
|
||||
expect(hasOverlap(out)).toBeNull();
|
||||
});
|
||||
|
||||
test("wraps when row doesn't fit", () => {
|
||||
const out = placeWidgets([
|
||||
spec("a", undefined, undefined, 8),
|
||||
spec("b", undefined, undefined, 8),
|
||||
]);
|
||||
expect(out.get("a")!.y).toBe(0);
|
||||
expect(out.get("b")!.y).toBeGreaterThan(0);
|
||||
expect(hasOverlap(out)).toBeNull();
|
||||
});
|
||||
|
||||
test("hidden widgets are skipped and reserve no cells", () => {
|
||||
const out = placeWidgets([
|
||||
spec("hidden", 0, 0, 12, 1, false),
|
||||
spec("visible", undefined, undefined, 6),
|
||||
]);
|
||||
expect(out.has("hidden")).toBe(false);
|
||||
expect(out.get("visible")).toEqual({ x: 0, y: 0, w: 6, h: 1 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("placeWidgets — explicit positions, no collision", () => {
|
||||
test("trusts non-colliding explicit positions exactly", () => {
|
||||
const out = placeWidgets([
|
||||
spec("a", 0, 0, 6),
|
||||
spec("b", 6, 0, 6),
|
||||
spec("c", 0, 1, 12),
|
||||
]);
|
||||
expect(out.get("a")).toEqual({ x: 0, y: 0, w: 6, h: 1 });
|
||||
expect(out.get("b")).toEqual({ x: 6, y: 0, w: 6, h: 1 });
|
||||
expect(out.get("c")).toEqual({ x: 0, y: 1, w: 12, h: 1 });
|
||||
expect(hasOverlap(out)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("placeWidgets — mixed-width collision (m/paliad#70 regression)", () => {
|
||||
test("1-col + 2-col on same row do not overlap when both explicit", () => {
|
||||
// Half-width left + half-width right is the canonical 'two widgets per
|
||||
// row' layout; pre-fix this was fine but the next regression below
|
||||
// exercises the actual bug.
|
||||
const out = placeWidgets([
|
||||
spec("left", 0, 0, 6),
|
||||
spec("right", 6, 0, 6),
|
||||
]);
|
||||
expect(hasOverlap(out)).toBeNull();
|
||||
});
|
||||
|
||||
test("4-col + 8-col both claiming (0,0) end up non-overlapping", () => {
|
||||
// Simulates a post-#69 layout where a 4-wide widget sits at (0, 0)
|
||||
// and an 8-wide widget got accidentally placed at (0, 0) too (e.g.
|
||||
// a buggy reset path or a stale spec from before #70). Placer must
|
||||
// honour the first one's position and fit the second somewhere
|
||||
// free — landing it on the same row at x=4 is acceptable (better
|
||||
// density) as long as nothing overlaps.
|
||||
const out = placeWidgets([
|
||||
spec("first", 0, 0, 4),
|
||||
spec("colliding", 0, 0, 8),
|
||||
]);
|
||||
expect(out.get("first")).toEqual({ x: 0, y: 0, w: 4, h: 1 });
|
||||
expect(out.get("colliding")!.w).toBe(8);
|
||||
expect(hasOverlap(out)).toBeNull();
|
||||
});
|
||||
|
||||
test("drag-drop swap of 12-wide onto 6-wide does not overlap", () => {
|
||||
// Setup before swap:
|
||||
// A at (0, 0, w=12) — full width row 0
|
||||
// B at (0, 1, w=6) — half row 1 left
|
||||
// C at (6, 1, w=6) — half row 1 right
|
||||
// User drags A onto B. reorderViaDnd swaps (x, y):
|
||||
// A.x=0, A.y=1
|
||||
// B.x=0, B.y=0
|
||||
// Result must not overlap C.
|
||||
const out = placeWidgets([
|
||||
spec("a", 0, 1, 12),
|
||||
spec("b", 0, 0, 6),
|
||||
spec("c", 6, 1, 6),
|
||||
]);
|
||||
expect(hasOverlap(out)).toBeNull();
|
||||
});
|
||||
|
||||
test("auto-flow widget steps past explicit blocker on same row", () => {
|
||||
// Explicit widget at (6, 0, w=6); auto-flow widget would pack into
|
||||
// (0, 0, w=6) which is fine — but the next auto-flow widget at w=6
|
||||
// would want (6, 0) which is taken. Placer must wrap it.
|
||||
const out = placeWidgets([
|
||||
spec("flow-a", undefined, undefined, 6),
|
||||
spec("anchored", 6, 0, 6),
|
||||
spec("flow-b", undefined, undefined, 6),
|
||||
]);
|
||||
expect(out.get("flow-a")).toEqual({ x: 0, y: 0, w: 6, h: 1 });
|
||||
expect(out.get("anchored")).toEqual({ x: 6, y: 0, w: 6, h: 1 });
|
||||
expect(out.get("flow-b")!.y).toBeGreaterThan(0);
|
||||
expect(hasOverlap(out)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("placeWidgets — resize-grow shifts siblings", () => {
|
||||
test("growing a 6-wide to 12-wide bumps the sibling on the same row", () => {
|
||||
// Pre-resize state:
|
||||
// A at (0, 0, w=6)
|
||||
// B at (6, 0, w=6)
|
||||
// User resizes A to w=12. resizeWidget() updates A.w but leaves B
|
||||
// at (6, 0). Placer must shift B down.
|
||||
const out = placeWidgets([
|
||||
spec("a", 0, 0, 12),
|
||||
spec("b", 6, 0, 6),
|
||||
]);
|
||||
expect(out.get("a")).toEqual({ x: 0, y: 0, w: 12, h: 1 });
|
||||
expect(out.get("b")!.y).toBeGreaterThan(0);
|
||||
expect(hasOverlap(out)).toBeNull();
|
||||
});
|
||||
|
||||
test("growing widget pushes only the first colliding sibling", () => {
|
||||
// A grows to 12-wide; B and C on row 0 are both colliding. Both must
|
||||
// move; their relative order on row 0 is preserved (B at x=0, C at
|
||||
// x=6) on row 1.
|
||||
const out = placeWidgets([
|
||||
spec("a", 0, 0, 12),
|
||||
spec("b", 0, 0, 4),
|
||||
spec("c", 4, 0, 4),
|
||||
]);
|
||||
expect(hasOverlap(out)).toBeNull();
|
||||
expect(out.get("a")!.y).toBe(0);
|
||||
expect(out.get("b")!.y).toBeGreaterThan(0);
|
||||
expect(out.get("c")!.y).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("placeWidgets — explicit position overflow clamp", () => {
|
||||
test("x+w > GRID_COLUMNS is clamped not rejected", () => {
|
||||
// A 12-wide widget with x=6 would extend past col 11. Placer must
|
||||
// clamp x to 0 (or wherever fits) so the widget renders inside the
|
||||
// grid.
|
||||
const out = placeWidgets([
|
||||
spec("wide", 6, 0, 12),
|
||||
]);
|
||||
const r = out.get("wide")!;
|
||||
expect(r.x + r.w).toBeLessThanOrEqual(GRID_COLUMNS);
|
||||
expect(r.w).toBe(12);
|
||||
});
|
||||
});
|
||||
|
||||
describe("placeWidgets — vertical (multi-row) widgets", () => {
|
||||
test("a 2-row-tall widget reserves both rows", () => {
|
||||
const out = placeWidgets([
|
||||
spec("tall", 0, 0, 6, 2),
|
||||
spec("collides-on-row-1", 0, 1, 6, 1),
|
||||
]);
|
||||
expect(out.get("tall")).toEqual({ x: 0, y: 0, w: 6, h: 2 });
|
||||
// The colliding widget must move because tall covers cols 0..5
|
||||
// on both row 0 and row 1. The placer may shift it to the right
|
||||
// half of row 1 (cols 6..11) or to a later row — either is fine
|
||||
// as long as nothing overlaps.
|
||||
const other = out.get("collides-on-row-1")!;
|
||||
expect(other.x >= 6 || other.y >= 2).toBe(true);
|
||||
expect(hasOverlap(out)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("placeWidgets — includeHidden (edit mode)", () => {
|
||||
test("hidden widgets are skipped by default", () => {
|
||||
const out = placeWidgets([
|
||||
spec("visible", 0, 0, 6),
|
||||
spec("hidden", 0, 0, 6, 1, false),
|
||||
]);
|
||||
expect(out.has("visible")).toBe(true);
|
||||
expect(out.has("hidden")).toBe(false);
|
||||
});
|
||||
|
||||
test("includeHidden:true places hidden widgets after visible ones", () => {
|
||||
// Regression for m/paliad#73 / t-paliad-238: in edit mode hidden
|
||||
// widgets MUST receive a placement, otherwise applyLayout leaves
|
||||
// their inline grid-column empty and CSS Grid auto-flows them as
|
||||
// 1×1 slivers ("super slim greyed-out column").
|
||||
const out = placeWidgets([
|
||||
spec("active", 0, 0, 12),
|
||||
spec("hidden", 0, 0, 6, 1, false),
|
||||
], { includeHidden: true });
|
||||
expect(out.has("hidden")).toBe(true);
|
||||
const h = out.get("hidden")!;
|
||||
// Must keep its requested width (6), not collapse to 1.
|
||||
expect(h.w).toBe(6);
|
||||
// Must land below the visible widget — never overlap or steal cells.
|
||||
expect(h.y).toBeGreaterThanOrEqual(1);
|
||||
expect(hasOverlap(out)).toBeNull();
|
||||
});
|
||||
|
||||
test("includeHidden two-pass: visible widgets keep priority over hidden", () => {
|
||||
// Hidden widget stored at (0, 0) shouldn't displace a visible
|
||||
// widget that wants (0, 0). The visible pass runs first, claims
|
||||
// (0, 0); the hidden widget is then placed wherever free — the
|
||||
// placer happily fits it next to the visible widget on the same
|
||||
// row if there's room. The hard invariant is just no-overlap.
|
||||
const out = placeWidgets([
|
||||
spec("active", 0, 0, 6),
|
||||
spec("hidden-at-origin", 0, 0, 6, 1, false),
|
||||
], { includeHidden: true });
|
||||
expect(out.get("active")).toEqual({ x: 0, y: 0, w: 6, h: 1 });
|
||||
expect(out.has("hidden-at-origin")).toBe(true);
|
||||
expect(hasOverlap(out)).toBeNull();
|
||||
});
|
||||
|
||||
test("multiple hidden widgets all receive valid placements", () => {
|
||||
const out = placeWidgets([
|
||||
spec("a", 0, 0, 12),
|
||||
spec("h1", undefined, undefined, 6, 1, false),
|
||||
spec("h2", undefined, undefined, 6, 1, false),
|
||||
spec("h3", undefined, undefined, 12, 1, false),
|
||||
], { includeHidden: true });
|
||||
expect(out.size).toBe(4);
|
||||
for (const r of out.values()) {
|
||||
expect(r.w).toBeGreaterThanOrEqual(1);
|
||||
expect(r.x + r.w).toBeLessThanOrEqual(GRID_COLUMNS);
|
||||
}
|
||||
expect(hasOverlap(out)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("clamp helpers", () => {
|
||||
test("clampW respects min/max bounds", () => {
|
||||
expect(clampW(2, { min_w: 4, max_w: 12 })).toBe(4);
|
||||
expect(clampW(20, { min_w: 4, max_w: 12 })).toBe(12);
|
||||
expect(clampW(0, { default_w: 6 })).toBe(6);
|
||||
expect(clampW(NaN, { default_w: 8 })).toBe(8);
|
||||
});
|
||||
|
||||
test("clampH respects min/max bounds and MAX_ROW_SPAN", () => {
|
||||
expect(clampH(0, { default_h: 2 })).toBe(2);
|
||||
expect(clampH(99, undefined)).toBe(5); // MAX_ROW_SPAN
|
||||
expect(clampH(1, { min_h: 3 })).toBe(3);
|
||||
});
|
||||
});
|
||||
264
frontend/src/client/dashboard-grid.ts
Normal file
264
frontend/src/client/dashboard-grid.ts
Normal file
@@ -0,0 +1,264 @@
|
||||
// dashboard-grid — pure layout math for the dashboard widget grid.
|
||||
//
|
||||
// Lives outside dashboard.ts so the placement logic is importable from
|
||||
// tests without dragging in the DOM-side rendering code. The grid is a
|
||||
// 12-column CSS Grid matching internal/services/dashboard_layout_spec.go;
|
||||
// rows grow vertically as widgets are placed.
|
||||
//
|
||||
// The core invariant is no-overlap: after placeWidgets() returns, every
|
||||
// pair of widgets occupies disjoint cells. Pre-overhaul callers wrote
|
||||
// computePlacements() to trust explicit (x, y) without checking — that
|
||||
// produced visual overlap whenever a drag or resize landed a widget on
|
||||
// cells another widget already covered (m/paliad#70). The collision-
|
||||
// aware placer below shifts colliding widgets to the next free row so
|
||||
// the rendered grid never overlaps regardless of the input spec.
|
||||
|
||||
export const GRID_COLUMNS = 12;
|
||||
export const MAX_ROW_SPAN = 5;
|
||||
|
||||
// Hard cap on the row-scan depth in findFreeSlot. The widget cap on a
|
||||
// single layout is 32 (LayoutWidgetCap on the Go side); each row holds
|
||||
// at least one widget, so 256 rows is an order-of-magnitude buffer
|
||||
// against runaway loops on pathological inputs.
|
||||
const MAX_SCAN_ROWS = 256;
|
||||
|
||||
export interface PlacedRect {
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
}
|
||||
|
||||
// WidgetSizeBound captures the per-widget min/max/default clamps the
|
||||
// catalog publishes. Optional fields keep callers from having to
|
||||
// synthesize zeroes when the catalog entry is missing.
|
||||
export interface WidgetSizeBound {
|
||||
default_w?: number;
|
||||
default_h?: number;
|
||||
min_w?: number;
|
||||
max_w?: number;
|
||||
min_h?: number;
|
||||
max_h?: number;
|
||||
}
|
||||
|
||||
// WidgetPlacementInput is the per-widget data the placer consumes. The
|
||||
// catalog bound is optional — when missing, defaults fall back to a
|
||||
// full-width 1-row widget.
|
||||
export interface WidgetPlacementInput {
|
||||
key: string;
|
||||
visible: boolean;
|
||||
x?: number;
|
||||
y?: number;
|
||||
w?: number;
|
||||
h?: number;
|
||||
bound?: WidgetSizeBound;
|
||||
}
|
||||
|
||||
export function clampW(w: number, bound: WidgetSizeBound | undefined): number {
|
||||
let v = Math.round(w);
|
||||
if (!Number.isFinite(v) || v <= 0) v = bound?.default_w ?? GRID_COLUMNS;
|
||||
v = Math.max(1, Math.min(GRID_COLUMNS, v));
|
||||
if (bound?.min_w && v < bound.min_w) v = bound.min_w;
|
||||
if (bound?.max_w && v > bound.max_w) v = bound.max_w;
|
||||
return v;
|
||||
}
|
||||
|
||||
export function clampH(h: number, bound: WidgetSizeBound | undefined): number {
|
||||
let v = Math.round(h);
|
||||
if (!Number.isFinite(v) || v <= 0) v = bound?.default_h ?? 1;
|
||||
v = Math.max(1, Math.min(MAX_ROW_SPAN, v));
|
||||
if (bound?.min_h && v < bound.min_h) v = bound.min_h;
|
||||
if (bound?.max_h && v > bound.max_h) v = bound.max_h;
|
||||
return v;
|
||||
}
|
||||
|
||||
// Occupancy bitmap: one row → Uint8Array of GRID_COLUMNS bits. Rows are
|
||||
// created lazily so the map only stores rows the layout actually
|
||||
// reaches. Cell value 1 = occupied.
|
||||
class Occupancy {
|
||||
private rows = new Map<number, Uint8Array>();
|
||||
|
||||
row(y: number): Uint8Array {
|
||||
let r = this.rows.get(y);
|
||||
if (!r) {
|
||||
r = new Uint8Array(GRID_COLUMNS);
|
||||
this.rows.set(y, r);
|
||||
}
|
||||
return r;
|
||||
}
|
||||
|
||||
free(x: number, y: number, w: number, h: number): boolean {
|
||||
if (x < 0 || y < 0 || x + w > GRID_COLUMNS) return false;
|
||||
for (let yy = y; yy < y + h; yy++) {
|
||||
const row = this.row(yy);
|
||||
for (let xx = x; xx < x + w; xx++) {
|
||||
if (row[xx]) return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
mark(x: number, y: number, w: number, h: number): void {
|
||||
for (let yy = y; yy < y + h; yy++) {
|
||||
const row = this.row(yy);
|
||||
for (let xx = x; xx < x + w; xx++) row[xx] = 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// findFreeSlot scans for the first (x, y) where a w×h block fits without
|
||||
// collision, starting at row startY. At each row preferX is tried first
|
||||
// — that keeps a widget close to its requested column when only the row
|
||||
// is blocked. Falls back to left-to-right scan within the row, then to
|
||||
// the next row. Caller guarantees w ≤ GRID_COLUMNS.
|
||||
function findFreeSlot(
|
||||
occ: Occupancy,
|
||||
startY: number,
|
||||
w: number,
|
||||
h: number,
|
||||
preferX: number,
|
||||
): { x: number; y: number } {
|
||||
for (let y = startY; y < startY + MAX_SCAN_ROWS; y++) {
|
||||
if (preferX >= 0 && preferX + w <= GRID_COLUMNS && occ.free(preferX, y, w, h)) {
|
||||
return { x: preferX, y };
|
||||
}
|
||||
for (let x = 0; x + w <= GRID_COLUMNS; x++) {
|
||||
if (x === preferX) continue;
|
||||
if (occ.free(x, y, w, h)) return { x, y };
|
||||
}
|
||||
}
|
||||
// Pathological fallback — caller's widget cap (32) makes this
|
||||
// unreachable in practice. Snap to the bottom-left so the widget at
|
||||
// least renders somewhere visible instead of vanishing.
|
||||
return { x: 0, y: startY + MAX_SCAN_ROWS };
|
||||
}
|
||||
|
||||
// PlaceOptions tunes the placer for the caller's render-vs-persist
|
||||
// needs.
|
||||
export interface PlaceOptions {
|
||||
// When true, hidden widgets are placed too — for edit-mode rendering
|
||||
// where the user can see + un-hide them inline. The two-pass order
|
||||
// (visible first, then hidden) guarantees hidden widgets never
|
||||
// displace visible ones: they get whatever cells are left below the
|
||||
// active layout. Default false matches view-mode behaviour and the
|
||||
// persistence path (materializePositions) where hidden widgets
|
||||
// retain their stored coordinates instead of being repacked.
|
||||
//
|
||||
// Without this option, hidden widgets in edit mode were left without
|
||||
// an explicit grid-column inline style by applyLayout(), so CSS Grid
|
||||
// auto-flowed them into the next free cell at 1×1 — the "super slim
|
||||
// greyed-out column" symptom of m/paliad#73 / t-paliad-238.
|
||||
includeHidden?: boolean;
|
||||
}
|
||||
|
||||
// placeWidgets assigns no-overlap grid coordinates to widgets. By
|
||||
// default only visible widgets receive placements; pass
|
||||
// {includeHidden:true} to also place hidden widgets after the visible
|
||||
// pass (used by applyLayout in edit mode).
|
||||
//
|
||||
// Algorithm — per pass:
|
||||
// 1. Clamp w/h against catalog bounds.
|
||||
// 2. If the spec carries explicit x and y, try that slot. On a
|
||||
// collision, search downward starting at the requested y for the
|
||||
// first free w×h block (preferring the requested x).
|
||||
// 3. If only x is explicit, search from y=0 at that x.
|
||||
// 4. Otherwise auto-flow: pack left-to-right under a running cursor;
|
||||
// when the row doesn't fit or is blocked by an explicitly-placed
|
||||
// widget, wrap to the next free row.
|
||||
//
|
||||
// The mixed-spec case (some widgets explicit, others auto-flow) is the
|
||||
// real-world layout — placing the explicit widgets first would change
|
||||
// the visual order, so we keep input order and let auto-flow widgets
|
||||
// step around any explicit blockers via the same collision search.
|
||||
//
|
||||
// Two-pass behaviour for hidden widgets: the visible pass owns its
|
||||
// own auto-flow cursor; the hidden pass continues from where the
|
||||
// visible pass left off so the hidden widgets stack right under the
|
||||
// active layout. The shared Occupancy bitmap guarantees the second
|
||||
// pass can never overlap a placed visible widget.
|
||||
export function placeWidgets(
|
||||
widgets: WidgetPlacementInput[],
|
||||
options: PlaceOptions = {},
|
||||
): Map<string, PlacedRect> {
|
||||
const out = new Map<string, PlacedRect>();
|
||||
const occ = new Occupancy();
|
||||
|
||||
// Auto-flow cursor — advances as we place flowed widgets. cursorY
|
||||
// tracks the row currently being filled; rowMaxH is the tallest
|
||||
// widget in that row so wrapping advances past it (not just past the
|
||||
// new widget's height — that would let taller previous neighbours
|
||||
// overlap into the wrap row).
|
||||
let cursorX = 0;
|
||||
let cursorY = 0;
|
||||
let rowMaxH = 0;
|
||||
|
||||
const placeOne = (w: WidgetPlacementInput): void => {
|
||||
const dw = clampW(w.w ?? w.bound?.default_w ?? GRID_COLUMNS, w.bound);
|
||||
const dh = clampH(w.h ?? w.bound?.default_h ?? 1, w.bound);
|
||||
|
||||
const hasX = typeof w.x === "number";
|
||||
const hasY = typeof w.y === "number";
|
||||
let placed: { x: number; y: number };
|
||||
|
||||
if (hasX && hasY) {
|
||||
// Clamp x so the widget never overflows the right edge — drag/
|
||||
// resize gestures can produce x+w > GRID_COLUMNS otherwise.
|
||||
const prefX = Math.max(0, Math.min(GRID_COLUMNS - dw, w.x as number));
|
||||
const prefY = Math.max(0, w.y as number);
|
||||
if (occ.free(prefX, prefY, dw, dh)) {
|
||||
placed = { x: prefX, y: prefY };
|
||||
} else {
|
||||
placed = findFreeSlot(occ, prefY, dw, dh, prefX);
|
||||
}
|
||||
} else if (hasX) {
|
||||
const prefX = Math.max(0, Math.min(GRID_COLUMNS - dw, w.x as number));
|
||||
placed = findFreeSlot(occ, 0, dw, dh, prefX);
|
||||
} else {
|
||||
// Auto-flow. Wrap the cursor when the widget wouldn't fit in the
|
||||
// remaining columns of the current row, then ask findFreeSlot to
|
||||
// honour the cursor's preferred (x, y) — that lets it step past
|
||||
// any explicit widget that already claimed cells under the
|
||||
// cursor.
|
||||
if (cursorX + dw > GRID_COLUMNS) {
|
||||
cursorY += rowMaxH || 1;
|
||||
cursorX = 0;
|
||||
rowMaxH = 0;
|
||||
}
|
||||
placed = findFreeSlot(occ, cursorY, dw, dh, cursorX);
|
||||
if (placed.y > cursorY) {
|
||||
// Wrap was forced by a collision deeper than the current row.
|
||||
cursorY = placed.y;
|
||||
rowMaxH = 0;
|
||||
}
|
||||
cursorX = placed.x + dw;
|
||||
if (dh > rowMaxH) rowMaxH = dh;
|
||||
}
|
||||
|
||||
occ.mark(placed.x, placed.y, dw, dh);
|
||||
out.set(w.key, { x: placed.x, y: placed.y, w: dw, h: dh });
|
||||
};
|
||||
|
||||
// Pass 1: visible widgets. They own the active layout.
|
||||
for (const w of widgets) {
|
||||
if (!w.visible) continue;
|
||||
placeOne(w);
|
||||
}
|
||||
|
||||
// Pass 2: hidden widgets (edit-mode only). Wrap the cursor to the
|
||||
// start of the next row before the second pass so the hidden tray
|
||||
// visually separates from the active layout — even if the last
|
||||
// visible widget left half a row open.
|
||||
if (options.includeHidden) {
|
||||
if (cursorX > 0) {
|
||||
cursorY += rowMaxH || 1;
|
||||
cursorX = 0;
|
||||
rowMaxH = 0;
|
||||
}
|
||||
for (const w of widgets) {
|
||||
if (w.visible) continue;
|
||||
placeOne(w);
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
@@ -2,6 +2,16 @@ import { initI18n, onLangChange, t, tDyn, getLang, translateEvent } from "./i18n
|
||||
import { initSidebar } from "./sidebar";
|
||||
import { renderAgendaTimeline, type AgendaItem } from "./agenda-render";
|
||||
import { openModal } from "./components/modal";
|
||||
import {
|
||||
GRID_COLUMNS,
|
||||
MAX_ROW_SPAN,
|
||||
placeWidgets,
|
||||
clampW as gridClampW,
|
||||
clampH as gridClampH,
|
||||
type PlacedRect,
|
||||
type WidgetPlacementInput,
|
||||
type WidgetSizeBound,
|
||||
} from "./dashboard-grid";
|
||||
|
||||
interface DashboardUser {
|
||||
id: string;
|
||||
@@ -156,10 +166,9 @@ interface WidgetCatalogEntry {
|
||||
settings?: WidgetSettingsSchema | null;
|
||||
}
|
||||
|
||||
// Grid constants — must match internal/services/dashboard_layout_spec.go
|
||||
const GRID_COLUMNS = 12;
|
||||
const MAX_ROW_SPAN = 5;
|
||||
|
||||
// Grid constants — must match internal/services/dashboard_layout_spec.go.
|
||||
// Re-exported from ./dashboard-grid so the placement math is shared with
|
||||
// the unit tests; the names below keep the local imports tidy.
|
||||
declare global {
|
||||
interface Window {
|
||||
__PALIAD_DASHBOARD__?: DashboardData | null;
|
||||
@@ -1913,10 +1922,15 @@ function applyLayout(): void {
|
||||
if (k) byKey.set(k, el);
|
||||
});
|
||||
|
||||
// Compute effective placements (with auto-flow fill-in for missing
|
||||
// y values). The visible widgets are placed deterministically so the
|
||||
// grid renders identically across reloads.
|
||||
const placements = computePlacements(currentLayout.widgets);
|
||||
// Compute effective placements. In edit mode we also include hidden
|
||||
// widgets so they render at their stored (or default) dimensions
|
||||
// dimmed-but-visible — without this they'd inherit no inline grid-
|
||||
// column and CSS Grid would auto-flow them as 1×1 slivers, producing
|
||||
// the "super slim greyed-out column" symptom (m/paliad#73). In view
|
||||
// mode hidden widgets are display:none and reserve no cells.
|
||||
const placements = computePlacements(currentLayout.widgets, {
|
||||
includeHidden: editMode,
|
||||
});
|
||||
|
||||
for (const w of currentLayout.widgets) {
|
||||
const el = byKey.get(w.key);
|
||||
@@ -1937,74 +1951,51 @@ function applyLayout(): void {
|
||||
}
|
||||
}
|
||||
|
||||
// PlacedRect is the resolved grid position for a widget — non-zero w/h,
|
||||
// concrete x/y (0-indexed) derived from spec values plus auto-flow
|
||||
// fill-in for missing y values.
|
||||
interface PlacedRect { x: number; y: number; w: number; h: number; }
|
||||
|
||||
// computePlacements assigns explicit grid coordinates to every visible
|
||||
// widget. Spec values win when present; missing values fall back to:
|
||||
// - w: catalog default_w, else GRID_COLUMNS
|
||||
// - h: catalog default_h, else 1
|
||||
// - x: 0 when also missing y; else as given
|
||||
// - y: auto-flow — packs left-to-right under the running cursor,
|
||||
// wrapping when the row doesn't fit.
|
||||
// computePlacements is the local adapter — it walks the layout's widgets,
|
||||
// resolves each widget's catalog bound, and hands the spec to the pure
|
||||
// placeWidgets() in ./dashboard-grid. The pure placer carries the no-
|
||||
// overlap invariant: if two widgets request colliding cells (drag-drop
|
||||
// swap with mismatched widths, resize-grow into a sibling, etc.) the
|
||||
// later one is shifted down to the next free row. See m/paliad#70.
|
||||
//
|
||||
// Auto-flow keeps pre-overhaul layouts (no positions on the wire)
|
||||
// rendering as a tidy single column without the visual mess the old
|
||||
// applyLayout produced. Hidden widgets are skipped — they contribute
|
||||
// no placement and don't reserve row space.
|
||||
function computePlacements(widgets: DashboardWidgetRef[]): Map<string, PlacedRect> {
|
||||
const out = new Map<string, PlacedRect>();
|
||||
// Track the tallest widget on the row currently being filled, so
|
||||
// wrapping advances cursorY past the bottom of the row (not just by
|
||||
// the new widget's height — that would let taller previous neighbours
|
||||
// overlap). Mirrors the Go-side packer in FactoryDefaultLayout.
|
||||
let cursorX = 0, cursorY = 0, rowMaxH = 0;
|
||||
for (const w of widgets) {
|
||||
if (!w.visible) continue;
|
||||
const def = lookupCatalog(w.key);
|
||||
const dw = clampW(w.w ?? def?.default_w ?? GRID_COLUMNS, def);
|
||||
const dh = clampH(w.h ?? def?.default_h ?? 1, def);
|
||||
let x = typeof w.x === "number" ? w.x : -1;
|
||||
let y = typeof w.y === "number" ? w.y : -1;
|
||||
if (x < 0) {
|
||||
if (cursorX + dw > GRID_COLUMNS) {
|
||||
cursorY += rowMaxH;
|
||||
cursorX = 0;
|
||||
rowMaxH = 0;
|
||||
}
|
||||
x = cursorX;
|
||||
y = cursorY;
|
||||
cursorX += dw;
|
||||
if (dh > rowMaxH) rowMaxH = dh;
|
||||
} else {
|
||||
// Explicit x/y from the spec — trust it. Don't move the cursor
|
||||
// because explicit positions can land anywhere; auto-flow widgets
|
||||
// are positioned independently.
|
||||
if (y < 0) y = cursorY;
|
||||
}
|
||||
out.set(w.key, { x, y, w: dw, h: dh });
|
||||
}
|
||||
return out;
|
||||
// includeHidden=true is used by applyLayout in edit mode to also place
|
||||
// hidden widgets after the visible pass — so the hidden tray renders
|
||||
// at proper size below the active layout. Default (false) matches the
|
||||
// persistence + render paths where hidden widgets carry no placement.
|
||||
function computePlacements(
|
||||
widgets: DashboardWidgetRef[],
|
||||
options: { includeHidden?: boolean } = {},
|
||||
): Map<string, PlacedRect> {
|
||||
const inputs: WidgetPlacementInput[] = widgets.map((w) => ({
|
||||
key: w.key,
|
||||
visible: w.visible,
|
||||
x: w.x,
|
||||
y: w.y,
|
||||
w: w.w,
|
||||
h: w.h,
|
||||
bound: toBound(lookupCatalog(w.key)),
|
||||
}));
|
||||
return placeWidgets(inputs, options);
|
||||
}
|
||||
|
||||
function clampW(w: number, def: WidgetCatalogEntry | undefined): number {
|
||||
let v = Math.round(w);
|
||||
if (!Number.isFinite(v) || v <= 0) v = def?.default_w ?? GRID_COLUMNS;
|
||||
v = Math.max(1, Math.min(GRID_COLUMNS, v));
|
||||
if (def?.min_w && v < def.min_w) v = def.min_w;
|
||||
if (def?.max_w && v > def.max_w) v = def.max_w;
|
||||
return v;
|
||||
return gridClampW(w, toBound(def));
|
||||
}
|
||||
|
||||
function clampH(h: number, def: WidgetCatalogEntry | undefined): number {
|
||||
let v = Math.round(h);
|
||||
if (!Number.isFinite(v) || v <= 0) v = def?.default_h ?? 1;
|
||||
v = Math.max(1, Math.min(MAX_ROW_SPAN, v));
|
||||
if (def?.min_h && v < def.min_h) v = def.min_h;
|
||||
if (def?.max_h && v > def.max_h) v = def.max_h;
|
||||
return v;
|
||||
return gridClampH(h, toBound(def));
|
||||
}
|
||||
|
||||
function toBound(def: WidgetCatalogEntry | undefined): WidgetSizeBound | undefined {
|
||||
if (!def) return undefined;
|
||||
return {
|
||||
default_w: def.default_w,
|
||||
default_h: def.default_h,
|
||||
min_w: def.min_w,
|
||||
max_w: def.max_w,
|
||||
min_h: def.min_h,
|
||||
max_h: def.max_h,
|
||||
};
|
||||
}
|
||||
|
||||
// filterByHorizonDays drops items whose key date is more than `days`
|
||||
|
||||
@@ -226,10 +226,12 @@ func validatePosition(i int, w DashboardWidgetRef, def WidgetDef) error {
|
||||
}
|
||||
|
||||
// SanitizeForRead applies the forgiving read-path rules: drop entries whose
|
||||
// keys are not in the catalog (catalog has shrunk) and bump the version to
|
||||
// the current one if missing. Settings on surviving entries pass through
|
||||
// unchanged — invalid settings on read are not worth aborting over and the
|
||||
// next write will reject them anyway.
|
||||
// keys are not in the catalog (catalog has shrunk), bump the version to
|
||||
// the current one if missing, and clamp w/h/x against the catalog's
|
||||
// MinW/MaxW/MinH/MaxH/grid bounds so a stale row with out-of-range sizes
|
||||
// can't strand the user with unrenderable widgets (m/paliad#73). Settings
|
||||
// on surviving entries pass through unchanged — invalid settings on read
|
||||
// are not worth aborting over and the next write will reject them anyway.
|
||||
//
|
||||
// Returns true if anything was changed; callers can use that to decide
|
||||
// whether to PUT the cleaned spec back.
|
||||
@@ -244,16 +246,88 @@ func (s *DashboardLayoutSpec) SanitizeForRead() bool {
|
||||
}
|
||||
out := make([]DashboardWidgetRef, 0, len(s.Widgets))
|
||||
for _, w := range s.Widgets {
|
||||
if _, ok := LookupWidgetDef(w.Key); !ok {
|
||||
def, ok := LookupWidgetDef(w.Key)
|
||||
if !ok {
|
||||
changed = true
|
||||
continue
|
||||
}
|
||||
if normalizePosition(&w, def) {
|
||||
changed = true
|
||||
}
|
||||
out = append(out, w)
|
||||
}
|
||||
s.Widgets = out
|
||||
return changed
|
||||
}
|
||||
|
||||
// normalizePosition clamps a widget's W/H/X to the catalog bounds and the
|
||||
// grid extent. Returns true if any field was modified. Zero W/H stay zero
|
||||
// (auto-flow / default sentinel — the placer fills them in). Negative X
|
||||
// snaps to 0; X+W overflowing the grid snaps X down.
|
||||
func normalizePosition(w *DashboardWidgetRef, def WidgetDef) bool {
|
||||
changed := false
|
||||
|
||||
if w.W < 0 {
|
||||
w.W = 0
|
||||
changed = true
|
||||
}
|
||||
if w.W > DashboardGridColumns {
|
||||
w.W = DashboardGridColumns
|
||||
changed = true
|
||||
}
|
||||
// W == 0 is the "auto / default" sentinel — leave it untouched so
|
||||
// downstream renderers can substitute DefaultW. Only clamp non-zero
|
||||
// values against the per-widget Min/Max.
|
||||
if w.W > 0 {
|
||||
if def.MinW > 0 && w.W < def.MinW {
|
||||
w.W = def.MinW
|
||||
changed = true
|
||||
}
|
||||
if def.MaxW > 0 && w.W > def.MaxW {
|
||||
w.W = def.MaxW
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
|
||||
if w.H < 0 {
|
||||
w.H = 0
|
||||
changed = true
|
||||
}
|
||||
if w.H > MaxGridRowSpan {
|
||||
w.H = MaxGridRowSpan
|
||||
changed = true
|
||||
}
|
||||
if w.H > 0 {
|
||||
if def.MinH > 0 && w.H < def.MinH {
|
||||
w.H = def.MinH
|
||||
changed = true
|
||||
}
|
||||
if def.MaxH > 0 && w.H > def.MaxH {
|
||||
w.H = def.MaxH
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
|
||||
if w.X < 0 {
|
||||
w.X = 0
|
||||
changed = true
|
||||
}
|
||||
if w.X >= DashboardGridColumns {
|
||||
w.X = DashboardGridColumns - 1
|
||||
changed = true
|
||||
}
|
||||
if w.W > 0 && w.X+w.W > DashboardGridColumns {
|
||||
w.X = DashboardGridColumns - w.W
|
||||
changed = true
|
||||
}
|
||||
if w.Y < 0 {
|
||||
w.Y = 0
|
||||
changed = true
|
||||
}
|
||||
|
||||
return changed
|
||||
}
|
||||
|
||||
// ParseDashboardLayoutSpec decodes JSON bytes and validates. Used by the
|
||||
// HTTP handler on incoming request bodies.
|
||||
func ParseDashboardLayoutSpec(b []byte) (DashboardLayoutSpec, error) {
|
||||
|
||||
@@ -279,6 +279,128 @@ func TestDashboardLayoutSpec_SanitizeForRead_DropsUnknownKeys(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestDashboardLayoutSpec_SanitizeForRead_ClampsOutOfRange covers the
|
||||
// m/paliad#73 recovery path: a stale row in user_dashboard_layouts
|
||||
// carrying a W below MinW (or above MaxW) must be normalised on load so
|
||||
// the user doesn't get stranded with super-slim columns. Pre-fix the
|
||||
// sanitizer only dropped unknown keys; sizes passed through verbatim.
|
||||
func TestDashboardLayoutSpec_SanitizeForRead_ClampsOutOfRange(t *testing.T) {
|
||||
// upcoming-deadlines: MinW=4, MaxW=12, MinH=1, MaxH=4 (per catalog).
|
||||
def, ok := LookupWidgetDef(WidgetUpcomingDeadlines)
|
||||
if !ok {
|
||||
t.Fatal("LookupWidgetDef(WidgetUpcomingDeadlines) = !ok")
|
||||
}
|
||||
cases := []struct {
|
||||
name string
|
||||
in DashboardWidgetRef
|
||||
wantW int
|
||||
wantH int
|
||||
wantX int
|
||||
wantY int
|
||||
wantOK bool // expected SanitizeForRead-returns-true
|
||||
}{
|
||||
{
|
||||
name: "W below MinW snaps to MinW",
|
||||
in: DashboardWidgetRef{Key: WidgetUpcomingDeadlines, Visible: true, X: 0, Y: 0, W: 1, H: 1},
|
||||
wantW: def.MinW,
|
||||
wantH: 1,
|
||||
wantOK: true,
|
||||
},
|
||||
{
|
||||
name: "W above MaxW snaps to MaxW",
|
||||
in: DashboardWidgetRef{Key: WidgetUpcomingDeadlines, Visible: true, X: 0, Y: 0, W: 99, H: 1},
|
||||
wantW: DashboardGridColumns,
|
||||
wantH: 1,
|
||||
wantOK: true,
|
||||
},
|
||||
{
|
||||
name: "W above grid width snaps to grid width",
|
||||
in: DashboardWidgetRef{Key: WidgetUpcomingDeadlines, Visible: true, X: 0, Y: 0, W: 50, H: 1},
|
||||
wantW: DashboardGridColumns,
|
||||
wantH: 1,
|
||||
wantOK: true,
|
||||
},
|
||||
{
|
||||
name: "H above MaxGridRowSpan snaps to MaxGridRowSpan",
|
||||
in: DashboardWidgetRef{Key: WidgetUpcomingDeadlines, Visible: true, X: 0, Y: 0, W: 6, H: 99},
|
||||
wantW: 6,
|
||||
wantH: def.MaxH, // upcoming-deadlines MaxH=4 < MaxGridRowSpan=5
|
||||
wantOK: true,
|
||||
},
|
||||
{
|
||||
name: "X+W overflowing grid snaps X down",
|
||||
in: DashboardWidgetRef{Key: WidgetUpcomingDeadlines, Visible: true, X: 10, Y: 0, W: 6, H: 1},
|
||||
wantW: 6,
|
||||
wantH: 1,
|
||||
wantX: 6, // 12 - 6 = 6
|
||||
wantOK: true,
|
||||
},
|
||||
{
|
||||
name: "W=0 stays 0 (auto / default sentinel)",
|
||||
in: DashboardWidgetRef{Key: WidgetUpcomingDeadlines, Visible: true, X: 0, Y: 0, W: 0, H: 0},
|
||||
wantW: 0,
|
||||
wantH: 0,
|
||||
wantOK: false,
|
||||
},
|
||||
{
|
||||
name: "negative X snaps to 0",
|
||||
in: DashboardWidgetRef{Key: WidgetUpcomingDeadlines, Visible: true, X: -3, Y: 0, W: 6, H: 1},
|
||||
wantW: 6,
|
||||
wantH: 1,
|
||||
wantX: 0,
|
||||
wantOK: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
s := DashboardLayoutSpec{Version: LayoutSpecVersion, Widgets: []DashboardWidgetRef{tc.in}}
|
||||
changed := s.SanitizeForRead()
|
||||
if changed != tc.wantOK {
|
||||
t.Errorf("SanitizeForRead returned %v; want %v", changed, tc.wantOK)
|
||||
}
|
||||
if len(s.Widgets) != 1 {
|
||||
t.Fatalf("expected 1 widget after sanitize, got %d", len(s.Widgets))
|
||||
}
|
||||
got := s.Widgets[0]
|
||||
if got.W != tc.wantW {
|
||||
t.Errorf("W = %d; want %d", got.W, tc.wantW)
|
||||
}
|
||||
if got.H != tc.wantH {
|
||||
t.Errorf("H = %d; want %d", got.H, tc.wantH)
|
||||
}
|
||||
if got.X != tc.wantX {
|
||||
t.Errorf("X = %d; want %d", got.X, tc.wantX)
|
||||
}
|
||||
if got.Y != tc.wantY {
|
||||
t.Errorf("Y = %d; want %d", got.Y, tc.wantY)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestDashboardLayoutSpec_SanitizeForRead_ClampedSpecPassesValidate is
|
||||
// the round-trip guarantee — after the sanitiser heals a stale row, the
|
||||
// result must be acceptable to Validate so the next PUT doesn't reject
|
||||
// the user's layout. Without this guarantee, sanitizing on read could
|
||||
// produce a layout the validator won't accept on the autosave path.
|
||||
func TestDashboardLayoutSpec_SanitizeForRead_ClampedSpecPassesValidate(t *testing.T) {
|
||||
s := DashboardLayoutSpec{
|
||||
Version: LayoutSpecVersion,
|
||||
Widgets: []DashboardWidgetRef{
|
||||
{Key: WidgetUpcomingDeadlines, Visible: true, X: 0, Y: 0, W: 1, H: 1},
|
||||
{Key: WidgetUpcomingDeadlines, Visible: true, X: 50, Y: 0, W: 99, H: 99}, // duplicate key — Validate will reject; this case checks size clamp at least
|
||||
},
|
||||
}
|
||||
// Trim to one widget for the validate assertion (duplicates are a
|
||||
// separate concern).
|
||||
s.Widgets = s.Widgets[:1]
|
||||
s.SanitizeForRead()
|
||||
if err := s.Validate(); err != nil {
|
||||
t.Errorf("Validate after SanitizeForRead returned %v; want nil", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDashboardLayoutSpec_SanitizeForRead_NoopOnClean(t *testing.T) {
|
||||
s := FactoryDefaultLayout()
|
||||
if s.SanitizeForRead() {
|
||||
|
||||
Reference in New Issue
Block a user