Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 28 additions & 29 deletions src/ui/src/dashboard/DashboardLayout.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useConfirm } from '@/components/ui/confirm-dialog'
import { Lock, Plus, Settings, Unlock } from 'lucide-react'
import { ResponsiveGridLayout } from 'react-grid-layout'
import { GridLayout } from 'react-grid-layout'
import 'react-grid-layout/css/styles.css'
import 'react-resizable/css/styles.css'
import DashboardHeader from './DashboardHeader'
Expand All @@ -12,7 +12,14 @@ import DashboardSettingsDialog from './components/DashboardSettingsDialog'
import SearchWidgetSettings from './components/SearchWidgetSettings'
import { getWidgetMeta, isWidgetCategoryEnabled } from './widgets/registry'
import { useModules } from '@/hooks/useModules'
import { GRID_ROW_HEIGHT, GRID_MARGIN, computeAvailableRows, fitWidgetHeight } from './utils/grid-utils'
import {
GRID_COLS,
GRID_ROW_HEIGHT,
GRID_MARGIN,
computeAvailableRows,
fitWidgetHeight,
mergeLayoutIntoWidgets,
} from './utils/grid-utils'
import { resolveTimeRange, type CalendarPreset } from './utils/resolveTimeRange'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
Expand Down Expand Up @@ -41,10 +48,11 @@ function DashboardGrid() {
const [containerWidth, setContainerWidth] = useState(0)
const [containerHeight, setContainerHeight] = useState(0)
const widgetsRef = useRef(widgets)
const skipLayoutChangeRef = useRef(false)
const lockedRef = useRef(locked)
const { widgetControl } = useModules()

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

useEffect(() => {
const el = containerRef.current
Expand Down Expand Up @@ -86,41 +94,29 @@ function DashboardGrid() {
}
}), [widgets, locked])

const handleLayoutChange = useCallback((newLayout) => {
if (skipLayoutChangeRef.current) {
skipLayoutChangeRef.current = false
return
}
const current = widgetsRef.current
let changed = false
const updated = current.map((w) => {
const item = newLayout.find((l) => l.i === w.id)
if (item && (w.x !== item.x || w.y !== item.y || w.w !== item.w || w.h !== item.h)) {
changed = true
return { ...w, x: item.x, y: item.y, w: item.w, h: item.h }
}
return w
})
if (changed) updateWidgets(updated)
// Persist only after intentional drag/resize. Do not use onLayoutChange:
// responsive breakpoint compaction previously overwrote the saved layout
// (and auto-saved it) when the window or DevTools width changed (#906).
const handleLayoutCommit = useCallback((newLayout) => {
if (lockedRef.current) return
const updated = mergeLayoutIntoWidgets(widgetsRef.current, newLayout)
if (updated) updateWidgets(updated)
}, [updateWidgets])

const availableRows = computeAvailableRows(containerHeight)

const handleAddWidget = (widget) => {
skipLayoutChangeRef.current = true
if (locked) setLocked(false)
const meta = getWidgetMeta(widget.type)
const clampedH = fitWidgetHeight(widget.h ?? meta?.defaultH ?? 3, meta?.minH ?? 2, availableRows)
updateWidgets([...widgets, { ...widget, h: clampedH }])
}

const handleRemoveWidget = (widgetId) => {
skipLayoutChangeRef.current = true
updateWidgets(widgets.filter((w) => w.id !== widgetId))
}

const handleDuplicateWidget = (widget) => {
skipLayoutChangeRef.current = true
const meta = getWidgetMeta(widget.type)
const clampedH = fitWidgetHeight(widget.h ?? meta?.defaultH ?? 3, meta?.minH ?? 2, availableRows)
const copy = { ...widget, id: `${widget.type}-${Date.now()}`, x: 0, y: Infinity, h: clampedH }
Expand Down Expand Up @@ -331,16 +327,19 @@ function DashboardGrid() {
</div>
)}
{!loading && activeDashboardId && widgets.length > 0 && containerWidth > 0 && (
<ResponsiveGridLayout
<GridLayout
className="layout"
layouts={{ lg: layout }}
breakpoints={{ lg: 1200, md: 996, sm: 768, xs: 480 }}
cols={{ lg: 12, md: 10, sm: 6, xs: 4 }}
rowHeight={GRID_ROW_HEIGHT}
layout={layout}
width={containerWidth - 24}
gridConfig={{
cols: GRID_COLS,
rowHeight: GRID_ROW_HEIGHT,
margin: [GRID_MARGIN, GRID_MARGIN],
}}
dragConfig={{ enabled: !locked, handle: '.widget-drag-handle' }}
resizeConfig={{ enabled: !locked }}
onLayoutChange={handleLayoutChange}
onDragStop={handleLayoutCommit}
onResizeStop={handleLayoutCommit}
>
{widgets.map((widget) => {
const meta = getWidgetMeta(widget.type)
Expand Down Expand Up @@ -390,7 +389,7 @@ function DashboardGrid() {
</div>
)
})}
</ResponsiveGridLayout>
</GridLayout>
)}
</div>

Expand Down
47 changes: 47 additions & 0 deletions src/ui/src/dashboard/utils/grid-utils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { describe, expect, it } from 'vitest'
import { fitWidgetHeight, mergeLayoutIntoWidgets } from './grid-utils'

describe('mergeLayoutIntoWidgets', () => {
const widgets = [
{ id: 'a', type: 'search', x: 0, y: 0, w: 6, h: 3 },
{ id: 'b', type: 'result', x: 6, y: 0, w: 6, h: 3 },
]

it('returns null when layout positions are unchanged', () => {
const layout = [
{ i: 'a', x: 0, y: 0, w: 6, h: 3 },
{ i: 'b', x: 6, y: 0, w: 6, h: 3 },
]
expect(mergeLayoutIntoWidgets(widgets, layout)).toBeNull()
})

it('updates only widgets whose grid position changed', () => {
const layout = [
{ i: 'a', x: 0, y: 0, w: 6, h: 3 },
{ i: 'b', x: 0, y: 3, w: 12, h: 4 },
]
expect(mergeLayoutIntoWidgets(widgets, layout)).toEqual([
{ id: 'a', type: 'search', x: 0, y: 0, w: 6, h: 3 },
{ id: 'b', type: 'result', x: 0, y: 3, w: 12, h: 4 },
])
})

it('ignores layout entries for unknown widget ids', () => {
const layout = [
{ i: 'a', x: 1, y: 2, w: 4, h: 5 },
{ i: 'ghost', x: 0, y: 0, w: 12, h: 1 },
]
expect(mergeLayoutIntoWidgets(widgets, layout)).toEqual([
{ id: 'a', type: 'search', x: 1, y: 2, w: 4, h: 5 },
{ id: 'b', type: 'result', x: 6, y: 0, w: 6, h: 3 },
])
})
})

describe('fitWidgetHeight', () => {
it('clamps default height into available rows', () => {
expect(fitWidgetHeight(8, 2, 5)).toBe(5)
expect(fitWidgetHeight(1, 2, 5)).toBe(2)
expect(fitWidgetHeight(3, 2, 0)).toBe(3)
})
})
41 changes: 41 additions & 0 deletions src/ui/src/dashboard/utils/grid-utils.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,23 @@
export const GRID_ROW_HEIGHT = 60
export const GRID_MARGIN = 10
/** Canonical dashboard column count (matches dashboard config.columns). */
export const GRID_COLS = 12

export interface GridLayoutPosition {
i: string
x: number
y: number
w: number
h: number
}

export interface WidgetGridPosition {
id: string
x?: number
y?: number
w?: number
h?: number
}

/** How many grid rows fit in the given pixel height. */
export function computeAvailableRows(
Expand All @@ -20,3 +38,26 @@ export function fitWidgetHeight(
if (availableRows <= 0) return defaultH
return Math.max(minH, Math.min(defaultH, availableRows))
}

/**
* Apply react-grid-layout positions onto widgets.
* Returns a new array when any x/y/w/h changed, otherwise null.
*/
export function mergeLayoutIntoWidgets<T extends WidgetGridPosition>(
widgets: T[],
newLayout: GridLayoutPosition[],
): T[] | null {
let changed = false
const updated = widgets.map((w) => {
const item = newLayout.find((l) => l.i === w.id)
if (
item &&
(w.x !== item.x || w.y !== item.y || w.w !== item.w || w.h !== item.h)
) {
changed = true
return { ...w, x: item.x, y: item.y, w: item.w, h: item.h }
}
return w
})
return changed ? updated : null
}
2 changes: 1 addition & 1 deletion src/version.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import (
// Version information for homer-core
var (
// VERSION_APPLICATION is the application version
VERSION_APPLICATION = "11.0.303"
VERSION_APPLICATION = "11.0.304"

// BuildDate is the build date
BuildDate = ""
Expand Down