Skip to content

Commit fde2c49

Browse files
authored
Merge pull request #907 from sipcapture/fix/906-dashboard-layout-11.0.304
fix(ui): keep dashboard layout after window resize (#906)
2 parents 1e67429 + 6545e09 commit fde2c49

4 files changed

Lines changed: 117 additions & 30 deletions

File tree

src/ui/src/dashboard/DashboardLayout.tsx

Lines changed: 28 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react'
22
import { useConfirm } from '@/components/ui/confirm-dialog'
33
import { Lock, Plus, Settings, Unlock } from 'lucide-react'
4-
import { ResponsiveGridLayout } from 'react-grid-layout'
4+
import { GridLayout } from 'react-grid-layout'
55
import 'react-grid-layout/css/styles.css'
66
import 'react-resizable/css/styles.css'
77
import DashboardHeader from './DashboardHeader'
@@ -12,7 +12,14 @@ import DashboardSettingsDialog from './components/DashboardSettingsDialog'
1212
import SearchWidgetSettings from './components/SearchWidgetSettings'
1313
import { getWidgetMeta, isWidgetCategoryEnabled } from './widgets/registry'
1414
import { useModules } from '@/hooks/useModules'
15-
import { GRID_ROW_HEIGHT, GRID_MARGIN, computeAvailableRows, fitWidgetHeight } from './utils/grid-utils'
15+
import {
16+
GRID_COLS,
17+
GRID_ROW_HEIGHT,
18+
GRID_MARGIN,
19+
computeAvailableRows,
20+
fitWidgetHeight,
21+
mergeLayoutIntoWidgets,
22+
} from './utils/grid-utils'
1623
import { resolveTimeRange, type CalendarPreset } from './utils/resolveTimeRange'
1724
import { Button } from '@/components/ui/button'
1825
import { Input } from '@/components/ui/input'
@@ -41,10 +48,11 @@ function DashboardGrid() {
4148
const [containerWidth, setContainerWidth] = useState(0)
4249
const [containerHeight, setContainerHeight] = useState(0)
4350
const widgetsRef = useRef(widgets)
44-
const skipLayoutChangeRef = useRef(false)
51+
const lockedRef = useRef(locked)
4552
const { widgetControl } = useModules()
4653

4754
useEffect(() => { widgetsRef.current = widgets }, [widgets])
55+
useEffect(() => { lockedRef.current = locked }, [locked])
4856

4957
useEffect(() => {
5058
const el = containerRef.current
@@ -86,41 +94,29 @@ function DashboardGrid() {
8694
}
8795
}), [widgets, locked])
8896

89-
const handleLayoutChange = useCallback((newLayout) => {
90-
if (skipLayoutChangeRef.current) {
91-
skipLayoutChangeRef.current = false
92-
return
93-
}
94-
const current = widgetsRef.current
95-
let changed = false
96-
const updated = current.map((w) => {
97-
const item = newLayout.find((l) => l.i === w.id)
98-
if (item && (w.x !== item.x || w.y !== item.y || w.w !== item.w || w.h !== item.h)) {
99-
changed = true
100-
return { ...w, x: item.x, y: item.y, w: item.w, h: item.h }
101-
}
102-
return w
103-
})
104-
if (changed) updateWidgets(updated)
97+
// Persist only after intentional drag/resize. Do not use onLayoutChange:
98+
// responsive breakpoint compaction previously overwrote the saved layout
99+
// (and auto-saved it) when the window or DevTools width changed (#906).
100+
const handleLayoutCommit = useCallback((newLayout) => {
101+
if (lockedRef.current) return
102+
const updated = mergeLayoutIntoWidgets(widgetsRef.current, newLayout)
103+
if (updated) updateWidgets(updated)
105104
}, [updateWidgets])
106105

107106
const availableRows = computeAvailableRows(containerHeight)
108107

109108
const handleAddWidget = (widget) => {
110-
skipLayoutChangeRef.current = true
111109
if (locked) setLocked(false)
112110
const meta = getWidgetMeta(widget.type)
113111
const clampedH = fitWidgetHeight(widget.h ?? meta?.defaultH ?? 3, meta?.minH ?? 2, availableRows)
114112
updateWidgets([...widgets, { ...widget, h: clampedH }])
115113
}
116114

117115
const handleRemoveWidget = (widgetId) => {
118-
skipLayoutChangeRef.current = true
119116
updateWidgets(widgets.filter((w) => w.id !== widgetId))
120117
}
121118

122119
const handleDuplicateWidget = (widget) => {
123-
skipLayoutChangeRef.current = true
124120
const meta = getWidgetMeta(widget.type)
125121
const clampedH = fitWidgetHeight(widget.h ?? meta?.defaultH ?? 3, meta?.minH ?? 2, availableRows)
126122
const copy = { ...widget, id: `${widget.type}-${Date.now()}`, x: 0, y: Infinity, h: clampedH }
@@ -331,16 +327,19 @@ function DashboardGrid() {
331327
</div>
332328
)}
333329
{!loading && activeDashboardId && widgets.length > 0 && containerWidth > 0 && (
334-
<ResponsiveGridLayout
330+
<GridLayout
335331
className="layout"
336-
layouts={{ lg: layout }}
337-
breakpoints={{ lg: 1200, md: 996, sm: 768, xs: 480 }}
338-
cols={{ lg: 12, md: 10, sm: 6, xs: 4 }}
339-
rowHeight={GRID_ROW_HEIGHT}
332+
layout={layout}
340333
width={containerWidth - 24}
334+
gridConfig={{
335+
cols: GRID_COLS,
336+
rowHeight: GRID_ROW_HEIGHT,
337+
margin: [GRID_MARGIN, GRID_MARGIN],
338+
}}
341339
dragConfig={{ enabled: !locked, handle: '.widget-drag-handle' }}
342340
resizeConfig={{ enabled: !locked }}
343-
onLayoutChange={handleLayoutChange}
341+
onDragStop={handleLayoutCommit}
342+
onResizeStop={handleLayoutCommit}
344343
>
345344
{widgets.map((widget) => {
346345
const meta = getWidgetMeta(widget.type)
@@ -390,7 +389,7 @@ function DashboardGrid() {
390389
</div>
391390
)
392391
})}
393-
</ResponsiveGridLayout>
392+
</GridLayout>
394393
)}
395394
</div>
396395

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { describe, expect, it } from 'vitest'
2+
import { fitWidgetHeight, mergeLayoutIntoWidgets } from './grid-utils'
3+
4+
describe('mergeLayoutIntoWidgets', () => {
5+
const widgets = [
6+
{ id: 'a', type: 'search', x: 0, y: 0, w: 6, h: 3 },
7+
{ id: 'b', type: 'result', x: 6, y: 0, w: 6, h: 3 },
8+
]
9+
10+
it('returns null when layout positions are unchanged', () => {
11+
const layout = [
12+
{ i: 'a', x: 0, y: 0, w: 6, h: 3 },
13+
{ i: 'b', x: 6, y: 0, w: 6, h: 3 },
14+
]
15+
expect(mergeLayoutIntoWidgets(widgets, layout)).toBeNull()
16+
})
17+
18+
it('updates only widgets whose grid position changed', () => {
19+
const layout = [
20+
{ i: 'a', x: 0, y: 0, w: 6, h: 3 },
21+
{ i: 'b', x: 0, y: 3, w: 12, h: 4 },
22+
]
23+
expect(mergeLayoutIntoWidgets(widgets, layout)).toEqual([
24+
{ id: 'a', type: 'search', x: 0, y: 0, w: 6, h: 3 },
25+
{ id: 'b', type: 'result', x: 0, y: 3, w: 12, h: 4 },
26+
])
27+
})
28+
29+
it('ignores layout entries for unknown widget ids', () => {
30+
const layout = [
31+
{ i: 'a', x: 1, y: 2, w: 4, h: 5 },
32+
{ i: 'ghost', x: 0, y: 0, w: 12, h: 1 },
33+
]
34+
expect(mergeLayoutIntoWidgets(widgets, layout)).toEqual([
35+
{ id: 'a', type: 'search', x: 1, y: 2, w: 4, h: 5 },
36+
{ id: 'b', type: 'result', x: 6, y: 0, w: 6, h: 3 },
37+
])
38+
})
39+
})
40+
41+
describe('fitWidgetHeight', () => {
42+
it('clamps default height into available rows', () => {
43+
expect(fitWidgetHeight(8, 2, 5)).toBe(5)
44+
expect(fitWidgetHeight(1, 2, 5)).toBe(2)
45+
expect(fitWidgetHeight(3, 2, 0)).toBe(3)
46+
})
47+
})

src/ui/src/dashboard/utils/grid-utils.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,23 @@
11
export const GRID_ROW_HEIGHT = 60
22
export const GRID_MARGIN = 10
3+
/** Canonical dashboard column count (matches dashboard config.columns). */
4+
export const GRID_COLS = 12
5+
6+
export interface GridLayoutPosition {
7+
i: string
8+
x: number
9+
y: number
10+
w: number
11+
h: number
12+
}
13+
14+
export interface WidgetGridPosition {
15+
id: string
16+
x?: number
17+
y?: number
18+
w?: number
19+
h?: number
20+
}
321

422
/** How many grid rows fit in the given pixel height. */
523
export function computeAvailableRows(
@@ -20,3 +38,26 @@ export function fitWidgetHeight(
2038
if (availableRows <= 0) return defaultH
2139
return Math.max(minH, Math.min(defaultH, availableRows))
2240
}
41+
42+
/**
43+
* Apply react-grid-layout positions onto widgets.
44+
* Returns a new array when any x/y/w/h changed, otherwise null.
45+
*/
46+
export function mergeLayoutIntoWidgets<T extends WidgetGridPosition>(
47+
widgets: T[],
48+
newLayout: GridLayoutPosition[],
49+
): T[] | null {
50+
let changed = false
51+
const updated = widgets.map((w) => {
52+
const item = newLayout.find((l) => l.i === w.id)
53+
if (
54+
item &&
55+
(w.x !== item.x || w.y !== item.y || w.w !== item.w || w.h !== item.h)
56+
) {
57+
changed = true
58+
return { ...w, x: item.x, y: item.y, w: item.w, h: item.h }
59+
}
60+
return w
61+
})
62+
return changed ? updated : null
63+
}

src/version.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ import (
2424
// Version information for homer-core
2525
var (
2626
// VERSION_APPLICATION is the application version
27-
VERSION_APPLICATION = "11.0.303"
27+
VERSION_APPLICATION = "11.0.304"
2828

2929
// BuildDate is the build date
3030
BuildDate = ""

0 commit comments

Comments
 (0)