feat: floor plan map, LQI edge coloring, zigbee path highlighting, eslint/test fixes, vite audit fix
This commit is contained in:
@@ -22,6 +22,7 @@ import { nodeTypes } from './nodes/nodeTypes'
|
||||
import { edgeTypes } from './edges/edgeTypes'
|
||||
import { SearchBar } from './SearchBar'
|
||||
import { AlignmentGuides } from './AlignmentGuides'
|
||||
import { FloorMapLayer } from './FloorMapLayer'
|
||||
import { useAlignmentGuides } from '@/hooks/useAlignmentGuides'
|
||||
import { setViewportCenterProjector } from '@/utils/viewportCenter'
|
||||
import type { NodeData, EdgeData } from '@/types'
|
||||
@@ -199,6 +200,7 @@ export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, o
|
||||
size={1}
|
||||
color={theme.colors.canvasDotColor}
|
||||
/>
|
||||
<FloorMapLayer screenToFlowPosition={screenToFlowPosition} />
|
||||
<SearchBar onOpenPending={onOpenPending} />
|
||||
<AlignmentGuides guides={guides} />
|
||||
<Controls>
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import { useCallback, useRef } from 'react'
|
||||
import { useCanvasStore } from '@/stores/canvasStore'
|
||||
|
||||
interface FloorMapLayerProps {
|
||||
screenToFlowPosition: (pos: { x: number; y: number }) => { x: number; y: number }
|
||||
}
|
||||
|
||||
interface ResizeState {
|
||||
startMouseX: number
|
||||
startMouseY: number
|
||||
startX: number
|
||||
startY: number
|
||||
startW: number
|
||||
startH: number
|
||||
edges: Set<'n' | 's' | 'e' | 'w'>
|
||||
}
|
||||
|
||||
export function FloorMapLayer({ screenToFlowPosition }: FloorMapLayerProps) {
|
||||
const floorMap = useCanvasStore((s) => s.floorMap)
|
||||
const updateFloorMap = useCanvasStore((s) => s.updateFloorMap)
|
||||
|
||||
const resizeRef = useRef<ResizeState | null>(null)
|
||||
|
||||
const onDragStart = useCallback((e: React.MouseEvent) => {
|
||||
if (!floorMap) return
|
||||
e.stopPropagation()
|
||||
const startX = e.clientX
|
||||
const startY = e.clientY
|
||||
const origPosX = floorMap.posX
|
||||
const origPosY = floorMap.posY
|
||||
|
||||
const onMove = (ev: MouseEvent) => {
|
||||
const start = screenToFlowPosition({ x: startX, y: startY })
|
||||
const cur = screenToFlowPosition({ x: ev.clientX, y: ev.clientY })
|
||||
updateFloorMap({ posX: origPosX + (cur.x - start.x), posY: origPosY + (cur.y - start.y) })
|
||||
}
|
||||
const onUp = () => {
|
||||
window.removeEventListener('mousemove', onMove)
|
||||
window.removeEventListener('mouseup', onUp)
|
||||
}
|
||||
window.addEventListener('mousemove', onMove)
|
||||
window.addEventListener('mouseup', onUp)
|
||||
}, [floorMap, updateFloorMap, screenToFlowPosition])
|
||||
|
||||
const onResizeStart = useCallback((e: React.MouseEvent, edges: Set<'n' | 's' | 'e' | 'w'>) => {
|
||||
if (!floorMap) return
|
||||
e.stopPropagation()
|
||||
resizeRef.current = {
|
||||
startMouseX: e.clientX,
|
||||
startMouseY: e.clientY,
|
||||
startX: floorMap.posX,
|
||||
startY: floorMap.posY,
|
||||
startW: floorMap.width,
|
||||
startH: floorMap.height,
|
||||
edges,
|
||||
}
|
||||
|
||||
const onMove = (ev: MouseEvent) => {
|
||||
const rs = resizeRef.current
|
||||
if (!rs) return
|
||||
const start = screenToFlowPosition({ x: rs.startMouseX, y: rs.startMouseY })
|
||||
const cur = screenToFlowPosition({ x: ev.clientX, y: ev.clientY })
|
||||
const dx = cur.x - start.x
|
||||
const dy = cur.y - start.y
|
||||
let x = rs.startX, y = rs.startY, w = rs.startW, h = rs.startH
|
||||
if (rs.edges.has('w')) { x += dx; w -= dx }
|
||||
if (rs.edges.has('e')) w += dx
|
||||
if (rs.edges.has('n')) { y += dy; h -= dy }
|
||||
if (rs.edges.has('s')) h += dy
|
||||
const MIN = 80
|
||||
if (w < MIN) {
|
||||
if (rs.edges.has('w')) x = rs.startX + rs.startW - MIN
|
||||
w = MIN
|
||||
}
|
||||
if (h < MIN) {
|
||||
if (rs.edges.has('n')) y = rs.startY + rs.startH - MIN
|
||||
h = MIN
|
||||
}
|
||||
updateFloorMap({ posX: x, posY: y, width: w, height: h })
|
||||
}
|
||||
const onUp = () => {
|
||||
resizeRef.current = null
|
||||
window.removeEventListener('mousemove', onMove)
|
||||
window.removeEventListener('mouseup', onUp)
|
||||
}
|
||||
window.addEventListener('mousemove', onMove)
|
||||
window.addEventListener('mouseup', onUp)
|
||||
}, [floorMap, updateFloorMap, screenToFlowPosition])
|
||||
|
||||
if (!floorMap || !floorMap.enabled) return null
|
||||
|
||||
const { imageData, posX, posY, width, height, opacity, locked } = floorMap
|
||||
|
||||
const hs: React.CSSProperties = {
|
||||
position: 'absolute',
|
||||
width: 10,
|
||||
height: 10,
|
||||
background: '#00d4ff',
|
||||
border: '2px solid #0d1117',
|
||||
borderRadius: 2,
|
||||
zIndex: 10,
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: posX,
|
||||
top: posY,
|
||||
width,
|
||||
height,
|
||||
opacity,
|
||||
zIndex: -1,
|
||||
pointerEvents: locked ? 'none' : 'auto',
|
||||
cursor: locked ? 'default' : 'move',
|
||||
}}
|
||||
onMouseDown={locked ? undefined : onDragStart}
|
||||
>
|
||||
<img
|
||||
src={imageData}
|
||||
alt="Floor plan"
|
||||
draggable={false}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'fill',
|
||||
pointerEvents: 'none',
|
||||
userSelect: 'none',
|
||||
}}
|
||||
/>
|
||||
{!locked && (
|
||||
<>
|
||||
<div style={{ ...hs, cursor: 'nw-resize', top: -5, left: -5 }} onMouseDown={(e) => onResizeStart(e, new Set(['n','w']))} />
|
||||
<div style={{ ...hs, cursor: 'n-resize', top: -5, left: '50%', marginLeft: -5 }} onMouseDown={(e) => onResizeStart(e, new Set(['n']))} />
|
||||
<div style={{ ...hs, cursor: 'ne-resize', top: -5, right: -5 }} onMouseDown={(e) => onResizeStart(e, new Set(['n','e']))} />
|
||||
<div style={{ ...hs, cursor: 'e-resize', top: '50%', marginTop: -5, right: -5 }} onMouseDown={(e) => onResizeStart(e, new Set(['e']))} />
|
||||
<div style={{ ...hs, cursor: 'se-resize', bottom: -5, right: -5 }} onMouseDown={(e) => onResizeStart(e, new Set(['s','e']))} />
|
||||
<div style={{ ...hs, cursor: 's-resize', bottom: -5, left: '50%', marginLeft: -5 }} onMouseDown={(e) => onResizeStart(e, new Set(['s']))} />
|
||||
<div style={{ ...hs, cursor: 'sw-resize', bottom: -5, left: -5 }} onMouseDown={(e) => onResizeStart(e, new Set(['s','w']))} />
|
||||
<div style={{ ...hs, cursor: 'w-resize', top: '50%', marginTop: -5, left: -5 }} onMouseDown={(e) => onResizeStart(e, new Set(['w']))} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
type EdgeProps,
|
||||
type Edge,
|
||||
} from '@xyflow/react'
|
||||
import type { EdgeData, EdgeType, Waypoint } from '@/types'
|
||||
import type { EdgeData, EdgeType, NodeData, Waypoint } from '@/types'
|
||||
import { useThemeStore } from '@/stores/themeStore'
|
||||
import { useCanvasStore } from '@/stores/canvasStore'
|
||||
import { THEMES } from '@/utils/themes'
|
||||
@@ -22,6 +22,13 @@ function getVlanColor(vlanId?: number): string {
|
||||
return VLAN_COLORS[vlanId % VLAN_COLORS.length]
|
||||
}
|
||||
|
||||
function getLqiColor(lqi: number): string {
|
||||
if (lqi >= 200) return '#00cc00'
|
||||
if (lqi >= 150) return '#88cc00'
|
||||
if (lqi >= 100) return '#ffaa00'
|
||||
return '#ff3300'
|
||||
}
|
||||
|
||||
// ── Waypoint drag handle ─────────────────────────────────────────────────────
|
||||
|
||||
interface WaypointHandleProps {
|
||||
@@ -289,7 +296,8 @@ export function HomelableEdge({ id, source, target, sourceHandleId, targetHandle
|
||||
const activeTheme = useThemeStore((s) => s.activeTheme)
|
||||
const theme = THEMES[activeTheme]
|
||||
const sourceType = useStore((s) => s.nodeLookup.get(source)?.type)
|
||||
const targetType = useStore((s) => s.nodeLookup.get(target)?.type)
|
||||
const targetNode = useStore((s) => s.nodeLookup.get(target))
|
||||
const targetType = targetNode?.type
|
||||
const isBidirectional = sourceType === 'proxmox' && targetType === 'proxmox'
|
||||
|
||||
const waypoints: Waypoint[] = Array.isArray(data?.waypoints) && data.waypoints.length > 0
|
||||
@@ -315,6 +323,8 @@ export function HomelableEdge({ id, source, target, sourceHandleId, targetHandle
|
||||
|
||||
const edgeType: EdgeType = data?.type ?? 'ethernet'
|
||||
const edgeColors = theme.colors.edgeColors
|
||||
const highlightedPath = useCanvasStore((s) => s.highlightedPath)
|
||||
const isHighlighted = highlightedPath.includes(id)
|
||||
|
||||
const BASE_STYLES: Record<EdgeType, React.CSSProperties> = {
|
||||
ethernet: { stroke: edgeColors.ethernet, strokeWidth: 2 },
|
||||
@@ -328,15 +338,36 @@ export function HomelableEdge({ id, source, target, sourceHandleId, targetHandle
|
||||
}
|
||||
|
||||
const customColor = data?.custom_color as string | undefined
|
||||
const strokeColor: string = selected
|
||||
|
||||
// Derive LQI color from the target node's properties for iot edges
|
||||
const lqiColor = (edgeType === 'iot' && targetNode?.data?.properties)
|
||||
? (() => {
|
||||
const props = (targetNode.data as NodeData).properties ?? []
|
||||
const lqiProp = props.find(
|
||||
(p: { key: string; value: string }) => p.key === 'LQI'
|
||||
)
|
||||
if (lqiProp) {
|
||||
const lqi = parseInt(lqiProp.value, 10)
|
||||
if (!isNaN(lqi)) return getLqiColor(lqi)
|
||||
}
|
||||
return null
|
||||
})()
|
||||
: null
|
||||
|
||||
const pathHighlightColor = '#00d4ff'
|
||||
const strokeColor: string = isHighlighted
|
||||
? pathHighlightColor
|
||||
: selected
|
||||
? theme.colors.edgeSelectedColor
|
||||
: customColor
|
||||
: lqiColor
|
||||
?? customColor
|
||||
?? (edgeType === 'vlan' ? getVlanColor(data?.vlan_id as number | undefined) : (BASE_STYLES[edgeType].stroke as string ?? edgeColors.ethernet))
|
||||
|
||||
const style: React.CSSProperties = {
|
||||
...BASE_STYLES[edgeType],
|
||||
...(edgeType === 'vlan' ? { stroke: getVlanColor(data?.vlan_id as number | undefined) } : {}),
|
||||
...(customColor ? { stroke: customColor } : {}),
|
||||
...(isHighlighted ? { stroke: pathHighlightColor, strokeWidth: 3, filter: `drop-shadow(0 0 6px ${pathHighlightColor}aa)` } : {}),
|
||||
...(selected ? { stroke: theme.colors.edgeSelectedColor, filter: `drop-shadow(0 0 4px ${theme.colors.edgeSelectedColor}88)` } : {}),
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
import { useState, useRef } from 'react'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import type { FloorMapConfig } from '@/types'
|
||||
|
||||
interface FloorMapModalProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
onSubmit: (config: FloorMapConfig) => void
|
||||
onRemove: () => void
|
||||
initial: FloorMapConfig | null
|
||||
}
|
||||
|
||||
export function FloorMapModal({ open, onClose, onSubmit, onRemove, initial }: FloorMapModalProps) {
|
||||
const [imageData, setImageData] = useState(initial?.imageData ?? '')
|
||||
const [width, setWidth] = useState(initial?.width ?? 800)
|
||||
const [height, setHeight] = useState(initial?.height ?? 600)
|
||||
const [opacity, setOpacity] = useState(initial?.opacity ?? 0.8)
|
||||
const [locked, setLocked] = useState(initial?.locked ?? false)
|
||||
const [enabled, setEnabled] = useState(initial?.enabled ?? true)
|
||||
const fileRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const handleFile = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
const reader = new FileReader()
|
||||
reader.onload = (ev) => {
|
||||
const dataUrl = ev.target?.result as string
|
||||
setImageData(dataUrl)
|
||||
const img = new Image()
|
||||
img.onload = () => {
|
||||
setWidth(img.naturalWidth)
|
||||
setHeight(img.naturalHeight)
|
||||
}
|
||||
img.src = dataUrl
|
||||
}
|
||||
reader.readAsDataURL(file)
|
||||
}
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!imageData) return
|
||||
onSubmit({
|
||||
imageData,
|
||||
posX: initial?.posX ?? 0,
|
||||
posY: initial?.posY ?? 0,
|
||||
width,
|
||||
height,
|
||||
opacity,
|
||||
locked,
|
||||
enabled,
|
||||
})
|
||||
}
|
||||
|
||||
const hasImage = !!imageData
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(o) => { if (!o) onClose() }}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{initial ? 'Edit Floor Plan' : 'Import Floor Plan'}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col gap-4 py-2">
|
||||
{!hasImage ? (
|
||||
<div
|
||||
className="flex flex-col items-center justify-center gap-2 border-2 border-dashed border-[#30363d] rounded-lg p-8 cursor-pointer hover:border-[#00d4ff]/50 transition-colors"
|
||||
onClick={() => fileRef.current?.click()}
|
||||
>
|
||||
<input ref={fileRef} type="file" accept="image/png,image/jpeg,image/webp" className="hidden" onChange={handleFile} />
|
||||
<span className="text-muted-foreground text-sm">Click to select a floor plan image</span>
|
||||
<span className="text-muted-foreground/50 text-xs">PNG, JPEG or WebP</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="relative rounded-lg overflow-hidden border border-[#30363d]" style={{ maxHeight: 200 }}>
|
||||
<img src={imageData} alt="Floor plan preview" className="w-full h-full object-contain" style={{ opacity }} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" variant="secondary" className="cursor-pointer" onClick={() => fileRef.current?.click()}>
|
||||
Replace Image
|
||||
</Button>
|
||||
<input ref={fileRef} type="file" accept="image/png,image/jpeg,image.webp" className="hidden" onChange={handleFile} />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label className="text-xs text-muted-foreground">Width (px)</Label>
|
||||
<Input type="number" value={width} onChange={(e) => setWidth(Math.max(80, Number(e.target.value)))} className="bg-[#21262d] border-[#30363d] text-xs h-8" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label className="text-xs text-muted-foreground">Height (px)</Label>
|
||||
<Input type="number" value={height} onChange={(e) => setHeight(Math.max(80, Number(e.target.value)))} className="bg-[#21262d] border-[#30363d] text-xs h-8" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label className="text-xs text-muted-foreground">Opacity: {Math.round(opacity * 100)}%</Label>
|
||||
<input
|
||||
type="range" min="0.05" max="1" step="0.05" value={opacity}
|
||||
onChange={(e) => setOpacity(Number(e.target.value))}
|
||||
className="w-full accent-[#00d4ff]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-6">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" checked={locked} onChange={(e) => setLocked(e.target.checked)} className="accent-[#00d4ff] w-3.5 h-3.5" />
|
||||
<span className="text-xs text-muted-foreground">Lock position & size</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" checked={enabled} onChange={(e) => setEnabled(e.target.checked)} className="accent-[#00d4ff] w-3.5 h-3.5" />
|
||||
<span className="text-xs text-muted-foreground">Show on canvas</span>
|
||||
</label>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="flex items-center justify-between sm:justify-between">
|
||||
{hasImage && initial && (
|
||||
<Button size="sm" variant="destructive" className="cursor-pointer" onClick={onRemove}>
|
||||
Remove
|
||||
</Button>
|
||||
)}
|
||||
<div className="flex gap-2 ml-auto">
|
||||
<Button size="sm" variant="ghost" className="cursor-pointer" onClick={onClose}>Cancel</Button>
|
||||
<Button size="sm" className="bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90 cursor-pointer" disabled={!hasImage} onClick={handleSubmit}>
|
||||
{initial ? 'Apply' : 'Import'}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createElement, useRef, useState } from 'react'
|
||||
import { createElement, useEffect, useRef, useState } from 'react'
|
||||
import { X, Edit, Trash2, ExternalLink, Plus, Pencil, Layers, Ungroup, Eye, EyeOff } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
@@ -9,6 +9,8 @@ import { getServiceUrl } from '@/utils/serviceUrl'
|
||||
import { splitIps } from '@/utils/maskIp'
|
||||
import { PROPERTY_ICONS, PROPERTY_ICON_NAMES, resolvePropertyIcon } from '@/utils/propertyIcons'
|
||||
import { formatTimestamp } from '@/utils/timeFormat'
|
||||
import { findZigbeePath } from '@/utils/zigbeePathfinding'
|
||||
import { isZigbeeType } from '@/utils/zigbeeProperties'
|
||||
import type { Node } from '@xyflow/react'
|
||||
|
||||
interface DetailPanelProps {
|
||||
@@ -22,7 +24,7 @@ type PropForm = { key: string; value: string; icon: string | null; visible: bool
|
||||
const EMPTY_PROP: PropForm = { key: '', value: '', icon: null, visible: true }
|
||||
|
||||
export function DetailPanel({ onEdit }: DetailPanelProps) {
|
||||
const { nodes, selectedNodeId, selectedNodeIds, setSelectedNode, deleteNode, updateNode, snapshotHistory, createGroup, ungroup, removeFromGroup, setNodeSize } = useCanvasStore()
|
||||
const { nodes, edges, selectedNodeId, selectedNodeIds, setSelectedNode, deleteNode, updateNode, snapshotHistory, createGroup, ungroup, removeFromGroup, setNodeSize, setHighlightedPath } = useCanvasStore()
|
||||
const serviceStatuses = useCanvasStore((s) => s.serviceStatuses)
|
||||
|
||||
const [addingForNode, setAddingForNode] = useState<string | null>(null)
|
||||
@@ -41,6 +43,25 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
|
||||
// Multi-select panel
|
||||
const multiSelected = (selectedNodeIds ?? []).filter((id) => nodes.some((n) => n.id === id))
|
||||
|
||||
// Zigbee path highlighting: when a zigbee node is selected, compute
|
||||
// the optimal route to the coordinator and highlight those edges.
|
||||
useEffect(() => {
|
||||
const targetNode = nodes.find((n) => n.id === selectedNodeId)
|
||||
if (!targetNode || !isZigbeeType(targetNode.data.type)) {
|
||||
setHighlightedPath([])
|
||||
return
|
||||
}
|
||||
|
||||
const coordinator = nodes.find((n) => n.data.type === 'zigbee_coordinator')
|
||||
if (!coordinator) {
|
||||
setHighlightedPath([])
|
||||
return
|
||||
}
|
||||
|
||||
const path = findZigbeePath(targetNode.id, coordinator.id, nodes, edges)
|
||||
setHighlightedPath(path)
|
||||
}, [selectedNodeId, nodes, edges, setHighlightedPath])
|
||||
|
||||
if (multiSelected.length > 1) {
|
||||
return (
|
||||
<MultiSelectPanel
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useCallback } from 'react'
|
||||
import { Plus, Save, ScanLine, ChevronLeft, ChevronRight, LayoutDashboard, Clock, EyeOff, Square, Settings, LogOut, Network, RadioTower, Type, PlusCircle, Pencil, Trash2 } from 'lucide-react'
|
||||
import { Plus, Save, ScanLine, ChevronLeft, ChevronRight, LayoutDashboard, Clock, EyeOff, Square, Settings, LogOut, Network, RadioTower, Type, PlusCircle, Pencil, Trash2, Image } from 'lucide-react'
|
||||
import { Logo } from '@/components/ui/Logo'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { useCanvasStore } from '@/stores/canvasStore'
|
||||
@@ -27,13 +27,14 @@ interface SidebarProps {
|
||||
onScan: () => void
|
||||
onZigbeeImport: () => void
|
||||
onZwaveImport: () => void
|
||||
onFloorMap: () => void
|
||||
onSave: () => void
|
||||
onOpenSettings: () => void
|
||||
onOpenHistory: () => void
|
||||
onOpenPending: (deviceId?: string, status?: 'pending' | 'hidden') => void
|
||||
}
|
||||
|
||||
export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbeeImport, onZwaveImport, onSave, onOpenSettings, onOpenHistory, onOpenPending }: SidebarProps) {
|
||||
export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbeeImport, onZwaveImport, onFloorMap, onSave, onOpenSettings, onOpenHistory, onOpenPending }: SidebarProps) {
|
||||
const [collapsed, setCollapsed] = useState(false)
|
||||
const logout = useAuthStore((s) => s.logout)
|
||||
const { designs, activeDesignId, setActiveDesign, addDesign, updateDesign, removeDesign } = useDesignStore()
|
||||
@@ -76,7 +77,7 @@ export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbee
|
||||
}
|
||||
}, [designs.length, removeDesign])
|
||||
|
||||
const { nodes, hasUnsavedChanges } = useCanvasStore()
|
||||
const { nodes, hasUnsavedChanges, floorMap } = useCanvasStore()
|
||||
|
||||
const networkNodes = nodes.filter((n) => n.data.type !== 'groupRect' && n.data.type !== 'text')
|
||||
const onlineCount = networkNodes.filter((n) => n.data.status === 'online').length
|
||||
@@ -228,6 +229,7 @@ export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbee
|
||||
{!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={RadioTower} label="Z-Wave Import" collapsed={collapsed} onClick={onZwaveImport} />}
|
||||
<SidebarItem icon={Image} label={floorMap ? 'Edit Floor Plan' : 'Add Floor Plan'} collapsed={collapsed} onClick={onFloorMap} />
|
||||
<SidebarItem
|
||||
icon={Save}
|
||||
label="Save Canvas"
|
||||
|
||||
@@ -37,6 +37,8 @@ function setupStore(nodeData: Partial<NodeData> = {}, serviceStatuses: Record<st
|
||||
createGroup: vi.fn(),
|
||||
ungroup: vi.fn(),
|
||||
setNodeSize: vi.fn(),
|
||||
setHighlightedPath: vi.fn(),
|
||||
edges: [],
|
||||
serviceStatuses,
|
||||
}
|
||||
// Support both the bare destructure call and the selector-based call.
|
||||
@@ -57,6 +59,8 @@ describe('DetailPanel', () => {
|
||||
snapshotHistory: vi.fn(),
|
||||
createGroup: vi.fn(),
|
||||
ungroup: vi.fn(),
|
||||
setHighlightedPath: vi.fn(),
|
||||
edges: [],
|
||||
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
|
||||
})
|
||||
|
||||
@@ -130,6 +134,8 @@ describe('DetailPanel', () => {
|
||||
snapshotHistory: vi.fn(),
|
||||
createGroup: vi.fn(),
|
||||
ungroup: vi.fn(),
|
||||
setHighlightedPath: vi.fn(),
|
||||
edges: [],
|
||||
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
|
||||
|
||||
render(<DetailPanel onEdit={vi.fn()} />)
|
||||
@@ -157,6 +163,8 @@ describe('DetailPanel', () => {
|
||||
snapshotHistory: vi.fn(),
|
||||
createGroup: vi.fn(),
|
||||
ungroup: vi.fn(),
|
||||
setHighlightedPath: vi.fn(),
|
||||
edges: [],
|
||||
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
|
||||
|
||||
render(<DetailPanel onEdit={vi.fn()} />)
|
||||
@@ -178,6 +186,8 @@ describe('DetailPanel', () => {
|
||||
snapshotHistory: vi.fn(),
|
||||
createGroup: vi.fn(),
|
||||
ungroup: vi.fn(),
|
||||
setHighlightedPath: vi.fn(),
|
||||
edges: [],
|
||||
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
|
||||
|
||||
render(<DetailPanel onEdit={vi.fn()} />)
|
||||
@@ -199,6 +209,8 @@ describe('DetailPanel', () => {
|
||||
snapshotHistory: vi.fn(),
|
||||
createGroup: vi.fn(),
|
||||
ungroup: vi.fn(),
|
||||
setHighlightedPath: vi.fn(),
|
||||
edges: [],
|
||||
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
|
||||
|
||||
render(<DetailPanel onEdit={vi.fn()} />)
|
||||
@@ -253,6 +265,8 @@ describe('DetailPanel', () => {
|
||||
deleteNode: vi.fn(),
|
||||
updateNode: vi.fn(),
|
||||
snapshotHistory: vi.fn(),
|
||||
setHighlightedPath: vi.fn(),
|
||||
edges: [],
|
||||
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
|
||||
render(<DetailPanel onEdit={vi.fn()} />)
|
||||
fireEvent.click(screen.getByLabelText('Close panel'))
|
||||
@@ -277,6 +291,8 @@ describe('DetailPanel', () => {
|
||||
deleteNode,
|
||||
updateNode: vi.fn(),
|
||||
snapshotHistory,
|
||||
setHighlightedPath: vi.fn(),
|
||||
edges: [],
|
||||
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(true)
|
||||
render(<DetailPanel onEdit={vi.fn()} />)
|
||||
@@ -295,6 +311,8 @@ describe('DetailPanel', () => {
|
||||
deleteNode,
|
||||
updateNode: vi.fn(),
|
||||
snapshotHistory,
|
||||
setHighlightedPath: vi.fn(),
|
||||
edges: [],
|
||||
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(false)
|
||||
render(<DetailPanel onEdit={vi.fn()} />)
|
||||
@@ -326,6 +344,8 @@ describe('DetailPanel', () => {
|
||||
snapshotHistory: vi.fn(),
|
||||
createGroup: vi.fn(),
|
||||
ungroup: vi.fn(),
|
||||
setHighlightedPath: vi.fn(),
|
||||
edges: [],
|
||||
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
|
||||
render(<DetailPanel onEdit={vi.fn()} />)
|
||||
// Two "Add" header buttons: first = properties, second = services
|
||||
@@ -351,6 +371,8 @@ describe('DetailPanel', () => {
|
||||
snapshotHistory: vi.fn(),
|
||||
createGroup: vi.fn(),
|
||||
ungroup: vi.fn(),
|
||||
setHighlightedPath: vi.fn(),
|
||||
edges: [],
|
||||
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
|
||||
render(<DetailPanel onEdit={vi.fn()} />)
|
||||
const addHeaders = screen.getAllByText('Add')
|
||||
@@ -374,6 +396,8 @@ describe('DetailPanel', () => {
|
||||
deleteNode: vi.fn(),
|
||||
updateNode,
|
||||
snapshotHistory: vi.fn(),
|
||||
setHighlightedPath: vi.fn(),
|
||||
edges: [],
|
||||
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
|
||||
render(<DetailPanel onEdit={vi.fn()} />)
|
||||
fireEvent.click(screen.getByTitle('Remove service'))
|
||||
@@ -389,6 +413,8 @@ describe('DetailPanel', () => {
|
||||
deleteNode: vi.fn(),
|
||||
updateNode: vi.fn(),
|
||||
snapshotHistory: vi.fn(),
|
||||
setHighlightedPath: vi.fn(),
|
||||
edges: [],
|
||||
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
|
||||
expect(() => render(<DetailPanel onEdit={vi.fn()} />)).not.toThrow()
|
||||
})
|
||||
@@ -420,6 +446,8 @@ describe('DetailPanel', () => {
|
||||
deleteNode: vi.fn(),
|
||||
updateNode,
|
||||
snapshotHistory: vi.fn(),
|
||||
setHighlightedPath: vi.fn(),
|
||||
edges: [],
|
||||
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
|
||||
|
||||
render(<DetailPanel onEdit={vi.fn()} />)
|
||||
@@ -445,6 +473,8 @@ describe('DetailPanel', () => {
|
||||
deleteNode: vi.fn(),
|
||||
updateNode,
|
||||
snapshotHistory: vi.fn(),
|
||||
setHighlightedPath: vi.fn(),
|
||||
edges: [],
|
||||
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
|
||||
|
||||
render(<DetailPanel onEdit={vi.fn()} />)
|
||||
@@ -608,6 +638,7 @@ describe('DetailPanel', () => {
|
||||
function setupSized(node: Partial<Node<NodeData>>, setNodeSize = vi.fn()) {
|
||||
const state = {
|
||||
nodes: [{ ...makeNode({}), ...node }],
|
||||
edges: [],
|
||||
selectedNodeId: 'n1',
|
||||
selectedNodeIds: [],
|
||||
setSelectedNode: vi.fn(),
|
||||
@@ -617,6 +648,7 @@ describe('DetailPanel', () => {
|
||||
createGroup: vi.fn(),
|
||||
ungroup: vi.fn(),
|
||||
setNodeSize,
|
||||
setHighlightedPath: vi.fn(),
|
||||
serviceStatuses: {},
|
||||
}
|
||||
vi.mocked(canvasStore.useCanvasStore).mockImplementation(
|
||||
|
||||
@@ -34,6 +34,7 @@ function makeGroupNode(id = 'g1', label = 'My Group', showBorder = true) {
|
||||
|
||||
const mockStore = {
|
||||
nodes: [],
|
||||
edges: [],
|
||||
selectedNodeId: null,
|
||||
selectedNodeIds: [],
|
||||
setSelectedNode: vi.fn(),
|
||||
@@ -43,6 +44,7 @@ const mockStore = {
|
||||
createGroup: vi.fn(),
|
||||
ungroup: vi.fn(),
|
||||
removeFromGroup: vi.fn(),
|
||||
setHighlightedPath: vi.fn(),
|
||||
}
|
||||
|
||||
function setupStore(overrides = {}) {
|
||||
|
||||
Reference in New Issue
Block a user