feat: add collapsible/expandable zones for canvas decluttering
- Add collapsed state to NodeData.custom_colors (type=groupRect only) - Implement toggleNodeCollapsed action in canvasStore (Zustand) - Extend GroupRectNode UI with smooth chevron toggle button - Rotating chevron icon (↓ → when collapsed) - Shows '+N' badge when zone is hidden - Reduces zone opacity to 0.6 when collapsed - All transitions target 60 FPS (ease-out 200ms) - Filter child nodes/edges in CanvasContainer based on parent collapse state - Breadth-first traversal handles multi-level nesting - Connecting edges to hidden nodes are automatically hidden - Add comprehensive test coverage - Store: toggleNodeCollapsed state mutation, unsaved flag - Component: chevron rendering, click handlers, opacity transitions - Persist collapsed state via YAML serialization (part of custom_colors) Benefits: - Declutter large Zigbee meshes, multi-building networks - Preserve layout structure without deleting nodes - Smooth 60 FPS transitions for UX polish CONTRIBUTING.md compliance: - Strict TypeScript types, no 'any' - Zustand store pattern, no prop drilling - Tests for store logic and component behavior - Frontend linting requirements met Co-authored-by: CyberClaw <noreply@openclaw.ai>
This commit is contained in:
@@ -0,0 +1,61 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { renderHook, act } from '@testing-library/react'
|
||||
import { useCanvasStore } from '../canvasStore'
|
||||
import type { Node } from '@xyflow/react'
|
||||
import type { NodeData } from '@/types'
|
||||
|
||||
describe('canvasStore - toggleNodeCollapsed', () => {
|
||||
it('toggles collapsed state on a zone node', () => {
|
||||
const { result } = renderHook(() => useCanvasStore())
|
||||
|
||||
const node: Node<NodeData> = {
|
||||
id: 'zone-1',
|
||||
data: { label: 'Test Zone', type: 'groupRect', status: 'online', services: [] },
|
||||
position: { x: 0, y: 0 },
|
||||
}
|
||||
|
||||
act(() => {
|
||||
result.current.addNode(node)
|
||||
})
|
||||
|
||||
let zone = result.current.nodes.find((n) => n.id === 'zone-1')
|
||||
expect(zone?.data.custom_colors?.collapsed).toBeUndefined()
|
||||
|
||||
act(() => {
|
||||
result.current.toggleNodeCollapsed('zone-1')
|
||||
})
|
||||
|
||||
zone = result.current.nodes.find((n) => n.id === 'zone-1')
|
||||
expect(zone?.data.custom_colors?.collapsed).toBe(true)
|
||||
|
||||
act(() => {
|
||||
result.current.toggleNodeCollapsed('zone-1')
|
||||
})
|
||||
|
||||
zone = result.current.nodes.find((n) => n.id === 'zone-1')
|
||||
expect(zone?.data.custom_colors?.collapsed).toBe(false)
|
||||
})
|
||||
|
||||
it('marks canvas as unsaved when toggling collapse', () => {
|
||||
const { result } = renderHook(() => useCanvasStore())
|
||||
|
||||
const node: Node<NodeData> = {
|
||||
id: 'zone-1',
|
||||
data: { label: 'Test Zone', type: 'groupRect', status: 'online', services: [] },
|
||||
position: { x: 0, y: 0 },
|
||||
}
|
||||
|
||||
act(() => {
|
||||
result.current.addNode(node)
|
||||
result.current.markSaved()
|
||||
})
|
||||
|
||||
expect(result.current.hasUnsavedChanges).toBe(false)
|
||||
|
||||
act(() => {
|
||||
result.current.toggleNodeCollapsed('zone-1')
|
||||
})
|
||||
|
||||
expect(result.current.hasUnsavedChanges).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -52,6 +52,7 @@ interface CanvasState {
|
||||
setEditingGroupRectId: (id: string | null) => void
|
||||
editingTextId: string | null
|
||||
setEditingTextId: (id: string | null) => void
|
||||
toggleNodeCollapsed: (id: string) => void
|
||||
createGroup: (nodeIds: string[], name: string) => void
|
||||
ungroup: (groupId: string) => void
|
||||
markSaved: () => void
|
||||
@@ -374,6 +375,25 @@ export const useCanvasStore = create<CanvasState>((set) => ({
|
||||
|
||||
setEditingTextId: (id) => set({ editingTextId: id }),
|
||||
|
||||
toggleNodeCollapsed: (id) =>
|
||||
set((state) => ({
|
||||
nodes: state.nodes.map((n) =>
|
||||
n.id === id
|
||||
? {
|
||||
...n,
|
||||
data: {
|
||||
...n.data,
|
||||
custom_colors: {
|
||||
...n.data.custom_colors,
|
||||
collapsed: !n.data.custom_colors?.collapsed,
|
||||
},
|
||||
},
|
||||
}
|
||||
: n
|
||||
),
|
||||
hasUnsavedChanges: true,
|
||||
})),
|
||||
|
||||
createGroup: (nodeIds, name) =>
|
||||
set((state) => {
|
||||
const PADDING_H = 24
|
||||
|
||||
Reference in New Issue
Block a user