diff --git a/src/ui/src/dashboard/DashboardLayout.tsx b/src/ui/src/dashboard/DashboardLayout.tsx index ab9f94c9..3f9e731a 100644 --- a/src/ui/src/dashboard/DashboardLayout.tsx +++ b/src/ui/src/dashboard/DashboardLayout.tsx @@ -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' @@ -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' @@ -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 @@ -86,28 +94,18 @@ 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) @@ -115,12 +113,10 @@ function DashboardGrid() { } 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 } @@ -331,16 +327,19 @@ function DashboardGrid() { )} {!loading && activeDashboardId && widgets.length > 0 && containerWidth > 0 && ( - {widgets.map((widget) => { const meta = getWidgetMeta(widget.type) @@ -390,7 +389,7 @@ function DashboardGrid() { ) })} - + )} diff --git a/src/ui/src/dashboard/utils/grid-utils.test.ts b/src/ui/src/dashboard/utils/grid-utils.test.ts new file mode 100644 index 00000000..66152803 --- /dev/null +++ b/src/ui/src/dashboard/utils/grid-utils.test.ts @@ -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) + }) +}) diff --git a/src/ui/src/dashboard/utils/grid-utils.ts b/src/ui/src/dashboard/utils/grid-utils.ts index da513993..5e8194be 100644 --- a/src/ui/src/dashboard/utils/grid-utils.ts +++ b/src/ui/src/dashboard/utils/grid-utils.ts @@ -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( @@ -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( + 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 +} diff --git a/src/version.go b/src/version.go index b6abeda6..7bdb0a26 100644 --- a/src/version.go +++ b/src/version.go @@ -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 = ""