Merge pull request #289 from nicolabottini/feat/autosave-canvas
feat: autosave canvas after configurable inactivity delay
This commit is contained in:
+37
-4
@@ -37,6 +37,8 @@ import { ScanHistoryModal } from '@/components/modals/ScanHistoryModal'
|
||||
import { ShortcutsModal } from '@/components/modals/ShortcutsModal'
|
||||
import { ConfirmAddToGroupModal } from '@/components/modals/ConfirmAddToGroupModal'
|
||||
import { useCanvasStore } from '@/stores/canvasStore'
|
||||
import { readAutosaveSettings, subscribeAutosaveSettings, type AutosaveSettings } from '@/utils/autosaveSettings'
|
||||
import { useAutosave } from '@/hooks/useAutosave'
|
||||
import { useDesignStore } from '@/stores/designStore'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
import { useThemeStore } from '@/stores/themeStore'
|
||||
@@ -53,7 +55,7 @@ import { buildProxmoxClusterEdges } from '@/components/proxmox/clusterEdges'
|
||||
const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
|
||||
|
||||
export default function App() {
|
||||
const { loadCanvas, applyLayout, markSaved, markUnsaved, selectedNodeId, selectedNodeIds, addNode, updateNode, deleteNode, onConnect, updateEdge, deleteEdge, setProxmoxContainerMode, setNodeZIndex, editingGroupRectId, setEditingGroupRectId, editingTextId, setEditingTextId, nodes, edges, snapshotHistory, undo, redo, addToGroup, addToContainer, floorMap, setFloorMap } = useCanvasStore()
|
||||
const { loadCanvas, applyLayout, markSaved, markUnsaved, hasUnsavedChanges, editSeq, selectedNodeId, selectedNodeIds, addNode, updateNode, deleteNode, onConnect, updateEdge, deleteEdge, setProxmoxContainerMode, setNodeZIndex, editingGroupRectId, setEditingGroupRectId, editingTextId, setEditingTextId, nodes, edges, snapshotHistory, undo, redo, addToGroup, addToContainer, floorMap, setFloorMap } = useCanvasStore()
|
||||
const canvasRef = useRef<HTMLDivElement>(null)
|
||||
const { isAuthenticated } = useAuthStore()
|
||||
const { activeTheme, setTheme, customStyle, setCustomStyle } = useThemeStore()
|
||||
@@ -61,6 +63,17 @@ export default function App() {
|
||||
|
||||
useStatusPolling()
|
||||
|
||||
const [autosave, setAutosave] = useState<AutosaveSettings>(readAutosaveSettings)
|
||||
useEffect(() => subscribeAutosaveSettings(setAutosave), [])
|
||||
|
||||
// Provenance: which design the in-memory canvas was loaded as. Differs from
|
||||
// activeDesignId (the selection) during a switch, so autosave gates on this to
|
||||
// avoid writing one design's canvas under another's id. Ref mirror for the
|
||||
// fire-time guard, which must read the live value without re-arming the timer.
|
||||
const [loadedDesignId, setLoadedDesignId] = useState<string | null>(null)
|
||||
const loadedDesignIdRef = useRef<string | null>(loadedDesignId)
|
||||
useEffect(() => { loadedDesignIdRef.current = loadedDesignId }, [loadedDesignId])
|
||||
|
||||
const [themeModalOpen, setThemeModalOpen] = useState(false)
|
||||
const [styleEditorType, setStyleEditorType] = useState<NodeType | null>(null)
|
||||
const [searchOpen, setSearchOpen] = useState(false)
|
||||
@@ -93,7 +106,7 @@ export default function App() {
|
||||
// Declare handleSave before the Ctrl+S effect so it is in scope.
|
||||
// Returns true on success, false on failure — the design-switch effect relies
|
||||
// on this to avoid loading (and clobbering) the canvas when a save fails.
|
||||
const handleSave = useCallback(async (designIdOverride?: string): Promise<boolean> => {
|
||||
const handleSave = useCallback(async (designIdOverride?: string, options?: { silent?: boolean }): Promise<boolean> => {
|
||||
try {
|
||||
const saveDesignId = designIdOverride ?? activeDesignId
|
||||
if (STANDALONE) {
|
||||
@@ -101,7 +114,7 @@ export default function App() {
|
||||
// Floor plans are backend-only (upload/serve), so standalone never persists one.
|
||||
standaloneStorage.saveCanvas(saveDesignId, { nodes, edges, theme_id: activeTheme, custom_style: customStyle })
|
||||
markSaved()
|
||||
toast.success('Canvas saved')
|
||||
if (!options?.silent) toast.success('Canvas saved')
|
||||
return true
|
||||
}
|
||||
const nodesToSave = nodes.map(serializeNode)
|
||||
@@ -110,7 +123,7 @@ export default function App() {
|
||||
if (floorMap) viewport.floor_map = floorMap
|
||||
await canvasApi.save({ nodes: nodesToSave, edges: edgesToSave, viewport, custom_style: customStyle, design_id: saveDesignId })
|
||||
markSaved()
|
||||
toast.success('Canvas saved')
|
||||
if (!options?.silent) toast.success('Canvas saved')
|
||||
return true
|
||||
} catch {
|
||||
toast.error('Save failed')
|
||||
@@ -122,6 +135,21 @@ export default function App() {
|
||||
const handleSaveRef = useRef(handleSave)
|
||||
useEffect(() => { handleSaveRef.current = handleSave }, [handleSave])
|
||||
|
||||
// Debounced, opt-in autosave. Pins the active design when armed and re-checks
|
||||
// it at fire time so a mid-switch save can't clobber the wrong design.
|
||||
useAutosave({
|
||||
enabled: autosave.enabled,
|
||||
delaySeconds: autosave.delay,
|
||||
hasUnsavedChanges,
|
||||
designId: loadedDesignId,
|
||||
// Debounce resets on each real user edit (editSeq), not on raw nodes/edges
|
||||
// identity — live status polling churns those arrays without a user edit and
|
||||
// would otherwise keep re-arming (and starving) the timer during monitoring.
|
||||
changeSignals: [editSeq],
|
||||
getLiveDesignId: () => loadedDesignIdRef.current,
|
||||
onSave: (designId) => { void handleSaveRef.current(designId, { silent: true }) },
|
||||
})
|
||||
|
||||
const loadCanvasFromApi = useCallback(async (designId?: string) => {
|
||||
try {
|
||||
const res = await canvasApi.load(designId)
|
||||
@@ -151,6 +179,9 @@ export default function App() {
|
||||
} catch {
|
||||
setFloorMap(null)
|
||||
loadCanvas(demoNodes, demoEdges)
|
||||
} finally {
|
||||
// Record provenance so autosave writes back under the design just loaded.
|
||||
setLoadedDesignId(designId ?? null)
|
||||
}
|
||||
}, [loadCanvas, setTheme, setCustomStyle, setFloorMap])
|
||||
|
||||
@@ -169,6 +200,8 @@ export default function App() {
|
||||
setFloorMap(null)
|
||||
loadCanvas(demoNodes, demoEdges)
|
||||
}
|
||||
// Record provenance so autosave writes back under the design just loaded.
|
||||
setLoadedDesignId(designId)
|
||||
}, [loadCanvas, setTheme, setCustomStyle, setFloorMap])
|
||||
|
||||
const loadDesignsAndCanvas = useCallback(async () => {
|
||||
|
||||
@@ -18,6 +18,12 @@ import {
|
||||
writeAlignmentSettings,
|
||||
subscribeAlignmentSettings,
|
||||
} from '@/utils/alignmentSettings'
|
||||
import {
|
||||
type AutosaveSettings,
|
||||
readAutosaveSettings,
|
||||
writeAutosaveSettings,
|
||||
subscribeAutosaveSettings,
|
||||
} from '@/utils/autosaveSettings'
|
||||
|
||||
const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
|
||||
|
||||
@@ -124,6 +130,7 @@ export function SettingsModal({ open, onClose }: SettingsModalProps) {
|
||||
const [zwInterval, setZwInterval] = useState(3600)
|
||||
const [zwSyncing, setZwSyncing] = useState(false)
|
||||
const [alignment, setAlignment] = useState<AlignmentSettings>(readAlignmentSettings)
|
||||
const [autosave, setAutosave] = useState<AutosaveSettings>(readAutosaveSettings)
|
||||
const hideIp = useCanvasStore((s) => s.hideIp)
|
||||
const setHideIp = useCanvasStore((s) => s.setHideIp)
|
||||
|
||||
@@ -160,6 +167,7 @@ export function SettingsModal({ open, onClose }: SettingsModalProps) {
|
||||
}, [open])
|
||||
|
||||
useEffect(() => subscribeAlignmentSettings(setAlignment), [])
|
||||
useEffect(() => subscribeAutosaveSettings(setAutosave), [])
|
||||
|
||||
const updateAlignment = (patch: Partial<AlignmentSettings>) => {
|
||||
const next = { ...alignment, ...patch }
|
||||
@@ -167,6 +175,12 @@ export function SettingsModal({ open, onClose }: SettingsModalProps) {
|
||||
writeAlignmentSettings(next)
|
||||
}
|
||||
|
||||
const updateAutosave = (patch: Partial<AutosaveSettings>) => {
|
||||
const next = { ...autosave, ...patch }
|
||||
setAutosave(next)
|
||||
writeAutosaveSettings(next)
|
||||
}
|
||||
|
||||
const handleSyncNow = async () => {
|
||||
setPmSyncing(true)
|
||||
try {
|
||||
@@ -353,6 +367,40 @@ export function SettingsModal({ open, onClose }: SettingsModalProps) {
|
||||
Distance at which dragged nodes snap to neighbours. Hold Alt while dragging to disable.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center justify-between gap-2 cursor-pointer">
|
||||
<span className="text-xs text-foreground">Autosave canvas</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={autosave.enabled}
|
||||
onChange={(e) => updateAutosave({ enabled: e.target.checked })}
|
||||
className="cursor-pointer accent-[#00d4ff]"
|
||||
aria-label="Toggle autosave"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className={autosave.enabled ? 'space-y-1.5' : 'space-y-1.5 opacity-50 pointer-events-none'}>
|
||||
<label className="text-xs text-muted-foreground">Save after</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
value={autosave.delay}
|
||||
onChange={(e) => updateAutosave({ delay: Number(e.target.value) })}
|
||||
disabled={!autosave.enabled}
|
||||
className="px-2 py-1 rounded-md text-xs bg-[#0d1117] border border-border text-foreground focus:outline-none focus:border-[#00d4ff]"
|
||||
aria-label="Autosave delay"
|
||||
>
|
||||
<option value={3}>3 s</option>
|
||||
<option value={5}>5 s</option>
|
||||
<option value={10}>10 s</option>
|
||||
<option value={30}>30 s</option>
|
||||
<option value={60}>60 s</option>
|
||||
</select>
|
||||
<span className="text-xs text-muted-foreground">of inactivity</span>
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground leading-tight">
|
||||
Saves silently after this many seconds with no changes. Manual Ctrl+S still works.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { useAutosave } from '../useAutosave'
|
||||
|
||||
interface Args {
|
||||
enabled?: boolean
|
||||
delaySeconds?: number
|
||||
hasUnsavedChanges?: boolean
|
||||
designId?: string | null
|
||||
changeSignals?: unknown[]
|
||||
getLiveDesignId?: () => string | null
|
||||
onSave?: (designId: string) => void
|
||||
}
|
||||
|
||||
function args(over: Args = {}) {
|
||||
const designId = 'designId' in over ? (over.designId ?? null) : 'design-a'
|
||||
return {
|
||||
enabled: over.enabled ?? true,
|
||||
delaySeconds: over.delaySeconds ?? 5,
|
||||
hasUnsavedChanges: over.hasUnsavedChanges ?? true,
|
||||
designId,
|
||||
changeSignals: over.changeSignals ?? [[], []],
|
||||
getLiveDesignId: over.getLiveDesignId ?? (() => designId),
|
||||
onSave: over.onSave ?? vi.fn(),
|
||||
}
|
||||
}
|
||||
|
||||
describe('useAutosave', () => {
|
||||
beforeEach(() => vi.useFakeTimers())
|
||||
afterEach(() => vi.useRealTimers())
|
||||
|
||||
it('saves after the inactivity delay when enabled with unsaved changes', () => {
|
||||
const onSave = vi.fn()
|
||||
renderHook(() => useAutosave(args({ onSave, delaySeconds: 5 })))
|
||||
expect(onSave).not.toHaveBeenCalled()
|
||||
vi.advanceTimersByTime(4999)
|
||||
expect(onSave).not.toHaveBeenCalled()
|
||||
vi.advanceTimersByTime(1)
|
||||
expect(onSave).toHaveBeenCalledTimes(1)
|
||||
expect(onSave).toHaveBeenCalledWith('design-a')
|
||||
})
|
||||
|
||||
it('does nothing when disabled', () => {
|
||||
const onSave = vi.fn()
|
||||
renderHook(() => useAutosave(args({ onSave, enabled: false })))
|
||||
vi.advanceTimersByTime(60_000)
|
||||
expect(onSave).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does nothing when there are no unsaved changes', () => {
|
||||
const onSave = vi.fn()
|
||||
renderHook(() => useAutosave(args({ onSave, hasUnsavedChanges: false })))
|
||||
vi.advanceTimersByTime(60_000)
|
||||
expect(onSave).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does nothing when the canvas has no known provenance', () => {
|
||||
const onSave = vi.fn()
|
||||
renderHook(() => useAutosave(args({ onSave, designId: null })))
|
||||
vi.advanceTimersByTime(60_000)
|
||||
expect(onSave).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('debounces: a change before the delay resets the timer', () => {
|
||||
const onSave = vi.fn()
|
||||
const { rerender } = renderHook((p: Args) => useAutosave(args(p)), {
|
||||
initialProps: { onSave, changeSignals: [1] },
|
||||
})
|
||||
vi.advanceTimersByTime(4000)
|
||||
rerender({ onSave, changeSignals: [2] }) // edit resets debounce
|
||||
vi.advanceTimersByTime(4000)
|
||||
expect(onSave).not.toHaveBeenCalled()
|
||||
vi.advanceTimersByTime(1000)
|
||||
expect(onSave).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('skips the save if a different canvas loaded while the timer was pending', () => {
|
||||
const onSave = vi.fn()
|
||||
// Pinned provenance at arm time = 'design-a', but a switch loaded 'design-b'
|
||||
// before the timer fired — the on-screen canvas is no longer design-a's.
|
||||
renderHook(() =>
|
||||
useAutosave(args({ onSave, designId: 'design-a', getLiveDesignId: () => 'design-b' })),
|
||||
)
|
||||
vi.advanceTimersByTime(5000)
|
||||
expect(onSave).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('saves under the pinned provenance id when it still matches at fire time', () => {
|
||||
const onSave = vi.fn()
|
||||
renderHook(() =>
|
||||
useAutosave(args({ onSave, designId: 'design-a', getLiveDesignId: () => 'design-a' })),
|
||||
)
|
||||
vi.advanceTimersByTime(5000)
|
||||
expect(onSave).toHaveBeenCalledTimes(1)
|
||||
expect(onSave).toHaveBeenCalledWith('design-a')
|
||||
})
|
||||
})
|
||||
@@ -7,7 +7,7 @@ import { useAuthStore } from '@/stores/authStore'
|
||||
vi.mock('@/stores/canvasStore')
|
||||
vi.mock('@/stores/authStore')
|
||||
|
||||
const mockUpdateNode = vi.fn()
|
||||
const mockSetNodeStatus = vi.fn()
|
||||
const mockNotifyScanDeviceFound = vi.fn()
|
||||
const mockSetServiceStatuses = vi.fn()
|
||||
|
||||
@@ -32,7 +32,7 @@ describe('useStatusPolling', () => {
|
||||
vi.stubGlobal('WebSocket', MockWebSocket)
|
||||
|
||||
vi.mocked(useCanvasStore).mockReturnValue({
|
||||
updateNode: mockUpdateNode,
|
||||
setNodeStatus: mockSetNodeStatus,
|
||||
notifyScanDeviceFound: mockNotifyScanDeviceFound,
|
||||
setServiceStatuses: mockSetServiceStatuses,
|
||||
} as ReturnType<typeof useCanvasStore>)
|
||||
@@ -50,7 +50,7 @@ describe('useStatusPolling', () => {
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
mockUpdateNode.mockClear()
|
||||
mockSetNodeStatus.mockClear()
|
||||
mockNotifyScanDeviceFound.mockClear()
|
||||
mockSetServiceStatuses.mockClear()
|
||||
})
|
||||
@@ -95,7 +95,7 @@ describe('useStatusPolling', () => {
|
||||
expect(ws.send).toHaveBeenCalledWith(JSON.stringify({ token: 'test-token' }))
|
||||
})
|
||||
|
||||
it('calls updateNode with correct data on status message', () => {
|
||||
it('calls setNodeStatus with correct data on status message', () => {
|
||||
renderHook(() => useStatusPolling())
|
||||
const ws = MockWebSocket.instances[0]
|
||||
ws.onmessage?.({
|
||||
@@ -106,7 +106,7 @@ describe('useStatusPolling', () => {
|
||||
response_time_ms: 42,
|
||||
}),
|
||||
})
|
||||
expect(mockUpdateNode).toHaveBeenCalledWith('node-1', {
|
||||
expect(mockSetNodeStatus).toHaveBeenCalledWith('node-1', {
|
||||
status: 'online',
|
||||
response_time_ms: 42,
|
||||
last_seen: '2024-01-01T12:00:00Z',
|
||||
@@ -123,7 +123,7 @@ describe('useStatusPolling', () => {
|
||||
checked_at: '2024-01-01T12:00:00Z',
|
||||
}),
|
||||
})
|
||||
expect(mockUpdateNode).toHaveBeenCalledWith('node-1', {
|
||||
expect(mockSetNodeStatus).toHaveBeenCalledWith('node-1', {
|
||||
status: 'offline',
|
||||
response_time_ms: undefined,
|
||||
last_seen: undefined,
|
||||
@@ -136,7 +136,7 @@ describe('useStatusPolling', () => {
|
||||
ws.onmessage?.({
|
||||
data: JSON.stringify({ node_id: 'node-1', status: 'online', response_time_ms: null }),
|
||||
})
|
||||
expect(mockUpdateNode).toHaveBeenCalledWith(
|
||||
expect(mockSetNodeStatus).toHaveBeenCalledWith(
|
||||
'node-1',
|
||||
expect.objectContaining({ response_time_ms: undefined }),
|
||||
)
|
||||
@@ -147,7 +147,7 @@ describe('useStatusPolling', () => {
|
||||
const ws = MockWebSocket.instances[0]
|
||||
ws.onmessage?.({ data: JSON.stringify({ type: 'scan_device_found' }) })
|
||||
expect(mockNotifyScanDeviceFound).toHaveBeenCalledOnce()
|
||||
expect(mockUpdateNode).not.toHaveBeenCalled()
|
||||
expect(mockSetNodeStatus).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('routes service_status messages to setServiceStatuses', () => {
|
||||
@@ -158,21 +158,21 @@ describe('useStatusPolling', () => {
|
||||
data: JSON.stringify({ type: 'service_status', node_id: 'node-9', services }),
|
||||
})
|
||||
expect(mockSetServiceStatuses).toHaveBeenCalledWith('node-9', services)
|
||||
expect(mockUpdateNode).not.toHaveBeenCalled()
|
||||
expect(mockSetNodeStatus).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('ignores malformed JSON without throwing', () => {
|
||||
renderHook(() => useStatusPolling())
|
||||
const ws = MockWebSocket.instances[0]
|
||||
expect(() => ws.onmessage?.({ data: 'not-valid-json{{' })).not.toThrow()
|
||||
expect(mockUpdateNode).not.toHaveBeenCalled()
|
||||
expect(mockSetNodeStatus).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('ignores messages with no node_id or status', () => {
|
||||
renderHook(() => useStatusPolling())
|
||||
const ws = MockWebSocket.instances[0]
|
||||
ws.onmessage?.({ data: JSON.stringify({ some: 'unknown-field' }) })
|
||||
expect(mockUpdateNode).not.toHaveBeenCalled()
|
||||
expect(mockSetNodeStatus).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('closes WebSocket on unmount', () => {
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
interface UseAutosaveOptions {
|
||||
/** Whether autosave is enabled (user opt-in). */
|
||||
enabled: boolean
|
||||
/** Inactivity delay in seconds before firing a save. */
|
||||
delaySeconds: number
|
||||
/** True when the canvas has edits not yet persisted. */
|
||||
hasUnsavedChanges: boolean
|
||||
/**
|
||||
* The design the in-memory canvas actually belongs to — its *provenance*, set
|
||||
* when a canvas is loaded, NOT the currently-selected design. These differ
|
||||
* during a design switch: the selection flips synchronously while the new
|
||||
* canvas loads asynchronously, so for a brief window the on-screen nodes still
|
||||
* belong to the previous design. Gating on provenance (not selection) is what
|
||||
* prevents saving one design's canvas under another design's id.
|
||||
*/
|
||||
designId: string | null
|
||||
/**
|
||||
* Values that represent canvas edits (e.g. nodes, edges). Any change to one
|
||||
* of these resets the debounce timer, so the save only fires after a quiet
|
||||
* period. Its length MUST stay constant across renders (React requires a
|
||||
* stable dependency-array size) — pass a fixed-shape array like [nodes, edges].
|
||||
*/
|
||||
changeSignals: readonly unknown[]
|
||||
/**
|
||||
* Reads the *live* canvas provenance at fire time. If it no longer matches the
|
||||
* id pinned when the timer was armed, a different canvas has since loaded, so
|
||||
* the save is skipped rather than written under the wrong (now-stale) id.
|
||||
*/
|
||||
getLiveDesignId: () => string | null
|
||||
/** Persist the canvas under the given design id. */
|
||||
onSave: (designId: string) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Debounced canvas autosave. Fires `onSave(designId)` after `delaySeconds` of
|
||||
* inactivity when enabled and there are unsaved changes. Opt-in only — the
|
||||
* caller decides whether `enabled` is set (see ADR: autosave defaults to off).
|
||||
*/
|
||||
export function useAutosave({
|
||||
enabled,
|
||||
delaySeconds,
|
||||
hasUnsavedChanges,
|
||||
designId,
|
||||
changeSignals,
|
||||
getLiveDesignId,
|
||||
onSave,
|
||||
}: UseAutosaveOptions): void {
|
||||
// Keep the latest callbacks in refs so the timer always calls the current
|
||||
// versions without re-arming (which would reset the debounce) on every render.
|
||||
const onSaveRef = useRef(onSave)
|
||||
const getLiveDesignIdRef = useRef(getLiveDesignId)
|
||||
useEffect(() => {
|
||||
onSaveRef.current = onSave
|
||||
getLiveDesignIdRef.current = getLiveDesignId
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || !hasUnsavedChanges || !designId) return
|
||||
const pinnedDesignId = designId
|
||||
const t = setTimeout(() => {
|
||||
// Skip if a different canvas has loaded while the timer was pending.
|
||||
if (getLiveDesignIdRef.current() !== pinnedDesignId) return
|
||||
onSaveRef.current(pinnedDesignId)
|
||||
}, delaySeconds * 1000)
|
||||
return () => clearTimeout(t)
|
||||
// changeSignals is spread so any canvas edit resets the debounce; its length
|
||||
// must stay constant (documented on the option).
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [enabled, delaySeconds, hasUnsavedChanges, designId, ...changeSignals])
|
||||
}
|
||||
@@ -24,7 +24,7 @@ const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
|
||||
|
||||
export function useStatusPolling() {
|
||||
const wsRef = useRef<WebSocket | null>(null)
|
||||
const { updateNode, notifyScanDeviceFound, setServiceStatuses } = useCanvasStore()
|
||||
const { setNodeStatus, notifyScanDeviceFound, setServiceStatuses } = useCanvasStore()
|
||||
const { isAuthenticated, token } = useAuthStore()
|
||||
|
||||
useEffect(() => {
|
||||
@@ -50,7 +50,9 @@ export function useStatusPolling() {
|
||||
} else if (msg.type === 'service_status' && msg.node_id && msg.services) {
|
||||
setServiceStatuses(msg.node_id, msg.services)
|
||||
} else if (msg.node_id && msg.status) {
|
||||
updateNode(msg.node_id, {
|
||||
// Live status is monitoring data, not a user edit — must not dirty the
|
||||
// canvas (otherwise autosave rewrites an untouched canvas every cycle).
|
||||
setNodeStatus(msg.node_id, {
|
||||
status: msg.status,
|
||||
response_time_ms: msg.response_time_ms ?? undefined,
|
||||
last_seen: msg.status === 'online' ? msg.checked_at : undefined,
|
||||
@@ -69,5 +71,5 @@ export function useStatusPolling() {
|
||||
ws.close()
|
||||
wsRef.current = null
|
||||
}
|
||||
}, [isAuthenticated, token, updateNode, notifyScanDeviceFound, setServiceStatuses])
|
||||
}, [isAuthenticated, token, setNodeStatus, notifyScanDeviceFound, setServiceStatuses])
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ function resetStore() {
|
||||
nodes: [],
|
||||
edges: [],
|
||||
hasUnsavedChanges: false,
|
||||
editSeq: 0,
|
||||
selectedNodeId: null,
|
||||
selectedNodeIds: [],
|
||||
editingGroupRectId: null,
|
||||
@@ -52,6 +53,79 @@ describe('canvasStore — nodes', () => {
|
||||
expect(useCanvasStore.getState().hasUnsavedChanges).toBe(false)
|
||||
})
|
||||
|
||||
it('setNodeStatus updates node status fields', () => {
|
||||
useCanvasStore.getState().addNode(makeNode('n1'))
|
||||
useCanvasStore.getState().setNodeStatus('n1', {
|
||||
status: 'online',
|
||||
response_time_ms: 42,
|
||||
last_seen: '2024-01-01T12:00:00Z',
|
||||
})
|
||||
const node = useCanvasStore.getState().nodes.find((n) => n.id === 'n1')
|
||||
expect(node?.data.status).toBe('online')
|
||||
expect(node?.data.response_time_ms).toBe(42)
|
||||
expect(node?.data.last_seen).toBe('2024-01-01T12:00:00Z')
|
||||
})
|
||||
|
||||
it('setNodeStatus does NOT mark the canvas unsaved (live status is not a user edit)', () => {
|
||||
useCanvasStore.getState().addNode(makeNode('n1'))
|
||||
useCanvasStore.setState({ hasUnsavedChanges: false })
|
||||
useCanvasStore.getState().setNodeStatus('n1', { status: 'offline' })
|
||||
expect(useCanvasStore.getState().hasUnsavedChanges).toBe(false)
|
||||
})
|
||||
|
||||
it('onNodesChange does NOT dirty the canvas on an initial dimensions measure', () => {
|
||||
useCanvasStore.getState().addNode(makeNode({ id: 'n1' }))
|
||||
useCanvasStore.setState({ hasUnsavedChanges: false })
|
||||
useCanvasStore.getState().onNodesChange([
|
||||
{ id: 'n1', type: 'dimensions', dimensions: { width: 120, height: 40 } },
|
||||
])
|
||||
expect(useCanvasStore.getState().hasUnsavedChanges).toBe(false)
|
||||
})
|
||||
|
||||
it('onNodesChange DOES dirty the canvas on a user resize (resizing: true)', () => {
|
||||
useCanvasStore.getState().addNode(makeNode({ id: 'n1' }))
|
||||
useCanvasStore.setState({ hasUnsavedChanges: false })
|
||||
useCanvasStore.getState().onNodesChange([
|
||||
{ id: 'n1', type: 'dimensions', dimensions: { width: 200, height: 80 }, resizing: true },
|
||||
])
|
||||
expect(useCanvasStore.getState().hasUnsavedChanges).toBe(true)
|
||||
})
|
||||
|
||||
it('onNodesChange does NOT dirty the canvas on a select-only change', () => {
|
||||
useCanvasStore.getState().addNode(makeNode({ id: 'n1' }))
|
||||
useCanvasStore.setState({ hasUnsavedChanges: false })
|
||||
useCanvasStore.getState().onNodesChange([{ id: 'n1', type: 'select', selected: true }])
|
||||
expect(useCanvasStore.getState().hasUnsavedChanges).toBe(false)
|
||||
})
|
||||
|
||||
it('editSeq bumps on a real user edit but not on status/select/measure churn', () => {
|
||||
const seq = () => useCanvasStore.getState().editSeq
|
||||
|
||||
// Real edit bumps.
|
||||
const before = seq()
|
||||
useCanvasStore.getState().addNode(makeNode('n1'))
|
||||
expect(seq()).toBe(before + 1)
|
||||
|
||||
// Live status update: no bump (not a user edit).
|
||||
const afterAdd = seq()
|
||||
useCanvasStore.getState().setNodeStatus('n1', { status: 'online' })
|
||||
expect(seq()).toBe(afterAdd)
|
||||
|
||||
// Select-only change: no bump.
|
||||
useCanvasStore.getState().onNodesChange([{ id: 'n1', type: 'select', selected: true }])
|
||||
expect(seq()).toBe(afterAdd)
|
||||
|
||||
// Initial dimensions measure: no bump.
|
||||
useCanvasStore.getState().onNodesChange([
|
||||
{ id: 'n1', type: 'dimensions', dimensions: { width: 120, height: 40 } },
|
||||
])
|
||||
expect(seq()).toBe(afterAdd)
|
||||
|
||||
// Another real edit bumps again.
|
||||
useCanvasStore.getState().updateNode('n1', { label: 'renamed' })
|
||||
expect(seq()).toBe(afterAdd + 1)
|
||||
})
|
||||
|
||||
it('setEditingTextId sets and clears editing text id', () => {
|
||||
const { setEditingTextId } = useCanvasStore.getState()
|
||||
setEditingTextId('t1')
|
||||
|
||||
@@ -22,6 +22,22 @@ type Clipboard = { nodes: Node<NodeData>[]; edges: Edge<EdgeData>[] }
|
||||
/** Resolve a node's effective parent id from either the RF field or domain data. */
|
||||
const parentIdOf = (n: Node<NodeData>): string | undefined => n.parentId ?? n.data.parent_id ?? undefined
|
||||
|
||||
/**
|
||||
* Whether a node change represents a real user edit that should dirty the canvas.
|
||||
* Excludes:
|
||||
* - 'select': selecting a node changes nothing persisted.
|
||||
* - 'dimensions' without resizing: React Flow emits these when it first measures
|
||||
* a node's size after mount/load. Counting them as edits marks a freshly
|
||||
* loaded canvas dirty before the user touches anything (autosave would then
|
||||
* save on every load). A user-driven resize sets `resizing === true` and still
|
||||
* dirties.
|
||||
*/
|
||||
function isUserNodeEdit(c: NodeChange<Node<NodeData>>): boolean {
|
||||
if (c.type === 'select') return false
|
||||
if (c.type === 'dimensions' && c.resizing !== true) return false
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep manually-routed edge waypoints attached to their nodes on drag (#279).
|
||||
*
|
||||
@@ -93,6 +109,14 @@ interface CanvasState {
|
||||
nodes: Node<NodeData>[]
|
||||
edges: Edge<EdgeData>[]
|
||||
hasUnsavedChanges: boolean
|
||||
/**
|
||||
* Monotonic counter incremented on every real user edit (auto-bumped whenever
|
||||
* an action sets hasUnsavedChanges to true). Consumers that need to react to
|
||||
* *edits specifically* — e.g. the autosave debounce — key off this instead of
|
||||
* the nodes/edges array identity, which also churns on live status updates and
|
||||
* selection changes that must NOT reset the debounce.
|
||||
*/
|
||||
editSeq: number
|
||||
selectedNodeId: string | null
|
||||
selectedNodeIds: string[]
|
||||
scanEventTs: number
|
||||
@@ -127,6 +151,14 @@ interface CanvasState {
|
||||
setSelectedNode: (id: string | null) => void
|
||||
addNode: (node: Node<NodeData>) => void
|
||||
updateNode: (id: string, data: Partial<NodeData>) => void
|
||||
/**
|
||||
* Apply a live status update to a node WITHOUT marking the canvas unsaved.
|
||||
* Status (online/offline, response time, last seen) is transient monitoring
|
||||
* data pushed by the backend, not a user edit — dirtying the canvas here would
|
||||
* make autosave rewrite an untouched canvas on every status cycle and could
|
||||
* clobber edits made elsewhere. Mirrors setServiceStatuses' live-overlay rule.
|
||||
*/
|
||||
setNodeStatus: (id: string, status: Pick<NodeData, 'status' | 'response_time_ms' | 'last_seen'>) => void
|
||||
deleteNode: (id: string) => void
|
||||
updateEdge: (id: string, data: Partial<EdgeData>) => void
|
||||
reconnectEdge: (id: string, connection: Connection) => void
|
||||
@@ -163,10 +195,33 @@ interface CanvasState {
|
||||
applyAllCustomStyles: (def: CustomStyleDef) => void
|
||||
}
|
||||
|
||||
export const useCanvasStore = create<CanvasState>((set) => ({
|
||||
export const useCanvasStore = create<CanvasState>((rawSet) => {
|
||||
// Wrap set so any update that flips hasUnsavedChanges to true also bumps
|
||||
// editSeq. This centralises the "an edit happened" signal instead of touching
|
||||
// every one of the ~two dozen mutating actions. Actions that update state
|
||||
// without dirtying (setNodeStatus, markSaved, loadCanvas, selection) omit
|
||||
// hasUnsavedChanges or set it false, so they never bump the counter.
|
||||
const set: typeof rawSet = ((partial, replace) => {
|
||||
rawSet((state) => {
|
||||
const next = typeof partial === 'function'
|
||||
? (partial as (s: CanvasState) => Partial<CanvasState>)(state)
|
||||
: partial
|
||||
if (
|
||||
next &&
|
||||
typeof next === 'object' &&
|
||||
(next as Partial<CanvasState>).hasUnsavedChanges === true &&
|
||||
!('editSeq' in next)
|
||||
) {
|
||||
return { ...next, editSeq: state.editSeq + 1 }
|
||||
}
|
||||
return next
|
||||
}, replace as false | undefined)
|
||||
}) as typeof rawSet
|
||||
return {
|
||||
nodes: [],
|
||||
edges: [],
|
||||
hasUnsavedChanges: false,
|
||||
editSeq: 0,
|
||||
selectedNodeId: null,
|
||||
selectedNodeIds: [],
|
||||
editingGroupRectId: null,
|
||||
@@ -323,19 +378,26 @@ export const useCanvasStore = create<CanvasState>((set) => ({
|
||||
// they don't follow a moved node on their own. Translate them by the same
|
||||
// delta the node moved so a clean routing stays clean after a drag (#279).
|
||||
const edges = translateWaypointsForMovedNodes(changes, state.nodes, nodes, state.edges)
|
||||
// Only set hasUnsavedChanges when a real edit occurred, so the set() wrapper
|
||||
// bumps editSeq only then. Selection- or measure-only changes leave the flag
|
||||
// untouched (carried over) and must not reset the autosave debounce.
|
||||
const edited = changes.some(isUserNodeEdit)
|
||||
return {
|
||||
nodes,
|
||||
edges,
|
||||
selectedNodeIds,
|
||||
hasUnsavedChanges: state.hasUnsavedChanges || changes.some((c) => c.type !== 'select'),
|
||||
...(edited ? { hasUnsavedChanges: true } : {}),
|
||||
}
|
||||
}),
|
||||
|
||||
onEdgesChange: (changes) =>
|
||||
set((state) => ({
|
||||
edges: applyEdgeChanges(changes, state.edges),
|
||||
hasUnsavedChanges: state.hasUnsavedChanges || changes.some((c) => c.type !== 'select'),
|
||||
})),
|
||||
set((state) => {
|
||||
const edited = changes.some((c) => c.type !== 'select')
|
||||
return {
|
||||
edges: applyEdgeChanges(changes, state.edges),
|
||||
...(edited ? { hasUnsavedChanges: true } : {}),
|
||||
}
|
||||
}),
|
||||
|
||||
onConnect: (connection) =>
|
||||
set((state) => {
|
||||
@@ -468,6 +530,18 @@ export const useCanvasStore = create<CanvasState>((set) => ({
|
||||
return { nodes, edges, hasUnsavedChanges: true }
|
||||
}),
|
||||
|
||||
setNodeStatus: (id, status) =>
|
||||
set((state) => {
|
||||
let changed = false
|
||||
const nodes = state.nodes.map((n) => {
|
||||
if (n.id !== id) return n
|
||||
changed = true
|
||||
return { ...n, data: { ...n.data, ...status } }
|
||||
})
|
||||
// No hasUnsavedChanges: live status is monitoring data, not a user edit.
|
||||
return changed ? { nodes } : {}
|
||||
}),
|
||||
|
||||
deleteNode: (id) =>
|
||||
set((state) => {
|
||||
const idsToRemove = new Set<string>()
|
||||
@@ -977,4 +1051,5 @@ export const useCanvasStore = create<CanvasState>((set) => ({
|
||||
})
|
||||
return { nodes, edges, hasUnsavedChanges: true }
|
||||
}),
|
||||
}))
|
||||
}
|
||||
})
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import {
|
||||
DEFAULT_AUTOSAVE_SETTINGS,
|
||||
readAutosaveSettings,
|
||||
writeAutosaveSettings,
|
||||
subscribeAutosaveSettings,
|
||||
} from '../autosaveSettings'
|
||||
|
||||
describe('autosaveSettings', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
it('defaults to disabled with a 5s delay', () => {
|
||||
expect(DEFAULT_AUTOSAVE_SETTINGS).toEqual({ enabled: false, delay: 5 })
|
||||
})
|
||||
|
||||
it('returns defaults when nothing stored', () => {
|
||||
expect(readAutosaveSettings()).toEqual(DEFAULT_AUTOSAVE_SETTINGS)
|
||||
})
|
||||
|
||||
it('roundtrips through localStorage', () => {
|
||||
writeAutosaveSettings({ enabled: true, delay: 30 })
|
||||
expect(readAutosaveSettings()).toEqual({ enabled: true, delay: 30 })
|
||||
})
|
||||
|
||||
it('falls back to defaults when stored value is corrupted', () => {
|
||||
localStorage.setItem('homelable.autosave', '{not json')
|
||||
expect(readAutosaveSettings()).toEqual(DEFAULT_AUTOSAVE_SETTINGS)
|
||||
})
|
||||
|
||||
it('fills missing fields from defaults', () => {
|
||||
localStorage.setItem('homelable.autosave', JSON.stringify({ enabled: true }))
|
||||
expect(readAutosaveSettings()).toEqual({ enabled: true, delay: DEFAULT_AUTOSAVE_SETTINGS.delay })
|
||||
})
|
||||
|
||||
it('rejects a non-positive delay and falls back to default', () => {
|
||||
localStorage.setItem('homelable.autosave', JSON.stringify({ enabled: true, delay: 0 }))
|
||||
expect(readAutosaveSettings().delay).toBe(DEFAULT_AUTOSAVE_SETTINGS.delay)
|
||||
localStorage.setItem('homelable.autosave', JSON.stringify({ enabled: true, delay: -10 }))
|
||||
expect(readAutosaveSettings().delay).toBe(DEFAULT_AUTOSAVE_SETTINGS.delay)
|
||||
})
|
||||
|
||||
it('rejects a non-numeric delay and falls back to default', () => {
|
||||
localStorage.setItem('homelable.autosave', JSON.stringify({ enabled: true, delay: 'soon' }))
|
||||
expect(readAutosaveSettings().delay).toBe(DEFAULT_AUTOSAVE_SETTINGS.delay)
|
||||
})
|
||||
|
||||
it('rejects a non-boolean enabled and falls back to default', () => {
|
||||
localStorage.setItem('homelable.autosave', JSON.stringify({ enabled: 'yes', delay: 10 }))
|
||||
expect(readAutosaveSettings().enabled).toBe(DEFAULT_AUTOSAVE_SETTINGS.enabled)
|
||||
})
|
||||
|
||||
it('notifies subscribers on write and stops after unsubscribe', () => {
|
||||
const listener = vi.fn()
|
||||
const unsubscribe = subscribeAutosaveSettings(listener)
|
||||
writeAutosaveSettings({ enabled: true, delay: 10 })
|
||||
expect(listener).toHaveBeenCalledWith({ enabled: true, delay: 10 })
|
||||
unsubscribe()
|
||||
writeAutosaveSettings({ enabled: false, delay: 3 })
|
||||
expect(listener).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,43 @@
|
||||
// Persisted client-side autosave preference.
|
||||
// Kept in localStorage (per-user UI preference, not canvas data).
|
||||
// Same-tab updates propagate via a CustomEvent so App.tsx and SettingsModal
|
||||
// can stay in sync without a global store.
|
||||
|
||||
export interface AutosaveSettings {
|
||||
enabled: boolean
|
||||
delay: number // seconds of inactivity before auto-saving
|
||||
}
|
||||
|
||||
export const DEFAULT_AUTOSAVE_SETTINGS: AutosaveSettings = { enabled: false, delay: 5 }
|
||||
|
||||
const KEY = 'homelable.autosave'
|
||||
const EVENT = 'homelable:autosave-settings-changed'
|
||||
|
||||
export function readAutosaveSettings(): AutosaveSettings {
|
||||
try {
|
||||
const raw = localStorage.getItem(KEY)
|
||||
if (!raw) return DEFAULT_AUTOSAVE_SETTINGS
|
||||
const parsed = JSON.parse(raw) as Partial<AutosaveSettings>
|
||||
return {
|
||||
enabled: typeof parsed.enabled === 'boolean' ? parsed.enabled : DEFAULT_AUTOSAVE_SETTINGS.enabled,
|
||||
delay: typeof parsed.delay === 'number' && parsed.delay > 0 ? parsed.delay : DEFAULT_AUTOSAVE_SETTINGS.delay,
|
||||
}
|
||||
} catch {
|
||||
return DEFAULT_AUTOSAVE_SETTINGS
|
||||
}
|
||||
}
|
||||
|
||||
export function writeAutosaveSettings(s: AutosaveSettings): void {
|
||||
try {
|
||||
localStorage.setItem(KEY, JSON.stringify(s))
|
||||
window.dispatchEvent(new CustomEvent<AutosaveSettings>(EVENT, { detail: s }))
|
||||
} catch {
|
||||
/* quota / SSR */
|
||||
}
|
||||
}
|
||||
|
||||
export function subscribeAutosaveSettings(listener: (s: AutosaveSettings) => void): () => void {
|
||||
const handler = (e: Event) => listener((e as CustomEvent<AutosaveSettings>).detail)
|
||||
window.addEventListener(EVENT, handler)
|
||||
return () => window.removeEventListener(EVENT, handler)
|
||||
}
|
||||
Reference in New Issue
Block a user