feat: floor plan map, LQI edge coloring, zigbee path highlighting, eslint/test fixes, vite audit fix
This commit is contained in:
@@ -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