fix(canvas): make collapse reachable + persist for every node type

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.
This commit is contained in:
Pouzor
2026-05-29 08:58:36 +02:00
parent 20e1820a4e
commit b71c96897a
8 changed files with 486 additions and 145 deletions
@@ -17,7 +17,7 @@ import '@xyflow/react/dist/style.css'
import { useCanvasStore } from '@/stores/canvasStore'
import { useThemeStore } from '@/stores/themeStore'
import { THEMES } from '@/utils/themes'
import { getVisibleNodeIds, rewireEdgesForCollapse } from '@/utils/collapseFilter'
import { computeCollapseInfo, rewireEdgesForCollapse } from '@/utils/collapseFilter'
import { nodeTypes } from './nodes/nodeTypes'
import { edgeTypes } from './edges/edgeTypes'
import { SearchBar } from './SearchBar'
@@ -57,14 +57,14 @@ export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, o
const theme = THEMES[activeTheme]
// Filter nodes and edges based on collapsed state (memoized — O(n)).
const visibleNodeIds = useMemo(() => getVisibleNodeIds(nodes), [nodes])
const collapseInfo = useMemo(() => computeCollapseInfo(nodes), [nodes])
const visibleNodes = useMemo(
() => nodes.filter((n) => visibleNodeIds.has(n.id)),
[nodes, visibleNodeIds],
() => nodes.filter((n) => collapseInfo.visibleIds.has(n.id)),
[nodes, collapseInfo],
)
const visibleEdges = useMemo(
() => rewireEdgesForCollapse(edges, nodes, visibleNodeIds),
[edges, nodes, visibleNodeIds],
() => rewireEdgesForCollapse(edges, nodes, collapseInfo.visibleIds, collapseInfo.hiddenBy),
[edges, nodes, collapseInfo],
)
const onNodeClick = useCallback((e: React.MouseEvent, node: Node<NodeData>) => {