b71c96897a
Three connected bugs in PR #158's collapse feature: 1. Toggle wired to the wrong component The chevron was on GroupRectNode and computed children via React Flow parentId. But in this codebase parentId is set by createGroup() on type=group nodes, not on groupRect zones — zones are decorative rectangles. Result: childrenCount was always 0 on every zone and the button never rendered, so the feature was unreachable from the UI. Fix: - Add the same chevron toggle to GroupNode (the actual container). parentId children are already known there, so the existing BFS in computeCollapseInfo hides them when data.collapsed flips. - For GroupRectNode, switch childrenCount to spatial containment so drawn zones also work: hit-test other nodes' bbox centres against the zone bbox. 2. Visibility filter ignored spatial zones Extend computeCollapseInfo with a second pass that hides every node whose centre lies inside a collapsed groupRect, plus the parentId subtrees of those nodes (so a Proxmox host inside a collapsed zone takes its VMs/LXCs with it). Edge rewiring routes vanished endpoints to the same visible zone via a unified hiddenBy map populated by both passes. 3. Save dropped data.collapsed for every type except groupRect The DevTools payload was the smoking gun: for a type=group node the serializer wrote custom_colors: {show_border: true} with no collapsed key, so the backend stored a stale false on every save. Only the groupRect branch of serializeNode/deserializeApiNode stashed and hoisted the flag. Move the stash + hoist to the general branch too (backend's custom_colors is dict[str, Any] so no schema change). Tests: 11 new cases for spatial containment + GroupNode toggle UI, and 4 round-trip cases for collapse on non-groupRect types.
186 lines
5.8 KiB
TypeScript
186 lines
5.8 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
import { fireEvent, render, screen } from '@testing-library/react'
|
|
import { GroupNode } from '../nodes/GroupNode'
|
|
import * as canvasStore from '@/stores/canvasStore'
|
|
import type { Node } from '@xyflow/react'
|
|
import type { NodeData } from '@/types'
|
|
|
|
vi.mock('@/stores/canvasStore')
|
|
|
|
vi.mock('@xyflow/react', () => ({
|
|
NodeResizer: ({ isVisible }: { isVisible: boolean }) => (
|
|
<div data-testid="node-resizer" data-visible={isVisible} />
|
|
),
|
|
Handle: () => null,
|
|
Position: { Top: 'top', Right: 'right', Bottom: 'bottom', Left: 'left' },
|
|
useReactFlow: () => ({}),
|
|
}))
|
|
|
|
vi.mock('@xyflow/react/dist/style.css', () => ({}))
|
|
|
|
function makeGroupNode(overrides: Partial<NodeData> = {}): Node<NodeData> {
|
|
return {
|
|
id: 'g1',
|
|
type: 'group',
|
|
position: { x: 0, y: 0 },
|
|
width: 400,
|
|
height: 250,
|
|
data: {
|
|
label: 'My Group',
|
|
type: 'group',
|
|
status: 'unknown',
|
|
services: [],
|
|
custom_colors: { show_border: true },
|
|
...overrides,
|
|
},
|
|
}
|
|
}
|
|
|
|
function renderGroupNode(props: Partial<Parameters<typeof GroupNode>[0]> = {}, storeNodes: unknown[] = []) {
|
|
const node = makeGroupNode(props.data)
|
|
vi.mocked(canvasStore.useCanvasStore).mockReturnValue({
|
|
nodes: storeNodes,
|
|
updateNode: vi.fn(),
|
|
snapshotHistory: vi.fn(),
|
|
toggleNodeCollapsed: vi.fn(),
|
|
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
|
|
|
|
return render(
|
|
<GroupNode
|
|
id="g1"
|
|
data={node.data}
|
|
selected={false}
|
|
dragging={false}
|
|
zIndex={1}
|
|
isConnectable={true}
|
|
positionAbsoluteX={0}
|
|
positionAbsoluteY={0}
|
|
{...props}
|
|
/>,
|
|
)
|
|
}
|
|
|
|
describe('GroupNode', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
})
|
|
|
|
it('renders the group label when show_border is true', () => {
|
|
renderGroupNode()
|
|
expect(screen.getByText('My Group')).toBeDefined()
|
|
})
|
|
|
|
it('hides the header when show_border is false and not selected', () => {
|
|
renderGroupNode({ data: makeGroupNode({ custom_colors: { show_border: false } }).data, selected: false })
|
|
expect(screen.queryByText('My Group')).toBeNull()
|
|
})
|
|
|
|
it('shows header when show_border is false but node is selected', () => {
|
|
renderGroupNode({ data: makeGroupNode({ custom_colors: { show_border: false } }).data, selected: true })
|
|
expect(screen.getByText('My Group')).toBeDefined()
|
|
})
|
|
|
|
it('shows NodeResizer only when selected', () => {
|
|
const { rerender } = renderGroupNode({ selected: false })
|
|
expect(screen.getByTestId('node-resizer').getAttribute('data-visible')).toBe('false')
|
|
|
|
vi.mocked(canvasStore.useCanvasStore).mockReturnValue({
|
|
nodes: [],
|
|
updateNode: vi.fn(),
|
|
snapshotHistory: vi.fn(),
|
|
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
|
|
|
|
rerender(
|
|
<GroupNode
|
|
id="g1"
|
|
data={makeGroupNode().data}
|
|
selected={true}
|
|
dragging={false}
|
|
zIndex={1}
|
|
isConnectable={true}
|
|
positionAbsoluteX={0}
|
|
positionAbsoluteY={0}
|
|
/>,
|
|
)
|
|
expect(screen.getByTestId('node-resizer').getAttribute('data-visible')).toBe('true')
|
|
})
|
|
|
|
it('allows dragging from the header while keeping rename controls nodrag', () => {
|
|
renderGroupNode({ selected: true })
|
|
|
|
expect(screen.getByText('My Group').closest('div')).not.toHaveClass('nodrag')
|
|
|
|
const renameButton = screen.getByTitle('Rename group')
|
|
expect(renameButton).toHaveClass('nodrag')
|
|
|
|
fireEvent.click(renameButton)
|
|
|
|
expect(screen.getByDisplayValue('My Group')).toHaveClass('nodrag')
|
|
})
|
|
|
|
it('shows online/offline status summary from children', () => {
|
|
const storeNodes = [
|
|
{ id: 'c1', parentId: 'g1', data: { status: 'online' } },
|
|
{ id: 'c2', parentId: 'g1', data: { status: 'offline' } },
|
|
{ id: 'c3', parentId: 'other', data: { status: 'online' } }, // different group — excluded
|
|
]
|
|
|
|
renderGroupNode({}, storeNodes)
|
|
// Two status indicators: one online, one offline (c3 excluded — wrong parent)
|
|
const statusSpans = screen.getAllByText(/● \d+/)
|
|
expect(statusSpans).toHaveLength(2)
|
|
})
|
|
|
|
it('does not show status summary when group has no children', () => {
|
|
renderGroupNode()
|
|
expect(screen.queryByText(/●/)).toBeNull()
|
|
})
|
|
|
|
it('renders a collapse toggle when the group has parentId children', () => {
|
|
const storeNodes = [
|
|
{ id: 'c1', parentId: 'g1', data: { status: 'online' } },
|
|
{ id: 'c2', parentId: 'g1', data: { status: 'online' } },
|
|
]
|
|
renderGroupNode({}, storeNodes)
|
|
expect(screen.getByTitle('Hide 2 items')).toBeDefined()
|
|
})
|
|
|
|
it('flips the toggle title when collapsed', () => {
|
|
const storeNodes = [
|
|
{ id: 'c1', parentId: 'g1', data: { status: 'online' } },
|
|
]
|
|
renderGroupNode({ data: makeGroupNode({ collapsed: true }).data }, storeNodes)
|
|
expect(screen.getByTitle('Show 1 hidden items')).toBeDefined()
|
|
})
|
|
|
|
it('calls toggleNodeCollapsed when the toggle is clicked', () => {
|
|
const toggleNodeCollapsed = vi.fn()
|
|
const storeNodes = [{ id: 'c1', parentId: 'g1', data: { status: 'online' } }]
|
|
vi.mocked(canvasStore.useCanvasStore).mockReturnValue({
|
|
nodes: storeNodes,
|
|
updateNode: vi.fn(),
|
|
snapshotHistory: vi.fn(),
|
|
toggleNodeCollapsed,
|
|
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
|
|
render(
|
|
<GroupNode
|
|
id="g1"
|
|
data={makeGroupNode().data}
|
|
selected={false}
|
|
dragging={false}
|
|
zIndex={1}
|
|
isConnectable={true}
|
|
positionAbsoluteX={0}
|
|
positionAbsoluteY={0}
|
|
/>,
|
|
)
|
|
fireEvent.click(screen.getByTitle('Hide 1 items'))
|
|
expect(toggleNodeCollapsed).toHaveBeenCalledWith('g1')
|
|
})
|
|
|
|
it('does not render the toggle when the group has no children', () => {
|
|
renderGroupNode()
|
|
expect(screen.queryByTitle(/Hide.*items|Show.*hidden/)).toBeNull()
|
|
})
|
|
})
|