feat(properties): drag to reorder node properties
Add native HTML5 drag-and-drop reorder to the property list in the detail panel. A grip handle appears when more than one property exists; dragging updates property order (persisted with canvas save) and is undoable via history snapshot. Closes #292 ha-relevant: yes
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
import { createElement, useRef, useState } from 'react'
|
import { createElement, useRef, useState } from 'react'
|
||||||
import { X, Edit, Trash2, ExternalLink, Plus, Pencil, Layers, Ungroup, Eye, EyeOff } from 'lucide-react'
|
import { X, Edit, Trash2, ExternalLink, Plus, Pencil, Layers, Ungroup, Eye, EyeOff, GripVertical } from 'lucide-react'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
|
|
||||||
@@ -37,6 +37,8 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
|
|||||||
const [newProp, setNewProp] = useState<PropForm>(EMPTY_PROP)
|
const [newProp, setNewProp] = useState<PropForm>(EMPTY_PROP)
|
||||||
const [editingPropIndex, setEditingPropIndex] = useState<number | null>(null)
|
const [editingPropIndex, setEditingPropIndex] = useState<number | null>(null)
|
||||||
const [editProp, setEditProp] = useState<PropForm>(EMPTY_PROP)
|
const [editProp, setEditProp] = useState<PropForm>(EMPTY_PROP)
|
||||||
|
const [dragPropIndex, setDragPropIndex] = useState<number | null>(null)
|
||||||
|
const [dragOverPropIndex, setDragOverPropIndex] = useState<number | null>(null)
|
||||||
|
|
||||||
// Multi-select panel
|
// Multi-select panel
|
||||||
const multiSelected = (selectedNodeIds ?? []).filter((id) => nodes.some((n) => n.id === id))
|
const multiSelected = (selectedNodeIds ?? []).filter((id) => nodes.some((n) => n.id === id))
|
||||||
@@ -202,6 +204,15 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
|
|||||||
setEditingPropIndex(null)
|
setEditingPropIndex(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleReorderProp = (from: number, to: number) => {
|
||||||
|
if (from === to || from < 0 || to < 0 || from >= properties.length || to >= properties.length) return
|
||||||
|
snapshotHistory()
|
||||||
|
const reordered = [...properties]
|
||||||
|
const [moved] = reordered.splice(from, 1)
|
||||||
|
reordered.splice(to, 0, moved)
|
||||||
|
updateNode(node.id, { properties: reordered })
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="w-72 shrink-0 flex flex-col border-l border-border bg-[#161b22] overflow-y-auto">
|
<aside className="w-72 shrink-0 flex flex-col border-l border-border bg-[#161b22] overflow-y-auto">
|
||||||
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
|
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
|
||||||
@@ -302,6 +313,17 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
|
|||||||
<PropertyBadge
|
<PropertyBadge
|
||||||
key={`${prop.key}-${i}`}
|
key={`${prop.key}-${i}`}
|
||||||
prop={prop}
|
prop={prop}
|
||||||
|
draggable={properties.length > 1}
|
||||||
|
isDragging={dragPropIndex === i}
|
||||||
|
isDragOver={dragOverPropIndex === i && dragPropIndex !== i}
|
||||||
|
onDragStart={() => setDragPropIndex(i)}
|
||||||
|
onDragEnter={() => { if (dragPropIndex !== null) setDragOverPropIndex(i) }}
|
||||||
|
onDragEnd={() => { setDragPropIndex(null); setDragOverPropIndex(null) }}
|
||||||
|
onDrop={() => {
|
||||||
|
if (dragPropIndex !== null) handleReorderProp(dragPropIndex, i)
|
||||||
|
setDragPropIndex(null)
|
||||||
|
setDragOverPropIndex(null)
|
||||||
|
}}
|
||||||
onToggleVisible={() => handleTogglePropVisible(i)}
|
onToggleVisible={() => handleTogglePropVisible(i)}
|
||||||
onEdit={() => handleStartEditProp(i)}
|
onEdit={() => handleStartEditProp(i)}
|
||||||
onRemove={() => handleRemoveProp(i)}
|
onRemove={() => handleRemoveProp(i)}
|
||||||
@@ -768,16 +790,41 @@ function PropertyForm({ form, onChange, onConfirm, onCancel, confirmLabel }: {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function PropertyBadge({ prop, onToggleVisible, onEdit, onRemove }: {
|
function PropertyBadge({ prop, draggable, isDragging, isDragOver, onDragStart, onDragEnter, onDragEnd, onDrop, onToggleVisible, onEdit, onRemove }: {
|
||||||
prop: NodeProperty
|
prop: NodeProperty
|
||||||
|
draggable: boolean
|
||||||
|
isDragging: boolean
|
||||||
|
isDragOver: boolean
|
||||||
|
onDragStart: () => void
|
||||||
|
onDragEnter: () => void
|
||||||
|
onDragEnd: () => void
|
||||||
|
onDrop: () => void
|
||||||
onToggleVisible: () => void
|
onToggleVisible: () => void
|
||||||
onEdit: () => void
|
onEdit: () => void
|
||||||
onRemove: () => void
|
onRemove: () => void
|
||||||
}) {
|
}) {
|
||||||
const Icon = resolvePropertyIcon(prop.icon)
|
const Icon = resolvePropertyIcon(prop.icon)
|
||||||
return (
|
return (
|
||||||
<div className="group flex items-center justify-between gap-2 px-2 py-1.5 rounded-md border text-xs transition-colors" style={{ background: '#21262d', borderColor: '#30363d' }}>
|
<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"
|
||||||
|
style={{
|
||||||
|
background: '#21262d',
|
||||||
|
borderColor: isDragOver ? '#00d4ff' : '#30363d',
|
||||||
|
opacity: isDragging ? 0.4 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
<div className="flex items-center gap-1.5 min-w-0">
|
<div className="flex items-center gap-1.5 min-w-0">
|
||||||
|
{draggable && (
|
||||||
|
<span className="shrink-0 cursor-grab active:cursor-grabbing text-[#8b949e] hover:text-[#00d4ff]" title="Drag to reorder">
|
||||||
|
{createElement(GripVertical, { size: 11 })}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{Icon && createElement(Icon, { size: 11, className: 'shrink-0 text-muted-foreground' })}
|
{Icon && createElement(Icon, { size: 11, className: 'shrink-0 text-muted-foreground' })}
|
||||||
<span className="font-medium truncate text-foreground" title={prop.key}>{prop.key}</span>
|
<span className="font-medium truncate text-foreground" title={prop.key}>{prop.key}</span>
|
||||||
<span className="text-muted-foreground truncate" title={prop.value}>· {prop.value}</span>
|
<span className="text-muted-foreground truncate" title={prop.value}>· {prop.value}</span>
|
||||||
|
|||||||
@@ -187,6 +187,44 @@ describe('DetailPanel', () => {
|
|||||||
expect(payload.properties).toHaveLength(0)
|
expect(payload.properties).toHaveLength(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('reorders properties on drag and drop', () => {
|
||||||
|
const updateNode = vi.fn()
|
||||||
|
vi.mocked(canvasStore.useCanvasStore).mockReturnValue({
|
||||||
|
nodes: [makeNode({ properties: [
|
||||||
|
{ key: 'A', value: '1', icon: null, visible: true },
|
||||||
|
{ key: 'B', value: '2', icon: null, visible: true },
|
||||||
|
{ key: 'C', value: '3', icon: null, visible: true },
|
||||||
|
] })],
|
||||||
|
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.properties.map((p: { key: string }) => p.key)).toEqual(['C', 'A', 'B'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('is not draggable with a single property', () => {
|
||||||
|
setupStore({ properties: [{ key: 'A', value: '1', icon: null, visible: true }] })
|
||||||
|
render(<DetailPanel onEdit={vi.fn()} />)
|
||||||
|
expect(screen.queryByTitle('Drag to reorder')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
it('does not submit add form when key is empty', () => {
|
it('does not submit add form when key is empty', () => {
|
||||||
const updateNode = vi.fn()
|
const updateNode = vi.fn()
|
||||||
vi.mocked(canvasStore.useCanvasStore).mockReturnValue({
|
vi.mocked(canvasStore.useCanvasStore).mockReturnValue({
|
||||||
|
|||||||
Reference in New Issue
Block a user