feat(editor): Improve empty-group style & behavior (no-changelog) (#38705)

This commit is contained in:
Daria 2026-09-28 12:57:29 +00:00 committed by GitHub
parent acaa281c54
commit a392f4e18e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
28 changed files with 1139 additions and 144 deletions

View File

@ -1897,6 +1897,7 @@
"logs.overview.header.switch.details": "Details",
"logs.overview.header.switch.overview": "Overview",
"logs.overview.body.empty.message": "Nothing to display yet. Execute the workflow to see execution logs.",
"logs.overview.body.emptyGroup": "empty",
"logs.overview.body.dataTooLarge.message": "This execution's data is too large to display.",
"logs.overview.body.empty.action": "Execute the workflow",
"logs.overview.body.summaryText.for": "{status} for {time}",

View File

@ -1775,6 +1775,81 @@ describe('useCanvasOperations', () => {
});
});
describe('addNodesAndConnections', () => {
it('keeps the replacement node aligned when an earlier batch item fails', async () => {
const nodeTypesStore = useNodeTypesStore();
const failedType = 'n8n-nodes-base.limited';
const replacementType = 'n8n-nodes-base.replacement';
const anchor = createTestNode({
id: 'anchor',
name: 'Empty Group Anchor',
type: NO_OP_NODE_TYPE,
parameters: { emptyGroupAnchor: true },
});
const group = { id: 'group', name: 'Group 1', nodeIds: [anchor.id] };
const nodesById = new Map([[anchor.id, anchor]]);
nodeTypesStore.nodeTypes = {
[failedType]: {
1: mockNodeTypeDescription({ name: failedType, maxNodes: 0 }),
},
[replacementType]: {
1: mockNodeTypeDescription({ name: replacementType }),
},
};
workflowDocumentStoreInstance.allNodes = [anchor];
workflowDocumentStoreInstance.connectionsBySourceNode = {};
vi.mocked(workflowDocumentStoreInstance.incomingConnectionsByNodeName).mockReturnValue({});
vi.mocked(workflowDocumentStoreInstance.outgoingConnectionsByNodeName).mockReturnValue({});
vi.spyOn(workflowDocumentStoreInstance, 'getNodeById').mockImplementation((id) =>
nodesById.get(id),
);
vi.spyOn(workflowDocumentStoreInstance, 'getNodeByName').mockImplementation(
(name) => [...nodesById.values()].find((node) => node.name === name) ?? null,
);
vi.spyOn(workflowDocumentStoreInstance, 'getGroupForNode').mockImplementation((id) =>
group.nodeIds.includes(id) ? group : undefined,
);
vi.spyOn(workflowDocumentStoreInstance, 'getGroupById').mockReturnValue(group);
vi.spyOn(workflowDocumentStoreInstance, 'getParentNodes').mockReturnValue([]);
vi.spyOn(workflowDocumentStoreInstance, 'getChildNodes').mockReturnValue([]);
vi.spyOn(workflowDocumentStoreInstance, 'getConnectionsBetweenNodes').mockReturnValue([]);
vi.spyOn(workflowDocumentStoreInstance, 'addNode').mockImplementation((addedNode) => {
nodesById.set(addedNode.id, addedNode);
workflowDocumentStoreInstance.allNodes = [...nodesById.values()];
});
vi.spyOn(workflowDocumentStoreInstance, 'replaceNodeInGroup').mockImplementation(
(_groupId, previousNodeId, newNodeId) => {
group.nodeIds = group.nodeIds.map((id) => (id === previousNodeId ? newNodeId : id));
},
);
vi.spyOn(workflowDocumentStoreInstance, 'removeNodeById').mockImplementation((id) => {
nodesById.delete(id);
workflowDocumentStoreInstance.allNodes = [...nodesById.values()];
});
vi.spyOn(nodeGroupOperationGuards, 'useCanvasNodeGroupOperationGuards').mockReturnValue({
isConnectionRemovalAllowedForNodeGroups: vi.fn().mockReturnValue(true),
isConnectionReplacementAllowedForNodeGroups: vi
.fn()
.mockReturnValue({ outcome: 'proceed' }),
isNodeReplacementAllowedForNodeGroups: vi.fn().mockReturnValue(true),
applyNodeGroupAutoExtend: vi.fn(),
});
const { addNodesAndConnections } = useCanvasOperations();
const { addedNodes } = await addNodesAndConnections(
[{ type: failedType, isAutoAdd: true }, { type: replacementType }],
[],
{ replaceNodeId: anchor.id, trackHistory: false, trackBulk: false },
);
const replacementNode = addedNodes.find((node) => node.type === replacementType);
expect(replacementNode).toBeDefined();
expect(group.nodeIds).toEqual([replacementNode?.id]);
expect(nodesById.has(anchor.id)).toBe(false);
});
});
describe('revertAddNode', () => {
it('deletes node if it exists', async () => {
const node = createTestNode();
@ -1928,8 +2003,8 @@ describe('useCanvasOperations', () => {
nodesById.delete(id);
});
const { deleteNode } = useCanvasOperations();
deleteNode(node.id, { trackHistory: true });
const { deleteNodes } = useCanvasOperations();
deleteNodes([node.id], { trackHistory: true, deleteWholeGroupIds: ['other-group'] });
expect(workflowDocumentStoreInstance.addNode).toHaveBeenCalled();
expect(workflowDocumentStoreInstance.replaceNodeInGroup).toHaveBeenCalled();
@ -1967,7 +2042,7 @@ describe('useCanvasOperations', () => {
});
const { deleteNodes } = useCanvasOperations();
deleteNodes([node.id], { trackHistory: true, preserveEmptyGroupAnchor: false });
deleteNodes([node.id], { trackHistory: true, deleteWholeGroupIds: [group.id] });
expect(workflowDocumentStoreInstance.addNode).not.toHaveBeenCalled();
expect(workflowDocumentStoreInstance.removeNodeById).toHaveBeenCalledWith(node.id);

View File

@ -200,6 +200,8 @@ type AddNodesBaseOptions = {
type AddNodesOptions = AddNodesBaseOptions & {
position?: XYPosition;
trackBulk?: boolean;
/** Maps each input index to the node that was actually added, if it succeeded. */
addedNodesByInputIndex?: Map<number, INodeUi>;
};
type AddNodeOptions = AddNodesBaseOptions & {
@ -754,19 +756,26 @@ export function useCanvasOperations() {
function deleteNodes(
ids: string[],
{ trackHistory = true, trackBulk = true, preserveEmptyGroupAnchor = true } = {},
{
trackHistory = true,
trackBulk = true,
deleteWholeGroupIds = [],
}: { trackHistory?: boolean; trackBulk?: boolean; deleteWholeGroupIds?: string[] } = {},
) {
const deleteWholeGroupIdSet = new Set(deleteWholeGroupIds);
if (trackHistory && trackBulk) {
historyStore.startRecordingUndo();
}
ids.forEach((id) =>
ids.forEach((id) => {
const group = workflowDocumentStore.value.getGroupForNode(id);
deleteNode(id, {
trackHistory,
trackBulk: false,
preserveEmptyGroupAnchor,
}),
);
preserveEmptyGroupAnchor: !group || !deleteWholeGroupIdSet.has(group.id),
});
});
if (trackHistory && trackBulk) {
historyStore.stopRecordingUndo();
@ -1176,6 +1185,7 @@ export function useCanvasOperations() {
newNode.placeholder = true;
}
addedNodes.push(newNode);
options.addedNodesByInputIndex?.set(index, newNode);
} catch (error) {
toast.showError(error, i18n.baseText('error'));
console.error(error);
@ -3762,11 +3772,13 @@ export function useCanvasOperations() {
historyStore.startRecordingUndo();
}
const addedNodesByInputIndex = new Map<number, INodeUi>();
const addedNodes = await addNodes(nodes, {
...options,
trackHistory,
trackBulk: false,
telemetry: true,
addedNodesByInputIndex,
});
let replacementGroupId: string | undefined;
@ -3775,7 +3787,9 @@ export function useCanvasOperations() {
// must not become the replacement target.
const replacementNodeIndex = nodes.findLastIndex((node) => !node.isAutoAdd);
const replacementNode =
replacementNodeIndex === -1 ? addedNodes.at(-1) : addedNodes[replacementNodeIndex];
replacementNodeIndex === -1
? addedNodes.at(-1)
: addedNodesByInputIndex.get(replacementNodeIndex);
if (replacementNode) {
const didReplace = replaceNode(options.replaceNodeId, replacementNode.id, {
trackHistory,
@ -3787,37 +3801,39 @@ export function useCanvasOperations() {
}
}
const allNodes = workflowDocumentStore.value.allNodes;
const offsetIndex = allNodes.length - nodes.length;
const connections: CanvasConnectionCreateData[] = addedConnections.map(({ from, to }) => {
const fromNode = allNodes[offsetIndex + from.nodeIndex];
const toNode = allNodes[offsetIndex + to.nodeIndex];
const connections: CanvasConnectionCreateData[] = addedConnections.flatMap(({ from, to }) => {
const fromNode = addedNodesByInputIndex.get(from.nodeIndex);
const toNode = addedNodesByInputIndex.get(to.nodeIndex);
if (!fromNode || !toNode) return [];
const type = from.type ?? to.type ?? NodeConnectionTypes.Main;
return {
source: fromNode.id,
sourceHandle: createCanvasConnectionHandleString({
mode: CanvasConnectionMode.Output,
type: isValidNodeConnectionType(type) ? type : NodeConnectionTypes.Main,
index: from.outputIndex ?? 0,
}),
target: toNode.id,
targetHandle: createCanvasConnectionHandleString({
mode: CanvasConnectionMode.Input,
type: isValidNodeConnectionType(type) ? type : NodeConnectionTypes.Main,
index: to.inputIndex ?? 0,
}),
data: {
source: {
return [
{
source: fromNode.id,
sourceHandle: createCanvasConnectionHandleString({
mode: CanvasConnectionMode.Output,
type: isValidNodeConnectionType(type) ? type : NodeConnectionTypes.Main,
index: from.outputIndex ?? 0,
type,
},
target: {
}),
target: toNode.id,
targetHandle: createCanvasConnectionHandleString({
mode: CanvasConnectionMode.Input,
type: isValidNodeConnectionType(type) ? type : NodeConnectionTypes.Main,
index: to.inputIndex ?? 0,
type,
}),
data: {
source: {
index: from.outputIndex ?? 0,
type,
},
target: {
index: to.inputIndex ?? 0,
type,
},
},
},
};
];
});
await addConnections(connections, { trackHistory, trackBulk: false });

View File

@ -23,6 +23,15 @@ export const NODE_CREATOR_OPEN_SOURCES: Record<
INSTANCE_AI: 'instance_ai',
'': '',
};
export function isNodeCreatorOpenFromConnection(source: NodeCreatorOpenSource) {
return [
NODE_CREATOR_OPEN_SOURCES.PLUS_ENDPOINT,
NODE_CREATOR_OPEN_SOURCES.NODE_CONNECTION_ACTION,
NODE_CREATOR_OPEN_SOURCES.NODE_CONNECTION_DROP,
].includes(source);
}
export const CORE_NODES_CATEGORY = 'Core Nodes';
export const HUMAN_IN_THE_LOOP_CATEGORY = 'HITL';
export const CUSTOM_NODES_CATEGORY = 'Custom Nodes';

View File

@ -6,6 +6,7 @@ import {
EVALUATION_TRIGGER_NODE_TYPE,
MANUAL_TRIGGER_NODE_TYPE,
NodeConnectionTypes,
isEmptyGroupAnchor,
} from 'n8n-workflow';
import {
createWorkflowDocumentId,
@ -19,14 +20,28 @@ import { useNodeTypesStore } from '../stores/nodeTypes.store';
import { useSourceControlStore } from '@/features/integrations/sourceControl.ee/sourceControl.store';
import { renderComponent } from '@/__tests__/render';
import NodeView from './NodeView.vue';
import { NO_OP_NODE_TYPE, SPLIT_IN_BATCHES_NODE_TYPE, VIEWS } from '../constants';
import {
NODE_CREATOR_OPEN_SOURCES,
NO_OP_NODE_TYPE,
SET_NODE_TYPE,
SPLIT_IN_BATCHES_NODE_TYPE,
VIEWS,
} from '../constants';
import { WorkflowIdKey, WorkflowDocumentStoreKey } from '../constants/injectionKeys';
import { computed, defineComponent, nextTick, shallowRef } from 'vue';
import { nodeViewEventBus } from '@/app/event-bus';
import { useProjectsStore } from '@/features/collaboration/projects/projects.store';
import type { Project } from '@/features/collaboration/projects/projects.types';
import { useHistoryStore } from '@/app/stores/history.store';
import { AddNodeCommand, AddNodeGroupCommand, BulkCommand } from '@/app/models/history';
import {
AddConnectionCommand,
AddNodeCommand,
AddNodeGroupCommand,
BulkCommand,
} from '@/app/models/history';
import { useNodeCreatorStore } from '@/features/shared/nodeCreator/nodeCreator.store';
import { useCanvasStore } from '@/app/stores/canvas.store';
import { DEFAULT_NODE_SIZE, snapPositionToGrid } from '@/app/utils/nodeViewUtils';
import { useTypeAvailabilityPoliciesStore } from '@n8n/frontend-module-type-availability-policies';
const mockMcpJsonNudgeGate = vi.hoisted(() => vi.fn());
@ -122,7 +137,7 @@ describe('NodeView', () => {
stubs: {
// Component stubs avoid loading async subtrees that can outlive the test environment.
LazyNodeCreation: defineComponent({
emits: ['addEmptyGroup', 'addNodes'],
emits: ['addEmptyGroup', 'addNodes', 'toggleNodeCreator', 'close'],
setup(_, { emit }) {
return {
addLoopReplacement: () => emit('addNodes', loopReplacementBatch),
@ -131,22 +146,27 @@ describe('NodeView', () => {
template: `<>
<button
data-test-id="node-creation-stub-add-empty-group"
@click="$emit('addEmptyGroup', [320, 240])"
@click="$emit('addEmptyGroup', false)"
/>
<button
data-test-id="node-creation-stub-add-empty-groups"
@click="$emit('addEmptyGroup', [320, 240]); $emit('addEmptyGroup', [640, 480])"
@click="$emit('addEmptyGroup', false); $emit('addEmptyGroup', false)"
/>
<button
data-test-id="node-creation-stub-add-loop-replacement"
@click="addLoopReplacement"
/>
<button
data-test-id="node-creation-stub-add-node"
@click="$emit('addNodes', { nodes: [{ type: '${SET_NODE_TYPE}', name: 'Added' }], connections: [] }); $emit('toggleNodeCreator', { createNodeActive: false, hasAddedNodes: true }); $emit('close')"
/>
</>`,
}),
LazySetupWorkflowCredentialsButton: { render: () => null },
WorkflowCanvas: defineComponent({
emits: ['copy:nodes', 'replace:node'],
emits: ['copy:nodes', 'replace:node', 'viewport:change'],
setup(_, { emit, expose }) {
const canvasStore = useCanvasStore();
expose({ ensureNodesAreVisible });
return {
copyNodeIds,
@ -154,11 +174,20 @@ describe('NodeView', () => {
const nodeId = workflowDocStore.allNodes[0]?.id;
if (nodeId) emit('replace:node', nodeId);
},
selectFirstGroup: () => {
const groupId = workflowDocStore.allGroups[0]?.id;
if (groupId) canvasStore.setSelectedGroupId(groupId);
},
};
},
template: `<div>
<button data-test-id="canvas-stub-copy" @click="$emit('copy:nodes', copyNodeIds)" />
<button data-test-id="canvas-stub-replace-first" @click="replaceFirstNode" />
<button data-test-id="canvas-stub-select-first-group" @click="selectFirstGroup" />
<button
data-test-id="canvas-stub-set-viewport"
@click="$emit('viewport:change', { x: 0, y: 0, zoom: 1 }, { width: 1000, height: 1000 })"
/>
<slot />
</div>`,
}),
@ -167,7 +196,7 @@ describe('NodeView', () => {
});
}
describe('Empty group creation', () => {
describe('Node group creation and output-plus behavior', () => {
function addReplacementNodeTypes() {
useNodeTypesStore().setNodeTypes([
mockNodeTypeDescription({
@ -193,7 +222,6 @@ describe('NodeView', () => {
}),
]);
}
it('creates a marked NoOp and its group as one undoable action', async () => {
routeMock.meta = { nodeView: true };
useWorkflowsListStore().addWorkflow(
@ -223,7 +251,6 @@ describe('NodeView', () => {
expect(anchor).toMatchObject({
type: NO_OP_NODE_TYPE,
name: 'No Operation, do nothing',
position: [320, 240],
parameters: { emptyGroupAnchor: true },
placeholder: true,
});
@ -271,6 +298,291 @@ describe('NodeView', () => {
expect(useHistoryStore().undoStack).toHaveLength(1);
});
it('centers the first empty group and collision-adjusts later groups', async () => {
routeMock.meta = { nodeView: true };
useWorkflowsListStore().addWorkflow(
createTestWorkflow({ id: 'w0', scopes: ['workflow:read', 'workflow:update'] }),
);
useNodeTypesStore().setNodeTypes([
mockNodeTypeDescription({
name: NO_OP_NODE_TYPE,
displayName: 'No Operation, do nothing',
properties: [],
}),
]);
const { findByTestId } = renderNodeView();
await userEvent.click(await findByTestId('canvas-stub-set-viewport'));
const addEmptyGroup = await findByTestId('node-creation-stub-add-empty-group');
await userEvent.click(addEmptyGroup);
await waitFor(() => expect(workflowDocumentStore.allGroups).toHaveLength(1));
const firstPosition = workflowDocumentStore.allNodes[0].position;
expect(firstPosition).toEqual(
snapPositionToGrid([500 - DEFAULT_NODE_SIZE[0] / 2, 500 - DEFAULT_NODE_SIZE[1] / 2]),
);
await userEvent.click(addEmptyGroup);
await waitFor(() => expect(workflowDocumentStore.allGroups).toHaveLength(2));
expect(workflowDocumentStore.allNodes[1].position).not.toEqual(firstPosition);
});
it('connects a new empty group to the selected empty group', async () => {
routeMock.meta = { nodeView: true };
useWorkflowsListStore().addWorkflow(
createTestWorkflow({ id: 'w0', scopes: ['workflow:read', 'workflow:update'] }),
);
useNodeTypesStore().setNodeTypes([
mockNodeTypeDescription({
name: NO_OP_NODE_TYPE,
displayName: 'No Operation, do nothing',
inputs: [NodeConnectionTypes.Main],
outputs: [NodeConnectionTypes.Main],
properties: [
{
displayName: 'Empty Group Anchor',
name: 'emptyGroupAnchor',
type: 'hidden',
default: false,
validateType: undefined,
},
],
}),
]);
const { findByTestId } = renderNodeView();
await userEvent.click(await findByTestId('canvas-stub-set-viewport'));
const addEmptyGroup = await findByTestId('node-creation-stub-add-empty-group');
await userEvent.click(addEmptyGroup);
await waitFor(() => expect(workflowDocumentStore.allGroups).toHaveLength(1));
const firstAnchor = workflowDocumentStore.allNodes[0];
expect(isEmptyGroupAnchor(firstAnchor)).toBe(true);
expect(workflowDocumentStore.allGroups[0].nodeIds).toEqual([firstAnchor.id]);
await userEvent.click(await findByTestId('canvas-stub-select-first-group'));
await userEvent.click(addEmptyGroup);
await waitFor(() => expect(workflowDocumentStore.allGroups).toHaveLength(2));
const secondGroup = workflowDocumentStore.allGroups[1];
const secondAnchor = workflowDocumentStore.allNodes[1];
expect(secondGroup.nodeIds).toEqual([secondAnchor.id]);
expect(secondAnchor.position[0]).toBeGreaterThan(firstAnchor.position[0]);
expect(workflowDocumentStore.connectionsBySourceNode).toMatchObject({
[firstAnchor.name]: {
[NodeConnectionTypes.Main]: [
[{ node: secondAnchor.name, type: NodeConnectionTypes.Main, index: 0 }],
],
},
});
const historyStore = useHistoryStore();
expect(historyStore.undoStack).toHaveLength(2);
const secondGroupAction = historyStore.undoStack[1];
expect(secondGroupAction).toBeInstanceOf(BulkCommand);
if (!(secondGroupAction instanceof BulkCommand)) {
throw new Error('Expected a bulk history action');
}
expect(secondGroupAction.commands).toEqual([
expect.any(AddNodeCommand),
expect.any(AddConnectionCommand),
expect.any(AddNodeGroupCommand),
]);
});
it("keeps a node added through a regular group's output plus outside the group", async () => {
routeMock.meta = { nodeView: true };
useWorkflowsListStore().addWorkflow(
createTestWorkflow({ id: 'w0', scopes: ['workflow:read', 'workflow:update'] }),
);
useNodeTypesStore().setNodeTypes([
mockNodeTypeDescription({
name: SET_NODE_TYPE,
inputs: [NodeConnectionTypes.Main],
outputs: [NodeConnectionTypes.Main],
}),
]);
const { findByTestId } = renderNodeView();
const source = createTestNode({
id: 'source',
name: 'Source',
type: SET_NODE_TYPE,
position: [0, 0],
});
workflowDocumentStore.addNode(source);
const group = workflowDocumentStore.createGroup([source.id], 'Group 1');
useNodeCreatorStore().openNodeCreatorForConnectingNode({
workflowId: workflowDocumentStore.workflowId,
connection: {
source: source.id,
sourceHandle: `outputs/${NodeConnectionTypes.Main}/0`,
},
eventSource: NODE_CREATOR_OPEN_SOURCES.PLUS_ENDPOINT,
});
await userEvent.click(await findByTestId('node-creation-stub-add-node'));
await waitFor(() => expect(workflowDocumentStore.allNodes).toHaveLength(2));
const addedNode = workflowDocumentStore.allNodes.find(({ id }) => id !== source.id);
expect(addedNode).toBeDefined();
expect(workflowDocumentStore.getGroupById(group.id)?.nodeIds).toEqual([source.id]);
expect(workflowDocumentStore.connectionsBySourceNode).toMatchObject({
[source.name]: {
[NodeConnectionTypes.Main]: [
[{ node: addedNode?.name, type: NodeConnectionTypes.Main, index: 0 }],
],
},
});
});
it("keeps a node inserted from a regular group's outgoing edge outside the group", async () => {
routeMock.meta = { nodeView: true };
useWorkflowsListStore().addWorkflow(
createTestWorkflow({ id: 'w0', scopes: ['workflow:read', 'workflow:update'] }),
);
useNodeTypesStore().setNodeTypes([
mockNodeTypeDescription({
name: SET_NODE_TYPE,
inputs: [NodeConnectionTypes.Main],
outputs: [NodeConnectionTypes.Main],
}),
]);
const { findByTestId } = renderNodeView();
const source = createTestNode({
id: 'source',
name: 'Source',
type: SET_NODE_TYPE,
position: [0, 0],
});
const target = createTestNode({
id: 'target',
name: 'Target',
type: SET_NODE_TYPE,
position: [500, 0],
});
workflowDocumentStore.setNodes([source, target]);
workflowDocumentStore.setConnections({
[source.name]: {
[NodeConnectionTypes.Main]: [
[{ node: target.name, type: NodeConnectionTypes.Main, index: 0 }],
],
},
});
workflowDocumentStore.setNodeGroups([
{ id: 'loaded-group', name: 'Loaded group', nodeIds: [source.id] },
]);
useNodeCreatorStore().openNodeCreatorForConnectingNode({
workflowId: workflowDocumentStore.workflowId,
connection: {
source: source.id,
sourceHandle: `outputs/${NodeConnectionTypes.Main}/0`,
target: target.id,
targetHandle: `inputs/${NodeConnectionTypes.Main}/0`,
},
eventSource: NODE_CREATOR_OPEN_SOURCES.NODE_CONNECTION_ACTION,
});
await userEvent.click(await findByTestId('node-creation-stub-add-node'));
await waitFor(() => expect(workflowDocumentStore.getNodeByName('Added')).toBeDefined());
const addedNode = workflowDocumentStore.getNodeByName('Added');
expect(addedNode).toBeDefined();
expect(workflowDocumentStore.getGroupById('loaded-group')?.nodeIds).toEqual([source.id]);
expect(workflowDocumentStore.connectionsBySourceNode).toMatchObject({
[source.name]: {
[NodeConnectionTypes.Main]: [
[{ node: addedNode?.name, type: NodeConnectionTypes.Main, index: 0 }],
],
},
[addedNode?.name ?? '']: {
[NodeConnectionTypes.Main]: [
[{ node: target.name, type: NodeConnectionTypes.Main, index: 0 }],
],
},
});
});
it.each([
['plus endpoint', NODE_CREATOR_OPEN_SOURCES.PLUS_ENDPOINT],
['connection action', NODE_CREATOR_OPEN_SOURCES.NODE_CONNECTION_ACTION],
['connection drop', NODE_CREATOR_OPEN_SOURCES.NODE_CONNECTION_DROP],
] as const)(
'replaces the empty-group anchor from %s and restores it with undo',
async (_, eventSource) => {
routeMock.meta = { nodeView: true };
useWorkflowsListStore().addWorkflow(
createTestWorkflow({ id: 'w0', scopes: ['workflow:read', 'workflow:update'] }),
);
useNodeTypesStore().setNodeTypes([
mockNodeTypeDescription({
name: NO_OP_NODE_TYPE,
displayName: 'No Operation, do nothing',
inputs: [NodeConnectionTypes.Main],
outputs: [NodeConnectionTypes.Main],
properties: [
{
displayName: 'Empty Group Anchor',
name: 'emptyGroupAnchor',
type: 'hidden',
default: false,
validateType: undefined,
},
],
}),
mockNodeTypeDescription({
name: SET_NODE_TYPE,
inputs: [NodeConnectionTypes.Main],
outputs: [NodeConnectionTypes.Main],
}),
]);
const { findByTestId } = renderNodeView();
await userEvent.click(await findByTestId('node-creation-stub-add-empty-group'));
await waitFor(() => expect(workflowDocumentStore.allGroups).toHaveLength(1));
const group = workflowDocumentStore.allGroups[0];
const anchor = workflowDocumentStore.allNodes[0];
useNodeCreatorStore().openNodeCreatorForConnectingNode({
workflowId: workflowDocumentStore.workflowId,
connection: {
source: anchor.id,
sourceHandle: `outputs/${NodeConnectionTypes.Main}/0`,
},
eventSource,
});
await userEvent.click(await findByTestId('node-creation-stub-add-node'));
await waitFor(() => expect(workflowDocumentStore.allNodes[0]?.id).not.toBe(anchor.id));
expect(workflowDocumentStore.allNodes).toHaveLength(1);
const addedNode = workflowDocumentStore.allNodes[0];
expect(addedNode.id).not.toBe(anchor.id);
expect(isEmptyGroupAnchor(addedNode)).toBe(false);
expect(workflowDocumentStore.getGroupById(group.id)?.nodeIds).toEqual([addedNode.id]);
const historyStore = useHistoryStore();
expect(historyStore.undoStack).toHaveLength(2);
const transition = historyStore.undoStack[1];
expect(transition).toBeInstanceOf(BulkCommand);
if (!(transition instanceof BulkCommand)) throw new Error('Expected a bulk history action');
const redoCommands = [];
for (let index = transition.commands.length - 1; index >= 0; index--) {
const command = transition.commands[index];
await command.revert();
redoCommands.push(command.getReverseCommand(Date.now()));
}
await waitFor(() => expect(workflowDocumentStore.allNodes).toEqual([anchor]));
expect(workflowDocumentStore.getGroupById(group.id)?.nodeIds).toEqual([anchor.id]);
for (let index = redoCommands.length - 1; index >= 0; index--) {
await redoCommands[index].revert();
}
await waitFor(() => expect(workflowDocumentStore.allNodes).toEqual([addedNode]));
expect(workflowDocumentStore.getGroupById(group.id)?.nodeIds).toEqual([addedNode.id]);
},
);
it('replaces the empty-group anchor with the selected batch and restores it on undo', async () => {
routeMock.meta = { nodeView: true };
useWorkflowsListStore().addWorkflow(

View File

@ -68,6 +68,7 @@ import {
ABOUT_MODAL_KEY,
PRODUCTION_ONLY_TRIGGER_NODE_TYPES,
HUMAN_IN_THE_LOOP_CATEGORY,
isNodeCreatorOpenFromConnection,
} from '@/app/constants';
import { useSourceControlStore } from '@/features/integrations/sourceControl.ee/sourceControl.store';
import { useNodeCreatorStore } from '@/features/shared/nodeCreator/nodeCreator.store';
@ -76,6 +77,7 @@ import {
jsonParse,
EVALUATION_TRIGGER_NODE_TYPE,
EVALUATION_NODE_TYPE,
getEmptyGroupAnchor,
isTriggerNode,
NodeHelpers,
NodeConnectionTypes,
@ -107,7 +109,7 @@ import { sourceControlEventBus } from '@/features/integrations/sourceControl.ee/
import { useTagsStore } from '@/features/shared/tags/tags.store';
import { injectNDVStore } from '@/features/ndv/shared/ndv.store';
import { getBounds, getNodeViewTab } from '@/app/utils/nodeViewUtils';
import { DEFAULT_NODE_SIZE, getBounds, getNodeViewTab } from '@/app/utils/nodeViewUtils';
import { isChatNode } from '@/app/utils/aiUtils';
import CanvasStopCurrentExecutionButton from '@/features/workflows/canvas/components/elements/buttons/CanvasStopCurrentExecutionButton.vue';
import CanvasStopWaitingForWebhookButton from '@/features/workflows/canvas/components/elements/buttons/CanvasStopWaitingForWebhookButton.vue';
@ -528,8 +530,8 @@ function onDeleteNode(id: string) {
}
}
function onDeleteNodes(ids: string[], deleteWholeGroups = false) {
deleteNodes(ids, { preserveEmptyGroupAnchor: !deleteWholeGroups });
function onDeleteNodes(ids: string[], deleteWholeGroupIds: string[] = []) {
deleteNodes(ids, { deleteWholeGroupIds });
}
function onRevertDeleteNode({ node }: { node: INodeUi }) {
@ -995,6 +997,23 @@ function onNodeCreatorClose() {
nodeCreatorReplaceTargetId.value = undefined;
}
function getOutputPlusEmptyGroupAnchorId(): string | undefined {
const isExplicitOutputAdd =
nodeCreatorStore.isCreateNodeActive &&
isNodeCreatorOpenFromConnection(nodeCreatorStore.openSource);
if (!isExplicitOutputAdd || !uiStore.lastInteractedWithNodeId) return undefined;
const { type, mode } = parseCanvasConnectionHandleString(uiStore.lastInteractedWithNodeHandle);
if (type !== NodeConnectionTypes.Main || mode !== CanvasConnectionMode.Output) return undefined;
const sourceNodeId = uiStore.lastInteractedWithNodeId;
const group = workflowDocumentStore.value.getGroupForNode(sourceNodeId);
if (!group) return undefined;
const anchor = getEmptyGroupAnchor(group, workflowDocumentStore.value.allNodes);
return anchor?.id === sourceNodeId ? anchor.id : undefined;
}
async function onAddNodesAndConnections(
{ nodes, connections }: AddedNodesAndConnections,
dragAndDrop = false,
@ -1003,8 +1022,9 @@ async function onAddNodesAndConnections(
if (!checkIfEditingIsAllowed()) {
return;
}
const replaceNodeId = nodeCreatorReplaceTargetId.value ?? getOutputPlusEmptyGroupAnchorId();
if (nodeCreatorReplaceTargetId.value !== undefined) {
if (replaceNodeId !== undefined) {
uiStore.resetLastInteractedWith();
nodes = nodes.map((x) => ({
@ -1018,7 +1038,7 @@ async function onAddNodesAndConnections(
position,
viewport: viewportBoundaries.value,
telemetry: true,
replaceNodeId: nodeCreatorReplaceTargetId.value,
replaceNodeId,
});
if (addedNodes.length > 0) {
@ -1027,23 +1047,48 @@ async function onAddNodesAndConnections(
}
}
async function onAddEmptyGroup(position: XYPosition, connectToLastInteractedNode = false) {
async function onAddEmptyGroup(connectToLastInteractedNode = false) {
if (!checkIfEditingIsAllowed() || isAddingEmptyGroup.value) return;
isAddingEmptyGroup.value = true;
// Seed generic placement at the viewport center while retaining collision handling for later groups.
if (
workflowDocumentStore.value.allNodes.length === 0 &&
viewportDimensions.value.width > 0 &&
viewportDimensions.value.height > 0
) {
const { xMin, xMax, yMin, yMax } = viewportBoundaries.value;
lastClickPosition.value = [
(xMin + xMax - DEFAULT_NODE_SIZE[0]) / 2,
(yMin + yMax - DEFAULT_NODE_SIZE[1]) / 2,
];
}
const ownsUndoBulk = historyStore.currentBulkAction === null;
if (ownsUndoBulk) historyStore.startRecordingUndo();
try {
const selectedGroup = canvasStore.selectedGroupId
? workflowDocumentStore.value.getGroupById(canvasStore.selectedGroupId)
: undefined;
const selectedEmptyGroupAnchor = selectedGroup
? getEmptyGroupAnchor(selectedGroup, workflowDocumentStore.value.allNodes)
: undefined;
// A selected empty group uses its hidden anchor as the normal add-node connection source.
if (!connectToLastInteractedNode && selectedEmptyGroupAnchor) {
uiStore.resetLastInteractedWith();
uiStore.lastInteractedWithNodeId = selectedEmptyGroupAnchor.id;
}
const shouldConnect = connectToLastInteractedNode || selectedEmptyGroupAnchor !== undefined;
const { addedNodes } = await addNodesAndConnections(
[
{
type: NO_OP_NODE_TYPE,
name: 'No Operation, do nothing',
position,
parameters: { emptyGroupAnchor: true },
placeholder: true,
isAutoAdd: !connectToLastInteractedNode,
isAutoAdd: !shouldConnect,
openDetail: false,
},
],

View File

@ -23,6 +23,7 @@ import {
import { createRunExecutionData, NodeConnectionTypes } from 'n8n-workflow';
import { createLogTree, flattenLogEntries } from '../logs.utils';
import type { useWorkflowDocumentStore } from '@/app/stores/workflowDocument.store';
import { NO_OP_NODE_TYPE } from '@/app/constants';
const { mockDocumentStore } = vi.hoisted(() => ({
mockDocumentStore: {
@ -228,6 +229,41 @@ describe('LogsOverviewPanel', () => {
expect(groupRow.queryByRole('img')).not.toBeInTheDocument();
});
it('should render an empty group as a non-expandable row with an empty badge', async () => {
const anchor = createTestNode({
id: 'anchor',
name: 'Empty group anchor',
type: NO_OP_NODE_TYPE,
parameters: { emptyGroupAnchor: true },
});
const workflow = createTestWorkflowObject({ id: 'w1', nodes: [anchor] });
workflow.getNode(anchor.name)!.parameters = { emptyGroupAnchor: true };
const execution = createTestWorkflowExecutionResponse({
id: 'e1',
data: createRunExecutionData({
resultData: { runData: { [anchor.name]: [createTestTaskData()] } },
}),
});
const logs = createLogTree(workflow, execution, {}, {}, undefined, [
{ id: 'empty-group', name: 'Empty Group', nodeIds: [anchor.id] },
]);
const rendered = render({
isOpen: true,
execution,
entries: logs,
flatLogEntries: flattenLogEntries(logs, {}),
});
await fireEvent.click(rendered.getByText('Overview'));
const tree = within(rendered.getByRole('tree'));
const groupRow = within(tree.getByText('Empty Group').closest('[role=treeitem]')!);
expect(groupRow.getByTestId('logs-empty-group-badge')).toHaveTextContent('empty');
expect(groupRow.getByLabelText('Toggle row')).not.toBeVisible();
expect(tree.getAllByRole('treeitem')).toHaveLength(1);
});
it('reflects a running member in the group row status', async () => {
const workflow = createTestWorkflowObject({
id: 'w1',

View File

@ -13,6 +13,7 @@ import {
getGroupTiming,
getSubtreeTotalConsumedTokens,
hasSubExecution,
isEmptyGroupLog,
} from '@/features/execution/logs/logs.utils';
import { useTimestamp } from '@vueuse/core';
import {
@ -22,7 +23,14 @@ import {
isNodeLog,
} from '@/features/execution/logs/logs.types';
import { N8nButton, N8nIcon, N8nIconButton, N8nText, N8nTooltip } from '@n8n/design-system';
import {
N8nBadge,
N8nButton,
N8nIcon,
N8nIconButton,
N8nText,
N8nTooltip,
} from '@n8n/design-system';
import AnimatedSpinner from '@/app/components/AnimatedSpinner.vue';
const props = defineProps<{
data: LogEntry;
@ -48,6 +56,7 @@ const now = useTimestamp({ interval: 1000 });
const nodeTypeStore = useNodeTypesStore();
const nodeData = computed(() => (isNodeLog(props.data) ? props.data : undefined));
const groupData = computed(() => (isGroupLog(props.data) ? props.data : undefined));
const isEmptyGroup = computed(() => isEmptyGroupLog(props.data));
const runData = computed(() => nodeData.value?.runData);
const type = computed(() =>
nodeData.value ? nodeTypeStore.getNodeType(nodeData.value.node.type) : null,
@ -128,7 +137,9 @@ const subtreeConsumedTokens = computed(() =>
props.shouldShowTokenCountColumn ? getSubtreeTotalConsumedTokens(props.data, false) : undefined,
);
const hasChildren = computed(() => props.data.children.length > 0 || hasSubExecution(props.data));
const hasChildren = computed(
() => !isEmptyGroup.value && (props.data.children.length > 0 || hasSubExecution(props.data)),
);
const indents = computed(() => {
const ret: Array<{ straight: boolean; curved: boolean }> = [];
@ -173,7 +184,7 @@ watch(
ref="containerRef"
role="treeitem"
tabindex="-1"
:aria-expanded="props.data.children.length > 0 && props.expanded"
:aria-expanded="isEmptyGroup ? undefined : props.data.children.length > 0 && props.expanded"
:aria-selected="props.isSelected"
:class="{
[$style.container]: true,
@ -200,6 +211,15 @@ watch(
:is-error="isError"
:is-deleted="latestInfo?.deleted ?? false"
/>
<N8nBadge
v-if="isEmptyGroup"
theme="tertiary"
size="xsmall"
:class="$style.emptyGroupBadge"
data-test-id="logs-empty-group-badge"
>
{{ locale.baseText('logs.overview.body.emptyGroup') }}
</N8nBadge>
<N8nText v-if="!isCompact" tag="div" color="text-light" size="small" :class="$style.timeTook">
<I18nT v-if="timeText !== undefined" :keypath="statusTextKeyPath" scope="global">
<template #status>
@ -402,6 +422,12 @@ watch(
padding-inline-start: var(--spacing--2xs);
}
.emptyGroupBadge {
flex-grow: 0;
flex-shrink: 0;
padding: 0 var(--spacing--4xs);
}
.timeTook {
flex-grow: 0;
flex-shrink: 0;

View File

@ -573,6 +573,27 @@ describe('LogsPanel', () => {
expect(await findByRole('treeitem', { selected: true })).toHaveTextContent(/AI Model/);
});
it('should handle arrow navigation without bubbling to canvas shortcuts', async () => {
const { getByTestId, findByRole } = render();
const overview = getByTestId('logs-overview');
const documentKeydown = vi.fn();
document.addEventListener('keydown', documentKeydown);
try {
await waitFor(async () =>
expect(await findByRole('treeitem', { selected: true })).toHaveTextContent(/AI Model/),
);
await fireEvent.keyDown(overview, { key: 'ArrowUp' });
expect(await findByRole('treeitem', { selected: true })).toHaveTextContent(/AI Agent/);
await fireEvent.keyDown(overview, { key: 'ArrowDown' });
expect(await findByRole('treeitem', { selected: true })).toHaveTextContent(/AI Model/);
expect(documentKeydown).not.toHaveBeenCalled();
} finally {
document.removeEventListener('keydown', documentKeydown);
}
});
it('should not select a log for the selected node on canvas if sync is disabled', async () => {
logsStore.toggleLogSelectionSync(false);

View File

@ -96,6 +96,7 @@ const keyMap = computed<KeyMap>(() => ({
j: selectNext,
k: selectPrev,
Escape: () => select(undefined),
// Keep the existing container-wide shortcuts as a fallback.
ArrowDown: selectNext,
ArrowUp: selectPrev,
Space: () => selected.value && toggleExpanded(selected.value),
@ -110,6 +111,20 @@ const keyMap = computed<KeyMap>(() => ({
: {}),
}));
function handleArrowNavigation(event: KeyboardEvent) {
// Handle overview navigation before the event reaches the canvas's document-level shortcuts.
if (event.key === 'ArrowDown') {
selectNext();
} else if (event.key === 'ArrowUp') {
selectPrev();
} else {
return;
}
event.preventDefault();
event.stopPropagation();
}
function handleResizeOverviewPanelEnd() {
if (isOverviewPanelFullWidth.value) {
select(undefined);
@ -211,6 +226,7 @@ function handleChangeOutputTableColumnCollapsing(columnName: string | null) {
:latest-node-info="latestNodeNameById"
:flat-log-entries="flatLogEntries"
:is-header-clickable="!isPoppedOut"
@keydown="handleArrowNavigation"
@click-header="onToggleOpen"
@select="select"
@clear-execution-data="resetExecutionData"

View File

@ -7,6 +7,7 @@ import {
} from '@/__tests__/mocks';
import {
createLogTree,
flattenLogEntries,
findSelectedLogEntry,
findSubExecutionLocator,
getDefaultCollapsedEntries,
@ -14,6 +15,7 @@ import {
getGroupTiming,
getSubtreeTotalConsumedTokens,
getTreeNodeData,
isEmptyGroupLog,
isSubNodeLog,
mergeStartData,
restoreChatHistory,
@ -43,7 +45,7 @@ import {
} from './logs.types';
import type { IExecutionResponse } from '@/features/execution/executions/executions.types';
import { createTestLogEntry } from './__test__/mocks';
import { AGENT_NODE_TYPE, CHAT_TRIGGER_NODE_TYPE } from '@/app/constants';
import { AGENT_NODE_TYPE, CHAT_TRIGGER_NODE_TYPE, NO_OP_NODE_TYPE } from '@/app/constants';
// A log tree of node entries only, all the way down (overrides the union-typed
// `children`/`parent` so assertions can read `.node`/`.runData` at any depth).
@ -1989,6 +1991,30 @@ describe('createLogTree with canvas groups', () => {
expect(logs.map((e) => e.type)).toEqual(['node', 'node', 'node', 'node']);
});
it('does not expose an empty-group anchor as an expandable child', () => {
const anchor = createTestNode({
id: 'anchor',
name: 'Empty group anchor',
type: NO_OP_NODE_TYPE,
parameters: { emptyGroupAnchor: true },
});
const workflow = createTestWorkflowObject({ id: 'w1', nodes: [anchor] });
workflow.getNode(anchor.name)!.parameters = { emptyGroupAnchor: true };
const response = createTestWorkflowExecutionResponse({
id: 'e1',
data: createRunExecutionData({
resultData: { runData: { [anchor.name]: [createTestTaskData()] } },
}),
});
const logs = createLogTree(workflow, response, {}, {}, undefined, [
{ id: 'empty-group', name: 'Empty Group', nodeIds: [anchor.id] },
]);
const groupEntry = expectGroup(logs[0]);
expect(isEmptyGroupLog(groupEntry)).toBe(true);
expect(flattenLogEntries(logs, {})).toEqual([groupEntry]);
});
it('folds contiguous grouped nodes into a single group segment', () => {
const logs = createLogTree(createLinearWorkflow(), linearResponse(), {}, {}, undefined, [
group,

View File

@ -17,6 +17,7 @@ import {
type IWorkflowGroup,
createEmptyRunExecutionData,
createRunExecutionData,
isEmptyGroupAnchor,
} from 'n8n-workflow';
import {
type GroupBoundaryRunData,
@ -661,7 +662,7 @@ export function flattenLogEntries(
for (const entry of entries) {
ret.push(entry);
if (!collapsedEntryIds[entry.id]) {
if (!collapsedEntryIds[entry.id] && !isEmptyGroupLog(entry)) {
flattenLogEntries(entry.children, collapsedEntryIds, ret);
}
}
@ -669,6 +670,15 @@ export function flattenLogEntries(
return ret;
}
export function isEmptyGroupLog(entry: LogEntry): entry is GroupLogEntry {
if (!isGroupLog(entry) || entry.group.nodeIds.length !== 1) return false;
const [anchorId] = entry.group.nodeIds;
return entry.children.some(
(child) => isNodeLog(child) && child.node.id === anchorId && isEmptyGroupAnchor(child.node),
);
}
export function getEntryAtRelativeIndex(
entries: LogEntry[],
id: string,

View File

@ -323,6 +323,35 @@ describe('useContextMenu', () => {
);
});
it('hides ungroup and convert actions for an empty group', () => {
const anchor = nodeFactory({ parameters: { emptyGroupAnchor: true } });
workflowDocumentStore.setNodes([...nodes, anchor]);
const group = workflowDocumentStore.createGroup([anchor.id], 'Empty group');
const { open, actions } = useContextMenu();
open(mockEvent, { source: 'group', groupId: group.id, nodeIds: group.nodeIds });
const ids = actions.value.map((action) => action.id);
expect(ids).not.toContain('ungroup_nodes');
expect(ids).not.toContain('extract_sub_workflow');
expect(ids).toContain('rename_group');
});
it('refreshes the target to the anchor when the last real member is deleted', () => {
const anchor = nodeFactory({ parameters: { emptyGroupAnchor: true } });
workflowDocumentStore.setNodes([...nodes, anchor]);
const group = workflowDocumentStore.createGroup([nodes[0].id], 'Empty group');
const { open, actions, targetNodeIds } = useContextMenu();
open(mockEvent, { source: 'group', groupId: group.id, nodeIds: group.nodeIds });
workflowDocumentStore.replaceNodeInGroup(group.id, nodes[0].id, anchor.id);
workflowDocumentStore.setNodes([...nodes.slice(1), anchor]);
expect(targetNodeIds.value).toEqual([anchor.id]);
const ids = actions.value.map((action) => action.id);
expect(ids).not.toContain('ungroup_nodes');
expect(ids).not.toContain('extract_sub_workflow');
});
it('falls back to the group actions alone when no member node resolves', () => {
const group = workflowDocumentStore.createGroup([nodes[0].id], 'My group');
const { open, actions } = useContextMenu();

View File

@ -3,6 +3,9 @@ import { useUIStore } from '@/app/stores/ui.store';
import { computed, ref, watch } from 'vue';
import { getMousePosition } from '@/app/utils/nodeViewUtils';
import { useContextMenuItems, type ContextMenuAction } from './useContextMenuItems';
import { getEmptyGroupAnchor } from 'n8n-workflow';
import { isPresent } from '@/app/utils/typesUtils';
import { injectWorkflowDocumentStore } from '@/app/stores/workflowDocument.store';
export type ContextMenuTarget = {
readOnly?: boolean;
@ -28,15 +31,26 @@ function getTargetEntityId(menuTarget: ContextMenuTarget): string | undefined {
export const useContextMenu = () => {
const uiStore = useUIStore();
const workflowDocumentStore = injectWorkflowDocumentStore();
const isOpen = computed(() => target.value !== undefined);
const targetNodeIds = computed(() => {
if (!target.value) return [];
const currentTarget = target.value;
return currentTarget.source === 'canvas' || currentTarget.source === 'group'
? currentTarget.nodeIds
: [currentTarget.nodeId];
if (currentTarget.source !== 'group') {
return currentTarget.source === 'canvas' ? currentTarget.nodeIds : [currentTarget.nodeId];
}
const group = workflowDocumentStore?.value?.getGroupById(currentTarget.groupId);
if (!group) return currentTarget.nodeIds;
const currentMemberNodes = group.nodeIds
.map((nodeId) => workflowDocumentStore?.value?.getNodeById(nodeId))
.filter(isPresent);
const anchor = getEmptyGroupAnchor(group, currentMemberNodes);
return anchor ? [anchor.id] : currentTarget.nodeIds;
});
const targetGroupId = computed(() =>

View File

@ -15,7 +15,7 @@ import { CANVAS_NODE_CONTEXT_FLAG } from '@n8n/api-types';
import { getResourcePermissions } from '@n8n/permissions';
import { useSettingsStore } from '@n8n/stores/settings.store';
import type { INode, INodeTypeDescription } from 'n8n-workflow';
import { NodeHelpers, WEBHOOK_NODE_TYPE } from 'n8n-workflow';
import { getEmptyGroupAnchor, NodeHelpers, WEBHOOK_NODE_TYPE } from 'n8n-workflow';
import { computed, type ComputedRef } from 'vue';
import { isPresent } from '@/app/utils/typesUtils';
import { useEditorContext } from '@/app/composables/useEditorContext';
@ -134,6 +134,14 @@ export function useContextMenuItems(
.filter(isPresent),
);
const isEmptyGroupTarget = computed(() => {
const groupId = targetGroupId?.value;
if (!groupId) return false;
const group = workflowDocumentStore?.value?.getGroupById(groupId);
return group !== undefined && getEmptyGroupAnchor(group, targetNodes.value) !== undefined;
});
// Mirrors the Cmd+G eligibility — the same resolver also produces the
// member ids at execution time, so enablement can't diverge from it.
const canGroupTargetNodes = computed(() => resolveGroupableNodeIds(targetNodeIds.value) !== null);
@ -257,12 +265,16 @@ export function useContextMenuItems(
shortcut: { keys: ['Space'] },
disabled: isReadOnly.value,
},
{
id: 'ungroup_nodes',
label: i18n.baseText('contextMenu.ungroupNodes'),
shortcut: { metaKey: true, shiftKey: true, keys: ['G'] },
disabled: isReadOnly.value,
},
...(!isEmptyGroupTarget.value
? [
{
id: 'ungroup_nodes' as const,
label: i18n.baseText('contextMenu.ungroupNodes'),
shortcut: { metaKey: true, shiftKey: true, keys: ['G'] },
disabled: isReadOnly.value,
},
]
: []),
...groupDescriptionActions,
]
: [];
@ -313,15 +325,17 @@ export function useContextMenuItems(
},
];
const extractionActions: Item[] = [
{
id: 'extract_sub_workflow',
divided: true,
label: i18n.baseText('contextMenu.extract', i18nOptions),
shortcut: { altKey: true, keys: ['X'] },
disabled: isReadOnly.value,
},
];
const extractionActions: Item[] = isEmptyGroupTarget.value
? []
: [
{
id: 'extract_sub_workflow',
divided: true,
label: i18n.baseText('contextMenu.extract', i18nOptions),
shortcut: { altKey: true, keys: ['X'] },
disabled: isReadOnly.value,
},
];
// Grouping doesn't apply to an existing group — it offers ungroup instead.
const groupingActions: Item[] = !isGroupTarget

View File

@ -8,7 +8,11 @@ import { useViewStacks } from '@/features/shared/nodeCreator/composables/useView
import { mockRestrictedNodeTypes } from '@/__tests__/mocks';
import { mockSimplifiedNodeType } from '../../__tests__/utils';
import NodesListPanel from './NodesListPanel.vue';
import { REGULAR_NODE_CREATOR_VIEW, DEBOUNCE_TIME } from '@/app/constants';
import {
REGULAR_NODE_CREATOR_VIEW,
DEBOUNCE_TIME,
NODE_CREATOR_OPEN_SOURCES,
} from '@/app/constants';
import type { ActionTypeDescription, NodeFilterType, SimplifiedNodeType } from '@/Interface';
import { createComponentRenderer } from '@/__tests__/render';
import { createTestNode } from '@/__tests__/mocks';
@ -16,6 +20,7 @@ import {
createWorkflowDocumentId,
useWorkflowDocumentStore,
} from '@/app/stores/workflowDocument.store';
import { useUIStore } from '@/app/stores/ui.store';
vi.mock('@/app/composables/useExternalHooks', () => ({
useExternalHooks: () => ({ run: vi.fn().mockResolvedValue(undefined) }),
@ -301,6 +306,57 @@ describe('NodesListPanel', () => {
});
});
describe('Group command visibility', () => {
function renderWithConnectionSource({ grouped }: { grouped: boolean }) {
return getWrapperComponent(() => {
const source = createTestNode({ id: 'source', name: 'Source' });
const workflowDocumentStore = useWorkflowDocumentStore(createWorkflowDocumentId(''));
workflowDocumentStore.setNodes([source]);
if (grouped) workflowDocumentStore.createGroup([source.id], 'Group 1');
useUIStore().lastInteractedWithNodeId = source.id;
const nodeCreatorStore = useNodeCreatorStore();
nodeCreatorStore.openSource = NODE_CREATOR_OPEN_SOURCES.PLUS_ENDPOINT;
nodeCreatorStore.setSelectedView(REGULAR_NODE_CREATOR_VIEW);
});
}
it('hides Group when adding from a plus inside a group', async () => {
renderWithConnectionSource({ grouped: true });
await nextTick();
expect(screen.queryByText('Group')).not.toBeInTheDocument();
await fireEvent.input(screen.getByTestId('node-creator-search-bar'), {
target: { value: 'group' },
});
await waitFor(() => expect(screen.queryByText('Group')).not.toBeInTheDocument());
});
it('shows Group when adding from a plus outside a group', async () => {
renderWithConnectionSource({ grouped: false });
await nextTick();
expect(screen.getByText('Group')).toBeInTheDocument();
});
it('shows Group from the global node creator despite stale grouped-node state', async () => {
getWrapperComponent(() => {
const source = createTestNode({ id: 'source', name: 'Source' });
const workflowDocumentStore = useWorkflowDocumentStore(createWorkflowDocumentId(''));
workflowDocumentStore.setNodes([source]);
workflowDocumentStore.createGroup([source.id], 'Group 1');
useUIStore().lastInteractedWithNodeId = source.id;
const nodeCreatorStore = useNodeCreatorStore();
nodeCreatorStore.openSource = NODE_CREATOR_OPEN_SOURCES.ADD_NODE_BUTTON;
nodeCreatorStore.setSelectedView(REGULAR_NODE_CREATOR_VIEW);
});
await nextTick();
expect(screen.getByText('Group')).toBeInTheDocument();
});
});
// Reproduces ADO-5590 (GH #33955): typing in the node-creator search field
// runs the fuzzy filter on every keystroke with no debounce, so rapid typing
// re-filters and re-renders on each character. AgentsMode debounces its

View File

@ -1,12 +1,14 @@
<script setup lang="ts">
import type { INodeCreateElement, NodeFilterType, SimplifiedNodeType } from '@/Interface';
import {
ADD_EMPTY_GROUP_NODE_CREATOR_ITEM,
AI_EVALUATION,
AI_NODE_CREATOR_VIEW,
AI_OTHERS_NODE_CREATOR_VIEW,
AI_UNCATEGORIZED_CATEGORY,
DEBOUNCE_TIME,
HUMAN_IN_THE_LOOP_CATEGORY,
isNodeCreatorOpenFromConnection,
REGULAR_NODE_CREATOR_VIEW,
TRIGGER_NODE_CREATOR_VIEW,
} from '@/app/constants';
@ -39,6 +41,8 @@ import CommunityNodeDocsLink from '@/features/settings/communityNodes/components
import CommunityNodeFooter from '@/features/settings/communityNodes/components/nodeCreator/CommunityNodeFooter.vue';
import CommunityNodeInfo from '@/features/settings/communityNodes/components/nodeCreator/CommunityNodeInfo.vue';
import { useUsersStore } from '@n8n/stores/users.store';
import { useUIStore } from '@/app/stores/ui.store';
import { injectWorkflowDocumentStore } from '@/app/stores/workflowDocument.store';
import { N8nIcon, N8nNotice } from '@n8n/design-system';
const i18n = useI18n();
@ -48,9 +52,22 @@ const { mergedNodes } = useNodeCreatorStore();
const { pushViewStack, popViewStack, updateCurrentViewStack } = useViewStacks();
const { setActiveItemIndex, attachKeydownEvent, detachKeydownEvent } = useKeyboardNavigation();
const nodeCreatorStore = useNodeCreatorStore();
const uiStore = useUIStore();
const workflowDocumentStore = injectWorkflowDocumentStore();
const { isAdminOrOwner } = useUsersStore();
const isAddingInsideGroup = computed(() => {
// Currently the only way to add an item inside group is from connection inside group
if (!isNodeCreatorOpenFromConnection(nodeCreatorStore.openSource)) return false;
const sourceNodeId = uiStore.lastInteractedWithNodeId;
return (
sourceNodeId !== undefined &&
workflowDocumentStore.value.getGroupForNode(sourceNodeId) !== undefined
);
});
const activeViewStack = computed(() => useViewStacks().activeViewStack);
const communityNodeDetails = computed(() => activeViewStack.value.communityNodeDetails);
@ -216,17 +233,21 @@ watch(
return;
}
const view = matchedView(mergedNodes);
const viewItems = isAddingInsideGroup.value
? // Forbid adding groups inside groups (nesting not supported)
view.items.filter((item) => item.key !== ADD_EMPTY_GROUP_NODE_CREATOR_ITEM)
: view.items;
const viewStack: ViewStack = {
title: view.title,
subtitle: view?.subtitle ?? '',
items: view.items as INodeCreateElement[],
items: viewItems as INodeCreateElement[],
nodeIcon: view.nodeIcon,
info: view.info,
hasSearch: true,
mode: 'nodes',
rootView: selectedView,
// Root search should include all nodes and command items.
searchItems: getNodeCreatorSearchItems(mergedNodes, view.items),
searchItems: getNodeCreatorSearchItems(mergedNodes, viewItems),
...additionalOptions[selectedView],
};
pushViewStack(viewStack);

View File

@ -118,6 +118,25 @@ describe('useActions', () => {
});
});
test.each([
NODE_CREATOR_OPEN_SOURCES.PLUS_ENDPOINT,
NODE_CREATOR_OPEN_SOURCES.NODE_CONNECTION_ACTION,
NODE_CREATOR_OPEN_SOURCES.NODE_CONNECTION_DROP,
])('should not insert a manual trigger when opened from %s', (openSource) => {
const nodeCreatorStore = useNodeCreatorStore();
mockDocumentStoreState.workflowTriggerNodes = [];
vi.spyOn(nodeCreatorStore, 'openSource', 'get').mockReturnValue(openSource);
vi.spyOn(nodeCreatorStore, 'selectedView', 'get').mockReturnValue(TRIGGER_NODE_CREATOR_VIEW);
const { getAddedNodesAndConnections } = useActions();
expect(getAddedNodesAndConnections([{ type: HTTP_REQUEST_NODE_TYPE }])).toEqual({
connections: [],
nodes: [{ type: HTTP_REQUEST_NODE_TYPE, openDetail: true }],
});
});
test('should not insert a manual trigger node when there is a trigger in the workflow', () => {
const nodeCreatorStore = useNodeCreatorStore();

View File

@ -22,9 +22,9 @@ import {
AI_CATEGORY_LANGUAGE_MODELS,
BASIC_CHAIN_NODE_TYPE,
CHAT_TRIGGER_NODE_TYPE,
isNodeCreatorOpenFromConnection,
MANUAL_TRIGGER_NODE_TYPE,
MESSAGE_AN_AGENT_NODE_TYPE,
NODE_CREATOR_OPEN_SOURCES,
NO_OP_NODE_TYPE,
OPEN_AI_ASSISTANT_NODE_TYPE,
OPEN_AI_NODE_MESSAGE_ASSISTANT_TYPE,
@ -61,11 +61,6 @@ export const useActions = () => {
const nodeTypesStore = useNodeTypesStore();
const uiStore = useUIStore();
const i18n = useI18n();
const singleNodeOpenSources = [
NODE_CREATOR_OPEN_SOURCES.PLUS_ENDPOINT,
NODE_CREATOR_OPEN_SOURCES.NODE_CONNECTION_ACTION,
NODE_CREATOR_OPEN_SOURCES.NODE_CONNECTION_DROP,
];
const actionsCategoryLocales = computed(() => {
return {
@ -260,11 +255,10 @@ export const useActions = () => {
const isTriggerPanel = selectedView === TRIGGER_NODE_CREATOR_VIEW;
const onlyStickyNodes = addedNodes.every((node) => node.type === STICKY_NODE_TYPE);
// If the node creator was opened from the plus endpoint, node connection action, or node connection drop
// then we do not want to append the manual trigger
const isSingleNodeOpenSource = singleNodeOpenSources.includes(openSource);
// Connection-based openings already have a source node, so do not prepend a trigger.
const isAddingFromConnection = isNodeCreatorOpenFromConnection(openSource);
return (
!isSingleNodeOpenSource &&
!isAddingFromConnection &&
!hasTrigger &&
!workflowContainsTrigger &&
isTriggerPanel &&

View File

@ -5,7 +5,7 @@ import { type MockedStore, mockedStore } from '@/__tests__/utils';
import { defaultSettings } from '@n8n/frontend-test-utils';
import { useSettingsStore } from '@n8n/stores/settings.store';
import NodeCreation from './NodeCreation.vue';
import type { AddedNodesAndConnections, XYPosition } from '@/Interface';
import type { AddedNodesAndConnections } from '@/Interface';
import { NODE_CREATOR_OPEN_SOURCES } from '@/app/constants';
import { useNodeCreatorStore } from '../nodeCreator.store';
@ -120,15 +120,13 @@ describe('NodeCreation', () => {
expect(queryByTestId('command-bar-button')).toBeInTheDocument();
});
it('emits the anchor position when Group is selected in the node creator', async () => {
it('uses regular node placement when Group is selected in the node creator', async () => {
const { getByTestId, emitted } = renderComponent({ pinia });
getByTestId('node-creator-stub-group').click();
await vi.waitFor(() => expect(emitted('addEmptyGroup')).toHaveLength(1));
const addEmptyGroupEvents = emitted<[XYPosition, boolean]>('addEmptyGroup');
expect(addEmptyGroupEvents[0][0]).toEqual([expect.any(Number), expect.any(Number)]);
expect(addEmptyGroupEvents[0][1]).toBe(false);
expect(emitted('addEmptyGroup')).toEqual([[false]]);
expect(emitted('toggleNodeCreator')).toEqual([
[{ createNodeActive: false, hasAddedNodes: true }],
]);
@ -142,8 +140,7 @@ describe('NodeCreation', () => {
getByTestId('node-creator-stub-group').click();
await vi.waitFor(() => expect(emitted('addEmptyGroup')).toHaveLength(1));
const addEmptyGroupEvents = emitted<[XYPosition, boolean]>('addEmptyGroup');
expect(addEmptyGroupEvents[0][1]).toBe(true);
expect(emitted('addEmptyGroup')).toEqual([[true]]);
});
it('hides the command bar button in canvas-only mode', () => {
settingsStore.settings = { ...defaultSettings, canvasOnly: true };

View File

@ -1,10 +1,11 @@
<script setup lang="ts">
/* eslint-disable vue/no-multiple-template-root */
import { computed, defineAsyncComponent, nextTick } from 'vue';
import { DEFAULT_NODE_SIZE, getMidCanvasPosition } from '@/app/utils/nodeViewUtils';
import { getMidCanvasPosition } from '@/app/utils/nodeViewUtils';
import {
DEFAULT_STICKY_HEIGHT,
DEFAULT_STICKY_WIDTH,
isNodeCreatorOpenFromConnection,
NODE_CREATOR_OPEN_SOURCES,
STICKY_NODE_TYPE,
} from '@/app/constants';
@ -16,7 +17,6 @@ import type {
AddedNodesAndConnections,
NodeTypeSelectedPayload,
ToggleNodeCreatorOptions,
XYPosition,
} from '@/Interface';
import { useActions } from '../composables/useActions';
import { useNodeCreatorStore } from '../nodeCreator.store';
@ -56,7 +56,7 @@ const props = withDefaults(defineProps<Props>(), {
const emit = defineEmits<{
addNodes: [value: AddedNodesAndConnections];
addEmptyGroup: [position: XYPosition, connectToLastInteractedNode: boolean];
addEmptyGroup: [connectToLastInteractedNode: boolean];
toggleNodeCreator: [value: ToggleNodeCreatorOptions];
close: [];
}>();
@ -72,12 +72,6 @@ const workflowId = useWorkflowId();
const settingsStore = useSettingsStore();
const nodeCreatorStore = useNodeCreatorStore();
const connectionOpenSources = new Set([
NODE_CREATOR_OPEN_SOURCES.PLUS_ENDPOINT,
NODE_CREATOR_OPEN_SOURCES.NODE_CONNECTION_ACTION,
NODE_CREATOR_OPEN_SOURCES.NODE_CONNECTION_DROP,
]);
const { getAddedNodesAndConnections } = useActions();
const { shouldShowCoachmark, onDismissCoachmark } = useNodeCreatorShortcutCoachmark();
@ -114,12 +108,7 @@ function addEmptyGroup() {
(document.activeElement as HTMLElement).blur();
}
const offset: [number, number] = [...uiStore.nodeViewOffsetPosition];
const position = getMidCanvasPosition(props.nodeViewScale, offset);
position[0] -= DEFAULT_NODE_SIZE[0] / 2;
position[1] -= DEFAULT_NODE_SIZE[1] / 2;
emit('addEmptyGroup', position, connectionOpenSources.has(nodeCreatorStore.openSource));
emit('addEmptyGroup', isNodeCreatorOpenFromConnection(nodeCreatorStore.openSource));
}
function closeNodeCreator(hasAddedNodes = false) {

View File

@ -25,7 +25,7 @@ import {
import type { useDeviceSupport } from '@n8n/composables/useDeviceSupport';
import { useVueFlow } from '@vue-flow/core';
import { SIMULATE_NODE_TYPE } from '@/app/constants';
import { NO_OP_NODE_TYPE, SIMULATE_NODE_TYPE } from '@/app/constants';
import { canvasEventBus } from '@/features/workflows/canvas/canvas.eventBus';
import { createEventBus } from '@n8n/utils/event-bus';
import { GROUP_PADDING_Y_BOTTOM, GROUP_PADDING_Y_TOP } from '../stores/canvasNodeGroups.constants';
@ -1008,6 +1008,39 @@ describe('Canvas', () => {
return { group, groupNode, ...rendered };
};
it('selects an expanded empty group when its anchor is selected', async () => {
workflowDocumentStore.setScopes(['workflow:update']);
workflowDocumentStore.setNodes([
createTestNode({
id: 'anchor',
type: NO_OP_NODE_TYPE,
parameters: { emptyGroupAnchor: true },
}),
]);
const group = workflowDocumentStore.createGroup(['anchor'], 'Empty group');
const groupNode = createCanvasGroupElement({
id: group.id,
name: group.name,
nodeIds: ['anchor'],
isCollapsed: false,
});
groupNode.data!.isEmptyGroup = true;
const rendered = renderComponent({
props: {
nodes: [groupNode, createCanvasNodeElement({ id: 'anchor', position: { x: 40, y: 40 } })],
},
global: {
provide: { [NodeGroupViewKey as symbol]: createNodeGroupViewMock(false) },
},
});
const vueFlow = useVueFlow(canvasId);
await waitFor(() => expect(vueFlow.findNode('anchor')).toBeDefined());
vueFlow.addSelectedNodes([vueFlow.findNode('anchor')!]);
await waitFor(() => expect(getSelectionRing(rendered.container)).toBeInTheDocument());
});
it('hides the selection toolbar when the selection is exactly one group', async () => {
const { groupNode, queryByTestId } = await setupExpandedGroupWithLooseNodes();
const vueFlow = useVueFlow(canvasId);
@ -1631,7 +1664,59 @@ describe('Canvas', () => {
await fireEvent.keyDown(document, { key: 'Backspace' });
await fireEvent.keyUp(document, { key: 'Backspace' });
expect(emitted()['delete:nodes']?.[0]).toEqual([['a', 'b'], true]);
expect(emitted()['delete:nodes']?.[0]).toEqual([['a', 'b'], [group.id]]);
});
it('only suppresses anchor preservation for explicitly selected groups', async () => {
workflowDocumentStore.setScopes(['workflow:update']);
workflowDocumentStore.setNodes([
createTestNode({ id: 'a1', name: 'Node A1' }),
createTestNode({ id: 'a2', name: 'Node A2' }),
createTestNode({ id: 'b1', name: 'Node B1' }),
]);
workflowDocumentStore.setNodeGroups([
{ id: 'g1', name: 'Group 1', nodeIds: ['a1', 'a2'] },
{ id: 'g2', name: 'Group 2', nodeIds: ['b1'] },
]);
const rendered = renderComponent({
props: {
nodes: [
createCanvasGroupElement({ id: 'g1', nodeIds: ['a1', 'a2'] }),
createCanvasGroupElement({ id: 'g2', nodeIds: ['b1'], position: { x: 400, y: 0 } }),
createCanvasNodeElement({ id: 'a1', label: 'Node A1' }),
createCanvasNodeElement({ id: 'a2', label: 'Node A2' }),
createCanvasNodeElement({ id: 'b1', label: 'Node B1', position: { x: 400, y: 120 } }),
],
},
global: {
provide: {
[NodeGroupViewKey as symbol]: createNodeGroupViewMock(false),
},
},
});
await waitFor(() =>
expect(rendered.container.querySelectorAll('.vue-flow__node')).toHaveLength(5),
);
const vueFlow = useVueFlow(canvasId);
vueFlow.addSelectedNodes([vueFlow.findNode('group:g1')!, vueFlow.findNode('b1')!]);
await waitFor(() =>
expect(vueFlow.getSelectedNodes.value.map(({ id }) => id)).toEqual(
expect.arrayContaining(['group:g1', 'a1', 'a2', 'b1']),
),
);
await fireEvent.keyDown(document, { key: 'Backspace' });
await fireEvent.keyUp(document, { key: 'Backspace' });
const deleteEvent = rendered.emitted()['delete:nodes']?.at(-1) as
| [string[], string[]?]
| undefined;
const [ids, deleteWholeGroupIds] = deleteEvent ?? [];
expect(ids).toEqual(expect.arrayContaining(['a1', 'a2', 'b1']));
expect(deleteWholeGroupIds).toEqual(['g1']);
});
});
@ -1845,6 +1930,24 @@ describe('Canvas', () => {
expect([...useContextMenu().targetNodeIds.value].sort()).toEqual(['a', 'b', 'node-3']);
});
it('only deletes explicitly selected groups from a mixed context-menu selection', async () => {
const { group, groupNode, looseNode, getByTestId, emitted } = await renderWithGroup();
const { addSelectedNodes, findNode } = useVueFlow(canvasId);
addSelectedNodes([findNode(groupNode.id)!, findNode(looseNode.id)!]);
await waitFor(() => expect(findNode(groupNode.id)?.selected).toBe(true));
await fireEvent.contextMenu(getByTestId('canvas-node-group'));
await waitFor(() => expect(useContextMenu().isOpen.value).toBe(true));
await waitFor(() => expect(getByTestId('context-menu-item-delete')).toBeInTheDocument());
await fireEvent.click(getByTestId('context-menu-item-delete'));
const deleteEvent = emitted()['delete:nodes']?.at(-1) as [string[], string[]?] | undefined;
const [ids, deleteWholeGroupIds] = deleteEvent ?? [];
expect(ids).toEqual(expect.arrayContaining(['a', 'b', 'node-3']));
expect(deleteWholeGroupIds).toEqual([group.id]);
});
it('copies the group members through the copy action', async () => {
const { getByTestId, emitted } = await renderWithGroup();

View File

@ -27,7 +27,6 @@ import { NODE_CREATOR_SHORTCUT_COACHMARK_KEY } from '@/features/shared/nodeCreat
import type { NodeCreatorOpenSource } from '@/Interface';
import type {
CanvasConnection,
CanvasGroupNode,
CanvasEventBusEvents,
CanvasGroupNodeData,
CanvasNode,
@ -68,7 +67,12 @@ import type {
import { getRectOfNodes, MarkerType, PanelPosition, useVueFlow, VueFlow } from '@vue-flow/core';
import { MiniMap } from '@vue-flow/minimap';
import { onKeyDown, onKeyUp, useThrottleFn, watchDebounced } from '@vueuse/core';
import { NodeConnectionTypes, type IConnections, type IWorkflowGroup } from 'n8n-workflow';
import {
getEmptyGroupAnchor,
NodeConnectionTypes,
type IConnections,
type IWorkflowGroup,
} from 'n8n-workflow';
import {
createCanvasConnectionHandleString,
shouldIgnoreCanvasShortcut,
@ -147,7 +151,7 @@ const emit = defineEmits<{
'replace:node': [id: string];
'create:node': [source: NodeCreatorOpenSource];
'create:sticky': [];
'delete:nodes': [ids: string[], deleteWholeGroups?: boolean];
'delete:nodes': [ids: string[], deleteWholeGroupIds?: string[]];
'update:nodes:enabled': [ids: string[]];
'copy:nodes': [ids: string[]];
'duplicate:nodes': [ids: string[]];
@ -480,6 +484,7 @@ const extractableGroupIds = computed(() => {
const ids = new Set<string>();
if (settingsStore.isSubworkflowConversionDisabled) return ids;
for (const group of workflowDocumentStore.value.allGroups) {
if (isEmptyGroup(group.id)) continue;
if (isSelectionExtractable(group.nodeIds).valid) {
ids.add(group.id);
}
@ -500,6 +505,23 @@ const soleSelectedGroupId = computed<string | null>(() => {
return selectedNodes.value.every((node) => memberIds.has(node.id)) ? groupId : null;
});
const soleSelectedEmptyGroup = computed(() => {
const selectedGroups = selectedNodesAndGroups.value.filter(isCanvasGroupNode);
if (selectedGroups.length !== 1) return false;
const groupNode = selectedGroups[0];
if (groupNode.data?.isEmptyGroup !== true) return false;
const groupId = parseCanvasGroupNodeId(groupNode.id);
if (!groupId) return false;
const group = workflowDocumentStore.value.getGroupById(groupId);
if (!group) return false;
const memberIds = new Set(group.nodeIds);
return selectedNodes.value.every((node) => memberIds.has(node.id));
});
const groupTelemetry = useCanvasNodeGroupTelemetry();
function handleGroupCreated(group: IWorkflowGroup, source: CanvasNodeGroupEventSource) {
@ -587,7 +609,7 @@ const keyMap = computed(() => {
},
shift_alt_t: async () => await onTidyUp({ source: 'keyboard-shortcut' }),
alt_x: {
disabled: () => settingsStore.isSubworkflowConversionDisabled,
disabled: () => settingsStore.isSubworkflowConversionDisabled || soleSelectedEmptyGroup.value,
run: emitWithSelectedNodes((ids) => emit('extract-workflow', ids)),
},
c: () => emit('start-chat'),
@ -804,14 +826,19 @@ const groupDrag = useCanvasNodeGroupDrag({
// Groups select as one unit: title bar and member selection stay in sync,
// and a fully selected group surfaces the selection instead of its members.
const { fullySelectedGroupMemberIds, selectedElementCount, selectionBoxBounds } =
useCanvasNodeGroupSelection({
canvasId: props.id,
isEnabled: () => props.showNodeGroups,
getGroupById: (id) => workflowDocumentStore.value.getGroupById(id),
getGroupForNode: (id) => workflowDocumentStore.value.getGroupForNode(id),
isGroupCollapsed: (id) => injectedNodeGroupView?.isGroupCollapsed(id) ?? false,
});
const {
fullySelectedGroupMemberIds,
selectedElementCount,
selectionBoxBounds,
explicitlySelectedGroupIds,
} = useCanvasNodeGroupSelection({
canvasId: props.id,
isEnabled: () => props.showNodeGroups,
getGroupById: (id) => workflowDocumentStore.value.getGroupById(id),
getGroupForNode: (id) => workflowDocumentStore.value.getGroupForNode(id),
isEmptyGroup,
isGroupCollapsed: (id) => injectedNodeGroupView?.isGroupCollapsed(id) ?? false,
});
// VueFlow sizes its selection box to the selected VueFlow nodes, but a group
// node is only the title bar — its expanded frame overflows the box. Feed the
@ -1004,6 +1031,7 @@ function onCanvasGroupUngroup(
) {
// Capture before deletion — the group is gone by the time we track.
const group = workflowDocumentStore.value.getGroupById(groupId);
if (!group || isEmptyGroup(groupId)) return;
// Ungrouping a collapsed group makes its hidden members reappear, so expand
// it first: the expansion pushes overlapping nodes aside, and the commit
// below persists that displacement (the group is gone after, so the push
@ -1016,19 +1044,27 @@ function onCanvasGroupUngroup(
commitPushedPositionsForSourceGroups([groupId]);
ungroup(groupId);
if (group) {
groupTelemetry.trackUngrouped(group, source);
}
groupTelemetry.trackUngrouped(group, source);
}
// Same downstream path as extracting the members through Alt+X or the
// context menu, so collapsed and expanded groups behave identically.
function onCanvasGroupExtract(groupId: string) {
const group = workflowDocumentStore.value.getGroupById(groupId);
if (!group) return;
if (!group || isEmptyGroup(groupId)) return;
emit('extract-workflow', [...group.nodeIds]);
}
function isEmptyGroup(groupId: string): boolean {
const group = workflowDocumentStore.value.getGroupById(groupId);
if (!group) return false;
const memberNodes = group.nodeIds
.map((nodeId) => workflowDocumentStore.value.getNodeById(nodeId))
.filter(isPresent);
return getEmptyGroupAnchor(group, memberNodes) !== undefined;
}
function onCanvasGroupAddNodesToChat(groupId: string) {
const group = workflowDocumentStore.value.getGroupById(groupId);
if (!group) return;
@ -1123,7 +1159,16 @@ function onDeleteSelection() {
const ids = selectedNodeIdsWithGroupMembers.value;
// Expand selected groups to their member nodes before deletion.
if (ids.length > 0) {
emit('delete:nodes', ids, selectedNodesAndGroups.value.some(isCanvasGroupNode));
const deleteWholeGroupIds = selectedNodesAndGroups.value
.filter(isCanvasGroupNode)
.map((node) => parseCanvasGroupNodeId(node.id))
.filter(
(groupId) =>
groupId &&
(selectedNodeIds.value.length === 0 || explicitlySelectedGroupIds.value.has(groupId)),
)
.filter(isPresent);
emit('delete:nodes', ids, deleteWholeGroupIds);
}
}
@ -1299,11 +1344,11 @@ const connectionCreated = ref(false);
const connectingHandle = ref<ConnectStartEvent>();
const connectedHandle = ref<Connection>();
function getEmptyGroupAnchor(
function getEmptyGroupAnchorFromCanvasNode(
nodeId: string | undefined,
): { nodeId: string; isCollapsed: boolean } | undefined {
if (!nodeId) return undefined;
const node = findNode(nodeId) as CanvasGroupNode | undefined;
const node = findNode(nodeId);
if (!node || !isCanvasGroupNode(node) || !node.data?.isEmptyGroup) return undefined;
const anchorId = node.data.group.nodeIds[0];
@ -1314,13 +1359,13 @@ function getEmptyGroupAnchor(
function normalizeEmptyGroupConnection(connection: Connection): Connection {
const normalized = { ...connection };
const sourceGroup = getEmptyGroupAnchor(connection.source);
const sourceGroup = getEmptyGroupAnchorFromCanvasNode(connection.source);
if (sourceGroup?.isCollapsed) {
normalized.source = sourceGroup.nodeId;
normalized.sourceHandle = createCanvasConnectionHandleString({ mode: 'outputs' });
}
const targetGroup = getEmptyGroupAnchor(connection.target);
const targetGroup = getEmptyGroupAnchorFromCanvasNode(connection.target);
if (targetGroup?.isCollapsed) {
normalized.target = targetGroup.nodeId;
normalized.targetHandle = createCanvasConnectionHandleString({ mode: 'inputs' });
@ -1330,7 +1375,7 @@ function normalizeEmptyGroupConnection(connection: Connection): Connection {
}
function normalizeEmptyGroupConnectionStart(handle: ConnectStartEvent): ConnectStartEvent {
const group = getEmptyGroupAnchor(handle.nodeId);
const group = getEmptyGroupAnchorFromCanvasNode(handle.nodeId);
if (!group?.isCollapsed) return handle;
return {
@ -1626,12 +1671,18 @@ async function onContextMenuAction(action: ContextMenuAction, nodeIds: string[],
return emit('create:sticky');
case 'copy':
return emit('copy:nodes', nodeIds);
case 'delete':
return emit(
'delete:nodes',
nodeIds,
Boolean(groupId) || selectedNodesAndGroups.value.some(isCanvasGroupNode),
case 'delete': {
const deleteWholeGroupIds = new Set(
selectedNodesAndGroups.value
.filter(isCanvasGroupNode)
.map((node) => parseCanvasGroupNodeId(node.id))
.filter((groupId) => groupId && explicitlySelectedGroupIds.value.has(groupId))
.filter(isPresent),
);
if (groupId) deleteWholeGroupIds.add(groupId);
return emit('delete:nodes', nodeIds, [...deleteWholeGroupIds]);
}
case 'select_all':
return onSelectAllNodes();
case 'deselect_all':

View File

@ -367,6 +367,45 @@ describe('CanvasNodeGroupTitleBar', () => {
expect(wrapper.queryByTestId('canvas-node-group-info')).toBeTruthy();
});
it('shows the description affordance and panel for a collapsed empty group', async () => {
const visibility = useCanvasNodeGroupDescriptionVisibility({
workflowId: () => 'wf-1',
getCurrentGroups: () => [
{
id: 'g1',
name: 'My group',
nodeIds: ['anchor'],
description: 'Empty group description',
},
],
onNodeGroupsChange: () => ({ off: () => {} }),
});
const wrapper = render(
{
data: makeData({
isCollapsed: true,
isEmptyGroup: true,
group: {
...baseGroup,
nodeIds: ['anchor'],
description: 'Empty group description',
},
}),
},
visibility,
);
expect(wrapper.getByTestId('canvas-node-group-info')).toBeVisible();
visibility.setVisible('g1', true);
await waitFor(() => {
expect(wrapper.getByTestId('canvas-node-group-description-panel')).toBeVisible();
});
expect(wrapper.getByTestId('canvas-node-group-description-text')).toHaveTextContent(
'Empty group description',
);
});
it('hides the info icon and description below the zoom threshold', () => {
viewportRef.value = { x: 0, y: 0, zoom: 0.5 };
const collapsed = render({ data: makeData({ isCollapsed: true }) });
@ -605,6 +644,16 @@ describe('CanvasNodeGroupTitleBar', () => {
expect(wrapper.emitted().ungroup).toEqual([['g1']]);
});
it('hides ungroup and convert actions for an empty group', () => {
const wrapper = render({
data: makeData({ isEmptyGroup: true }),
canExtract: true,
});
expect(wrapper.queryByTestId('canvas-node-group-ungroup')).toBeNull();
expect(wrapper.queryByTestId('canvas-node-group-extract')).toBeNull();
});
// The toolbar offers the same actions whether the group is collapsed or
// expanded.
it.each([{ isCollapsed: true }, { isCollapsed: false }])(

View File

@ -107,6 +107,7 @@ const markStatus = computed(() => MARK_STATUSES.find((status) => status === exec
const wrapperClasses = computed(() => [
$style.wrapper,
{
[$style.emptyGroup]: isEmptyGroup.value,
[$style.collapsed]: isCollapsed.value,
[$style.selected]: props.selected,
[$style.deactivated]: allNodesDisabled.value,
@ -465,6 +466,7 @@ function onWrapperPointerDown(event: PointerEvent) {
>
<div :class="$style.toolbarItems">
<KeyboardShortcutTooltip
v-if="!isEmptyGroup"
:label="i18n.baseText('canvas.selection.toolbar.ungroup')"
:shortcut="UNGROUP_NODES_SHORTCUT"
>
@ -479,7 +481,7 @@ function onWrapperPointerDown(event: PointerEvent) {
/>
</KeyboardShortcutTooltip>
<KeyboardShortcutTooltip
v-if="canExtract"
v-if="canExtract && !isEmptyGroup"
:label="extractLabel"
:shortcut="EXTRACT_WORKFLOW_SHORTCUT"
>
@ -799,6 +801,11 @@ function onWrapperPointerDown(event: PointerEvent) {
}
}
.wrapper.emptyGroup .titleBar {
background: var(--background--hover);
@include styles.canvas-node-border(dashed);
}
/* stylelint-disable */
.wrapper.collapsed.running .titleBar::after,
.wrapper.collapsed.waiting .titleBar::after {
@ -983,6 +990,12 @@ function onWrapperPointerDown(event: PointerEvent) {
z-index: 0;
}
.wrapper.emptyGroup .frame {
background: var(--background--hover);
@include styles.canvas-node-border(dashed);
border-top: none;
}
.selectionRing {
position: absolute;
top: 0;
@ -1076,4 +1089,8 @@ function onWrapperPointerDown(event: PointerEvent) {
.descriptionEmpty {
color: var(--text-color--disabled);
}
.wrapper.emptyGroup .descriptionEmpty {
color: var(--text-color--subtler);
}
</style>

View File

@ -1,6 +1,7 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { createPinia, setActivePinia } from 'pinia';
import { computed } from 'vue';
import { NO_OP_NODE_TYPE } from '@/app/constants';
import { useCanvasNodeGroupActions } from './useCanvasNodeGroupActions';
import {
@ -407,6 +408,26 @@ describe('useCanvasNodeGroupActions', () => {
expect(selectedGroupIds.value).toEqual([group.id]);
});
it('does not include an empty group in ungroup actions', () => {
const anchor = {
id: 'anchor',
name: 'Empty group anchor',
parameters: { emptyGroupAnchor: true },
position: [0, 0] as [number, number],
type: NO_OP_NODE_TYPE,
typeVersion: 1,
};
workflowDocumentStore.setNodes([anchor]);
const group = workflowDocumentStore.createGroup([anchor.id], 'Empty group');
const { canUngroup, selectedGroupIds } = useCanvasNodeGroupActions(
computed(() => [createCanvasGraphGroupNode({ id: group.id, nodeIds: group.nodeIds })]),
);
expect(selectedGroupIds.value).toEqual([]);
expect(canUngroup.value).toBe(false);
});
it('is empty when no selected node belongs to a group', () => {
const { selectedGroupIds } = useCanvasNodeGroupActions(
computed(() => [createCanvasGraphNode({ id: 'a' })]),

View File

@ -1,11 +1,12 @@
import type { GraphNode } from '@vue-flow/core';
import { useI18n } from '@n8n/i18n';
import type { IWorkflowGroup } from 'n8n-workflow';
import { getEmptyGroupAnchor, type IWorkflowGroup } from 'n8n-workflow';
import type { MaybeRefOrGetter } from 'vue';
import { computed, toValue } from 'vue';
import { useSelectionValidation } from '@/app/composables/useSelectionValidation';
import { injectWorkflowDocumentStore } from '@/app/stores/workflowDocument.store';
import { isPresent } from '@/app/utils/typesUtils';
import { useHistoryStore } from '@/app/stores/history.store';
import { AddNodeGroupCommand, UpdateNodeGroupCommand } from '@/app/models/history';
import {
@ -55,7 +56,15 @@ export function useCanvasNodeGroupActions(
ids.add(group.id);
}
}
return Array.from(ids);
return Array.from(ids).filter((id) => {
const group = workflowDocumentStore.value.getGroupById(id);
if (!group) return false;
const memberNodes = group.nodeIds
.map((nodeId) => workflowDocumentStore.value.getNodeById(nodeId))
.filter(isPresent);
return getEmptyGroupAnchor(group, memberNodes) === undefined;
});
});
const canUngroup = computed(() => selectedGroupIds.value.length > 0);

View File

@ -1,4 +1,4 @@
import { computed, toValue, watch, type MaybeRefOrGetter } from 'vue';
import { computed, ref, toValue, watch, type MaybeRefOrGetter } from 'vue';
import { useVueFlow } from '@vue-flow/core';
import type { IWorkflowGroup } from 'n8n-workflow';
import { isPresent } from '@/app/utils/typesUtils';
@ -16,6 +16,7 @@ export interface UseCanvasNodeGroupSelectionDeps {
isEnabled: MaybeRefOrGetter<boolean>;
getGroupById: (groupId: string) => IWorkflowGroup | undefined;
getGroupForNode: (nodeId: string) => IWorkflowGroup | undefined;
isEmptyGroup: (groupId: string) => boolean;
isGroupCollapsed: (groupId: string) => boolean;
}
@ -47,6 +48,7 @@ export function useCanvasNodeGroupSelection(deps: UseCanvasNodeGroupSelectionDep
// Selection snapshot from the last reconciliation — diffing against it
// tells user-driven changes apart from our own.
let lastSelectedIds = new Set<string>();
const explicitlySelectedGroupIds = ref<Set<string>>(new Set());
const selectedIds = computed(() => new Set(getSelectedNodes.value.map((node) => node.id)));
@ -110,7 +112,10 @@ export function useCanvasNodeGroupSelection(deps: UseCanvasNodeGroupSelectionDep
// mounts around an already-selected member is a grouping operation, so
// it must still fold the selection into the new group.
const isDirectMemberSelection =
group.nodeIds.length === 1 && !target.has(groupNodeId) && added.includes(group.nodeIds[0]);
group.nodeIds.length === 1 &&
!deps.isEmptyGroup(group.id) &&
!target.has(groupNodeId) &&
added.includes(group.nodeIds[0]);
if (isDirectMemberSelection) continue;
if (group.nodeIds.every((memberId) => target.has(memberId))) {
target.add(groupNodeId);
@ -171,6 +176,7 @@ export function useCanvasNodeGroupSelection(deps: UseCanvasNodeGroupSelectionDep
watch([selectedIds, userSelectionActive, mountedGroupNodeIdsKey], () => {
if (!toValue(deps.isEnabled)) {
explicitlySelectedGroupIds.value = new Set();
lastSelectedIds = selectedIds.value;
return;
}
@ -179,6 +185,18 @@ export function useCanvasNodeGroupSelection(deps: UseCanvasNodeGroupSelectionDep
if (userSelectionActive.value) return;
const currentIds = selectedIds.value;
const added = [...currentIds].filter((id) => !lastSelectedIds.has(id));
const removed = [...lastSelectedIds].filter((id) => !currentIds.has(id));
const nextExplicitlySelectedGroupIds = new Set(explicitlySelectedGroupIds.value);
for (const id of added) {
const groupId = parseCanvasGroupNodeId(id);
if (groupId) nextExplicitlySelectedGroupIds.add(groupId);
}
for (const id of removed) {
const groupId = parseCanvasGroupNodeId(id);
if (groupId) nextExplicitlySelectedGroupIds.delete(groupId);
}
explicitlySelectedGroupIds.value = nextExplicitlySelectedGroupIds;
const target = reconcile(currentIds);
const isInSync =
target.size === currentIds.size && [...target].every((id) => currentIds.has(id));
@ -234,5 +252,6 @@ export function useCanvasNodeGroupSelection(deps: UseCanvasNodeGroupSelectionDep
fullySelectedGroupMemberIds,
selectedElementCount,
selectionBoxBounds,
explicitlySelectedGroupIds: computed(() => explicitlySelectedGroupIds.value),
};
}