feat(detail-panel): make services list draggable to reorder
Mirrors the existing properties drag-and-drop (native HTML5 drag) so services can be reordered the same way.
This commit is contained in:
@@ -39,6 +39,8 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
|
||||
const [editProp, setEditProp] = useState<PropForm>(EMPTY_PROP)
|
||||
const [dragPropIndex, setDragPropIndex] = useState<number | null>(null)
|
||||
const [dragOverPropIndex, setDragOverPropIndex] = useState<number | null>(null)
|
||||
const [dragSvcIndex, setDragSvcIndex] = useState<number | null>(null)
|
||||
const [dragOverSvcIndex, setDragOverSvcIndex] = useState<number | null>(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 ? (
|
||||
<ServiceForm key={`edit-${i}`} form={editSvc} onChange={setEditSvc} onConfirm={handleSaveEdit} onCancel={() => setEditingFor(null)} confirmLabel="Save" autoFocus />
|
||||
) : (
|
||||
<ServiceBadge key={`${svc.port ?? 'host'}-${svc.protocol}-${svc.path ?? ''}-${i}`} svc={svc} host={host} status={serviceStatuses[serviceStatusKey(node.id, svc.port, svc.protocol)]} onEdit={() => handleStartEdit(i)} onRemove={() => handleRemoveService(i)} />
|
||||
<ServiceBadge
|
||||
key={`${svc.port ?? 'host'}-${svc.protocol}-${svc.path ?? ''}-${i}`}
|
||||
svc={svc}
|
||||
host={host}
|
||||
status={serviceStatuses[serviceStatusKey(node.id, svc.port, svc.protocol)]}
|
||||
draggable={services.length > 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)}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
@@ -853,7 +882,20 @@ const CATEGORY_COLORS: Record<string, string> = {
|
||||
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 (
|
||||
<div
|
||||
draggable={draggable}
|
||||
onDragStart={onDragStart}
|
||||
onDragEnter={onDragEnter}
|
||||
onDragOver={(e) => 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,
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-1.5 min-w-0 flex-1">
|
||||
{draggable && (
|
||||
<span className="shrink-0 cursor-grab active:cursor-grabbing text-[#8b949e] hover:text-[#00d4ff]" title="Drag to reorder">
|
||||
{createElement(GripVertical, { size: 11 })}
|
||||
</span>
|
||||
)}
|
||||
<span className="shrink-0 w-1.5 h-1.5 rounded-full" style={{ backgroundColor: color }} />
|
||||
|
||||
{url ? (
|
||||
|
||||
@@ -430,6 +430,44 @@ describe('DetailPanel', () => {
|
||||
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
|
||||
expect(() => render(<DetailPanel onEdit={vi.fn()} />)).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<typeof canvasStore.useCanvasStore>)
|
||||
|
||||
render(<DetailPanel onEdit={vi.fn()} />)
|
||||
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(<DetailPanel onEdit={vi.fn()} />)
|
||||
expect(screen.queryByTitle('Drag to reorder')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Services — edit', () => {
|
||||
|
||||
Reference in New Issue
Block a user