From a392f4e18edf04e60faf377968d6f5bc020bc229 Mon Sep 17 00:00:00 2001 From: Daria Date: Mon, 28 Sep 2026 12:57:29 +0000 Subject: [PATCH] feat(editor): Improve empty-group style & behavior (no-changelog) (#38705) --- .../frontend/@n8n/i18n/src/locales/en.json | 1 + .../composables/useCanvasOperations.test.ts | 81 ++++- .../app/composables/useCanvasOperations.ts | 78 +++-- .../src/app/constants/nodeCreator.ts | 9 + .../editor-ui/src/app/views/NodeView.test.ts | 330 +++++++++++++++++- .../editor-ui/src/app/views/NodeView.vue | 61 +++- .../logs/components/LogsOverviewPanel.test.ts | 36 ++ .../logs/components/LogsOverviewRow.vue | 32 +- .../logs/components/LogsPanel.test.ts | 21 ++ .../execution/logs/components/LogsPanel.vue | 16 + .../execution/logs/logs.utils.test.ts | 28 +- .../src/features/execution/logs/logs.utils.ts | 12 +- .../composables/useContextMenu.test.ts | 29 ++ .../contextMenu/composables/useContextMenu.ts | 20 +- .../composables/useContextMenuItems.ts | 46 ++- .../components/Panel/NodesListPanel.test.ts | 58 ++- .../components/Panel/NodesListPanel.vue | 25 +- .../composables/useActions.test.ts | 19 + .../nodeCreator/composables/useActions.ts | 14 +- .../nodeCreator/views/NodeCreation.test.ts | 11 +- .../shared/nodeCreator/views/NodeCreation.vue | 19 +- .../canvas/components/Canvas.test.ts | 107 +++++- .../workflows/canvas/components/Canvas.vue | 105 ++++-- .../groups/CanvasNodeGroupTitleBar.test.ts | 49 +++ .../groups/CanvasNodeGroupTitleBar.vue | 19 +- .../useCanvasNodeGroupActions.test.ts | 21 ++ .../composables/useCanvasNodeGroupActions.ts | 13 +- .../useCanvasNodeGroupSelection.ts | 23 +- 28 files changed, 1139 insertions(+), 144 deletions(-) diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json index afa5edf45a5..2c28e2deaeb 100644 --- a/packages/frontend/@n8n/i18n/src/locales/en.json +++ b/packages/frontend/@n8n/i18n/src/locales/en.json @@ -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}", diff --git a/packages/frontend/editor-ui/src/app/composables/useCanvasOperations.test.ts b/packages/frontend/editor-ui/src/app/composables/useCanvasOperations.test.ts index 0c47ab0066c..13e90013e12 100644 --- a/packages/frontend/editor-ui/src/app/composables/useCanvasOperations.test.ts +++ b/packages/frontend/editor-ui/src/app/composables/useCanvasOperations.test.ts @@ -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); diff --git a/packages/frontend/editor-ui/src/app/composables/useCanvasOperations.ts b/packages/frontend/editor-ui/src/app/composables/useCanvasOperations.ts index 283b7c4c19a..163cec9f7ec 100644 --- a/packages/frontend/editor-ui/src/app/composables/useCanvasOperations.ts +++ b/packages/frontend/editor-ui/src/app/composables/useCanvasOperations.ts @@ -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; }; 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(); 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 }); diff --git a/packages/frontend/editor-ui/src/app/constants/nodeCreator.ts b/packages/frontend/editor-ui/src/app/constants/nodeCreator.ts index db29aac29fb..5b4b17dcbb5 100644 --- a/packages/frontend/editor-ui/src/app/constants/nodeCreator.ts +++ b/packages/frontend/editor-ui/src/app/constants/nodeCreator.ts @@ -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'; diff --git a/packages/frontend/editor-ui/src/app/views/NodeView.test.ts b/packages/frontend/editor-ui/src/app/views/NodeView.test.ts index 7c2bc9ee1bd..7eb2c00a97e 100644 --- a/packages/frontend/editor-ui/src/app/views/NodeView.test.ts +++ b/packages/frontend/editor-ui/src/app/views/NodeView.test.ts @@ -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: `<>