diff --git a/packages/frontend/editor-ui/src/features/workflows/canvas/components/Canvas.test.ts b/packages/frontend/editor-ui/src/features/workflows/canvas/components/Canvas.test.ts index 58a21040345..321309f30cc 100644 --- a/packages/frontend/editor-ui/src/features/workflows/canvas/components/Canvas.test.ts +++ b/packages/frontend/editor-ui/src/features/workflows/canvas/components/Canvas.test.ts @@ -374,6 +374,94 @@ describe('Canvas', () => { }); }); + describe('group body click', () => { + const setupGroups = async () => { + workflowDocumentStore.setNodeGroups([{ id: 'g1', name: 'Group 1', nodeIds: ['node-1'] }]); + const nodeGroupView = createNodeGroupViewMock(false); + const rendered = renderComponent({ + props: { + nodes: [createCanvasNodeElement({ id: 'node-1' }), createCanvasGroupNode({ id: 'g1' })], + }, + global: { provide: { [NodeGroupViewKey as symbol]: nodeGroupView } }, + }); + + await waitFor(() => + expect(rendered.container.querySelectorAll('.vue-flow__node')).toHaveLength(2), + ); + + const frame = rendered.container.querySelector( + '[data-id="group:g1"] [data-test-id="canvas-node-group-frame"]', + )!; + + return { ...rendered, frame, nodeGroupView, vueFlow: useVueFlow(canvasId) }; + }; + + it('selects the whole group when its body is clicked, without toggling collapse', async () => { + const { frame, emitted, nodeGroupView, vueFlow } = await setupGroups(); + + await fireEvent.pointerDown(frame); + await fireEvent.click(frame); + + // Title bar selected; the reconciler extends it to the members. + await waitFor(() => + expect(vueFlow.getSelectedNodes.value.map(({ id }) => id).sort()).toEqual([ + 'group:g1', + 'node-1', + ]), + ); + expect(nodeGroupView.isGroupCollapsed('g1')).toBe(false); + expect(emitted()['click:pane']).toBeUndefined(); + }); + + it('selects the group on a body click while one of its members is selected', async () => { + const { frame, vueFlow } = await setupGroups(); + + vueFlow.addSelectedNodes([vueFlow.findNode('node-1')!]); + await waitFor(() => + expect(vueFlow.getSelectedNodes.value.map(({ id }) => id)).toContain('node-1'), + ); + + await fireEvent.pointerDown(frame); + await fireEvent.click(frame); + + await waitFor(() => + expect(vueFlow.getSelectedNodes.value.map(({ id }) => id).sort()).toEqual([ + 'group:g1', + 'node-1', + ]), + ); + }); + + it('keeps the group selected when its body is clicked again', async () => { + const { frame, vueFlow } = await setupGroups(); + + await fireEvent.pointerDown(frame); + await fireEvent.click(frame); + await waitFor(() => + expect(vueFlow.getSelectedNodes.value.map(({ id }) => id)).toContain('group:g1'), + ); + + await fireEvent.pointerDown(frame); + await fireEvent.click(frame); + + await waitFor(() => + expect(vueFlow.getSelectedNodes.value.map(({ id }) => id)).toContain('group:g1'), + ); + }); + + it('treats modifier body clicks as selection gestures without toggling collapse', async () => { + const { frame, nodeGroupView, vueFlow } = await setupGroups(); + + await fireEvent.pointerDown(frame, { metaKey: true }); + await fireEvent.click(frame, { metaKey: true }); + + await waitFor(() => + expect(vueFlow.getSelectedNodes.value.map(({ id }) => id)).toContain('group:g1'), + ); + expect(nodeGroupView.isGroupCollapsed('g1')).toBe(false); + }); + }); + describe('expanded group selection', () => { const setupExpandedGroup = async (initialCollapsed = false) => { workflowDocumentStore.setNodeGroups([ diff --git a/packages/frontend/editor-ui/src/features/workflows/canvas/components/Canvas.vue b/packages/frontend/editor-ui/src/features/workflows/canvas/components/Canvas.vue index a26ebaaf89d..6e5a3f4a95d 100644 --- a/packages/frontend/editor-ui/src/features/workflows/canvas/components/Canvas.vue +++ b/packages/frontend/editor-ui/src/features/workflows/canvas/components/Canvas.vue @@ -827,7 +827,14 @@ function onPointerDownCapture() { function onNodeClick({ event, node }: NodeMouseEvent) { if (isCanvasGroupNode(node)) { - // Modifier clicks keep VueFlow's multi-select behavior (cmd/ctrl+click). + // Clicks on the group body (frame) are plain selection gestures — + // VueFlow's native click handling already selected the group (plain + // click) or toggled its selection (cmd/ctrl+click). + const isBodyClick = + event.target instanceof Element && event.target.closest('[data-canvas-node-group-body]'); + if (isBodyClick) return; + + // Modifier clicks on the header keep VueFlow's multi-select behavior. if (event.ctrlKey || event.metaKey || event.shiftKey) return; // A plain click on the title bar toggles collapse instead of selecting — @@ -1766,6 +1773,15 @@ defineExpose({ width: var(--canvas-selection-box--width) !important; height: var(--canvas-selection-box--height) !important; } + + // Group title bars (and their pointer-interactive frames) must stay behind + // canvas nodes at all times. VueFlow raises selected nodes by +1000, which + // would float a selected group's frame above any overlapping node that + // isn't part of the same selection — swallowing its pointer events (dead + // clicks, hover flicker). + :global(.vue-flow__node-canvas-node-group) { + z-index: -1 !important; + } /* stylelint-enable declaration-no-important */ } diff --git a/packages/frontend/editor-ui/src/features/workflows/canvas/components/elements/groups/CanvasNodeGroupTitleBar.vue b/packages/frontend/editor-ui/src/features/workflows/canvas/components/elements/groups/CanvasNodeGroupTitleBar.vue index 823c58468e8..d9afe6333c5 100644 --- a/packages/frontend/editor-ui/src/features/workflows/canvas/components/elements/groups/CanvasNodeGroupTitleBar.vue +++ b/packages/frontend/editor-ui/src/features/workflows/canvas/components/elements/groups/CanvasNodeGroupTitleBar.vue @@ -316,6 +316,7 @@ function onWrapperPointerDown(event: PointerEvent) { v-if="!isCollapsed" :class="$style.frame" :style="frameStyle" + data-canvas-node-group-body data-test-id="canvas-node-group-frame" /> @@ -487,6 +488,10 @@ function onWrapperPointerDown(event: PointerEvent) { opacity: 1; } +// The frame is a pointer surface: clicking empty group space selects the +// group and dragging it moves the group, through the same VueFlow node +// machinery as the title bar. Member nodes and edges render above it, so +// only clicks on truly empty group space land here. .frame { position: absolute; left: 0; @@ -495,7 +500,6 @@ function onWrapperPointerDown(event: PointerEvent) { @include styles.canvas-node-border(dashed); border-top: none; border-radius: 0 0 var(--radius--lg) var(--radius--lg); - pointer-events: none; box-sizing: border-box; z-index: 0; }