feat: editable node groups (add/remove members, description)

Make Node Groups mutable instead of fixed-at-creation:
- removeFromGroup / addToGroup store actions (inverse pair, history-aware)
- right panel: per-member remove button + editable group description
  (reuses data.notes, no backend/serializer change)
- drag a node over a group → confirm modal to add it (getIntersectingNodes)

Tests: +21 (store, panel, canvas detection, modal).

ha-relevant: yes
This commit is contained in:
Pouzor
2026-06-10 16:50:36 +02:00
parent 71cc183efb
commit 7ea1fc2d69
9 changed files with 443 additions and 14 deletions
@@ -9,6 +9,9 @@ import type { NodeData, EdgeData } from '@/types'
// Capture props passed to ReactFlow so we can test the callbacks
let rfProps: Record<string, unknown> = {}
// Hoisted holder so the mock factory can read the configurable intersection set.
const rf = vi.hoisted(() => ({ intersecting: [] as unknown[] }))
vi.mock('@xyflow/react', () => ({
ReactFlow: (props: Record<string, unknown>) => {
rfProps = props
@@ -21,7 +24,13 @@ vi.mock('@xyflow/react', () => ({
ConnectionMode: { Loose: 'loose' },
SelectionMode: { Partial: 'partial' },
Position: { Top: 'top', Right: 'right', Bottom: 'bottom', Left: 'left' },
useReactFlow: () => ({ fitView: vi.fn() }),
useReactFlow: () => ({
fitView: vi.fn(),
screenToFlowPosition: vi.fn(),
getIntersectingNodes: () => rf.intersecting,
setNodes: vi.fn(),
getNodes: () => [],
}),
}))
vi.mock('@xyflow/react/dist/style.css', () => ({}))
@@ -42,6 +51,7 @@ function makeEdge(id: string): Edge<EdgeData> {
describe('CanvasContainer', () => {
beforeEach(() => {
rfProps = {}
rf.intersecting = []
useCanvasStore.setState({ nodes: [], edges: [], selectedNodeId: null })
useThemeStore.setState({ activeTheme: 'default' })
})
@@ -154,6 +164,49 @@ describe('CanvasContainer', () => {
expect(rfProps.onNodeDragStart).toBe(onNodeDragStart)
})
// ── Drag onto group → onRequestAddToGroup ─────────────────────────────────
function groupNode(id: string): Node<NodeData> {
return { id, type: 'group', position: { x: 0, y: 0 }, data: { label: id, type: 'group', status: 'unknown', services: [] } }
}
it('fires onRequestAddToGroup when a node is dropped over a group', () => {
const onRequestAddToGroup = vi.fn()
const node = makeNode('n1')
const group = groupNode('g1')
rf.intersecting = [group]
render(<CanvasContainer onRequestAddToGroup={onRequestAddToGroup} />)
;(rfProps.onNodeDragStop as (...args: unknown[]) => unknown)({} as MouseEvent, node, [node])
expect(onRequestAddToGroup).toHaveBeenCalledWith({ nodeId: 'n1', groupId: 'g1' })
})
it('does not fire onRequestAddToGroup when no group is under the node', () => {
const onRequestAddToGroup = vi.fn()
const node = makeNode('n1')
rf.intersecting = [makeNode('n2')]
render(<CanvasContainer onRequestAddToGroup={onRequestAddToGroup} />)
;(rfProps.onNodeDragStop as (...args: unknown[]) => unknown)({} as MouseEvent, node, [node])
expect(onRequestAddToGroup).not.toHaveBeenCalled()
})
it('does not fire onRequestAddToGroup for an already-parented node', () => {
const onRequestAddToGroup = vi.fn()
const node = { ...makeNode('n1'), parentId: 'gOther' }
rf.intersecting = [groupNode('g1')]
render(<CanvasContainer onRequestAddToGroup={onRequestAddToGroup} />)
;(rfProps.onNodeDragStop as (...args: unknown[]) => unknown)({} as MouseEvent, node, [node])
expect(onRequestAddToGroup).not.toHaveBeenCalled()
})
it('does not fire onRequestAddToGroup when the dragged node is itself a group', () => {
const onRequestAddToGroup = vi.fn()
const node = groupNode('g2')
rf.intersecting = [groupNode('g1')]
render(<CanvasContainer onRequestAddToGroup={onRequestAddToGroup} />)
;(rfProps.onNodeDragStop as (...args: unknown[]) => unknown)({} as MouseEvent, node, [node])
expect(onRequestAddToGroup).not.toHaveBeenCalled()
})
// ── Canvas settings ───────────────────────────────────────────────────────
it('enables snapToGrid', () => {