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
@@ -30,10 +30,11 @@ interface CanvasContainerProps {
onEdgeDoubleClick?: (edge: Edge<EdgeData>) => void
onNodeDoubleClick?: (node: Node<NodeData>) => void
onNodeDragStart?: () => void
onRequestAddToGroup?: (payload: { nodeId: string; groupId: string }) => void
onOpenPending?: (deviceId: string) => void
}
export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, onNodeDoubleClick, onNodeDragStart, onOpenPending }: CanvasContainerProps) {
export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, onNodeDoubleClick, onNodeDragStart, onRequestAddToGroup, onOpenPending }: CanvasContainerProps) {
const [lassoMode, setLassoMode] = useState(true)
const {
nodes, edges,
@@ -42,7 +43,7 @@ export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, o
fitViewPending, clearFitViewPending,
copySelectedNodes, pasteNodes,
} = useCanvasStore()
const { fitView, screenToFlowPosition } = useReactFlow()
const { fitView, screenToFlowPosition, getIntersectingNodes } = useReactFlow<Node<NodeData>>()
// Track the last cursor position over the canvas so paste lands under it.
const cursorRef = useRef<{ x: number; y: number } | null>(null)
@@ -125,6 +126,17 @@ export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, o
const { guides, onNodeDrag, onNodeDragStop } = useAlignmentGuides()
// Drop a top-level node onto a group → ask App to confirm adding it. Runs
// before the alignment snap so detection uses the dropped position.
const handleNodeDragStop = useCallback<NonNullable<typeof onNodeDragStop>>((event, dragNode, dragNodes) => {
if (onRequestAddToGroup && dragNode && !dragNode.parentId &&
dragNode.data.type !== 'group' && dragNode.data.type !== 'groupRect') {
const group = getIntersectingNodes(dragNode).find((n) => n.data.type === 'group')
if (group) onRequestAddToGroup({ nodeId: dragNode.id, groupId: group.id })
}
onNodeDragStop(event, dragNode, dragNodes)
}, [onRequestAddToGroup, getIntersectingNodes, onNodeDragStop])
return (
<div className="w-full h-full" style={{ background: theme.colors.canvasBackground }} onMouseMove={onMouseMove}>
<ReactFlow
@@ -139,7 +151,7 @@ export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, o
onNodeDoubleClick={handleNodeDoubleClick}
onNodeDragStart={onNodeDragStart}
onNodeDrag={onNodeDrag}
onNodeDragStop={onNodeDragStop}
onNodeDragStop={handleNodeDragStop}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
deleteKeyCode={['Backspace', 'Delete']}