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:
@@ -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