feat(Loop Over Items (Split in Batches) Node): Automatically add a loop + rename (#7228)

Github issue / Community forum post (link here to close automatically):

---------

Co-authored-by: Michael Kret <michael.k@radency.com>
This commit is contained in:
Elias Meire
2023-10-06 15:31:18 +02:00
committed by GitHub
parent afa683a06f
commit 7b773cc5cc
18 changed files with 584 additions and 221 deletions

View File

@@ -23,7 +23,7 @@
<script setup lang="ts">
import { reactive, computed, toRefs, getCurrentInstance } from 'vue';
import type { ActionTypeDescription, SimplifiedNodeType } from '@/Interface';
import { WEBHOOK_NODE_TYPE } from '@/constants';
import { WEBHOOK_NODE_TYPE, DRAG_EVENT_DATA_KEY } from '@/constants';
import { getNewNodePosition, NODE_SIZE } from '@/utils/nodeViewUtils';
import NodeIcon from '@/components/NodeIcon.vue';
@@ -41,7 +41,7 @@ const props = defineProps<Props>();
const instance = getCurrentInstance();
const telemetry = instance?.proxy.$telemetry;
const { getActionData, getNodeTypesWithManualTrigger, setAddedNodeActionParameters } = useActions();
const { getActionData, getAddedNodesAndConnections, setAddedNodeActionParameters } = useActions();
const { activeViewStack } = useViewStacks();
const state = reactive({
@@ -72,13 +72,13 @@ function onDragStart(event: DragEvent): void {
*/
document.body.addEventListener('dragover', onDragOver);
const { pageX: x, pageY: y } = event;
if (event.dataTransfer) {
if (event.dataTransfer && actionData.value.key) {
event.dataTransfer.effectAllowed = 'copy';
event.dataTransfer.dropEffect = 'copy';
event.dataTransfer.setDragImage(state.draggableDataTransfer as Element, 0, 0);
event.dataTransfer.setData(
'nodeTypeName',
getNodeTypesWithManualTrigger(actionData.value?.key).join(','),
DRAG_EVENT_DATA_KEY,
JSON.stringify(getAddedNodesAndConnections([{ type: actionData.value.key }])),
);
if (telemetry) {
state.storeWatcher = setAddedNodeActionParameters(

View File

@@ -42,7 +42,11 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import type { SimplifiedNodeType } from '@/Interface';
import { COMMUNITY_NODES_INSTALLATION_DOCS_URL, DEFAULT_SUBCATEGORY } from '@/constants';
import {
COMMUNITY_NODES_INSTALLATION_DOCS_URL,
DEFAULT_SUBCATEGORY,
DRAG_EVENT_DATA_KEY,
} from '@/constants';
import { isCommunityPackageName } from '@/utils';
import { getNewNodePosition, NODE_SIZE } from '@/utils/nodeViewUtils';
@@ -67,7 +71,7 @@ const i18n = useI18n();
const telemetry = useTelemetry();
const { actions } = useNodeCreatorStore();
const { getNodeTypesWithManualTrigger } = useActions();
const { getAddedNodesAndConnections } = useActions();
const dragging = ref(false);
const draggablePosition = ref({ x: -100, y: -100 });
@@ -140,8 +144,8 @@ function onDragStart(event: DragEvent): void {
event.dataTransfer.dropEffect = 'copy';
event.dataTransfer.setDragImage(draggableDataTransfer.value as Element, 0, 0);
event.dataTransfer.setData(
'nodeTypeName',
getNodeTypesWithManualTrigger(props.nodeType.name).join(','),
DRAG_EVENT_DATA_KEY,
JSON.stringify(getAddedNodesAndConnections([{ type: props.nodeType.name }])),
);
}

View File

@@ -147,7 +147,7 @@ function onSelected(actionCreateElement: INodeCreateElement) {
emit('nodeTypeSelected', [actionData.key as string, actionNode]);
} else {
emit('nodeTypeSelected', getNodeTypesWithManualTrigger(actionData.key));
emit('nodeTypeSelected', [actionData.key as string]);
}
if (telemetry) setAddedNodeActionParameters(actionData, telemetry, rootView.value);

View File

@@ -18,7 +18,6 @@ import { useNodeCreatorStore } from '@/stores/nodeCreator.store';
import { TriggerView, RegularView, AIView, AINodesView } from '../viewsData';
import { transformNodeType } from '../utils';
import { useViewStacks } from '../composables/useViewStacks';
import { useActions } from '../composables/useActions';
import { useKeyboardNavigation } from '../composables/useKeyboardNavigation';
import ItemsRenderer from '../Renderers/ItemsRenderer.vue';
import CategorizedItemsRenderer from '../Renderers/CategorizedItemsRenderer.vue';
@@ -38,7 +37,6 @@ const telemetry = useTelemetry();
const { mergedNodes, actions } = useNodeCreatorStore();
const { baseUrl } = useRootStore();
const { getNodeTypesWithManualTrigger } = useActions();
const { pushViewStack, popViewStack } = useViewStacks();
const { registerKeyHook } = useKeyboardNavigation();
@@ -47,10 +45,7 @@ const activeViewStack = computed(() => useViewStacks().activeViewStack);
const globalSearchItemsDiff = computed(() => useViewStacks().globalSearchItemsDiff);
function selectNodeType(nodeTypes: string[]) {
emit(
'nodeTypeSelected',
nodeTypes.length === 1 ? getNodeTypesWithManualTrigger(nodeTypes[0]) : nodeTypes,
);
emit('nodeTypeSelected', nodeTypes);
}
function onSelected(item: INodeCreateElement) {

View File

@@ -31,10 +31,11 @@ import { useKeyboardNavigation } from './composables/useKeyboardNavigation';
import { useActionsGenerator } from './composables/useActionsGeneration';
import NodesListPanel from './Panel/NodesListPanel.vue';
import { useUIStore } from '@/stores';
import { DRAG_EVENT_DATA_KEY } from '@/constants';
export interface Props {
active?: boolean;
onNodeTypeSelected?: (nodeType: string) => void;
onNodeTypeSelected?: (nodeType: string[]) => void;
}
const props = defineProps<Props>();
@@ -93,12 +94,12 @@ function onDrop(event: DragEvent) {
return;
}
const nodeTypeName = event.dataTransfer.getData('nodeTypeName');
const dragData = event.dataTransfer.getData(DRAG_EVENT_DATA_KEY);
const nodeCreatorBoundingRect = (state.nodeCreator as Element).getBoundingClientRect();
// Abort drag end event propagation if dropped inside nodes panel
if (
nodeTypeName &&
dragData &&
event.pageX >= nodeCreatorBoundingRect.x &&
event.pageY >= nodeCreatorBoundingRect.y
) {

View File

@@ -0,0 +1,93 @@
import { setActivePinia } from 'pinia';
import { createTestingPinia } from '@pinia/testing';
import { useNodeCreatorStore } from '@/stores/nodeCreator.store';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { useActions } from '../composables/useActions';
import {
HTTP_REQUEST_NODE_TYPE,
MANUAL_TRIGGER_NODE_TYPE,
NODE_CREATOR_OPEN_SOURCES,
NO_OP_NODE_TYPE,
SCHEDULE_TRIGGER_NODE_TYPE,
SPLIT_IN_BATCHES_NODE_TYPE,
TRIGGER_NODE_CREATOR_VIEW,
} from '@/constants';
describe('useActions', () => {
beforeAll(() => {
setActivePinia(createTestingPinia());
});
afterEach(() => {
vi.clearAllMocks();
});
describe('getAddedNodesAndConnections', () => {
test('should insert a manual trigger node when there are no triggers', () => {
const workflowsStore = useWorkflowsStore();
const nodeCreatorStore = useNodeCreatorStore();
vi.spyOn(workflowsStore, 'workflowTriggerNodes', 'get').mockReturnValue([]);
vi.spyOn(nodeCreatorStore, 'openSource', 'get').mockReturnValue(
NODE_CREATOR_OPEN_SOURCES.ADD_NODE_BUTTON,
);
vi.spyOn(nodeCreatorStore, 'selectedView', 'get').mockReturnValue(TRIGGER_NODE_CREATOR_VIEW);
const { getAddedNodesAndConnections } = useActions();
expect(getAddedNodesAndConnections([{ type: HTTP_REQUEST_NODE_TYPE }])).toEqual({
connections: [{ from: { nodeIndex: 0 }, to: { nodeIndex: 1 } }],
nodes: [
{ type: MANUAL_TRIGGER_NODE_TYPE, isAutoAdd: true },
{ type: HTTP_REQUEST_NODE_TYPE, openDetail: true },
],
});
});
test('should not insert a manual trigger node when there is a trigger in the workflow', () => {
const workflowsStore = useWorkflowsStore();
const nodeCreatorStore = useNodeCreatorStore();
vi.spyOn(workflowsStore, 'workflowTriggerNodes', 'get').mockReturnValue([
{ type: SCHEDULE_TRIGGER_NODE_TYPE } as never,
]);
vi.spyOn(nodeCreatorStore, 'openSource', 'get').mockReturnValue(
NODE_CREATOR_OPEN_SOURCES.ADD_NODE_BUTTON,
);
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 insert a No Op node when a Loop Over Items Node is added', () => {
const workflowsStore = useWorkflowsStore();
const nodeCreatorStore = useNodeCreatorStore();
vi.spyOn(workflowsStore, 'workflowTriggerNodes', 'get').mockReturnValue([]);
vi.spyOn(nodeCreatorStore, 'openSource', 'get').mockReturnValue(
NODE_CREATOR_OPEN_SOURCES.ADD_NODE_BUTTON,
);
vi.spyOn(nodeCreatorStore, 'selectedView', 'get').mockReturnValue(TRIGGER_NODE_CREATOR_VIEW);
const { getAddedNodesAndConnections } = useActions();
expect(getAddedNodesAndConnections([{ type: SPLIT_IN_BATCHES_NODE_TYPE }])).toEqual({
connections: [
{ from: { nodeIndex: 0 }, to: { nodeIndex: 1 } },
{ from: { nodeIndex: 1, outputIndex: 1 }, to: { nodeIndex: 2 } },
{ from: { nodeIndex: 2 }, to: { nodeIndex: 1 } },
],
nodes: [
{ isAutoAdd: true, type: MANUAL_TRIGGER_NODE_TYPE },
{ openDetail: true, type: SPLIT_IN_BATCHES_NODE_TYPE },
{ isAutoAdd: true, name: 'Replace Me', type: NO_OP_NODE_TYPE },
],
});
});
});
});

View File

@@ -2,6 +2,9 @@ import { getCurrentInstance, computed } from 'vue';
import type { IDataObject, INodeParameters } from 'n8n-workflow';
import type {
ActionTypeDescription,
AddedNode,
AddedNodeConnection,
AddedNodesAndConnections,
INodeCreateElement,
IUpdateInformation,
LabelCreateElement,
@@ -9,11 +12,14 @@ import type {
import {
MANUAL_TRIGGER_NODE_TYPE,
NODE_CREATOR_OPEN_SOURCES,
NO_OP_NODE_TYPE,
SCHEDULE_TRIGGER_NODE_TYPE,
SPLIT_IN_BATCHES_NODE_TYPE,
STICKY_NODE_TYPE,
TRIGGER_NODE_CREATOR_VIEW,
WEBHOOK_NODE_TYPE,
} from '@/constants';
import { i18n } from '@/plugins/i18n';
import type { BaseTextKey } from '@/plugins/i18n';
import type { Telemetry } from '@/plugins/telemetry';
@@ -144,15 +150,13 @@ export const useActions = () => {
};
}
function getNodeTypesWithManualTrigger(nodeType?: string): string[] {
if (!nodeType) return [];
function shouldPrependManualTrigger(addedNodes: AddedNode[]): boolean {
const { selectedView, openSource } = useNodeCreatorStore();
const { workflowTriggerNodes } = useWorkflowsStore();
const isTrigger = useNodeTypesStore().isTriggerNode(nodeType);
const hasTrigger = addedNodes.some((node) => useNodeTypesStore().isTriggerNode(node.type));
const workflowContainsTrigger = workflowTriggerNodes.length > 0;
const isTriggerPanel = selectedView === TRIGGER_NODE_CREATOR_VIEW;
const isStickyNode = nodeType === STICKY_NODE_TYPE;
const onlyStickyNodes = addedNodes.every((node) => node.type === STICKY_NODE_TYPE);
const singleNodeOpenSources = [
NODE_CREATOR_OPEN_SOURCES.PLUS_ENDPOINT,
NODE_CREATOR_OPEN_SOURCES.NODE_CONNECTION_ACTION,
@@ -162,16 +166,65 @@ export const useActions = () => {
// 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);
const shouldAppendManualTrigger =
return (
!isSingleNodeOpenSource &&
!isTrigger &&
!hasTrigger &&
!workflowContainsTrigger &&
isTriggerPanel &&
!isStickyNode;
!onlyStickyNodes
);
}
const nodeTypes = shouldAppendManualTrigger ? [MANUAL_TRIGGER_NODE_TYPE, nodeType] : [nodeType];
function getAddedNodesAndConnections(addedNodes: AddedNode[]): AddedNodesAndConnections {
if (addedNodes.length === 0) {
return { nodes: [], connections: [] };
}
return nodeTypes;
const nodes: AddedNode[] = [];
const connections: AddedNodeConnection[] = [];
const nodeToAutoOpen = addedNodes.find((node) => node.type !== MANUAL_TRIGGER_NODE_TYPE);
if (nodeToAutoOpen) {
nodeToAutoOpen.openDetail = true;
}
if (shouldPrependManualTrigger(addedNodes)) {
addedNodes.unshift({ type: MANUAL_TRIGGER_NODE_TYPE, isAutoAdd: true });
connections.push({
from: { nodeIndex: 0 },
to: { nodeIndex: 1 },
});
}
addedNodes.forEach((node, index) => {
nodes.push(node);
switch (node.type) {
case SPLIT_IN_BATCHES_NODE_TYPE: {
const splitInBatchesIndex = index;
const noOpIndex = splitInBatchesIndex + 1;
nodes.push({
type: NO_OP_NODE_TYPE,
isAutoAdd: true,
name: i18n.baseText('nodeView.replaceMe'),
});
connections.push(
{
from: { nodeIndex: splitInBatchesIndex, outputIndex: 1 },
to: { nodeIndex: noOpIndex },
},
{
from: { nodeIndex: noOpIndex },
to: { nodeIndex: splitInBatchesIndex },
},
);
break;
}
}
});
return { nodes, connections };
}
// Hook into addNode action to set the last node parameters & track the action selected
@@ -211,7 +264,7 @@ export const useActions = () => {
actionsCategoryLocales,
getPlaceholderTriggerActions,
parseCategoryActions,
getNodeTypesWithManualTrigger,
getAddedNodesAndConnections,
getActionData,
setAddedNodeActionParameters,
};