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()
|
||||
|
||||
Reference in New Issue
Block a user