fix(dashboard): t-paliad-238 — hidden widgets render at proper size in edit mode

Symptom (m, 2026-05-22): "super slim columns which I can move but not
resize - and they seem greyed out." Hidden widgets in edit mode were
rendering as 1×1 slivers because applyLayout left their inline grid-
column empty — placeWidgets skipped non-visible entries entirely, so
CSS Grid auto-flowed them into the next free cell at 1/12th width.
The greyed-out + no-resize-handle parts were correct UX signalling
that the widget is hidden; the slim rendering was the bug.

Fix:
- placeWidgets() gains a {includeHidden} option. When true, a second
  pass places hidden widgets after the visible pass — collision-aware
  + cursor-aware so the hidden tray stacks below the active layout
  without ever displacing a visible widget. applyLayout() passes
  includeHidden:true in edit mode.
- materializePositions() keeps the default (hidden widgets retain
  their stored coordinates so un-hiding restores them in place).

Server-side recovery (belt-and-braces):
- SanitizeForRead now also clamps each widget's W/H/X against the
  catalog Min/Max + grid bounds on load. Stale rows with W below MinW
  (or above MaxW, or X+W overflowing the grid) heal on the next
  /api/me/dashboard-layout GET and the cleaned spec is persisted
  back. W=0 stays 0 (auto/default sentinel — the placer expands it).
- The validator stays strict on write; the read-path sanitiser only
  exists to recover users who got into a bad state under the old
  rules.

Tests:
- bun: 4 new cases in dashboard-grid.test.ts pin includeHidden
  behaviour (hidden skipped by default, two-pass ordering, multi-
  hidden, no-overlap invariant).
- go: 7 sub-tests in dashboard_layout_spec_test.go cover each
  SanitizeForRead clamp (MinW, MaxW, grid-width, MaxH, X+W overflow,
  W=0 sentinel, negative X) plus a round-trip Validate guarantee.
This commit is contained in:
mAi
2026-05-22 15:51:43 +02:00
parent 92d0340d74
commit 4cd2f05d33
5 changed files with 332 additions and 16 deletions

View File

@@ -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) {

View File

@@ -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() {