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
This commit is contained in:
@@ -2,6 +2,7 @@ import { useState, useEffect } from 'react'
|
|||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { settingsApi } from '@/api/client'
|
import { settingsApi } from '@/api/client'
|
||||||
|
import { useCanvasStore } from '@/stores/canvasStore'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import {
|
import {
|
||||||
type AlignmentSettings,
|
type AlignmentSettings,
|
||||||
@@ -10,6 +11,8 @@ import {
|
|||||||
subscribeAlignmentSettings,
|
subscribeAlignmentSettings,
|
||||||
} from '@/utils/alignmentSettings'
|
} from '@/utils/alignmentSettings'
|
||||||
|
|
||||||
|
const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
|
||||||
|
|
||||||
interface SettingsModalProps {
|
interface SettingsModalProps {
|
||||||
open: boolean
|
open: boolean
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
@@ -19,9 +22,11 @@ export function SettingsModal({ open, onClose }: SettingsModalProps) {
|
|||||||
const [interval, setIntervalValue] = useState(60)
|
const [interval, setIntervalValue] = useState(60)
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [alignment, setAlignment] = useState<AlignmentSettings>(readAlignmentSettings)
|
const [alignment, setAlignment] = useState<AlignmentSettings>(readAlignmentSettings)
|
||||||
|
const hideIp = useCanvasStore((s) => s.hideIp)
|
||||||
|
const setHideIp = useCanvasStore((s) => s.setHideIp)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return
|
if (!open || STANDALONE) return
|
||||||
settingsApi.get()
|
settingsApi.get()
|
||||||
.then((res) => setIntervalValue(res.data.interval_seconds))
|
.then((res) => setIntervalValue(res.data.interval_seconds))
|
||||||
.catch(() => {/* use default */})
|
.catch(() => {/* use default */})
|
||||||
@@ -36,6 +41,12 @@ export function SettingsModal({ open, onClose }: SettingsModalProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleSave = async () => {
|
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)
|
setSaving(true)
|
||||||
try {
|
try {
|
||||||
await settingsApi.save({ interval_seconds: interval })
|
await settingsApi.save({ interval_seconds: interval })
|
||||||
@@ -57,6 +68,7 @@ export function SettingsModal({ open, onClose }: SettingsModalProps) {
|
|||||||
|
|
||||||
<div className="space-y-5 py-2">
|
<div className="space-y-5 py-2">
|
||||||
{/* Status checker */}
|
{/* Status checker */}
|
||||||
|
{!STANDALONE && (
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<label className="text-xs text-muted-foreground">Status check interval (s)</label>
|
<label className="text-xs text-muted-foreground">Status check interval (s)</label>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -74,6 +86,7 @@ export function SettingsModal({ open, onClose }: SettingsModalProps) {
|
|||||||
How often node health is polled (ping, HTTP, SSH…)
|
How often node health is polled (ping, HTTP, SSH…)
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Canvas */}
|
{/* Canvas */}
|
||||||
<div className="pt-3 border-t border-border space-y-3">
|
<div className="pt-3 border-t border-border space-y-3">
|
||||||
@@ -90,6 +103,17 @@ export function SettingsModal({ open, onClose }: SettingsModalProps) {
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
<label className="flex items-center justify-between gap-2 cursor-pointer">
|
||||||
|
<span className="text-xs text-foreground">Hide IP addresses</span>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={hideIp}
|
||||||
|
onChange={(e) => setHideIp(e.target.checked)}
|
||||||
|
className="cursor-pointer accent-[#00d4ff]"
|
||||||
|
aria-label="Toggle IP address masking"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
<div className={alignment.enabled ? 'space-y-1.5' : 'space-y-1.5 opacity-50 pointer-events-none'}>
|
<div className={alignment.enabled ? 'space-y-1.5' : 'space-y-1.5 opacity-50 pointer-events-none'}>
|
||||||
<label className="text-xs text-muted-foreground">Snap distance</label>
|
<label className="text-xs text-muted-foreground">Snap distance</label>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ vi.mock('@/api/client', () => ({
|
|||||||
|
|
||||||
import { settingsApi } from '@/api/client'
|
import { settingsApi } from '@/api/client'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
|
import { useCanvasStore } from '@/stores/canvasStore'
|
||||||
|
|
||||||
describe('SettingsModal', () => {
|
describe('SettingsModal', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -64,6 +65,17 @@ describe('SettingsModal', () => {
|
|||||||
expect(onClose).not.toHaveBeenCalled()
|
expect(onClose).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('reflects and persists the hide-IP preference', async () => {
|
||||||
|
useCanvasStore.setState({ hideIp: false })
|
||||||
|
localStorage.removeItem('homelable.hideIp')
|
||||||
|
render(<SettingsModal open onClose={vi.fn()} />)
|
||||||
|
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 () => {
|
it('calls onClose on Cancel', async () => {
|
||||||
const onClose = vi.fn()
|
const onClose = vi.fn()
|
||||||
render(<SettingsModal open onClose={onClose} />)
|
render(<SettingsModal open onClose={onClose} />)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useCallback, useEffect, useRef } from 'react'
|
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 { Logo } from '@/components/ui/Logo'
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||||
import { useCanvasStore } from '@/stores/canvasStore'
|
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 networkNodes = nodes.filter((n) => n.data.type !== 'groupRect' && n.data.type !== 'text')
|
||||||
const onlineCount = networkNodes.filter((n) => n.data.status === 'online').length
|
const onlineCount = networkNodes.filter((n) => n.data.status === 'online').length
|
||||||
@@ -253,13 +253,6 @@ export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbee
|
|||||||
<SidebarItem icon={Type} label="Add Text" collapsed={collapsed} onClick={onAddText} />
|
<SidebarItem icon={Type} label="Add Text" collapsed={collapsed} onClick={onAddText} />
|
||||||
{!STANDALONE && <SidebarItem icon={ScanLine} label="Scan Network" collapsed={collapsed} onClick={handleScan} />}
|
{!STANDALONE && <SidebarItem icon={ScanLine} label="Scan Network" collapsed={collapsed} onClick={handleScan} />}
|
||||||
{!STANDALONE && <SidebarItem icon={Network} label="Zigbee Import" collapsed={collapsed} onClick={onZigbeeImport} />}
|
{!STANDALONE && <SidebarItem icon={Network} label="Zigbee Import" collapsed={collapsed} onClick={onZigbeeImport} />}
|
||||||
<SidebarItem
|
|
||||||
icon={hideIp ? EyeOff : Eye}
|
|
||||||
label={hideIp ? 'Show IPs' : 'Hide IPs'}
|
|
||||||
collapsed={collapsed}
|
|
||||||
onClick={toggleHideIp}
|
|
||||||
active={hideIp}
|
|
||||||
/>
|
|
||||||
<SidebarItem
|
<SidebarItem
|
||||||
icon={Save}
|
icon={Save}
|
||||||
label="Save Canvas"
|
label="Save Canvas"
|
||||||
@@ -268,14 +261,12 @@ export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbee
|
|||||||
badge={hasUnsavedChanges}
|
badge={hasUnsavedChanges}
|
||||||
accent
|
accent
|
||||||
/>
|
/>
|
||||||
{!STANDALONE && (
|
|
||||||
<SidebarItem
|
<SidebarItem
|
||||||
icon={Settings}
|
icon={Settings}
|
||||||
label="Settings"
|
label="Settings"
|
||||||
collapsed={collapsed}
|
collapsed={collapsed}
|
||||||
onClick={onOpenSettings}
|
onClick={onOpenSettings}
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
{!STANDALONE && (
|
{!STANDALONE && (
|
||||||
<SidebarItem
|
<SidebarItem
|
||||||
icon={LogOut}
|
icon={LogOut}
|
||||||
|
|||||||
@@ -46,15 +46,12 @@ const makeNode = (id: string, status: NodeData['status'], type: NodeData['type']
|
|||||||
data: { label: id, type, status, services: [] },
|
data: { label: id, type, status, services: [] },
|
||||||
})
|
})
|
||||||
|
|
||||||
const mockToggleHideIp = vi.fn()
|
|
||||||
const mockLogout = vi.fn()
|
const mockLogout = vi.fn()
|
||||||
|
|
||||||
function mockStore(overrides: Partial<ReturnType<typeof useCanvasStore>> = {}) {
|
function mockStore(overrides: Partial<ReturnType<typeof useCanvasStore>> = {}) {
|
||||||
vi.mocked(useCanvasStore).mockReturnValue({
|
vi.mocked(useCanvasStore).mockReturnValue({
|
||||||
nodes: [],
|
nodes: [],
|
||||||
hasUnsavedChanges: false,
|
hasUnsavedChanges: false,
|
||||||
hideIp: false,
|
|
||||||
toggleHideIp: mockToggleHideIp,
|
|
||||||
addNode: vi.fn(),
|
addNode: vi.fn(),
|
||||||
scanEventTs: 0,
|
scanEventTs: 0,
|
||||||
...overrides,
|
...overrides,
|
||||||
@@ -191,16 +188,10 @@ describe('Sidebar', () => {
|
|||||||
expect(defaultProps.onSave).toHaveBeenCalledOnce()
|
expect(defaultProps.onSave).toHaveBeenCalledOnce()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('calls toggleHideIp when Hide IPs is clicked', () => {
|
it('calls onOpenSettings when Settings is clicked', () => {
|
||||||
render(<Sidebar {...defaultProps} />)
|
render(<Sidebar {...defaultProps} />)
|
||||||
fireEvent.click(screen.getByText('Hide IPs'))
|
fireEvent.click(screen.getByText('Settings'))
|
||||||
expect(mockToggleHideIp).toHaveBeenCalledOnce()
|
expect(defaultProps.onOpenSettings).toHaveBeenCalledOnce()
|
||||||
})
|
|
||||||
|
|
||||||
it('shows Show IPs label when hideIp is true', () => {
|
|
||||||
mockStore({ hideIp: true })
|
|
||||||
render(<Sidebar {...defaultProps} />)
|
|
||||||
expect(screen.getByText('Show IPs')).toBeInTheDocument()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// ── Unsaved changes badge ──────────────────────────────────────────────────
|
// ── Unsaved changes badge ──────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -800,6 +800,26 @@ describe('canvasStore', () => {
|
|||||||
expect(useCanvasStore.getState().nodes).toHaveLength(1)
|
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) ---
|
// --- Node resizing (width / height) ---
|
||||||
|
|
||||||
it('addNode preserves explicit width and height', () => {
|
it('addNode preserves explicit width and height', () => {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import type { NodeData, EdgeData, NodeType, EdgeType, NodeTypeStyle, EdgeTypeSty
|
|||||||
import { generateUUID } from '@/utils/uuid'
|
import { generateUUID } from '@/utils/uuid'
|
||||||
import { normalizeHandle, removedBottomHandleIds } from '@/utils/handleUtils'
|
import { normalizeHandle, removedBottomHandleIds } from '@/utils/handleUtils'
|
||||||
import { applyOpacity } from '@/utils/colorUtils'
|
import { applyOpacity } from '@/utils/colorUtils'
|
||||||
|
import { readHideIp, writeHideIp } from '@/utils/ipDisplay'
|
||||||
|
|
||||||
type HistoryEntry = { nodes: Node<NodeData>[]; edges: Edge<EdgeData>[] }
|
type HistoryEntry = { nodes: Node<NodeData>[]; edges: Edge<EdgeData>[] }
|
||||||
type Clipboard = { nodes: Node<NodeData>[]; edges: Edge<EdgeData>[] }
|
type Clipboard = { nodes: Node<NodeData>[]; edges: Edge<EdgeData>[] }
|
||||||
@@ -69,6 +70,7 @@ interface CanvasState {
|
|||||||
notifyScanDeviceFound: () => void
|
notifyScanDeviceFound: () => void
|
||||||
hideIp: boolean
|
hideIp: boolean
|
||||||
toggleHideIp: () => void
|
toggleHideIp: () => void
|
||||||
|
setHideIp: (value: boolean) => void
|
||||||
applyTypeNodeStyle: (nodeType: NodeType, style: NodeTypeStyle) => void
|
applyTypeNodeStyle: (nodeType: NodeType, style: NodeTypeStyle) => void
|
||||||
applyTypeEdgeStyle: (edgeType: EdgeType, style: EdgeTypeStyle) => void
|
applyTypeEdgeStyle: (edgeType: EdgeType, style: EdgeTypeStyle) => void
|
||||||
applyAllCustomStyles: (def: CustomStyleDef) => void
|
applyAllCustomStyles: (def: CustomStyleDef) => void
|
||||||
@@ -82,7 +84,7 @@ export const useCanvasStore = create<CanvasState>((set) => ({
|
|||||||
selectedNodeIds: [],
|
selectedNodeIds: [],
|
||||||
editingGroupRectId: null,
|
editingGroupRectId: null,
|
||||||
editingTextId: null,
|
editingTextId: null,
|
||||||
hideIp: false,
|
hideIp: readHideIp(),
|
||||||
scanEventTs: 0,
|
scanEventTs: 0,
|
||||||
fitViewPending: false,
|
fitViewPending: false,
|
||||||
|
|
||||||
@@ -579,7 +581,16 @@ export const useCanvasStore = create<CanvasState>((set) => ({
|
|||||||
|
|
||||||
notifyScanDeviceFound: () => set({ scanEventTs: Date.now() }),
|
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) => {
|
loadCanvas: (nodes, edges) => {
|
||||||
// React Flow requires parents before children in the array
|
// React Flow requires parents before children in the array
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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 */
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user