feat: add PropertiesPanel for node and edge property editing
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
229
frontend/src/components/network/panels/PropertiesPanel.tsx
Normal file
229
frontend/src/components/network/panels/PropertiesPanel.tsx
Normal file
@@ -0,0 +1,229 @@
|
||||
import { useCallback } from 'react'
|
||||
import { Trash2 } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { DeviceProperties, DiagramEdge } from '@/types'
|
||||
import type { Node, Edge } from '@xyflow/react'
|
||||
import type { DeviceNodeData } from '../nodes/DeviceNode'
|
||||
|
||||
interface PropertiesPanelProps {
|
||||
selectedNode: Node | null
|
||||
selectedEdge: Edge | null
|
||||
onNodeUpdate: (nodeId: string, data: Partial<DeviceNodeData>) => void
|
||||
onEdgeUpdate: (edgeId: string, data: Partial<DiagramEdge>) => void
|
||||
onDeleteNode: (nodeId: string) => void
|
||||
onDeleteEdge: (edgeId: string) => void
|
||||
}
|
||||
|
||||
const STATUS_OPTIONS = ['unknown', 'online', 'offline', 'degraded'] as const
|
||||
const CONNECTION_TYPE_OPTIONS = ['ethernet', 'fiber', 'wifi', 'vpn', 'vlan', 'wan'] as const
|
||||
|
||||
function FieldLabel({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<label className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{children}
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldInput({ value, onChange, placeholder, mono }: {
|
||||
value: string
|
||||
onChange: (val: string) => void
|
||||
placeholder?: string
|
||||
mono?: boolean
|
||||
}) {
|
||||
return (
|
||||
<input
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
className={cn(
|
||||
'w-full rounded border border-default bg-input px-2 py-1.5 text-xs text-primary placeholder:text-muted-foreground focus:border-accent focus:outline-none',
|
||||
mono && 'font-mono',
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function PropertiesPanel({
|
||||
selectedNode,
|
||||
selectedEdge,
|
||||
onNodeUpdate,
|
||||
onEdgeUpdate,
|
||||
onDeleteNode,
|
||||
onDeleteEdge,
|
||||
}: PropertiesPanelProps) {
|
||||
const handlePropertyChange = useCallback((field: keyof DeviceProperties, value: string) => {
|
||||
if (!selectedNode) return
|
||||
const nodeData = selectedNode.data as unknown as DeviceNodeData
|
||||
onNodeUpdate(selectedNode.id, {
|
||||
properties: { ...nodeData.properties, [field]: value },
|
||||
} as Partial<DeviceNodeData>)
|
||||
}, [selectedNode, onNodeUpdate])
|
||||
|
||||
const handleLabelChange = useCallback((value: string) => {
|
||||
if (!selectedNode) return
|
||||
onNodeUpdate(selectedNode.id, { label: value } as Partial<DeviceNodeData>)
|
||||
}, [selectedNode, onNodeUpdate])
|
||||
|
||||
if (!selectedNode && !selectedEdge) {
|
||||
return (
|
||||
<div className="flex h-full w-[260px] flex-col items-center justify-center border-l border-default bg-sidebar px-4">
|
||||
<p className="text-center text-xs text-muted-foreground">
|
||||
Select a device or connection to edit its properties
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (selectedEdge) {
|
||||
const edgeData = (selectedEdge.data || {}) as Record<string, unknown>
|
||||
const connectionType = (edgeData.connectionType as string) || 'ethernet'
|
||||
const isCustomType = !CONNECTION_TYPE_OPTIONS.includes(connectionType as typeof CONNECTION_TYPE_OPTIONS[number])
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-[260px] flex-col border-l border-default bg-sidebar">
|
||||
<div className="border-b border-default px-3 py-2">
|
||||
<h3 className="text-xs font-semibold text-heading">Connection</h3>
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col gap-3 overflow-y-auto p-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<FieldLabel>Label</FieldLabel>
|
||||
<FieldInput
|
||||
value={(selectedEdge.label as string) || ''}
|
||||
onChange={val => onEdgeUpdate(selectedEdge.id, { label: val || null })}
|
||||
placeholder="Connection label"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<FieldLabel>Connection Type</FieldLabel>
|
||||
<select
|
||||
value={isCustomType ? '__custom__' : connectionType}
|
||||
onChange={e => {
|
||||
const val = e.target.value
|
||||
if (val !== '__custom__') {
|
||||
onEdgeUpdate(selectedEdge.id, { connectionType: val })
|
||||
}
|
||||
}}
|
||||
className="w-full rounded border border-default bg-input px-2 py-1.5 text-xs text-primary focus:border-accent focus:outline-none"
|
||||
>
|
||||
{CONNECTION_TYPE_OPTIONS.map(opt => (
|
||||
<option key={opt} value={opt}>{opt.charAt(0).toUpperCase() + opt.slice(1)}</option>
|
||||
))}
|
||||
<option value="__custom__">Custom...</option>
|
||||
</select>
|
||||
{isCustomType && (
|
||||
<FieldInput
|
||||
value={connectionType}
|
||||
onChange={val => onEdgeUpdate(selectedEdge.id, { connectionType: val })}
|
||||
placeholder="Custom type name"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<FieldLabel>Speed</FieldLabel>
|
||||
<FieldInput
|
||||
value={(edgeData.speed as string) || ''}
|
||||
onChange={val => onEdgeUpdate(selectedEdge.id, { speed: val || null })}
|
||||
placeholder="e.g. 1 Gbps"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<FieldLabel>Notes</FieldLabel>
|
||||
<FieldInput
|
||||
value={(edgeData.notes as string) || ''}
|
||||
onChange={val => onEdgeUpdate(selectedEdge.id, { notes: val || null })}
|
||||
placeholder="Port info, cable type..."
|
||||
mono
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-t border-default p-3">
|
||||
<button
|
||||
onClick={() => onDeleteEdge(selectedEdge.id)}
|
||||
className="flex w-full items-center justify-center gap-1.5 rounded border border-red-500/30 px-2 py-1.5 text-xs text-red-400 hover:bg-red-500/10"
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
Delete Connection
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const nodeData = selectedNode!.data as unknown as DeviceNodeData
|
||||
const props = nodeData.properties || {} as DeviceProperties
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-[260px] flex-col border-l border-default bg-sidebar">
|
||||
<div className="border-b border-default px-3 py-2">
|
||||
<h3 className="text-xs font-semibold text-heading">Device Properties</h3>
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col gap-3 overflow-y-auto p-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<FieldLabel>Label</FieldLabel>
|
||||
<FieldInput value={nodeData.label} onChange={handleLabelChange} placeholder="Device name" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<FieldLabel>Hostname</FieldLabel>
|
||||
<FieldInput value={props.hostname || ''} onChange={v => handlePropertyChange('hostname', v)} placeholder="e.g. core-rtr-01" mono />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<FieldLabel>IP Address</FieldLabel>
|
||||
<FieldInput value={props.ip || ''} onChange={v => handlePropertyChange('ip', v)} placeholder="e.g. 10.0.0.1" mono />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<FieldLabel>Subnet</FieldLabel>
|
||||
<FieldInput value={props.subnet || ''} onChange={v => handlePropertyChange('subnet', v)} placeholder="e.g. 10.0.0.0/24" mono />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<FieldLabel>Vendor</FieldLabel>
|
||||
<FieldInput value={props.vendor || ''} onChange={v => handlePropertyChange('vendor', v)} placeholder="e.g. Cisco" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<FieldLabel>Model</FieldLabel>
|
||||
<FieldInput value={props.model || ''} onChange={v => handlePropertyChange('model', v)} placeholder="e.g. ISR 4331" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<FieldLabel>Role</FieldLabel>
|
||||
<FieldInput value={props.role || ''} onChange={v => handlePropertyChange('role', v)} placeholder="e.g. Core gateway" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<FieldLabel>VLAN</FieldLabel>
|
||||
<FieldInput value={props.vlan || ''} onChange={v => handlePropertyChange('vlan', v)} placeholder="e.g. 10" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<FieldLabel>Status</FieldLabel>
|
||||
<select
|
||||
value={props.status || 'unknown'}
|
||||
onChange={e => handlePropertyChange('status', e.target.value)}
|
||||
className="w-full rounded border border-default bg-input px-2 py-1.5 text-xs text-primary focus:border-accent focus:outline-none"
|
||||
>
|
||||
{STATUS_OPTIONS.map(opt => (
|
||||
<option key={opt} value={opt}>{opt.charAt(0).toUpperCase() + opt.slice(1)}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<FieldLabel>Notes</FieldLabel>
|
||||
<textarea
|
||||
value={props.notes || ''}
|
||||
onChange={e => handlePropertyChange('notes', e.target.value)}
|
||||
placeholder="Additional notes..."
|
||||
rows={3}
|
||||
className="w-full resize-none rounded border border-default bg-input px-2 py-1.5 text-xs text-primary placeholder:text-muted-foreground focus:border-accent focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-t border-default p-3">
|
||||
<button
|
||||
onClick={() => onDeleteNode(selectedNode!.id)}
|
||||
className="flex w-full items-center justify-center gap-1.5 rounded border border-red-500/30 px-2 py-1.5 text-xs text-red-400 hover:bg-red-500/10"
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
Delete Device
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user