From c67b1775a5b9d136fd5a8ffef5442171ccda0e8e Mon Sep 17 00:00:00 2001 From: Pouzor Date: Fri, 5 Jun 2026 10:52:44 +0200 Subject: [PATCH 1/2] feat(canvas): copy/paste nodes across designs Clipboard now holds nodes + internal edges and survives design switches (loadCanvas no longer clears it), so a selection copied in one design can be pasted into another. Copy pulls in children of selected groups/ containers; paste remaps node/edge/parent IDs and lands the bounding-box center under the cursor (or viewport center). Shortcut handling moved into CanvasContainer for flow-coordinate projection. ha-relevant: yes --- frontend/src/App.tsx | 10 +- .../src/components/canvas/CanvasContainer.tsx | 32 ++++- .../src/stores/__tests__/canvasStore.test.ts | 115 +++++++++++++++-- frontend/src/stores/canvasStore.ts | 120 +++++++++++++++--- 4 files changed, 235 insertions(+), 42 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 1acf369..afca1b5 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -42,7 +42,7 @@ const STANDALONE = import.meta.env.VITE_STANDALONE === 'true' const STANDALONE_STORAGE_KEY = 'homelable_canvas' export default function App() { - const { loadCanvas, markSaved, markUnsaved, selectedNodeId, selectedNodeIds, addNode, updateNode, deleteNode, onConnect, updateEdge, deleteEdge, setProxmoxContainerMode, setNodeZIndex, editingGroupRectId, setEditingGroupRectId, editingTextId, setEditingTextId, nodes, edges, snapshotHistory, undo, redo, copySelectedNodes, pasteNodes } = useCanvasStore() + const { loadCanvas, markSaved, markUnsaved, selectedNodeId, selectedNodeIds, addNode, updateNode, deleteNode, onConnect, updateEdge, deleteEdge, setProxmoxContainerMode, setNodeZIndex, editingGroupRectId, setEditingGroupRectId, editingTextId, setEditingTextId, nodes, edges, snapshotHistory, undo, redo } = useCanvasStore() const canvasRef = useRef(null) const { isAuthenticated } = useAuthStore() const { activeTheme, setTheme, customStyle, setCustomStyle } = useThemeStore() @@ -213,12 +213,8 @@ export default function App() { // Keep refs for store actions so keydown handler is always up-to-date without re-registering const undoRef = useRef(undo) const redoRef = useRef(redo) - const copyRef = useRef(copySelectedNodes) - const pasteRef = useRef(pasteNodes) useEffect(() => { undoRef.current = undo }, [undo]) useEffect(() => { redoRef.current = redo }, [redo]) - useEffect(() => { copyRef.current = copySelectedNodes }, [copySelectedNodes]) - useEffect(() => { pasteRef.current = pasteNodes }, [pasteNodes]) // Global keyboard shortcuts useEffect(() => { @@ -232,8 +228,8 @@ export default function App() { if (ctrl && e.key === 'z') { e.preventDefault(); undoRef.current(); return } if (ctrl && (e.key === 'y' || (e.shiftKey && e.key === 'z'))) { e.preventDefault(); redoRef.current(); return } if (ctrl && e.key === 'k') { e.preventDefault(); setSearchOpen(true); return } - if (ctrl && e.key === 'c' && !isInput) { copyRef.current(); return } - if (ctrl && e.key === 'v' && !isInput) { pasteRef.current(); return } + // Copy/paste (Ctrl/Cmd+C/V) handled in CanvasContainer so paste can place + // nodes under the cursor / viewport center. if (e.key === '?' && !isInput) { setShortcutsOpen(true); return } } window.addEventListener('keydown', handler) diff --git a/frontend/src/components/canvas/CanvasContainer.tsx b/frontend/src/components/canvas/CanvasContainer.tsx index be30641..b789680 100644 --- a/frontend/src/components/canvas/CanvasContainer.tsx +++ b/frontend/src/components/canvas/CanvasContainer.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { ReactFlow, Background, @@ -40,8 +40,34 @@ export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, o onNodesChange, onEdgesChange, setSelectedNode, snapshotHistory, fitViewPending, clearFitViewPending, + copySelectedNodes, pasteNodes, } = useCanvasStore() - const { fitView } = useReactFlow() + const { fitView, screenToFlowPosition } = useReactFlow() + + // Track the last cursor position over the canvas so paste lands under it. + const cursorRef = useRef<{ x: number; y: number } | null>(null) + const onMouseMove = useCallback((e: React.MouseEvent) => { + cursorRef.current = { x: e.clientX, y: e.clientY } + }, []) + + // Copy / paste shortcuts. Registered here (inside ReactFlowProvider) so paste + // can project the cursor / viewport center into flow coordinates. + useEffect(() => { + const handler = (e: KeyboardEvent) => { + if (!(e.ctrlKey || e.metaKey)) return + const el = e.target as HTMLElement + const isInput = el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.isContentEditable + if (isInput) return + if (e.key === 'c') { + copySelectedNodes() + } else if (e.key === 'v') { + const screen = cursorRef.current ?? { x: window.innerWidth / 2, y: window.innerHeight / 2 } + pasteNodes(screenToFlowPosition(screen)) + } + } + window.addEventListener('keydown', handler) + return () => window.removeEventListener('keydown', handler) + }, [copySelectedNodes, pasteNodes, screenToFlowPosition]) // Fit view after canvas loads (fitViewPending is set by loadCanvas) useEffect(() => { @@ -100,7 +126,7 @@ export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, o const { guides, onNodeDrag, onNodeDragStop } = useAlignmentGuides() return ( -
+
{ editingTextId: null, past: [], future: [], - clipboard: [], + clipboard: { nodes: [], edges: [] }, }) }) @@ -690,25 +690,112 @@ describe('canvasStore', () => { }) useCanvasStore.getState().copySelectedNodes() const { clipboard } = useCanvasStore.getState() - expect(clipboard).toHaveLength(1) - expect(clipboard[0].id).toBe('a') + expect(clipboard.nodes).toHaveLength(1) + expect(clipboard.nodes[0].id).toBe('a') }) - it('pasteNodes creates new nodes with new IDs and offset position', () => { - const node = { ...makeNode('src'), position: { x: 100, y: 100 }, selected: true } - useCanvasStore.setState({ nodes: [node], edges: [], clipboard: [node] }) + it('copySelectedNodes captures edges whose endpoints are both selected', () => { + useCanvasStore.setState({ + nodes: [ + { ...makeNode('a'), selected: true }, + { ...makeNode('b'), selected: true }, + { ...makeNode('c'), selected: false }, + ], + edges: [makeEdge('e-ab', 'a', 'b'), makeEdge('e-bc', 'b', 'c')], + }) + useCanvasStore.getState().copySelectedNodes() + const { clipboard } = useCanvasStore.getState() + expect(clipboard.nodes.map((n) => n.id).sort()).toEqual(['a', 'b']) + expect(clipboard.edges).toHaveLength(1) + expect(clipboard.edges[0].id).toBe('e-ab') + }) + + it('copySelectedNodes pulls in children of a selected group', () => { + useCanvasStore.setState({ + nodes: [ + { ...makeNode('g', { type: 'group' }), type: 'group', selected: true }, + { ...makeNode('child', { parent_id: 'g' }), parentId: 'g', selected: false }, + ], + edges: [], + }) + useCanvasStore.getState().copySelectedNodes() + expect(useCanvasStore.getState().clipboard.nodes.map((n) => n.id).sort()).toEqual(['child', 'g']) + }) + + it('pasteNodes creates new nodes with new IDs and a cascade offset by default', () => { + const node = { ...makeNode('src'), position: { x: 100, y: 100 } } + useCanvasStore.setState({ nodes: [], edges: [], clipboard: { nodes: [node], edges: [] } }) useCanvasStore.getState().pasteNodes() const { nodes } = useCanvasStore.getState() - expect(nodes).toHaveLength(2) - const pasted = nodes.find((n) => n.id !== 'src')! - expect(pasted).toBeDefined() - expect(pasted.position.x).toBe(150) - expect(pasted.position.y).toBe(150) - expect(pasted.selected).toBe(false) + expect(nodes).toHaveLength(1) + const pasted = nodes[0] + expect(pasted.id).not.toBe('src') + expect(pasted.position).toEqual({ x: 150, y: 150 }) + expect(pasted.selected).toBe(true) + }) + + it('pasteNodes centers the pasted bounding box on the target point', () => { + const node = { ...makeNode('src'), position: { x: 0, y: 0 }, width: 100, height: 100 } + useCanvasStore.setState({ nodes: [], edges: [], clipboard: { nodes: [node], edges: [] } }) + useCanvasStore.getState().pasteNodes({ x: 500, y: 300 }) + const pasted = useCanvasStore.getState().nodes[0] + // bbox center (50,50) shifted onto (500,300) → top-left at (450,250) + expect(pasted.position).toEqual({ x: 450, y: 250 }) + }) + + it('pasteNodes remaps edge endpoints to the new node IDs', () => { + const a = { ...makeNode('a') } + const b = { ...makeNode('b') } + useCanvasStore.setState({ + nodes: [], + edges: [], + clipboard: { nodes: [a, b], edges: [makeEdge('e-ab', 'a', 'b')] }, + }) + useCanvasStore.getState().pasteNodes() + const { nodes, edges } = useCanvasStore.getState() + expect(edges).toHaveLength(1) + const ids = nodes.map((n) => n.id) + expect(ids).toContain(edges[0].source) + expect(ids).toContain(edges[0].target) + expect(edges[0].source).not.toBe('a') + expect(edges[0].id).not.toBe('e-ab') + }) + + it('pasteNodes preserves parent-child relationship under remapped IDs', () => { + const group = { ...makeNode('g', { type: 'group' }), type: 'group', position: { x: 0, y: 0 } } + const child = { ...makeNode('child', { parent_id: 'g' }), parentId: 'g', extent: 'parent' as const, position: { x: 20, y: 30 } } + useCanvasStore.setState({ nodes: [], edges: [], clipboard: { nodes: [group, child], edges: [] } }) + useCanvasStore.getState().pasteNodes() + const { nodes } = useCanvasStore.getState() + const newGroup = nodes.find((n) => n.data.type === 'group')! + const newChild = nodes.find((n) => n.id !== newGroup.id)! + expect(newChild.parentId).toBe(newGroup.id) + expect(newChild.data.parent_id).toBe(newGroup.id) + // Child keeps its parent-relative position (no offset applied to children) + expect(newChild.position).toEqual({ x: 20, y: 30 }) + // Group (the root) precedes its child in the array + expect(nodes.findIndex((n) => n.id === newGroup.id)).toBeLessThan( + nodes.findIndex((n) => n.id === newChild.id), + ) + }) + + it('clipboard survives loadCanvas so nodes can be pasted into another design', () => { + useCanvasStore.setState({ + nodes: [{ ...makeNode('a'), selected: true }], + edges: [], + }) + useCanvasStore.getState().copySelectedNodes() + // Switch to another design: loadCanvas replaces nodes/edges. + useCanvasStore.getState().loadCanvas([makeNode('other')], []) + expect(useCanvasStore.getState().clipboard.nodes).toHaveLength(1) + useCanvasStore.getState().pasteNodes() + const ids = useCanvasStore.getState().nodes.map((n) => n.id) + expect(ids).toContain('other') + expect(ids).toHaveLength(2) }) it('pasteNodes does nothing when clipboard is empty', () => { - useCanvasStore.setState({ nodes: [makeNode('n1')], edges: [], clipboard: [] }) + useCanvasStore.setState({ nodes: [makeNode('n1')], edges: [], clipboard: { nodes: [], edges: [] } }) useCanvasStore.getState().pasteNodes() expect(useCanvasStore.getState().nodes).toHaveLength(1) }) @@ -867,7 +954,7 @@ describe('canvasStore — custom style apply', () => { editingTextId: null, past: [], future: [], - clipboard: [], + clipboard: { nodes: [], edges: [] }, }) }) diff --git a/frontend/src/stores/canvasStore.ts b/frontend/src/stores/canvasStore.ts index ef28809..a58c08a 100644 --- a/frontend/src/stores/canvasStore.ts +++ b/frontend/src/stores/canvasStore.ts @@ -15,6 +15,10 @@ import { normalizeHandle, removedBottomHandleIds } from '@/utils/handleUtils' import { applyOpacity } from '@/utils/colorUtils' type HistoryEntry = { nodes: Node[]; edges: Edge[] } +type Clipboard = { nodes: Node[]; edges: Edge[] } + +/** Resolve a node's effective parent id from either the RF field or domain data. */ +const parentIdOf = (n: Node): string | undefined => n.parentId ?? n.data.parent_id ?? undefined interface CanvasState { nodes: Node[] @@ -31,10 +35,12 @@ interface CanvasState { undo: () => void redo: () => void - // Clipboard - clipboard: Node[] + // Clipboard — survives design switches so nodes can be pasted into another design + clipboard: Clipboard copySelectedNodes: () => void - pasteNodes: () => void + /** Paste clipboard into the current canvas. `center` (flow coords) lands the + * pasted bounding-box center under the cursor / viewport center. */ + pasteNodes: (center?: { x: number; y: number }) => void onNodesChange: (changes: NodeChange>[]) => void onEdgesChange: (changes: EdgeChange>[]) => void @@ -82,7 +88,7 @@ export const useCanvasStore = create((set) => ({ past: [], future: [], - clipboard: [], + clipboard: { nodes: [], edges: [] }, snapshotHistory: () => set((state) => ({ @@ -117,24 +123,100 @@ export const useCanvasStore = create((set) => ({ }), copySelectedNodes: () => - set((state) => ({ - clipboard: state.nodes.filter((n) => n.selected), - })), - - pasteNodes: () => set((state) => { - if (state.clipboard.length === 0) return state - const newNodes = state.clipboard.map((n) => ({ - ...n, + // Start from explicitly selected nodes, then pull in all descendants so a + // copied group / container brings its children along. + const ids = new Set(state.nodes.filter((n) => n.selected).map((n) => n.id)) + if (ids.size === 0) return { clipboard: { nodes: [], edges: [] } } + let grew = true + while (grew) { + grew = false + for (const n of state.nodes) { + const pid = parentIdOf(n) + if (pid && ids.has(pid) && !ids.has(n.id)) { + ids.add(n.id) + grew = true + } + } + } + const nodes = state.nodes.filter((n) => ids.has(n.id)) + // Keep only edges whose both endpoints are inside the copied set. + const edges = state.edges.filter((e) => ids.has(e.source) && ids.has(e.target)) + return { clipboard: { nodes, edges } } + }), + + pasteNodes: (center) => + set((state) => { + const clip = state.clipboard + if (clip.nodes.length === 0) return state + + // Fresh ids for every copied node; edges/parent links are remapped through it. + const idMap = new Map() + clip.nodes.forEach((n) => idMap.set(n.id, generateUUID())) + + // A "root" is a copied node whose parent was not also copied — these carry + // absolute positions and receive the paste offset; children move with them. + const isRoot = (n: Node) => { + const pid = parentIdOf(n) + return !pid || !idMap.has(pid) + } + const roots = clip.nodes.filter(isRoot) + + // Default cascade offset; when a target center is given, shift the root + // bounding-box center onto it instead. + let offsetX = 50 + let offsetY = 50 + if (center && roots.length > 0) { + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity + for (const n of roots) { + const w = n.width ?? n.measured?.width ?? 200 + const h = n.height ?? n.measured?.height ?? 80 + minX = Math.min(minX, n.position.x) + minY = Math.min(minY, n.position.y) + maxX = Math.max(maxX, n.position.x + w) + maxY = Math.max(maxY, n.position.y + h) + } + offsetX = center.x - (minX + maxX) / 2 + offsetY = center.y - (minY + maxY) / 2 + } + + const pasted = clip.nodes.map((n) => { + const root = isRoot(n) + const newParentId = root ? undefined : idMap.get(parentIdOf(n)!) + return { + ...n, + id: idMap.get(n.id)!, + position: root + ? { x: n.position.x + offsetX, y: n.position.y + offsetY } + : { ...n.position }, + selected: true, + parentId: newParentId, + extent: newParentId ? ('parent' as const) : undefined, + data: { ...n.data, parent_id: newParentId }, + } + }) + + const pastedEdges = clip.edges.map((e) => ({ + ...e, id: generateUUID(), - position: { x: n.position.x + 50, y: n.position.y + 50 }, + source: idMap.get(e.source)!, + target: idMap.get(e.target)!, selected: false, - parentId: undefined, - extent: undefined, - data: { ...n.data, parent_id: undefined }, })) + + // React Flow requires parents before children within the appended block. + const parents = pasted.filter((n) => !n.parentId) + const children = pasted.filter((n) => !!n.parentId) + const pastedNodes = [...parents, ...children] + + // Deselect everything already on the canvas so only the paste is selected. + const existing = state.nodes.map((n) => (n.selected ? { ...n, selected: false } : n)) + return { - nodes: [...state.nodes, ...newNodes], + nodes: [...existing, ...pastedNodes], + edges: [...state.edges, ...pastedEdges], + selectedNodeId: null, + selectedNodeIds: pastedNodes.map((n) => n.id), past: [...state.past.slice(-49), { nodes: state.nodes, edges: state.edges }], future: [], hasUnsavedChanges: true, @@ -503,7 +585,9 @@ export const useCanvasStore = create((set) => ({ // React Flow requires parents before children in the array const parents = nodes.filter((n) => !n.parentId) const children = nodes.filter((n) => !!n.parentId) - set({ nodes: [...parents, ...children], edges, hasUnsavedChanges: false, selectedNodeId: null, past: [], future: [], clipboard: [], fitViewPending: true }) + // NOTE: clipboard is intentionally preserved here so nodes copied in one + // design can be pasted after switching to another design. + set({ nodes: [...parents, ...children], edges, hasUnsavedChanges: false, selectedNodeId: null, past: [], future: [], fitViewPending: true }) }, clearFitViewPending: () => set({ fitViewPending: false }), From b52bbc6d9fadf082f3aae713c9e27e891c64bab7 Mon Sep 17 00:00:00 2001 From: Pouzor Date: Fri, 5 Jun 2026 11:26:28 +0200 Subject: [PATCH 2/2] feat(settings): move Hide IP toggle into Settings modal, persist it Hide-IP was a sidebar button held only in memory, so it reset on reload. Moved it into the Settings modal Canvas section and persist it to localStorage (new ipDisplay util); the canvas store now seeds hideIp from storage and writes through on toggleHideIp/setHideIp. Settings is now also reachable in standalone (no-backend) builds, with the backend-only status interval guarded so the modal still works there. ha-relevant: yes --- .../src/components/modals/SettingsModal.tsx | 26 ++++++++++++++++++- .../modals/__tests__/SettingsModal.test.tsx | 12 +++++++++ frontend/src/components/panels/Sidebar.tsx | 25 ++++++------------ .../panels/__tests__/Sidebar.test.tsx | 15 +++-------- .../src/stores/__tests__/canvasStore.test.ts | 20 ++++++++++++++ frontend/src/stores/canvasStore.ts | 15 +++++++++-- .../src/utils/__tests__/ipDisplay.test.ts | 22 ++++++++++++++++ frontend/src/utils/ipDisplay.ts | 21 +++++++++++++++ 8 files changed, 124 insertions(+), 32 deletions(-) create mode 100644 frontend/src/utils/__tests__/ipDisplay.test.ts create mode 100644 frontend/src/utils/ipDisplay.ts diff --git a/frontend/src/components/modals/SettingsModal.tsx b/frontend/src/components/modals/SettingsModal.tsx index e3cba24..5804b34 100644 --- a/frontend/src/components/modals/SettingsModal.tsx +++ b/frontend/src/components/modals/SettingsModal.tsx @@ -2,6 +2,7 @@ import { useState, useEffect } from 'react' import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog' import { Button } from '@/components/ui/button' import { settingsApi } from '@/api/client' +import { useCanvasStore } from '@/stores/canvasStore' import { toast } from 'sonner' import { type AlignmentSettings, @@ -10,6 +11,8 @@ import { subscribeAlignmentSettings, } from '@/utils/alignmentSettings' +const STANDALONE = import.meta.env.VITE_STANDALONE === 'true' + interface SettingsModalProps { open: boolean onClose: () => void @@ -19,9 +22,11 @@ export function SettingsModal({ open, onClose }: SettingsModalProps) { const [interval, setIntervalValue] = useState(60) const [saving, setSaving] = useState(false) const [alignment, setAlignment] = useState(readAlignmentSettings) + const hideIp = useCanvasStore((s) => s.hideIp) + const setHideIp = useCanvasStore((s) => s.setHideIp) useEffect(() => { - if (!open) return + if (!open || STANDALONE) return settingsApi.get() .then((res) => setIntervalValue(res.data.interval_seconds)) .catch(() => {/* use default */}) @@ -36,6 +41,12 @@ export function SettingsModal({ open, onClose }: SettingsModalProps) { } const handleSave = async () => { + // Canvas prefs (alignment, hide-IP) persist on change; only the backend + // status-check interval needs an API round-trip. + if (STANDALONE) { + onClose() + return + } setSaving(true) try { await settingsApi.save({ interval_seconds: interval }) @@ -57,6 +68,7 @@ export function SettingsModal({ open, onClose }: SettingsModalProps) {
{/* Status checker */} + {!STANDALONE && (
@@ -74,6 +86,7 @@ export function SettingsModal({ open, onClose }: SettingsModalProps) { How often node health is polled (ping, HTTP, SSH…)

+ )} {/* Canvas */}
@@ -90,6 +103,17 @@ export function SettingsModal({ open, onClose }: SettingsModalProps) { /> + +
diff --git a/frontend/src/components/modals/__tests__/SettingsModal.test.tsx b/frontend/src/components/modals/__tests__/SettingsModal.test.tsx index 40763a8..7da48d8 100644 --- a/frontend/src/components/modals/__tests__/SettingsModal.test.tsx +++ b/frontend/src/components/modals/__tests__/SettingsModal.test.tsx @@ -12,6 +12,7 @@ vi.mock('@/api/client', () => ({ import { settingsApi } from '@/api/client' import { toast } from 'sonner' +import { useCanvasStore } from '@/stores/canvasStore' describe('SettingsModal', () => { beforeEach(() => { @@ -64,6 +65,17 @@ describe('SettingsModal', () => { expect(onClose).not.toHaveBeenCalled() }) + it('reflects and persists the hide-IP preference', async () => { + useCanvasStore.setState({ hideIp: false }) + localStorage.removeItem('homelable.hideIp') + render() + const checkbox = screen.getByLabelText('Toggle IP address masking') as HTMLInputElement + expect(checkbox.checked).toBe(false) + fireEvent.click(checkbox) + expect(useCanvasStore.getState().hideIp).toBe(true) + expect(localStorage.getItem('homelable.hideIp')).toBe('true') + }) + it('calls onClose on Cancel', async () => { const onClose = vi.fn() render() diff --git a/frontend/src/components/panels/Sidebar.tsx b/frontend/src/components/panels/Sidebar.tsx index 74f8ed8..1155f7f 100644 --- a/frontend/src/components/panels/Sidebar.tsx +++ b/frontend/src/components/panels/Sidebar.tsx @@ -1,5 +1,5 @@ import { useState, useCallback, useEffect, useRef } from 'react' -import { Plus, Save, ScanLine, ChevronLeft, ChevronRight, LayoutDashboard, Clock, EyeOff, RefreshCw, Loader2, Square, Eye, Settings, StopCircle, LogOut, Network, Type, PlusCircle, Pencil, Trash2 } from 'lucide-react' +import { Plus, Save, ScanLine, ChevronLeft, ChevronRight, LayoutDashboard, Clock, EyeOff, RefreshCw, Loader2, Square, Settings, StopCircle, LogOut, Network, Type, PlusCircle, Pencil, Trash2 } from 'lucide-react' import { Logo } from '@/components/ui/Logo' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { useCanvasStore } from '@/stores/canvasStore' @@ -90,7 +90,7 @@ export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbee } } - const { nodes, hasUnsavedChanges, hideIp, toggleHideIp } = useCanvasStore() + const { nodes, hasUnsavedChanges } = useCanvasStore() const networkNodes = nodes.filter((n) => n.data.type !== 'groupRect' && n.data.type !== 'text') const onlineCount = networkNodes.filter((n) => n.data.status === 'online').length @@ -253,13 +253,6 @@ export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbee {!STANDALONE && } {!STANDALONE && } - - {!STANDALONE && ( - - )} + {!STANDALONE && ( > = {}) { vi.mocked(useCanvasStore).mockReturnValue({ nodes: [], hasUnsavedChanges: false, - hideIp: false, - toggleHideIp: mockToggleHideIp, addNode: vi.fn(), scanEventTs: 0, ...overrides, @@ -191,16 +188,10 @@ describe('Sidebar', () => { expect(defaultProps.onSave).toHaveBeenCalledOnce() }) - it('calls toggleHideIp when Hide IPs is clicked', () => { + it('calls onOpenSettings when Settings is clicked', () => { render() - fireEvent.click(screen.getByText('Hide IPs')) - expect(mockToggleHideIp).toHaveBeenCalledOnce() - }) - - it('shows Show IPs label when hideIp is true', () => { - mockStore({ hideIp: true }) - render() - expect(screen.getByText('Show IPs')).toBeInTheDocument() + fireEvent.click(screen.getByText('Settings')) + expect(defaultProps.onOpenSettings).toHaveBeenCalledOnce() }) // ── Unsaved changes badge ────────────────────────────────────────────────── diff --git a/frontend/src/stores/__tests__/canvasStore.test.ts b/frontend/src/stores/__tests__/canvasStore.test.ts index bd4c5d8..370b00a 100644 --- a/frontend/src/stores/__tests__/canvasStore.test.ts +++ b/frontend/src/stores/__tests__/canvasStore.test.ts @@ -800,6 +800,26 @@ describe('canvasStore', () => { expect(useCanvasStore.getState().nodes).toHaveLength(1) }) + // --- Hide IP preference (persisted to localStorage) --- + + it('toggleHideIp flips the flag and persists it', () => { + localStorage.removeItem('homelable.hideIp') + useCanvasStore.setState({ hideIp: false }) + useCanvasStore.getState().toggleHideIp() + expect(useCanvasStore.getState().hideIp).toBe(true) + expect(localStorage.getItem('homelable.hideIp')).toBe('true') + useCanvasStore.getState().toggleHideIp() + expect(useCanvasStore.getState().hideIp).toBe(false) + expect(localStorage.getItem('homelable.hideIp')).toBe('false') + }) + + it('setHideIp sets the flag and persists it', () => { + localStorage.removeItem('homelable.hideIp') + useCanvasStore.getState().setHideIp(true) + expect(useCanvasStore.getState().hideIp).toBe(true) + expect(localStorage.getItem('homelable.hideIp')).toBe('true') + }) + // --- Node resizing (width / height) --- it('addNode preserves explicit width and height', () => { diff --git a/frontend/src/stores/canvasStore.ts b/frontend/src/stores/canvasStore.ts index a58c08a..9b0f8eb 100644 --- a/frontend/src/stores/canvasStore.ts +++ b/frontend/src/stores/canvasStore.ts @@ -13,6 +13,7 @@ import type { NodeData, EdgeData, NodeType, EdgeType, NodeTypeStyle, EdgeTypeSty import { generateUUID } from '@/utils/uuid' import { normalizeHandle, removedBottomHandleIds } from '@/utils/handleUtils' import { applyOpacity } from '@/utils/colorUtils' +import { readHideIp, writeHideIp } from '@/utils/ipDisplay' type HistoryEntry = { nodes: Node[]; edges: Edge[] } type Clipboard = { nodes: Node[]; edges: Edge[] } @@ -69,6 +70,7 @@ interface CanvasState { notifyScanDeviceFound: () => void hideIp: boolean toggleHideIp: () => void + setHideIp: (value: boolean) => void applyTypeNodeStyle: (nodeType: NodeType, style: NodeTypeStyle) => void applyTypeEdgeStyle: (edgeType: EdgeType, style: EdgeTypeStyle) => void applyAllCustomStyles: (def: CustomStyleDef) => void @@ -82,7 +84,7 @@ export const useCanvasStore = create((set) => ({ selectedNodeIds: [], editingGroupRectId: null, editingTextId: null, - hideIp: false, + hideIp: readHideIp(), scanEventTs: 0, fitViewPending: false, @@ -579,7 +581,16 @@ export const useCanvasStore = create((set) => ({ notifyScanDeviceFound: () => set({ scanEventTs: Date.now() }), - toggleHideIp: () => set((s) => ({ hideIp: !s.hideIp })), + toggleHideIp: () => set((s) => { + const hideIp = !s.hideIp + writeHideIp(hideIp) + return { hideIp } + }), + + setHideIp: (value) => { + writeHideIp(value) + set({ hideIp: value }) + }, loadCanvas: (nodes, edges) => { // React Flow requires parents before children in the array diff --git a/frontend/src/utils/__tests__/ipDisplay.test.ts b/frontend/src/utils/__tests__/ipDisplay.test.ts new file mode 100644 index 0000000..5c17527 --- /dev/null +++ b/frontend/src/utils/__tests__/ipDisplay.test.ts @@ -0,0 +1,22 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { readHideIp, writeHideIp } from '@/utils/ipDisplay' + +describe('ipDisplay persistence', () => { + beforeEach(() => localStorage.clear()) + + it('defaults to false when nothing is stored', () => { + expect(readHideIp()).toBe(false) + }) + + it('round-trips true', () => { + writeHideIp(true) + expect(localStorage.getItem('homelable.hideIp')).toBe('true') + expect(readHideIp()).toBe(true) + }) + + it('round-trips false', () => { + writeHideIp(true) + writeHideIp(false) + expect(readHideIp()).toBe(false) + }) +}) diff --git a/frontend/src/utils/ipDisplay.ts b/frontend/src/utils/ipDisplay.ts new file mode 100644 index 0000000..02ab40d --- /dev/null +++ b/frontend/src/utils/ipDisplay.ts @@ -0,0 +1,21 @@ +// Persisted client-side preference for masking IP addresses on the canvas. +// Kept in localStorage (per-user UI preference, not canvas data) so it +// survives a page reload. + +const KEY = 'homelable.hideIp' + +export function readHideIp(): boolean { + try { + return localStorage.getItem(KEY) === 'true' + } catch { + return false + } +} + +export function writeHideIp(value: boolean): void { + try { + localStorage.setItem(KEY, String(value)) + } catch { + /* quota / SSR */ + } +}