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:
@@ -514,6 +514,113 @@ describe('canvasStore', () => {
|
||||
expect(useCanvasStore.getState().hasUnsavedChanges).toBe(true)
|
||||
})
|
||||
|
||||
// ── addToGroup ──────────────────────────────────────────────────────────────
|
||||
|
||||
it('addToGroup nests a top-level node with parent-relative position', () => {
|
||||
const group = { ...makeNode('g1', { type: 'group', label: 'G' }), position: { x: 76, y: 52 }, width: 448, height: 252 }
|
||||
const child = { ...makeNode('n1'), position: { x: 300, y: 200 } }
|
||||
useCanvasStore.setState({ nodes: [group, child] })
|
||||
|
||||
useCanvasStore.getState().addToGroup('g1', 'n1')
|
||||
|
||||
const moved = useCanvasStore.getState().nodes.find((n) => n.id === 'n1')
|
||||
expect(moved?.parentId).toBe('g1')
|
||||
expect(moved?.extent).toBe('parent')
|
||||
expect(moved?.data.parent_id).toBe('g1')
|
||||
// 300-76=224, 200-52=148
|
||||
expect(moved?.position).toEqual({ x: 224, y: 148 })
|
||||
})
|
||||
|
||||
it('addToGroup places the group before the child in the array', () => {
|
||||
const child = { ...makeNode('n1'), position: { x: 300, y: 200 } }
|
||||
const group = { ...makeNode('g1', { type: 'group', label: 'G' }), position: { x: 0, y: 0 } }
|
||||
// child first to prove reordering
|
||||
useCanvasStore.setState({ nodes: [child, group] })
|
||||
|
||||
useCanvasStore.getState().addToGroup('g1', 'n1')
|
||||
|
||||
const { nodes } = useCanvasStore.getState()
|
||||
expect(nodes.findIndex((n) => n.id === 'g1')).toBeLessThan(nodes.findIndex((n) => n.id === 'n1'))
|
||||
})
|
||||
|
||||
it('addToGroup is a no-op when target is not a group', () => {
|
||||
const notGroup = { ...makeNode('s1'), position: { x: 0, y: 0 } }
|
||||
const child = { ...makeNode('n1'), position: { x: 50, y: 50 } }
|
||||
useCanvasStore.setState({ nodes: [notGroup, child] })
|
||||
useCanvasStore.getState().markSaved()
|
||||
|
||||
useCanvasStore.getState().addToGroup('s1', 'n1')
|
||||
|
||||
expect(useCanvasStore.getState().nodes.find((n) => n.id === 'n1')?.parentId).toBeUndefined()
|
||||
expect(useCanvasStore.getState().hasUnsavedChanges).toBe(false)
|
||||
})
|
||||
|
||||
it('addToGroup is a no-op when child already belongs to the group', () => {
|
||||
const group = { ...makeNode('g1', { type: 'group', label: 'G' }), position: { x: 0, y: 0 } }
|
||||
const child = { ...makeNode('n1'), position: { x: 50, y: 50 }, parentId: 'g1', extent: 'parent' as const }
|
||||
useCanvasStore.setState({ nodes: [group, child] })
|
||||
useCanvasStore.getState().markSaved()
|
||||
|
||||
useCanvasStore.getState().addToGroup('g1', 'n1')
|
||||
|
||||
expect(useCanvasStore.getState().hasUnsavedChanges).toBe(false)
|
||||
})
|
||||
|
||||
it('addToGroup snapshots history and marks unsaved', () => {
|
||||
const group = { ...makeNode('g1', { type: 'group', label: 'G' }), position: { x: 0, y: 0 } }
|
||||
const child = { ...makeNode('n1'), position: { x: 50, y: 50 } }
|
||||
useCanvasStore.setState({ nodes: [group, child] })
|
||||
useCanvasStore.getState().markSaved()
|
||||
|
||||
useCanvasStore.getState().addToGroup('g1', 'n1')
|
||||
|
||||
expect(useCanvasStore.getState().past).toHaveLength(1)
|
||||
expect(useCanvasStore.getState().hasUnsavedChanges).toBe(true)
|
||||
})
|
||||
|
||||
// ── removeFromGroup ─────────────────────────────────────────────────────────
|
||||
|
||||
it('removeFromGroup releases the child to absolute coords and keeps the group', () => {
|
||||
const group = { ...makeNode('g1', { type: 'group', label: 'G' }), position: { x: 76, y: 52 } }
|
||||
const child = { ...makeNode('n1'), position: { x: 224, y: 148 }, parentId: 'g1', extent: 'parent' as const }
|
||||
useCanvasStore.setState({ nodes: [group, child] })
|
||||
|
||||
useCanvasStore.getState().removeFromGroup('g1', 'n1')
|
||||
|
||||
const { nodes } = useCanvasStore.getState()
|
||||
const released = nodes.find((n) => n.id === 'n1')
|
||||
expect(released?.parentId).toBeUndefined()
|
||||
expect(released?.extent).toBeUndefined()
|
||||
expect(released?.data.parent_id).toBeUndefined()
|
||||
// 224+76=300, 148+52=200
|
||||
expect(released?.position).toEqual({ x: 300, y: 200 })
|
||||
// group survives
|
||||
expect(nodes.find((n) => n.id === 'g1')).toBeDefined()
|
||||
})
|
||||
|
||||
it('removeFromGroup is a no-op when child is not in the group', () => {
|
||||
const group = { ...makeNode('g1', { type: 'group', label: 'G' }), position: { x: 0, y: 0 } }
|
||||
const child = { ...makeNode('n1'), position: { x: 50, y: 50 } }
|
||||
useCanvasStore.setState({ nodes: [group, child] })
|
||||
useCanvasStore.getState().markSaved()
|
||||
|
||||
useCanvasStore.getState().removeFromGroup('g1', 'n1')
|
||||
|
||||
expect(useCanvasStore.getState().hasUnsavedChanges).toBe(false)
|
||||
})
|
||||
|
||||
it('removeFromGroup snapshots history and marks unsaved', () => {
|
||||
const group = { ...makeNode('g1', { type: 'group', label: 'G' }), position: { x: 0, y: 0 } }
|
||||
const child = { ...makeNode('n1'), position: { x: 50, y: 50 }, parentId: 'g1', extent: 'parent' as const }
|
||||
useCanvasStore.setState({ nodes: [group, child] })
|
||||
useCanvasStore.getState().markSaved()
|
||||
|
||||
useCanvasStore.getState().removeFromGroup('g1', 'n1')
|
||||
|
||||
expect(useCanvasStore.getState().past).toHaveLength(1)
|
||||
expect(useCanvasStore.getState().hasUnsavedChanges).toBe(true)
|
||||
})
|
||||
|
||||
it('updateEdge updates edge data and marks unsaved', () => {
|
||||
useCanvasStore.setState((s) => ({ edges: [...s.edges, makeEdge('e1', 'n1', 'n2')] }))
|
||||
useCanvasStore.getState().markSaved()
|
||||
|
||||
@@ -68,6 +68,8 @@ interface CanvasState {
|
||||
toggleNodeCollapsed: (id: string) => void
|
||||
createGroup: (nodeIds: string[], name: string) => void
|
||||
ungroup: (groupId: string) => void
|
||||
addToGroup: (groupId: string, childId: string) => void
|
||||
removeFromGroup: (groupId: string, childId: string) => void
|
||||
markSaved: () => void
|
||||
markUnsaved: () => void
|
||||
loadCanvas: (nodes: Node<NodeData>[], edges: Edge<EdgeData>[]) => void
|
||||
@@ -583,6 +585,77 @@ export const useCanvasStore = create<CanvasState>((set) => ({
|
||||
}
|
||||
}),
|
||||
|
||||
// Nest an existing top-level node inside a group. Inverse of removeFromGroup.
|
||||
addToGroup: (groupId, childId) =>
|
||||
set((state) => {
|
||||
const group = state.nodes.find((n) => n.id === groupId)
|
||||
const child = state.nodes.find((n) => n.id === childId)
|
||||
if (!group || !child || group.data.type !== 'group') return state
|
||||
if (child.id === groupId || child.parentId === groupId) return state
|
||||
|
||||
const updatedNodes = state.nodes.map((n) => {
|
||||
if (n.id !== childId) return n
|
||||
return {
|
||||
...n,
|
||||
parentId: groupId,
|
||||
extent: 'parent' as const,
|
||||
// Absolute → group-relative. Clamp so the node stays inside the box.
|
||||
position: {
|
||||
x: Math.max(8, n.position.x - group.position.x),
|
||||
y: Math.max(8, n.position.y - group.position.y),
|
||||
},
|
||||
selected: false,
|
||||
data: { ...n.data, parent_id: groupId },
|
||||
}
|
||||
})
|
||||
|
||||
// React Flow requires the parent to precede its children in the array.
|
||||
const others = updatedNodes.filter((n) => n.id !== childId)
|
||||
const movedChild = updatedNodes.find((n) => n.id === childId)!
|
||||
const groupIdx = others.findIndex((n) => n.id === groupId)
|
||||
const nodes = [
|
||||
...others.slice(0, groupIdx + 1),
|
||||
movedChild,
|
||||
...others.slice(groupIdx + 1),
|
||||
]
|
||||
|
||||
return {
|
||||
nodes,
|
||||
hasUnsavedChanges: true,
|
||||
past: [...state.past.slice(-49), { nodes: state.nodes, edges: state.edges }],
|
||||
future: [],
|
||||
}
|
||||
}),
|
||||
|
||||
// Release a single child from a group back to the canvas. Group stays.
|
||||
removeFromGroup: (groupId, childId) =>
|
||||
set((state) => {
|
||||
const group = state.nodes.find((n) => n.id === groupId)
|
||||
const child = state.nodes.find((n) => n.id === childId)
|
||||
if (!group || !child || child.parentId !== groupId) return state
|
||||
|
||||
const nodes = state.nodes.map((n) => {
|
||||
if (n.id !== childId) return n
|
||||
return {
|
||||
...n,
|
||||
parentId: undefined,
|
||||
extent: undefined,
|
||||
position: {
|
||||
x: n.position.x + group.position.x,
|
||||
y: n.position.y + group.position.y,
|
||||
},
|
||||
data: { ...n.data, parent_id: undefined },
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
nodes,
|
||||
hasUnsavedChanges: true,
|
||||
past: [...state.past.slice(-49), { nodes: state.nodes, edges: state.edges }],
|
||||
future: [],
|
||||
}
|
||||
}),
|
||||
|
||||
markSaved: () => set({ hasUnsavedChanges: false }),
|
||||
|
||||
markUnsaved: () => set({ hasUnsavedChanges: true }),
|
||||
|
||||
Reference in New Issue
Block a user