mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 05:29:44 +02:00
feat(editor): Improve empty-group style & behavior (no-changelog) (#38705)
This commit is contained in:
parent
acaa281c54
commit
a392f4e18e
|
|
@ -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}",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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(() =>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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 &&
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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':
|
||||
|
|
|
|||
|
|
@ -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 }])(
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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' })]),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user