diff --git a/frontend/src/components/panels/DetailPanel.tsx b/frontend/src/components/panels/DetailPanel.tsx index 68e88e8..637bddb 100644 --- a/frontend/src/components/panels/DetailPanel.tsx +++ b/frontend/src/components/panels/DetailPanel.tsx @@ -39,6 +39,8 @@ export function DetailPanel({ onEdit }: DetailPanelProps) { const [editProp, setEditProp] = useState(EMPTY_PROP) const [dragPropIndex, setDragPropIndex] = useState(null) const [dragOverPropIndex, setDragOverPropIndex] = useState(null) + const [dragSvcIndex, setDragSvcIndex] = useState(null) + const [dragOverSvcIndex, setDragOverSvcIndex] = useState(null) // Multi-select panel const multiSelected = (selectedNodeIds ?? []).filter((id) => nodes.some((n) => n.id === id)) @@ -158,6 +160,15 @@ export function DetailPanel({ onEdit }: DetailPanelProps) { setEditingFor(null) } + const handleReorderService = (from: number, to: number) => { + if (from === to || from < 0 || to < 0 || from >= services.length || to >= services.length) return + snapshotHistory() + const reordered = [...services] + const [moved] = reordered.splice(from, 1) + reordered.splice(to, 0, moved) + updateNode(node.id, { services: reordered }) + } + // --- Property handlers --- const properties: NodeProperty[] = data.properties ?? [] @@ -351,7 +362,25 @@ export function DetailPanel({ onEdit }: DetailPanelProps) { editingIndex === i ? ( setEditingFor(null)} confirmLabel="Save" autoFocus /> ) : ( - handleStartEdit(i)} onRemove={() => handleRemoveService(i)} /> + 1} + isDragging={dragSvcIndex === i} + isDragOver={dragOverSvcIndex === i && dragSvcIndex !== i} + onDragStart={() => setDragSvcIndex(i)} + onDragEnter={() => { if (dragSvcIndex !== null) setDragOverSvcIndex(i) }} + onDragEnd={() => { setDragSvcIndex(null); setDragOverSvcIndex(null) }} + onDrop={() => { + if (dragSvcIndex !== null) handleReorderService(dragSvcIndex, i) + setDragSvcIndex(null) + setDragOverSvcIndex(null) + }} + onEdit={() => handleStartEdit(i)} + onRemove={() => handleRemoveService(i)} + /> ) )} @@ -853,7 +882,20 @@ const CATEGORY_COLORS: Record = { web: '#00d4ff', database: '#a855f7', monitoring: '#39d353', storage: '#e3b341', security: '#f85149', remote: '#8b949e', } -function ServiceBadge({ svc, host, status, onEdit, onRemove }: { svc: ServiceInfo; host?: string; status?: ServiceStatus; onEdit: () => void; onRemove: () => void }) { +function ServiceBadge({ svc, host, status, draggable, isDragging, isDragOver, onDragStart, onDragEnter, onDragEnd, onDrop, onEdit, onRemove }: { + svc: ServiceInfo + host?: string + status?: ServiceStatus + draggable: boolean + isDragging: boolean + isDragOver: boolean + onDragStart: () => void + onDragEnter: () => void + onDragEnd: () => void + onDrop: () => void + onEdit: () => void + onRemove: () => void +}) { const url = getServiceUrl(svc, host) // Manually-added services carry no category, so they fell back to grey even // when they're reachable HTTP/HTTPS. Treat any resolvable web URL as `web`. @@ -864,10 +906,25 @@ function ServiceBadge({ svc, host, status, onEdit, onRemove }: { svc: ServiceInf return (
e.preventDefault()} + onDragEnd={onDragEnd} + onDrop={(e) => { e.preventDefault(); onDrop() }} className="group flex items-center justify-between gap-2 px-2 py-1.5 rounded-md border text-xs transition-colors min-w-0" - style={{ background: '#21262d', borderColor: '#30363d' }} + style={{ + background: '#21262d', + borderColor: isDragOver ? '#00d4ff' : '#30363d', + opacity: isDragging ? 0.4 : 1, + }} >
+ {draggable && ( + + {createElement(GripVertical, { size: 11 })} + + )} {url ? ( diff --git a/frontend/src/components/panels/__tests__/DetailPanel.test.tsx b/frontend/src/components/panels/__tests__/DetailPanel.test.tsx index 0755dec..b40ba35 100644 --- a/frontend/src/components/panels/__tests__/DetailPanel.test.tsx +++ b/frontend/src/components/panels/__tests__/DetailPanel.test.tsx @@ -430,6 +430,44 @@ describe('DetailPanel', () => { } as unknown as ReturnType) expect(() => render()).not.toThrow() }) + + it('reorders services on drag and drop', () => { + const updateNode = vi.fn() + vi.mocked(canvasStore.useCanvasStore).mockReturnValue({ + nodes: [makeNode({ services: [ + { port: 80, protocol: 'tcp', service_name: 'A' }, + { port: 81, protocol: 'tcp', service_name: 'B' }, + { port: 82, protocol: 'tcp', service_name: 'C' }, + ] })], + selectedNodeId: 'n1', + selectedNodeIds: [], + setSelectedNode: vi.fn(), + deleteNode: vi.fn(), + updateNode, + snapshotHistory: vi.fn(), + createGroup: vi.fn(), + ungroup: vi.fn(), + } as unknown as ReturnType) + + render() + const rows = screen.getAllByTitle('Drag to reorder').map( + (g) => g.closest('[draggable="true"]') as HTMLElement, + ) + // Drag last (C) onto first (A) position + fireEvent.dragStart(rows[2]) + fireEvent.dragEnter(rows[0]) + fireEvent.drop(rows[0]) + + expect(updateNode).toHaveBeenCalledOnce() + const [, payload] = updateNode.mock.calls[0] + expect(payload.services.map((s: { service_name: string }) => s.service_name)).toEqual(['C', 'A', 'B']) + }) + + it('is not draggable with a single service', () => { + setupStore({ services: [{ port: 80, protocol: 'tcp', service_name: 'A' }] }) + render() + expect(screen.queryByTitle('Drag to reorder')).toBeNull() + }) }) describe('Services — edit', () => {