feat: add Group Rectangle — resizable decorative zones on canvas
- New GroupRectNode: rounded rect with NodeResizer (8 handles), no connection handles, always behind network nodes via negative zIndex - GroupRectModal: label, font preset (Inter/Mono/Serif), 3×3 text position grid, text/border/background color pickers, z-order 1–9 - Sidebar: "Add Rectangle" button (below Add Node), group rects excluded from node count stats - Save/load: size persisted in custom_colors.width/height, no backend schema changes required - elevateNodesOnSelect=false on ReactFlow so selected rects never pop above network nodes - Tests: 3 new store tests + 9 GroupRectModal tests (66 total, all pass)
This commit is contained in:
+150
-21
@@ -14,6 +14,7 @@ import { LoginPage } from '@/components/LoginPage'
|
||||
import { NodeModal } from '@/components/modals/NodeModal'
|
||||
import { EdgeModal } from '@/components/modals/EdgeModal'
|
||||
import { ScanConfigModal } from '@/components/modals/ScanConfigModal'
|
||||
import { GroupRectModal, type GroupRectFormData } from '@/components/modals/GroupRectModal'
|
||||
import { useCanvasStore } from '@/stores/canvasStore'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
import { canvasApi } from '@/api/client'
|
||||
@@ -25,13 +26,14 @@ const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
|
||||
const STANDALONE_STORAGE_KEY = 'homelable_canvas'
|
||||
|
||||
export default function App() {
|
||||
const { loadCanvas, markSaved, selectedNodeId, addNode, updateNode, onConnect, updateEdge, deleteEdge, setProxmoxContainerMode, nodes, edges } = useCanvasStore()
|
||||
const { loadCanvas, markSaved, selectedNodeId, addNode, updateNode, deleteNode, onConnect, updateEdge, deleteEdge, setProxmoxContainerMode, setNodeZIndex, editingGroupRectId, setEditingGroupRectId, nodes, edges } = useCanvasStore()
|
||||
const canvasRef = useRef<HTMLDivElement>(null)
|
||||
const { isAuthenticated } = useAuthStore()
|
||||
|
||||
useStatusPolling()
|
||||
|
||||
const [addNodeOpen, setAddNodeOpen] = useState(false)
|
||||
const [addGroupRectOpen, setAddGroupRectOpen] = useState(false)
|
||||
const [editNodeId, setEditNodeId] = useState<string | null>(null)
|
||||
const [pendingConnection, setPendingConnection] = useState<Connection | null>(null)
|
||||
const [editEdgeId, setEditEdgeId] = useState<string | null>(null)
|
||||
@@ -46,26 +48,55 @@ export default function App() {
|
||||
toast.success('Canvas saved')
|
||||
return
|
||||
}
|
||||
const nodesToSave = nodes.map((n) => ({
|
||||
id: n.id,
|
||||
type: n.data.type,
|
||||
label: n.data.label,
|
||||
hostname: n.data.hostname ?? null,
|
||||
ip: n.data.ip ?? null,
|
||||
mac: n.data.mac ?? null,
|
||||
os: n.data.os ?? null,
|
||||
status: n.data.status,
|
||||
check_method: n.data.check_method ?? null,
|
||||
check_target: n.data.check_target ?? null,
|
||||
services: n.data.services ?? [],
|
||||
notes: n.data.notes ?? null,
|
||||
parent_id: n.data.parent_id ?? null,
|
||||
container_mode: n.data.container_mode ?? false,
|
||||
custom_colors: n.data.custom_colors ?? null,
|
||||
custom_icon: n.data.custom_icon ?? null,
|
||||
pos_x: n.position.x,
|
||||
pos_y: n.position.y,
|
||||
}))
|
||||
const nodesToSave = nodes.map((n) => {
|
||||
if (n.data.type === 'groupRect') {
|
||||
return {
|
||||
id: n.id,
|
||||
type: 'groupRect',
|
||||
label: n.data.label,
|
||||
hostname: null,
|
||||
ip: null,
|
||||
mac: null,
|
||||
os: null,
|
||||
status: 'unknown',
|
||||
check_method: null,
|
||||
check_target: null,
|
||||
services: [],
|
||||
notes: null,
|
||||
parent_id: null,
|
||||
container_mode: false,
|
||||
custom_icon: null,
|
||||
pos_x: n.position.x,
|
||||
pos_y: n.position.y,
|
||||
// Persist size and all rect config inside custom_colors
|
||||
custom_colors: {
|
||||
...n.data.custom_colors,
|
||||
width: n.measured?.width ?? n.width ?? 360,
|
||||
height: n.measured?.height ?? n.height ?? 240,
|
||||
},
|
||||
}
|
||||
}
|
||||
return {
|
||||
id: n.id,
|
||||
type: n.data.type,
|
||||
label: n.data.label,
|
||||
hostname: n.data.hostname ?? null,
|
||||
ip: n.data.ip ?? null,
|
||||
mac: n.data.mac ?? null,
|
||||
os: n.data.os ?? null,
|
||||
status: n.data.status,
|
||||
check_method: n.data.check_method ?? null,
|
||||
check_target: n.data.check_target ?? null,
|
||||
services: n.data.services ?? [],
|
||||
notes: n.data.notes ?? null,
|
||||
parent_id: n.data.parent_id ?? null,
|
||||
container_mode: n.data.container_mode ?? false,
|
||||
custom_colors: n.data.custom_colors ?? null,
|
||||
custom_icon: n.data.custom_icon ?? null,
|
||||
pos_x: n.position.x,
|
||||
pos_y: n.position.y,
|
||||
}
|
||||
})
|
||||
const edgesToSave = edges.map((e) => ({
|
||||
id: e.id,
|
||||
source: e.source,
|
||||
@@ -121,6 +152,20 @@ export default function App() {
|
||||
.map((n: NodeData & { id: string }) => [n.id, n.container_mode !== false])
|
||||
)
|
||||
const rfNodes = apiNodes.map((n: NodeData & { id: string; pos_x: number; pos_y: number; parent_id?: string }) => {
|
||||
if (n.type === 'groupRect') {
|
||||
const w = n.custom_colors?.width ?? 360
|
||||
const h = n.custom_colors?.height ?? 240
|
||||
const z = n.custom_colors?.z_order ?? 1
|
||||
return {
|
||||
id: n.id,
|
||||
type: 'groupRect',
|
||||
position: { x: n.pos_x, y: n.pos_y },
|
||||
data: n,
|
||||
width: w,
|
||||
height: h,
|
||||
zIndex: z - 10,
|
||||
}
|
||||
}
|
||||
const parentIsContainer = n.parent_id ? (proxmoxContainerMap.get(n.parent_id) ?? false) : false
|
||||
return {
|
||||
id: n.id,
|
||||
@@ -181,6 +226,58 @@ export default function App() {
|
||||
toast.success(`Added "${data.label}"`)
|
||||
}, [addNode, nodes])
|
||||
|
||||
const handleAddGroupRect = useCallback((data: GroupRectFormData) => {
|
||||
const id = crypto.randomUUID()
|
||||
const newNode: Node<NodeData> = {
|
||||
id,
|
||||
type: 'groupRect',
|
||||
position: { x: 200, y: 200 },
|
||||
data: {
|
||||
label: data.label,
|
||||
type: 'groupRect',
|
||||
status: 'unknown',
|
||||
services: [],
|
||||
custom_colors: {
|
||||
border: data.border_color,
|
||||
background: data.background_color,
|
||||
text_color: data.text_color,
|
||||
text_position: data.text_position,
|
||||
font: data.font,
|
||||
z_order: data.z_order,
|
||||
},
|
||||
},
|
||||
width: 360,
|
||||
height: 240,
|
||||
zIndex: data.z_order - 10,
|
||||
}
|
||||
addNode(newNode)
|
||||
}, [addNode])
|
||||
|
||||
const handleUpdateGroupRect = useCallback((data: GroupRectFormData) => {
|
||||
if (!editingGroupRectId) return
|
||||
const existing = nodes.find((n) => n.id === editingGroupRectId)
|
||||
updateNode(editingGroupRectId, {
|
||||
label: data.label,
|
||||
custom_colors: {
|
||||
...existing?.data.custom_colors,
|
||||
border: data.border_color,
|
||||
background: data.background_color,
|
||||
text_color: data.text_color,
|
||||
text_position: data.text_position,
|
||||
font: data.font,
|
||||
z_order: data.z_order,
|
||||
},
|
||||
})
|
||||
setNodeZIndex(editingGroupRectId, data.z_order - 10)
|
||||
setEditingGroupRectId(null)
|
||||
}, [editingGroupRectId, nodes, updateNode, setNodeZIndex, setEditingGroupRectId])
|
||||
|
||||
const handleDeleteGroupRect = useCallback(() => {
|
||||
if (!editingGroupRectId) return
|
||||
deleteNode(editingGroupRectId)
|
||||
setEditingGroupRectId(null)
|
||||
}, [editingGroupRectId, deleteNode, setEditingGroupRectId])
|
||||
|
||||
const handleEditNode = useCallback((id: string) => {
|
||||
setEditNodeId(id)
|
||||
}, [])
|
||||
@@ -284,6 +381,7 @@ export default function App() {
|
||||
<div className="flex h-screen w-screen overflow-hidden bg-[#0d1117]">
|
||||
<Sidebar
|
||||
onAddNode={() => setAddNodeOpen(true)}
|
||||
onAddGroupRect={() => setAddGroupRectOpen(true)}
|
||||
onScan={() => setScanConfigOpen(true)}
|
||||
onSave={handleSave}
|
||||
onNodeApproved={setEditNodeId}
|
||||
@@ -352,6 +450,37 @@ export default function App() {
|
||||
/>
|
||||
)}
|
||||
|
||||
<GroupRectModal
|
||||
open={addGroupRectOpen}
|
||||
onClose={() => setAddGroupRectOpen(false)}
|
||||
onSubmit={handleAddGroupRect}
|
||||
title="Add Rectangle"
|
||||
/>
|
||||
|
||||
{/* key forces re-mount when editing a different rect */}
|
||||
<GroupRectModal
|
||||
key={editingGroupRectId ?? 'rect-edit'}
|
||||
open={!!editingGroupRectId}
|
||||
onClose={() => setEditingGroupRectId(null)}
|
||||
onSubmit={handleUpdateGroupRect}
|
||||
onDelete={handleDeleteGroupRect}
|
||||
initial={(() => {
|
||||
const n = editingGroupRectId ? nodes.find((nd) => nd.id === editingGroupRectId) : null
|
||||
if (!n) return undefined
|
||||
const rc = n.data.custom_colors ?? {}
|
||||
return {
|
||||
label: n.data.label,
|
||||
font: rc.font ?? 'inter',
|
||||
text_color: rc.text_color ?? '#e6edf3',
|
||||
text_position: rc.text_position ?? 'top-left',
|
||||
border_color: rc.border ?? '#00d4ff',
|
||||
background_color: rc.background ?? '#00d4ff0d',
|
||||
z_order: rc.z_order ?? 1,
|
||||
}
|
||||
})()}
|
||||
title="Edit Rectangle"
|
||||
/>
|
||||
|
||||
<Toaster theme="dark" position="bottom-right" />
|
||||
</ReactFlowProvider>
|
||||
</TooltipProvider>
|
||||
|
||||
@@ -57,6 +57,7 @@ export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick }:
|
||||
snapGrid={[16, 16]}
|
||||
fitView
|
||||
colorMode="dark"
|
||||
elevateNodesOnSelect={false}
|
||||
connectionMode={ConnectionMode.Loose}
|
||||
isValidConnection={(connection) => connection.source !== connection.target}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { NodeResizer, type NodeProps, type Node } from '@xyflow/react'
|
||||
import { useCanvasStore } from '@/stores/canvasStore'
|
||||
import type { NodeData, TextPosition } from '@/types'
|
||||
|
||||
const FONT_FAMILIES: Record<string, string> = {
|
||||
inter: 'Inter, sans-serif',
|
||||
mono: '"JetBrains Mono", monospace',
|
||||
serif: 'Georgia, serif',
|
||||
}
|
||||
|
||||
interface AlignStyle {
|
||||
alignItems: string
|
||||
justifyContent: string
|
||||
textAlign: React.CSSProperties['textAlign']
|
||||
}
|
||||
|
||||
const POSITION_STYLES: Record<TextPosition, AlignStyle> = {
|
||||
'top-left': { alignItems: 'flex-start', justifyContent: 'flex-start', textAlign: 'left' },
|
||||
'top-center': { alignItems: 'flex-start', justifyContent: 'center', textAlign: 'center' },
|
||||
'top-right': { alignItems: 'flex-start', justifyContent: 'flex-end', textAlign: 'right' },
|
||||
'middle-left': { alignItems: 'center', justifyContent: 'flex-start', textAlign: 'left' },
|
||||
'center': { alignItems: 'center', justifyContent: 'center', textAlign: 'center' },
|
||||
'middle-right': { alignItems: 'center', justifyContent: 'flex-end', textAlign: 'right' },
|
||||
'bottom-left': { alignItems: 'flex-end', justifyContent: 'flex-start', textAlign: 'left' },
|
||||
'bottom-center': { alignItems: 'flex-end', justifyContent: 'center', textAlign: 'center' },
|
||||
'bottom-right': { alignItems: 'flex-end', justifyContent: 'flex-end', textAlign: 'right' },
|
||||
}
|
||||
|
||||
export function GroupRectNode({ id, data, selected }: NodeProps<Node<NodeData>>) {
|
||||
const setEditingGroupRectId = useCanvasStore((s) => s.setEditingGroupRectId)
|
||||
|
||||
const rc = data.custom_colors ?? {}
|
||||
const borderColor = rc.border ?? '#00d4ff'
|
||||
const backgroundColor = rc.background ?? 'rgba(0,212,255,0.05)'
|
||||
const textColor = rc.text_color ?? '#e6edf3'
|
||||
const fontFamily = FONT_FAMILIES[rc.font ?? 'inter'] ?? FONT_FAMILIES.inter
|
||||
const textPos = (rc.text_position ?? 'top-left') as TextPosition
|
||||
const posStyle = POSITION_STYLES[textPos]
|
||||
|
||||
return (
|
||||
<>
|
||||
<NodeResizer
|
||||
isVisible={selected}
|
||||
minWidth={80}
|
||||
minHeight={60}
|
||||
handleStyle={{
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: 2,
|
||||
background: '#00d4ff',
|
||||
border: '1px solid #0d1117',
|
||||
}}
|
||||
lineStyle={{ borderColor: '#00d4ff55', borderWidth: 1 }}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
alignItems: posStyle.alignItems,
|
||||
justifyContent: posStyle.justifyContent,
|
||||
padding: 12,
|
||||
background: backgroundColor,
|
||||
border: `${selected ? 2 : 1}px solid ${selected ? '#00d4ff' : borderColor}`,
|
||||
borderRadius: 10,
|
||||
fontFamily,
|
||||
color: textColor,
|
||||
fontSize: 12,
|
||||
fontWeight: 500,
|
||||
boxSizing: 'border-box',
|
||||
cursor: 'default',
|
||||
}}
|
||||
onDoubleClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setEditingGroupRectId(id)
|
||||
}}
|
||||
>
|
||||
{data.label && (
|
||||
<span style={{ textAlign: posStyle.textAlign, userSelect: 'none', whiteSpace: 'pre-wrap' }}>
|
||||
{data.label}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { IspNode, RouterNode, SwitchNode, ServerNode, VmNode, LxcNode, NasNode, IotNode, ApNode, CameraNode, PrinterNode, ComputerNode, CplNode, GenericNode } from './index'
|
||||
import { ProxmoxGroupNode } from './ProxmoxGroupNode'
|
||||
import { GroupRectNode } from './GroupRectNode'
|
||||
|
||||
export const nodeTypes = {
|
||||
isp: IspNode,
|
||||
@@ -17,4 +18,5 @@ export const nodeTypes = {
|
||||
computer: ComputerNode,
|
||||
cpl: CplNode,
|
||||
generic: GenericNode,
|
||||
groupRect: GroupRectNode,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
import { useState } from 'react'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import type { TextPosition } from '@/types'
|
||||
|
||||
export interface GroupRectFormData {
|
||||
label: string
|
||||
font: string
|
||||
text_color: string
|
||||
text_position: TextPosition
|
||||
border_color: string
|
||||
background_color: string
|
||||
z_order: number
|
||||
}
|
||||
|
||||
const DEFAULT_FORM: GroupRectFormData = {
|
||||
label: '',
|
||||
font: 'inter',
|
||||
text_color: '#e6edf3',
|
||||
text_position: 'top-left',
|
||||
border_color: '#00d4ff',
|
||||
background_color: '#00d4ff0d',
|
||||
z_order: 1,
|
||||
}
|
||||
|
||||
const FONTS = [
|
||||
{ value: 'inter', label: 'Inter (sans-serif)' },
|
||||
{ value: 'mono', label: 'JetBrains Mono' },
|
||||
{ value: 'serif', label: 'Serif' },
|
||||
]
|
||||
|
||||
const TEXT_POSITIONS: { value: TextPosition; label: string }[] = [
|
||||
{ value: 'top-left', label: '↖' },
|
||||
{ value: 'top-center', label: '↑' },
|
||||
{ value: 'top-right', label: '↗' },
|
||||
{ value: 'middle-left', label: '←' },
|
||||
{ value: 'center', label: '·' },
|
||||
{ value: 'middle-right', label: '→' },
|
||||
{ value: 'bottom-left', label: '↙' },
|
||||
{ value: 'bottom-center', label: '↓' },
|
||||
{ value: 'bottom-right', label: '↘' },
|
||||
]
|
||||
|
||||
interface GroupRectModalProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
onSubmit: (data: GroupRectFormData) => void
|
||||
onDelete?: () => void
|
||||
initial?: Partial<GroupRectFormData>
|
||||
title?: string
|
||||
}
|
||||
|
||||
export function GroupRectModal({ open, onClose, onSubmit, onDelete, initial, title = 'Add Rectangle' }: GroupRectModalProps) {
|
||||
const [form, setForm] = useState<GroupRectFormData>({ ...DEFAULT_FORM, ...initial })
|
||||
|
||||
const set = <K extends keyof GroupRectFormData>(key: K, value: GroupRectFormData[K]) =>
|
||||
setForm((f) => ({ ...f, [key]: value }))
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
onSubmit(form)
|
||||
onClose()
|
||||
}
|
||||
|
||||
const colorFields = [
|
||||
{ key: 'text_color' as const, label: 'Text' },
|
||||
{ key: 'border_color' as const, label: 'Border' },
|
||||
{ key: 'background_color' as const, label: 'Background' },
|
||||
]
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
|
||||
<DialogContent className="bg-[#161b22] border-[#30363d] text-foreground max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-sm font-semibold">{title}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4 mt-2">
|
||||
{/* Label */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label className="text-xs text-muted-foreground">Label</Label>
|
||||
<Input
|
||||
value={form.label}
|
||||
onChange={(e) => set('label', e.target.value)}
|
||||
placeholder="Zone name…"
|
||||
className="bg-[#21262d] border-[#30363d] text-sm h-8"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Font */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label className="text-xs text-muted-foreground">Font</Label>
|
||||
<Select value={form.font} onValueChange={(v) => set('font', v)}>
|
||||
<SelectTrigger className="bg-[#21262d] border-[#30363d] text-sm h-8">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-[#21262d] border-[#30363d]">
|
||||
{FONTS.map((f) => (
|
||||
<SelectItem key={f.value} value={f.value} className="text-sm">
|
||||
{f.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Text position 3×3 grid */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label className="text-xs text-muted-foreground">Text Position</Label>
|
||||
<div className="grid grid-cols-3 gap-1">
|
||||
{TEXT_POSITIONS.map(({ value, label }) => {
|
||||
const isSelected = form.text_position === value
|
||||
return (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
title={value}
|
||||
onClick={() => set('text_position', value)}
|
||||
className="h-8 rounded text-base transition-colors"
|
||||
style={{
|
||||
background: isSelected ? '#00d4ff22' : '#21262d',
|
||||
border: `1px solid ${isSelected ? '#00d4ff88' : '#30363d'}`,
|
||||
color: isSelected ? '#00d4ff' : '#8b949e',
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Colors */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label className="text-xs text-muted-foreground">Colors</Label>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{colorFields.map(({ key, label }) => (
|
||||
<div key={key} className="flex flex-col gap-1 items-center">
|
||||
<label
|
||||
className="relative w-full h-7 rounded-md border cursor-pointer overflow-hidden"
|
||||
style={{ borderColor: '#30363d' }}
|
||||
>
|
||||
<input
|
||||
type="color"
|
||||
value={form[key]}
|
||||
onChange={(e) => set(key, e.target.value)}
|
||||
className="absolute inset-0 w-full h-full cursor-pointer opacity-0"
|
||||
/>
|
||||
<div className="w-full h-full rounded-sm" style={{ background: form[key] }} />
|
||||
</label>
|
||||
<span className="text-[9px] text-muted-foreground/60">{label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Z-order */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label className="text-xs text-muted-foreground">Z-Order (1 = furthest back)</Label>
|
||||
<Select value={String(form.z_order)} onValueChange={(v) => set('z_order', Number(v))}>
|
||||
<SelectTrigger className="bg-[#21262d] border-[#30363d] text-sm h-8">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-[#21262d] border-[#30363d]">
|
||||
{[1, 2, 3, 4, 5, 6, 7, 8, 9].map((n) => (
|
||||
<SelectItem key={n} value={String(n)} className="text-sm font-mono">
|
||||
{n}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between gap-2 pt-1">
|
||||
{onDelete && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-[#f85149] hover:text-[#f85149] hover:bg-[#f85149]/10"
|
||||
onClick={() => { onDelete(); onClose() }}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
)}
|
||||
<div className="flex gap-2 ml-auto">
|
||||
<Button type="button" variant="ghost" size="sm" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" size="sm" className="bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90">
|
||||
{title === 'Add Rectangle' ? 'Add' : 'Save'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { GroupRectModal, type GroupRectFormData } from '../GroupRectModal'
|
||||
|
||||
describe('GroupRectModal', () => {
|
||||
it('renders nothing when closed', () => {
|
||||
const { container } = render(
|
||||
<GroupRectModal open={false} onClose={vi.fn()} onSubmit={vi.fn()} />
|
||||
)
|
||||
expect(container.querySelector('[role="dialog"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('renders form fields when open', () => {
|
||||
render(<GroupRectModal open onClose={vi.fn()} onSubmit={vi.fn()} />)
|
||||
expect(screen.getByPlaceholderText('Zone name…')).toBeDefined()
|
||||
expect(screen.getByText('Add Rectangle')).toBeDefined()
|
||||
expect(screen.getByText('Text Position')).toBeDefined()
|
||||
expect(screen.getByText('Z-Order (1 = furthest back)')).toBeDefined()
|
||||
})
|
||||
|
||||
it('renders Edit Rectangle title when provided', () => {
|
||||
render(<GroupRectModal open onClose={vi.fn()} onSubmit={vi.fn()} title="Edit Rectangle" />)
|
||||
expect(screen.getByText('Edit Rectangle')).toBeDefined()
|
||||
})
|
||||
|
||||
it('calls onSubmit with form data on submit', () => {
|
||||
const onSubmit = vi.fn()
|
||||
render(<GroupRectModal open onClose={vi.fn()} onSubmit={onSubmit} />)
|
||||
const input = screen.getByPlaceholderText('Zone name…')
|
||||
fireEvent.change(input, { target: { value: 'DMZ' } })
|
||||
fireEvent.click(screen.getByText('Add'))
|
||||
expect(onSubmit).toHaveBeenCalledOnce()
|
||||
const submitted = onSubmit.mock.calls[0][0] as GroupRectFormData
|
||||
expect(submitted.label).toBe('DMZ')
|
||||
expect(submitted.font).toBe('inter')
|
||||
expect(submitted.z_order).toBe(1)
|
||||
})
|
||||
|
||||
it('calls onClose when Cancel is clicked', () => {
|
||||
const onClose = vi.fn()
|
||||
render(<GroupRectModal open onClose={onClose} onSubmit={vi.fn()} />)
|
||||
fireEvent.click(screen.getByText('Cancel'))
|
||||
expect(onClose).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('shows Delete button when onDelete is provided', () => {
|
||||
const onDelete = vi.fn()
|
||||
render(<GroupRectModal open onClose={vi.fn()} onSubmit={vi.fn()} onDelete={onDelete} />)
|
||||
expect(screen.getByText('Delete')).toBeDefined()
|
||||
})
|
||||
|
||||
it('calls onDelete and onClose when Delete is clicked', () => {
|
||||
const onDelete = vi.fn()
|
||||
const onClose = vi.fn()
|
||||
render(<GroupRectModal open onClose={onClose} onSubmit={vi.fn()} onDelete={onDelete} />)
|
||||
fireEvent.click(screen.getByText('Delete'))
|
||||
expect(onDelete).toHaveBeenCalledOnce()
|
||||
expect(onClose).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('pre-fills form from initial prop', () => {
|
||||
render(
|
||||
<GroupRectModal
|
||||
open
|
||||
onClose={vi.fn()}
|
||||
onSubmit={vi.fn()}
|
||||
initial={{ label: 'Pre-filled', z_order: 5, font: 'mono' }}
|
||||
/>
|
||||
)
|
||||
const input = screen.getByPlaceholderText('Zone name…') as HTMLInputElement
|
||||
expect(input.value).toBe('Pre-filled')
|
||||
})
|
||||
|
||||
it('selects text position button', () => {
|
||||
const onSubmit = vi.fn()
|
||||
render(<GroupRectModal open onClose={vi.fn()} onSubmit={onSubmit} />)
|
||||
// Click bottom-right (↘)
|
||||
fireEvent.click(screen.getByTitle('bottom-right'))
|
||||
fireEvent.click(screen.getByText('Add'))
|
||||
const submitted = onSubmit.mock.calls[0][0] as GroupRectFormData
|
||||
expect(submitted.text_position).toBe('bottom-right')
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useCallback, useEffect, useRef } from 'react'
|
||||
import { Network, Plus, Save, ScanLine, ChevronLeft, ChevronRight, LayoutDashboard, Clock, EyeOff, Trash2, RefreshCw, Loader2 } from 'lucide-react'
|
||||
import { Network, Plus, Save, ScanLine, ChevronLeft, ChevronRight, LayoutDashboard, Clock, EyeOff, Trash2, RefreshCw, Loader2, Square } from 'lucide-react'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { useCanvasStore } from '@/stores/canvasStore'
|
||||
import { scanApi } from '@/api/client'
|
||||
@@ -30,18 +30,20 @@ interface ScanRun {
|
||||
|
||||
interface SidebarProps {
|
||||
onAddNode: () => void
|
||||
onAddGroupRect: () => void
|
||||
onScan: () => void
|
||||
onSave: () => void
|
||||
onNodeApproved: (nodeId: string) => void
|
||||
}
|
||||
|
||||
export function Sidebar({ onAddNode, onScan, onSave, onNodeApproved }: SidebarProps) {
|
||||
export function Sidebar({ onAddNode, onAddGroupRect, onScan, onSave, onNodeApproved }: SidebarProps) {
|
||||
const [collapsed, setCollapsed] = useState(false)
|
||||
const [activeView, setActiveView] = useState<SidebarView>('canvas')
|
||||
const { nodes, hasUnsavedChanges } = useCanvasStore()
|
||||
|
||||
const onlineCount = nodes.filter((n) => n.data.status === 'online').length
|
||||
const offlineCount = nodes.filter((n) => n.data.status === 'offline').length
|
||||
const networkNodes = nodes.filter((n) => n.data.type !== 'groupRect')
|
||||
const onlineCount = networkNodes.filter((n) => n.data.status === 'online').length
|
||||
const offlineCount = networkNodes.filter((n) => n.data.status === 'offline').length
|
||||
|
||||
const handleScan = useCallback(async () => {
|
||||
try {
|
||||
@@ -110,7 +112,7 @@ export function Sidebar({ onAddNode, onScan, onSave, onNodeApproved }: SidebarPr
|
||||
<div className="px-3 py-2 border-t border-border text-xs text-muted-foreground space-y-0.5">
|
||||
<div className="flex justify-between">
|
||||
<span>Total</span>
|
||||
<span className="text-foreground font-mono">{nodes.length}</span>
|
||||
<span className="text-foreground font-mono">{networkNodes.length}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-[#39d353]">Online</span>
|
||||
@@ -126,6 +128,7 @@ export function Sidebar({ onAddNode, onScan, onSave, onNodeApproved }: SidebarPr
|
||||
{/* Actions */}
|
||||
<div className="flex flex-col gap-0.5 p-2 border-t border-border">
|
||||
<SidebarItem icon={Plus} label="Add Node" collapsed={collapsed} onClick={onAddNode} />
|
||||
<SidebarItem icon={Square} label="Add Rectangle" collapsed={collapsed} onClick={onAddGroupRect} />
|
||||
{!STANDALONE && <SidebarItem icon={ScanLine} label="Scan Network" collapsed={collapsed} onClick={handleScan} />}
|
||||
<SidebarItem
|
||||
icon={Save}
|
||||
|
||||
@@ -25,6 +25,7 @@ describe('canvasStore', () => {
|
||||
edges: [],
|
||||
hasUnsavedChanges: false,
|
||||
selectedNodeId: null,
|
||||
editingGroupRectId: null,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -190,6 +191,40 @@ describe('canvasStore', () => {
|
||||
expect(updatedChild?.extent).toBeUndefined()
|
||||
})
|
||||
|
||||
it('setEditingGroupRectId sets and clears the editing id', () => {
|
||||
useCanvasStore.getState().setEditingGroupRectId('rect-1')
|
||||
expect(useCanvasStore.getState().editingGroupRectId).toBe('rect-1')
|
||||
useCanvasStore.getState().setEditingGroupRectId(null)
|
||||
expect(useCanvasStore.getState().editingGroupRectId).toBeNull()
|
||||
})
|
||||
|
||||
it('setNodeZIndex updates the node zIndex and marks unsaved', () => {
|
||||
useCanvasStore.getState().addNode(makeNode('n1'))
|
||||
useCanvasStore.getState().markSaved()
|
||||
useCanvasStore.getState().setNodeZIndex('n1', -5)
|
||||
const node = useCanvasStore.getState().nodes.find((n) => n.id === 'n1')
|
||||
expect(node?.zIndex).toBe(-5)
|
||||
expect(useCanvasStore.getState().hasUnsavedChanges).toBe(true)
|
||||
})
|
||||
|
||||
it('addNode with groupRect type preserves zIndex and dimensions', () => {
|
||||
const rectNode: Node<NodeData> = {
|
||||
id: 'rect-1',
|
||||
type: 'groupRect',
|
||||
position: { x: 100, y: 100 },
|
||||
data: { label: 'Zone A', type: 'groupRect', status: 'unknown', services: [] },
|
||||
width: 360,
|
||||
height: 240,
|
||||
zIndex: -9,
|
||||
}
|
||||
useCanvasStore.getState().addNode(rectNode)
|
||||
const stored = useCanvasStore.getState().nodes.find((n) => n.id === 'rect-1')
|
||||
expect(stored?.type).toBe('groupRect')
|
||||
expect(stored?.zIndex).toBe(-9)
|
||||
expect(stored?.width).toBe(360)
|
||||
expect(stored?.height).toBe(240)
|
||||
})
|
||||
|
||||
it('loadCanvas sorts parents before children', () => {
|
||||
const parent = makeNode('p1')
|
||||
const child: Node<NodeData> = { ...makeNode('c1', { parent_id: 'p1' }), parentId: 'p1', extent: 'parent' }
|
||||
|
||||
@@ -28,6 +28,9 @@ interface CanvasState {
|
||||
updateEdge: (id: string, data: Partial<EdgeData>) => void
|
||||
deleteEdge: (id: string) => void
|
||||
setProxmoxContainerMode: (proxmoxId: string, enabled: boolean) => void
|
||||
setNodeZIndex: (id: string, zIndex: number) => void
|
||||
editingGroupRectId: string | null
|
||||
setEditingGroupRectId: (id: string | null) => void
|
||||
markSaved: () => void
|
||||
loadCanvas: (nodes: Node<NodeData>[], edges: Edge<EdgeData>[]) => void
|
||||
notifyScanDeviceFound: () => void
|
||||
@@ -38,6 +41,7 @@ export const useCanvasStore = create<CanvasState>((set) => ({
|
||||
edges: [],
|
||||
hasUnsavedChanges: false,
|
||||
selectedNodeId: null,
|
||||
editingGroupRectId: null,
|
||||
scanEventTs: 0,
|
||||
|
||||
onNodesChange: (changes) =>
|
||||
@@ -141,6 +145,14 @@ export const useCanvasStore = create<CanvasState>((set) => ({
|
||||
return { nodes, hasUnsavedChanges: true }
|
||||
}),
|
||||
|
||||
setNodeZIndex: (id, zIndex) =>
|
||||
set((state) => ({
|
||||
nodes: state.nodes.map((n) => n.id === id ? { ...n, zIndex } : n),
|
||||
hasUnsavedChanges: true,
|
||||
})),
|
||||
|
||||
setEditingGroupRectId: (id) => set({ editingGroupRectId: id }),
|
||||
|
||||
markSaved: () => set({ hasUnsavedChanges: false }),
|
||||
|
||||
notifyScanDeviceFound: () => set({ scanEventTs: Date.now() }),
|
||||
|
||||
@@ -14,6 +14,18 @@ export type NodeType =
|
||||
| 'computer'
|
||||
| 'cpl'
|
||||
| 'generic'
|
||||
| 'groupRect'
|
||||
|
||||
export type TextPosition =
|
||||
| 'top-left'
|
||||
| 'top-center'
|
||||
| 'top-right'
|
||||
| 'middle-left'
|
||||
| 'center'
|
||||
| 'middle-right'
|
||||
| 'bottom-left'
|
||||
| 'bottom-center'
|
||||
| 'bottom-right'
|
||||
|
||||
export type EdgeType = 'ethernet' | 'wifi' | 'iot' | 'vlan' | 'virtual' | 'cluster'
|
||||
|
||||
@@ -45,7 +57,18 @@ export interface NodeData extends Record<string, unknown> {
|
||||
notes?: string
|
||||
parent_id?: string
|
||||
container_mode?: boolean
|
||||
custom_colors?: { border?: string; background?: string; icon?: string }
|
||||
custom_colors?: {
|
||||
border?: string
|
||||
background?: string
|
||||
icon?: string
|
||||
// Group rectangle extras (type === 'groupRect')
|
||||
text_color?: string
|
||||
text_position?: TextPosition
|
||||
font?: string
|
||||
z_order?: number
|
||||
width?: number
|
||||
height?: number
|
||||
}
|
||||
custom_icon?: string
|
||||
}
|
||||
|
||||
@@ -76,6 +99,7 @@ export const NODE_TYPE_LABELS: Record<NodeType, string> = {
|
||||
computer: 'Computer',
|
||||
cpl: 'CPL / Powerline',
|
||||
generic: 'Generic Device',
|
||||
groupRect: 'Group Rectangle',
|
||||
}
|
||||
|
||||
export const STATUS_COLORS: Record<NodeStatus, string> = {
|
||||
|
||||
Reference in New Issue
Block a user