- {(['none', 'snake', 'flow'] as AnimMode[]).map((mode, i) => (
+ {(['none', 'basic', 'snake', 'flow'] as AnimMode[]).map((mode, i) => (
))}
@@ -175,6 +177,16 @@ export function EdgeModal({ open, onClose, onSubmit, onDelete, initial, title =
+ {onClearWaypoints && initial?.waypoints && initial.waypoints.length > 0 && (
+
-
-
- {hardwareOpen && (
-
- Show on node
-
-
- )}
-
- {hardwareOpen && (
-
- )}
-
- )}
-
{/* Bottom connection points (not for group containers) */}
{form.type !== 'groupRect' && form.type !== 'group' && (
diff --git a/frontend/src/components/modals/__tests__/EdgeModal.test.tsx b/frontend/src/components/modals/__tests__/EdgeModal.test.tsx
index 9d9cd51..7b97730 100644
--- a/frontend/src/components/modals/__tests__/EdgeModal.test.tsx
+++ b/frontend/src/components/modals/__tests__/EdgeModal.test.tsx
@@ -122,6 +122,21 @@ describe('EdgeModal', () => {
expect(onSubmit.mock.calls[0][0].animated).toBe('flow')
})
+ it('selecting Basic sends animated: "basic"', () => {
+ const onSubmit = vi.fn()
+ render(
)
+ fireEvent.click(screen.getByText('Basic'))
+ fireEvent.click(screen.getByRole('button', { name: 'Connect' }))
+ expect(onSubmit.mock.calls[0][0].animated).toBe('basic')
+ })
+
+ it('pre-fills animation from initial "basic" string', () => {
+ const onSubmit = vi.fn()
+ render(
)
+ fireEvent.click(screen.getByRole('button', { name: 'Connect' }))
+ expect(onSubmit.mock.calls[0][0].animated).toBe('basic')
+ })
+
it('selecting None after Snake omits animated from payload', () => {
const onSubmit = vi.fn()
render(
)
@@ -187,4 +202,55 @@ describe('EdgeModal', () => {
expect(onDelete).toHaveBeenCalledOnce()
expect(onClose).toHaveBeenCalledOnce()
})
+
+ // ── Waypoints / Clear path ────────────────────────────────────────────────
+
+ it('does not show Clear path button when onClearWaypoints is not provided', () => {
+ render(
)
+ expect(screen.queryByText(/Clear path/)).toBeNull()
+ })
+
+ it('does not show Clear path button when waypoints are empty', () => {
+ render(
)
+ expect(screen.queryByText(/Clear path/)).toBeNull()
+ })
+
+ it('does not show Clear path button when no initial waypoints', () => {
+ render(
)
+ expect(screen.queryByText(/Clear path/)).toBeNull()
+ })
+
+ it('shows Clear path button with count when waypoints exist', () => {
+ render(
+
,
+ )
+ expect(screen.getByText('Clear path (2 points)')).toBeDefined()
+ })
+
+ it('shows singular "point" when only one waypoint', () => {
+ render(
+
,
+ )
+ expect(screen.getByText('Clear path (1 point)')).toBeDefined()
+ })
+
+ it('calls onClearWaypoints and onClose when Clear path is clicked', () => {
+ const onClearWaypoints = vi.fn()
+ const onClose = vi.fn()
+ render(
+
,
+ )
+ fireEvent.click(screen.getByText('Clear path (1 point)'))
+ expect(onClearWaypoints).toHaveBeenCalledOnce()
+ expect(onClose).toHaveBeenCalledOnce()
+ })
})
diff --git a/frontend/src/components/modals/__tests__/NodeModal.test.tsx b/frontend/src/components/modals/__tests__/NodeModal.test.tsx
index adef950..4b24085 100644
--- a/frontend/src/components/modals/__tests__/NodeModal.test.tsx
+++ b/frontend/src/components/modals/__tests__/NodeModal.test.tsx
@@ -293,91 +293,6 @@ describe('NodeModal', () => {
expect(screen.getByText(/Using default colors for/)).toBeDefined()
})
- // ── Hardware section ──────────────────────────────────────────────────
-
- it('renders Hardware toggle button', () => {
- renderModal()
- expect(screen.getByText('Hardware')).toBeDefined()
- })
-
- it('hardware fields are hidden by default', () => {
- renderModal()
- expect(screen.queryByPlaceholderText('e.g. Intel Xeon E5-2680')).toBeNull()
- })
-
- it('expands hardware fields on toggle click', () => {
- renderModal()
- fireEvent.click(screen.getByText('Hardware'))
- expect(screen.getByPlaceholderText('e.g. Intel Xeon E5-2680')).toBeDefined()
- expect(screen.getByPlaceholderText('e.g. 8')).toBeDefined()
- expect(screen.getByPlaceholderText('e.g. 32')).toBeDefined()
- expect(screen.getByPlaceholderText('e.g. 500')).toBeDefined()
- })
-
- it('auto-expands when initial has hardware data', () => {
- renderModal({ initial: { ...BASE, cpu_count: 8, ram_gb: 32 } })
- expect(screen.getByPlaceholderText('e.g. Intel Xeon E5-2680')).toBeDefined()
- })
-
- it('pre-fills hardware fields from initial', () => {
- renderModal({ initial: { ...BASE, cpu_model: 'Intel i5', cpu_count: 4, ram_gb: 16, disk_gb: 500 } })
- expect((screen.getByPlaceholderText('e.g. Intel Xeon E5-2680') as HTMLInputElement).value).toBe('Intel i5')
- })
-
- it('submits hardware fields when filled', () => {
- const { onSubmit } = renderModal()
- fireEvent.change(screen.getByPlaceholderText('My Server'), { target: { value: 'Homelab' } })
- fireEvent.click(screen.getByText('Hardware'))
- fireEvent.change(screen.getByPlaceholderText('e.g. Intel Xeon E5-2680'), { target: { value: 'Intel i7-12700K' } })
- fireEvent.change(screen.getByPlaceholderText('e.g. 8'), { target: { value: '12' } })
- fireEvent.change(screen.getByPlaceholderText('e.g. 32'), { target: { value: '64' } })
- fireEvent.change(screen.getByPlaceholderText('e.g. 500'), { target: { value: '2000' } })
- fireEvent.click(screen.getByRole('button', { name: 'Add' }))
- const data = onSubmit.mock.calls[0][0] as Partial
- expect(data.cpu_model).toBe('Intel i7-12700K')
- expect(data.cpu_count).toBe(12)
- expect(data.ram_gb).toBe(64)
- expect(data.disk_gb).toBe(2000)
- })
-
- it('hides Hardware section for groupRect type', () => {
- renderModal({ initial: { type: 'groupRect' } })
- expect(screen.queryByText('Hardware')).toBeNull()
- })
-
- it('show_hardware toggle hidden when section is collapsed', () => {
- renderModal()
- expect(screen.queryByText('Show on node')).toBeNull()
- })
-
- it('show_hardware toggle appears when section is expanded', () => {
- renderModal()
- fireEvent.click(screen.getByText('Hardware'))
- expect(screen.getByText('Show on node')).toBeDefined()
- })
-
- it('show_hardware defaults to falsy', () => {
- const { onSubmit } = renderModal()
- fireEvent.change(screen.getByPlaceholderText('My Server'), { target: { value: 'Node' } })
- fireEvent.click(screen.getByRole('button', { name: 'Add' }))
- expect(onSubmit.mock.calls[0][0].show_hardware).toBeFalsy()
- })
-
- it('toggling show_hardware sets it to true', () => {
- const { onSubmit } = renderModal()
- fireEvent.change(screen.getByPlaceholderText('My Server'), { target: { value: 'Node' } })
- fireEvent.click(screen.getByText('Hardware'))
- fireEvent.click(screen.getByRole('switch'))
- fireEvent.click(screen.getByRole('button', { name: 'Add' }))
- expect(onSubmit.mock.calls[0][0].show_hardware).toBe(true)
- })
-
- it('pre-fills show_hardware from initial', () => {
- const { onSubmit } = renderModal({ initial: { label: 'Node', show_hardware: true, cpu_count: 8 } })
- fireEvent.click(screen.getByRole('button', { name: 'Add' }))
- expect(onSubmit.mock.calls[0][0].show_hardware).toBe(true)
- })
-
// ── Bottom connection points ───────────────────────────────────────────
it('shows Bottom Connection Points for server type', () => {
diff --git a/frontend/src/components/panels/DetailPanel.tsx b/frontend/src/components/panels/DetailPanel.tsx
index 690860f..1c9d82b 100644
--- a/frontend/src/components/panels/DetailPanel.tsx
+++ b/frontend/src/components/panels/DetailPanel.tsx
@@ -1,10 +1,11 @@
-import { useState } from 'react'
+import { createElement, 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'
import { useCanvasStore } from '@/stores/canvasStore'
-import { NODE_TYPE_LABELS, STATUS_COLORS, type ServiceInfo, type NodeData } from '@/types'
+import { NODE_TYPE_LABELS, STATUS_COLORS, type ServiceInfo, type NodeData, type NodeProperty } from '@/types'
import { getServiceUrl } from '@/utils/serviceUrl'
+import { PROPERTY_ICONS, PROPERTY_ICON_NAMES, resolvePropertyIcon } from '@/utils/propertyIcons'
import type { Node } from '@xyflow/react'
interface DetailPanelProps {
@@ -14,6 +15,9 @@ interface DetailPanelProps {
type SvcForm = { port: string; protocol: 'tcp' | 'udp'; service_name: string }
const EMPTY_FORM: SvcForm = { port: '', protocol: 'tcp', service_name: '' }
+type PropForm = { key: string; value: string; icon: string | null; visible: boolean }
+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 } = useCanvasStore()
@@ -24,6 +28,12 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
const [groupName, setGroupName] = useState('')
const [creatingGroup, setCreatingGroup] = useState(false)
+ // Properties state
+ const [addingProp, setAddingProp] = useState(false)
+ const [newProp, setNewProp] = useState(EMPTY_PROP)
+ const [editingPropIndex, setEditingPropIndex] = useState(null)
+ const [editProp, setEditProp] = useState(EMPTY_PROP)
+
// Multi-select panel
const multiSelected = (selectedNodeIds ?? []).filter((id) => nodes.some((n) => n.id === id))
@@ -119,6 +129,52 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
setEditingFor(null)
}
+ // --- Property handlers ---
+ const properties: NodeProperty[] = data.properties ?? []
+
+ const handleAddProp = () => {
+ if (!newProp.key.trim() || !newProp.value.trim()) return
+ snapshotHistory()
+ const prop: NodeProperty = { key: newProp.key.trim(), value: newProp.value.trim(), icon: newProp.icon, visible: newProp.visible }
+ updateNode(node.id, { properties: [...properties, prop] })
+ setNewProp(EMPTY_PROP)
+ setAddingProp(false)
+ }
+
+ const handleRemoveProp = (index: number) => {
+ snapshotHistory()
+ updateNode(node.id, { properties: properties.filter((_, i) => i !== index) })
+ if (editingPropIndex === index) setEditingPropIndex(null)
+ }
+
+ const handleTogglePropVisible = (index: number) => {
+ snapshotHistory()
+ updateNode(node.id, {
+ properties: properties.map((p, i) => i === index ? { ...p, visible: !p.visible } : p),
+ })
+ }
+
+ const handleStartEditProp = (index: number) => {
+ const p = properties[index]
+ if (!p) return
+ setEditProp({ key: p.key, value: p.value, icon: p.icon, visible: p.visible })
+ setEditingPropIndex(index)
+ setAddingProp(false)
+ }
+
+ const handleSaveEditProp = () => {
+ if (editingPropIndex === null || !editProp.key.trim() || !editProp.value.trim()) return
+ snapshotHistory()
+ updateNode(node.id, {
+ properties: properties.map((p, i) =>
+ i === editingPropIndex
+ ? { key: editProp.key.trim(), value: editProp.value.trim(), icon: editProp.icon, visible: editProp.visible }
+ : p
+ ),
+ })
+ setEditingPropIndex(null)
+ }
+
return (