From 0b4e9ea1e5c7092ec82d81a37fe50fe956bbc518 Mon Sep 17 00:00:00 2001 From: jason Date: Sat, 21 Mar 2026 22:00:27 -0500 Subject: [PATCH] Add Service Mapper context menus, node edit modal, and edge type toggle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Right-click on canvas → add any node type at cursor position - Right-click on node → edit, duplicate, or delete - Right-click on edge → toggle animation, set edge type (bezier/smooth/step/straight), delete - Double-click a node → NodeEditModal (label, accent color, rack module link) - ContextMenu component: viewport-clamped, closes on outside click or Escape - All actions persist to API; React Flow state updated optimistically Co-Authored-By: Claude Sonnet 4.6 --- client/src/components/mapper/ContextMenu.tsx | 87 +++++ .../src/components/mapper/NodeEditModal.tsx | 153 ++++++++ .../src/components/mapper/ServiceMapper.tsx | 359 +++++++++++++++++- 3 files changed, 595 insertions(+), 4 deletions(-) create mode 100644 client/src/components/mapper/ContextMenu.tsx create mode 100644 client/src/components/mapper/NodeEditModal.tsx diff --git a/client/src/components/mapper/ContextMenu.tsx b/client/src/components/mapper/ContextMenu.tsx new file mode 100644 index 0000000..0ca76d1 --- /dev/null +++ b/client/src/components/mapper/ContextMenu.tsx @@ -0,0 +1,87 @@ +import { useEffect, useRef, type ReactNode } from 'react'; + +interface MenuItem { + label: string; + icon?: ReactNode; + onClick: () => void; + variant?: 'default' | 'danger'; + checked?: boolean; + separator?: false; +} + +interface SeparatorItem { + separator: true; +} + +export type ContextMenuEntry = MenuItem | SeparatorItem; + +interface ContextMenuProps { + x: number; + y: number; + items: ContextMenuEntry[]; + onClose: () => void; +} + +export function ContextMenu({ x, y, items, onClose }: ContextMenuProps) { + const menuRef = useRef(null); + + useEffect(() => { + function handleMouseDown(e: MouseEvent) { + if (menuRef.current && !menuRef.current.contains(e.target as Node)) { + onClose(); + } + } + function handleKeyDown(e: KeyboardEvent) { + if (e.key === 'Escape') onClose(); + } + document.addEventListener('mousedown', handleMouseDown); + document.addEventListener('keydown', handleKeyDown); + return () => { + document.removeEventListener('mousedown', handleMouseDown); + document.removeEventListener('keydown', handleKeyDown); + }; + }, [onClose]); + + // Clamp so menu doesn't overflow right/bottom edge + const menuWidth = 192; + const menuHeight = items.length * 34; + const clampedX = Math.min(x, window.innerWidth - menuWidth - 8); + const clampedY = Math.min(y, window.innerHeight - menuHeight - 8); + + return ( +
+ {items.map((item, i) => { + if ('separator' in item && item.separator) { + return
; + } + const mi = item as MenuItem; + return ( + + ); + })} +
+ ); +} diff --git a/client/src/components/mapper/NodeEditModal.tsx b/client/src/components/mapper/NodeEditModal.tsx new file mode 100644 index 0000000..3a8797c --- /dev/null +++ b/client/src/components/mapper/NodeEditModal.tsx @@ -0,0 +1,153 @@ +import { useState, useEffect, type FormEvent } from 'react'; +import { toast } from 'sonner'; +import { Modal } from '../ui/Modal'; +import { Button } from '../ui/Button'; +import { apiClient } from '../../api/client'; +import { useRackStore } from '../../store/useRackStore'; + +const COLOR_SWATCHES = [ + '#3b82f6', // blue + '#10b981', // emerald + '#f59e0b', // amber + '#ef4444', // red + '#8b5cf6', // violet + '#ec4899', // pink + '#06b6d4', // cyan + '#84cc16', // lime + '#f97316', // orange + '#6b7280', // gray +]; + +export interface NodeEditModalProps { + open: boolean; + onClose: () => void; + nodeId: string; + initialLabel: string; + initialColor?: string; + initialModuleId?: string | null; + onSaved: (updated: { label: string; color: string; moduleId: string | null }) => void; +} + +export function NodeEditModal({ + open, + onClose, + nodeId, + initialLabel, + initialColor, + initialModuleId, + onSaved, +}: NodeEditModalProps) { + const { racks } = useRackStore(); + const [label, setLabel] = useState(initialLabel); + const [color, setColor] = useState(initialColor ?? '#3b82f6'); + const [moduleId, setModuleId] = useState(initialModuleId ?? ''); + const [loading, setLoading] = useState(false); + + useEffect(() => { + if (open) { + setLabel(initialLabel); + setColor(initialColor ?? '#3b82f6'); + setModuleId(initialModuleId ?? ''); + } + }, [open, initialLabel, initialColor, initialModuleId]); + + async function handleSubmit(e: FormEvent) { + e.preventDefault(); + if (!label.trim()) return; + setLoading(true); + try { + await apiClient.nodes.update(nodeId, { + label: label.trim(), + color, + moduleId: moduleId || null, + }); + onSaved({ label: label.trim(), color, moduleId: moduleId || null }); + toast.success('Node updated'); + onClose(); + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Save failed'); + } finally { + setLoading(false); + } + } + + // All modules across all racks, flat list + const allModules = racks.flatMap((r) => + r.modules.map((m) => ({ id: m.id, label: `[${r.name}] ${m.name}` })) + ); + + return ( + +
+ {/* Label */} +
+ + setLabel(e.target.value)} + disabled={loading} + placeholder="Node label" + autoFocus + className="w-full bg-slate-900 border border-slate-600 rounded-lg px-3 py-2 text-sm text-slate-100 placeholder:text-slate-600 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50" + /> +
+ + {/* Color */} +
+ +
+ {COLOR_SWATCHES.map((c) => ( +
+
+ + {/* Module link */} +
+ + +
+ +
+ + +
+
+
+ ); +} diff --git a/client/src/components/mapper/ServiceMapper.tsx b/client/src/components/mapper/ServiceMapper.tsx index 7214652..edfd3e0 100644 --- a/client/src/components/mapper/ServiceMapper.tsx +++ b/client/src/components/mapper/ServiceMapper.tsx @@ -9,9 +9,10 @@ * ✅ Edge creation by connecting handles * ✅ Minimap, controls, dot background * ✅ PNG export - * ⚠️ Right-click context menus (canvas + node + edge) — TODO - * ⚠️ Node edit modal (label, color, link to module) — TODO - * ⚠️ Edge type/animation toggle — TODO + * ✅ Node/edge delete persisted to DB (Delete key) + * ✅ Right-click context menus (canvas + node + edge) + * ✅ Node edit modal (label, color, link to module) — double-click or context menu + * ✅ Edge type/animation toggle via context menu * ⚠️ Multi-select operations — functional but no toolbar actions */ import { useEffect, useRef, useCallback, useState } from 'react'; @@ -23,6 +24,7 @@ import { addEdge, useNodesState, useEdgesState, + useReactFlow, type Node, type Edge, type OnConnect, @@ -33,6 +35,7 @@ import { } from '@xyflow/react'; import '@xyflow/react/dist/style.css'; import { toast } from 'sonner'; +import { Edit2, Copy, Trash2, Zap, ZapOff, ArrowRight, Plus } from 'lucide-react'; import { DeviceNode } from './nodes/DeviceNode'; import { ServiceNode as ServiceNodeComponent } from './nodes/ServiceNode'; @@ -46,9 +49,11 @@ import { UserNode } from './nodes/UserNode'; import { NoteNode } from './nodes/NoteNode'; import { MapToolbar } from './MapToolbar'; +import { ContextMenu, type ContextMenuEntry } from './ContextMenu'; +import { NodeEditModal } from './NodeEditModal'; import { useMapStore } from '../../store/useMapStore'; import { apiClient } from '../../api/client'; -import type { ServiceMap } from '../../types'; +import type { ServiceMap, NodeType } from '../../types'; const NODE_TYPES = { DEVICE: DeviceNode, @@ -63,6 +68,27 @@ const NODE_TYPES = { NOTE: NoteNode, }; +const ADD_NODE_OPTIONS: { type: NodeType; label: string }[] = [ + { type: 'DEVICE', label: 'Device' }, + { type: 'SERVICE', label: 'Service' }, + { type: 'DATABASE', label: 'Database' }, + { type: 'API', label: 'API' }, + { type: 'EXTERNAL', label: 'External' }, + { type: 'USER', label: 'User' }, + { type: 'VLAN', label: 'VLAN' }, + { type: 'FIREWALL', label: 'Firewall' }, + { type: 'LOAD_BALANCER', label: 'Load Balancer' }, + { type: 'NOTE', label: 'Note' }, +]; + +const EDGE_TYPES_CYCLE = ['default', 'smoothstep', 'step', 'straight'] as const; +const EDGE_TYPE_LABELS: Record = { + default: 'Default (bezier)', + smoothstep: 'Smooth step', + step: 'Step', + straight: 'Straight', +}; + function toFlowNodes(map: ServiceMap): Node[] { return map.nodes.map((n) => ({ id: n.id, @@ -89,12 +115,39 @@ function toFlowEdges(map: ServiceMap): Edge[] { })); } +// ---- Context menu state ---- + +type CtxMenu = + | { kind: 'canvas'; x: number; y: number; flowX: number; flowY: number } + | { + kind: 'node'; + x: number; + y: number; + nodeId: string; + label: string; + color?: string; + moduleId?: string | null; + } + | { kind: 'edge'; x: number; y: number; edgeId: string; animated: boolean; edgeType: string } + | null; + +type NodeEditState = { + nodeId: string; + label: string; + color?: string; + moduleId?: string | null; +} | null; + function ServiceMapperInner() { const { maps, activeMap, fetchMaps, loadMap, createMap, setActiveMap } = useMapStore(); const [nodes, setNodes, onNodesChange] = useNodesState([]); const [edges, setEdges, onEdgesChange] = useEdgesState([]); const flowContainerRef = useRef(null); const saveTimerRef = useRef | null>(null); + const { screenToFlowPosition } = useReactFlow(); + + const [ctxMenu, setCtxMenu] = useState(null); + const [nodeEditState, setNodeEditState] = useState(null); // Load maps list on mount useEffect(() => { @@ -144,6 +197,28 @@ function ServiceMapperInner() { [onNodesChange] ); + // Persist node deletions + const handleNodesDelete = useCallback(async (deleted: Node[]) => { + for (const node of deleted) { + try { + await apiClient.nodes.delete(node.id); + } catch { + toast.error(`Failed to delete node "${(node.data as { label?: string }).label ?? node.id}"`); + } + } + }, []); + + // Persist edge deletions + const handleEdgesDelete = useCallback(async (deleted: Edge[]) => { + for (const edge of deleted) { + try { + await apiClient.edges.delete(edge.id); + } catch { + toast.error('Failed to delete connection'); + } + } + }, []); + const handleEdgesChange = useCallback( (changes: EdgeChange[]) => { onEdgesChange(changes); @@ -178,6 +253,252 @@ function ServiceMapperInner() { [activeMap, setEdges] ); + // ---- Context menu handlers ---- + + const onPaneContextMenu = useCallback( + (event: MouseEvent | React.MouseEvent) => { + event.preventDefault(); + if (!activeMap) return; + const flowPos = screenToFlowPosition({ x: event.clientX, y: event.clientY }); + setCtxMenu({ kind: 'canvas', x: event.clientX, y: event.clientY, flowX: flowPos.x, flowY: flowPos.y }); + }, + [activeMap, screenToFlowPosition] + ); + + const onNodeContextMenu = useCallback((event: React.MouseEvent, node: Node) => { + event.preventDefault(); + const d = node.data as { label?: string; color?: string; module?: { id: string } }; + setCtxMenu({ + kind: 'node', + x: event.clientX, + y: event.clientY, + nodeId: node.id, + label: d.label ?? '', + color: d.color, + moduleId: d.module?.id ?? null, + }); + }, []); + + const onEdgeContextMenu = useCallback((event: React.MouseEvent, edge: Edge) => { + event.preventDefault(); + setCtxMenu({ + kind: 'edge', + x: event.clientX, + y: event.clientY, + edgeId: edge.id, + animated: edge.animated ?? false, + edgeType: edge.type ?? 'default', + }); + }, []); + + const onNodeDoubleClick = useCallback((_event: React.MouseEvent, node: Node) => { + const d = node.data as { label?: string; color?: string; module?: { id: string } }; + setNodeEditState({ + nodeId: node.id, + label: d.label ?? '', + color: d.color, + moduleId: d.module?.id ?? null, + }); + }, []); + + // ---- Canvas context menu: add node ---- + + async function handleAddNode(nodeType: NodeType, flowX: number, flowY: number) { + if (!activeMap) return; + try { + const created = await apiClient.maps.addNode(activeMap.id, { + label: nodeType.charAt(0) + nodeType.slice(1).toLowerCase().replace(/_/g, ' '), + nodeType, + positionX: flowX, + positionY: flowY, + }); + setNodes((nds) => [ + ...nds, + { + id: created.id, + type: created.nodeType, + position: { x: created.positionX, y: created.positionY }, + data: { + label: created.label, + color: created.color, + icon: created.icon, + metadata: created.metadata, + module: created.module, + }, + }, + ]); + } catch { + toast.error('Failed to add node'); + } + } + + // ---- Node context menu: duplicate ---- + + async function handleDuplicateNode(nodeId: string) { + if (!activeMap) return; + const source = nodes.find((n) => n.id === nodeId); + if (!source) return; + const d = source.data as { label?: string; color?: string; module?: { id: string } }; + try { + const created = await apiClient.maps.addNode(activeMap.id, { + label: `${d.label ?? 'Node'} copy`, + nodeType: (source.type ?? 'SERVICE') as NodeType, + positionX: source.position.x + 40, + positionY: source.position.y + 40, + color: d.color, + moduleId: d.module?.id, + }); + setNodes((nds) => [ + ...nds, + { + id: created.id, + type: created.nodeType, + position: { x: created.positionX, y: created.positionY }, + data: { + label: created.label, + color: created.color, + icon: created.icon, + metadata: created.metadata, + module: created.module, + }, + }, + ]); + toast.success('Node duplicated'); + } catch { + toast.error('Failed to duplicate node'); + } + } + + // ---- Node context menu: delete ---- + + async function handleDeleteNode(nodeId: string) { + try { + await apiClient.nodes.delete(nodeId); + setNodes((nds) => nds.filter((n) => n.id !== nodeId)); + } catch { + toast.error('Failed to delete node'); + } + } + + // ---- Edge context menu: toggle animation ---- + + async function handleToggleEdgeAnimation(edgeId: string, currentAnimated: boolean) { + const newAnimated = !currentAnimated; + try { + await apiClient.edges.update(edgeId, { animated: newAnimated }); + setEdges((eds) => + eds.map((e) => (e.id === edgeId ? { ...e, animated: newAnimated } : e)) + ); + } catch { + toast.error('Failed to update edge'); + } + } + + // ---- Edge context menu: change type ---- + + async function handleSetEdgeType(edgeId: string, edgeType: string) { + try { + await apiClient.edges.update(edgeId, { edgeType }); + setEdges((eds) => + eds.map((e) => (e.id === edgeId ? { ...e, type: edgeType } : e)) + ); + } catch { + toast.error('Failed to update edge'); + } + } + + // ---- Edge context menu: delete ---- + + async function handleDeleteEdge(edgeId: string) { + try { + await apiClient.edges.delete(edgeId); + setEdges((eds) => eds.filter((e) => e.id !== edgeId)); + } catch { + toast.error('Failed to delete connection'); + } + } + + // ---- Node edit modal save ---- + + function handleNodeEditSaved(updated: { label: string; color: string; moduleId: string | null }) { + if (!nodeEditState) return; + setNodes((nds) => + nds.map((n) => + n.id === nodeEditState.nodeId + ? { ...n, data: { ...n.data, label: updated.label, color: updated.color } } + : n + ) + ); + } + + // ---- Build context menu items ---- + + function buildContextMenuItems(): ContextMenuEntry[] { + if (!ctxMenu) return []; + + if (ctxMenu.kind === 'canvas') { + const { flowX, flowY } = ctxMenu; + return ADD_NODE_OPTIONS.map((opt) => ({ + label: `Add ${opt.label}`, + icon: , + onClick: () => handleAddNode(opt.type, flowX, flowY), + })); + } + + if (ctxMenu.kind === 'node') { + const { nodeId, label, color, moduleId } = ctxMenu; + return [ + { + label: 'Edit node', + icon: , + onClick: () => setNodeEditState({ nodeId, label, color, moduleId }), + }, + { + label: 'Duplicate', + icon: , + onClick: () => handleDuplicateNode(nodeId), + }, + { separator: true as const }, + { + label: 'Delete node', + icon: , + variant: 'danger' as const, + onClick: () => handleDeleteNode(nodeId), + }, + ]; + } + + if (ctxMenu.kind === 'edge') { + const { edgeId, animated, edgeType } = ctxMenu; + const typeItems: ContextMenuEntry[] = EDGE_TYPES_CYCLE.map((t) => ({ + label: EDGE_TYPE_LABELS[t], + icon: , + checked: edgeType === t, + onClick: () => handleSetEdgeType(edgeId, t), + })); + return [ + { + label: animated ? 'Stop animation' : 'Animate edge', + icon: animated ? : , + onClick: () => handleToggleEdgeAnimation(edgeId, animated), + }, + { separator: true as const }, + ...typeItems, + { separator: true as const }, + { + label: 'Delete connection', + icon: , + variant: 'danger' as const, + onClick: () => handleDeleteEdge(edgeId), + }, + ]; + } + + return []; + } + + // ---- Map management ---- + async function handleSelectMap(id: string) { try { await loadMap(id); @@ -236,6 +557,13 @@ function ServiceMapperInner() { onNodesChange={handleNodesChange} onEdgesChange={handleEdgesChange} onConnect={onConnect} + onNodesDelete={handleNodesDelete} + onEdgesDelete={handleEdgesDelete} + onPaneContextMenu={onPaneContextMenu} + onNodeContextMenu={onNodeContextMenu} + onEdgeContextMenu={onEdgeContextMenu} + onNodeDoubleClick={onNodeDoubleClick} + onPaneClick={() => setCtxMenu(null)} snapToGrid snapGrid={[15, 15]} fitView @@ -257,6 +585,29 @@ function ServiceMapperInner() { )}
+ + {/* Context menu portal */} + {ctxMenu && ( + setCtxMenu(null)} + /> + )} + + {/* Node edit modal */} + {nodeEditState && ( + setNodeEditState(null)} + nodeId={nodeEditState.nodeId} + initialLabel={nodeEditState.label} + initialColor={nodeEditState.color} + initialModuleId={nodeEditState.moduleId} + onSaved={handleNodeEditSaved} + /> + )} ); }