mirror of
https://github.com/n8n-io/n8n.git
synced 2025-02-02 07:01:30 -08:00
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:
parent
afa683a06f
commit
7b773cc5cc
|
@ -9,8 +9,16 @@ describe('SQL editors', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should preserve changes when opening-closing Postgres node', () => {
|
it('should preserve changes when opening-closing Postgres node', () => {
|
||||||
workflowPage.actions.addInitialNodeToCanvas('Postgres', { action: 'Execute a SQL query', keepNdvOpen: true });
|
workflowPage.actions.addInitialNodeToCanvas('Postgres', {
|
||||||
ndv.getters.sqlEditorContainer().find('.cm-content').type('SELECT * FROM `testTable`').type('{esc}');
|
action: 'Execute a SQL query',
|
||||||
|
keepNdvOpen: true,
|
||||||
|
});
|
||||||
|
ndv.getters
|
||||||
|
.sqlEditorContainer()
|
||||||
|
.click()
|
||||||
|
.find('.cm-content')
|
||||||
|
.type('SELECT * FROM `testTable`')
|
||||||
|
.type('{esc}');
|
||||||
ndv.actions.close();
|
ndv.actions.close();
|
||||||
workflowPage.actions.openNode('Postgres');
|
workflowPage.actions.openNode('Postgres');
|
||||||
ndv.getters.sqlEditorContainer().find('.cm-content').type('{end} LIMIT 10').type('{esc}');
|
ndv.getters.sqlEditorContainer().find('.cm-content').type('{end} LIMIT 10').type('{esc}');
|
||||||
|
@ -20,7 +28,10 @@ describe('SQL editors', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not push NDV header out with a lot of code in Postgres editor', () => {
|
it('should not push NDV header out with a lot of code in Postgres editor', () => {
|
||||||
workflowPage.actions.addInitialNodeToCanvas('Postgres', { action: 'Execute a SQL query', keepNdvOpen: true });
|
workflowPage.actions.addInitialNodeToCanvas('Postgres', {
|
||||||
|
action: 'Execute a SQL query',
|
||||||
|
keepNdvOpen: true,
|
||||||
|
});
|
||||||
cy.fixture('Dummy_javascript.txt').then((code) => {
|
cy.fixture('Dummy_javascript.txt').then((code) => {
|
||||||
ndv.getters.sqlEditorContainer().find('.cm-content').paste(code);
|
ndv.getters.sqlEditorContainer().find('.cm-content').paste(code);
|
||||||
});
|
});
|
||||||
|
@ -28,7 +39,10 @@ describe('SQL editors', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not push NDV header out with a lot of code in MySQL editor', () => {
|
it('should not push NDV header out with a lot of code in MySQL editor', () => {
|
||||||
workflowPage.actions.addInitialNodeToCanvas('MySQL', { action: 'Execute a SQL query', keepNdvOpen: true });
|
workflowPage.actions.addInitialNodeToCanvas('MySQL', {
|
||||||
|
action: 'Execute a SQL query',
|
||||||
|
keepNdvOpen: true,
|
||||||
|
});
|
||||||
cy.fixture('Dummy_javascript.txt').then((code) => {
|
cy.fixture('Dummy_javascript.txt').then((code) => {
|
||||||
ndv.getters.sqlEditorContainer().find('.cm-content').paste(code);
|
ndv.getters.sqlEditorContainer().find('.cm-content').paste(code);
|
||||||
});
|
});
|
||||||
|
|
|
@ -321,6 +321,35 @@ describe('Node Creator', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should correctly append a No Op node when Loop Over Items node is added (from add button)', () => {
|
||||||
|
nodeCreatorFeature.actions.openNodeCreator();
|
||||||
|
|
||||||
|
nodeCreatorFeature.getters.searchBar().find('input').type('Loop Over Items');
|
||||||
|
nodeCreatorFeature.getters.getCreatorItem('Loop Over Items').click();
|
||||||
|
NDVModal.actions.close();
|
||||||
|
|
||||||
|
WorkflowPage.getters.canvasNodes().should('have.length', 3);
|
||||||
|
WorkflowPage.getters.nodeConnections().should('have.length', 3);
|
||||||
|
|
||||||
|
WorkflowPage.getters.getConnectionBetweenNodes('Loop Over Items', 'Replace Me').should('exist');
|
||||||
|
WorkflowPage.getters.getConnectionBetweenNodes('Replace Me', 'Loop Over Items').should('exist');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should correctly append a No Op node when Loop Over Items node is added (from connection)', () => {
|
||||||
|
WorkflowPage.actions.addNodeToCanvas('Manual');
|
||||||
|
cy.get('.plus-endpoint').should('be.visible').click();
|
||||||
|
|
||||||
|
nodeCreatorFeature.getters.searchBar().find('input').type('Loop Over Items');
|
||||||
|
nodeCreatorFeature.getters.getCreatorItem('Loop Over Items').click();
|
||||||
|
NDVModal.actions.close();
|
||||||
|
|
||||||
|
WorkflowPage.getters.canvasNodes().should('have.length', 3);
|
||||||
|
WorkflowPage.getters.nodeConnections().should('have.length', 3);
|
||||||
|
|
||||||
|
WorkflowPage.getters.getConnectionBetweenNodes('Loop Over Items', 'Replace Me').should('exist');
|
||||||
|
WorkflowPage.getters.getConnectionBetweenNodes('Replace Me', 'Loop Over Items').should('exist');
|
||||||
|
});
|
||||||
|
|
||||||
it('should have most relevenat nodes on top when searching', () => {
|
it('should have most relevenat nodes on top when searching', () => {
|
||||||
nodeCreatorFeature.getters.canvasAddButton().click();
|
nodeCreatorFeature.getters.canvasAddButton().click();
|
||||||
|
|
||||||
|
|
|
@ -1652,3 +1652,27 @@ export type N8nBanners = {
|
||||||
component: Component;
|
component: Component;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type AddedNode = {
|
||||||
|
type: string;
|
||||||
|
openDetail?: boolean;
|
||||||
|
isAutoAdd?: boolean;
|
||||||
|
name?: string;
|
||||||
|
position?: XYPosition;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AddedNodeConnection = {
|
||||||
|
from: { nodeIndex: number; outputIndex?: number };
|
||||||
|
to: { nodeIndex: number; inputIndex?: number };
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AddedNodesAndConnections = {
|
||||||
|
nodes: AddedNode[];
|
||||||
|
connections: AddedNodeConnection[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ToggleNodeCreatorOptions = {
|
||||||
|
createNodeActive: boolean;
|
||||||
|
source?: NodeCreatorOpenSource;
|
||||||
|
nodeCreatorView?: string;
|
||||||
|
};
|
||||||
|
|
|
@ -1,8 +1,107 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { defineAsyncComponent, reactive } from 'vue';
|
||||||
|
import { getMidCanvasPosition } from '@/utils/nodeViewUtils';
|
||||||
|
import {
|
||||||
|
DEFAULT_STICKY_HEIGHT,
|
||||||
|
DEFAULT_STICKY_WIDTH,
|
||||||
|
NODE_CREATOR_OPEN_SOURCES,
|
||||||
|
STICKY_NODE_TYPE,
|
||||||
|
} from '@/constants';
|
||||||
|
import { useUIStore } from '@/stores/ui.store';
|
||||||
|
import type { AddedNodesAndConnections, ToggleNodeCreatorOptions } from '@/Interface';
|
||||||
|
import { useActions } from './NodeCreator/composables/useActions';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
nodeViewScale: number;
|
||||||
|
createNodeActive?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-unsafe-assignment
|
||||||
|
const NodeCreator = defineAsyncComponent(
|
||||||
|
async () => import('@/components/Node/NodeCreator/NodeCreator.vue'),
|
||||||
|
);
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
createNodeActive: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(event: 'addNodes', value: AddedNodesAndConnections): void;
|
||||||
|
(event: 'toggleNodeCreator', value: ToggleNodeCreatorOptions): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const state = reactive({
|
||||||
|
showStickyButton: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const uiStore = useUIStore();
|
||||||
|
|
||||||
|
const { getAddedNodesAndConnections } = useActions();
|
||||||
|
|
||||||
|
function onCreateMenuHoverIn(mouseinEvent: MouseEvent) {
|
||||||
|
const buttonsWrapper = mouseinEvent.target as Element;
|
||||||
|
|
||||||
|
// Once the popup menu is hovered, it's pointer events are disabled so it's not interfering with element underneath it.
|
||||||
|
state.showStickyButton = true;
|
||||||
|
const moveCallback = (mousemoveEvent: MouseEvent) => {
|
||||||
|
if (buttonsWrapper) {
|
||||||
|
const wrapperBounds = buttonsWrapper.getBoundingClientRect();
|
||||||
|
const wrapperH = wrapperBounds.height;
|
||||||
|
const wrapperW = wrapperBounds.width;
|
||||||
|
const wrapperLeftNear = wrapperBounds.left;
|
||||||
|
const wrapperLeftFar = wrapperLeftNear + wrapperW;
|
||||||
|
const wrapperTopNear = wrapperBounds.top;
|
||||||
|
const wrapperTopFar = wrapperTopNear + wrapperH;
|
||||||
|
const inside =
|
||||||
|
mousemoveEvent.pageX > wrapperLeftNear &&
|
||||||
|
mousemoveEvent.pageX < wrapperLeftFar &&
|
||||||
|
mousemoveEvent.pageY > wrapperTopNear &&
|
||||||
|
mousemoveEvent.pageY < wrapperTopFar;
|
||||||
|
if (!inside) {
|
||||||
|
state.showStickyButton = false;
|
||||||
|
document.removeEventListener('mousemove', moveCallback, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('mousemove', moveCallback, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openNodeCreator() {
|
||||||
|
emit('toggleNodeCreator', {
|
||||||
|
source: NODE_CREATOR_OPEN_SOURCES.ADD_NODE_BUTTON,
|
||||||
|
createNodeActive: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function addStickyNote() {
|
||||||
|
if (document.activeElement) {
|
||||||
|
(document.activeElement as HTMLElement).blur();
|
||||||
|
}
|
||||||
|
|
||||||
|
const offset: [number, number] = [...uiStore.nodeViewOffsetPosition];
|
||||||
|
|
||||||
|
const position = getMidCanvasPosition(props.nodeViewScale, offset);
|
||||||
|
position[0] -= DEFAULT_STICKY_WIDTH / 2;
|
||||||
|
position[1] -= DEFAULT_STICKY_HEIGHT / 2;
|
||||||
|
|
||||||
|
emit('addNodes', getAddedNodesAndConnections([{ type: STICKY_NODE_TYPE, position }]));
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeNodeCreator() {
|
||||||
|
emit('toggleNodeCreator', { createNodeActive: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
function nodeTypeSelected(nodeTypes: string[]) {
|
||||||
|
emit('addNodes', getAddedNodesAndConnections(nodeTypes.map((type) => ({ type }))));
|
||||||
|
closeNodeCreator();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<div
|
<div
|
||||||
v-if="!createNodeActive"
|
v-if="!createNodeActive"
|
||||||
:class="[$style.nodeButtonsWrapper, showStickyButton ? $style.noEvents : '']"
|
:class="[$style.nodeButtonsWrapper, state.showStickyButton ? $style.noEvents : '']"
|
||||||
@mouseenter="onCreateMenuHoverIn"
|
@mouseenter="onCreateMenuHoverIn"
|
||||||
>
|
>
|
||||||
<div :class="$style.nodeCreatorButton" data-test-id="node-creator-plus-button">
|
<div :class="$style.nodeCreatorButton" data-test-id="node-creator-plus-button">
|
||||||
|
@ -15,7 +114,7 @@
|
||||||
:title="$locale.baseText('nodeView.addNode')"
|
:title="$locale.baseText('nodeView.addNode')"
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
:class="[$style.addStickyButton, showStickyButton ? $style.visibleButton : '']"
|
:class="[$style.addStickyButton, state.showStickyButton ? $style.visibleButton : '']"
|
||||||
@click="addStickyNote"
|
@click="addStickyNote"
|
||||||
data-test-id="add-sticky-button"
|
data-test-id="add-sticky-button"
|
||||||
>
|
>
|
||||||
|
@ -37,111 +136,6 @@
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
|
||||||
import { defineAsyncComponent, defineComponent } from 'vue';
|
|
||||||
import { getMidCanvasPosition } from '@/utils/nodeViewUtils';
|
|
||||||
import {
|
|
||||||
DEFAULT_STICKY_HEIGHT,
|
|
||||||
DEFAULT_STICKY_WIDTH,
|
|
||||||
NODE_CREATOR_OPEN_SOURCES,
|
|
||||||
STICKY_NODE_TYPE,
|
|
||||||
} from '@/constants';
|
|
||||||
import { mapStores } from 'pinia';
|
|
||||||
import { useUIStore } from '@/stores/ui.store';
|
|
||||||
|
|
||||||
const NodeCreator = defineAsyncComponent(
|
|
||||||
async () => import('@/components/Node/NodeCreator/NodeCreator.vue'),
|
|
||||||
);
|
|
||||||
|
|
||||||
export default defineComponent({
|
|
||||||
name: 'node-creation',
|
|
||||||
components: {
|
|
||||||
NodeCreator,
|
|
||||||
},
|
|
||||||
props: {
|
|
||||||
nodeViewScale: {
|
|
||||||
type: Number,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
createNodeActive: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
showStickyButton: false,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
computed: {
|
|
||||||
...mapStores(useUIStore),
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
onCreateMenuHoverIn(mouseinEvent: MouseEvent) {
|
|
||||||
const buttonsWrapper = mouseinEvent.target as Element;
|
|
||||||
|
|
||||||
// Once the popup menu is hovered, it's pointer events are disabled so it's not interfering with element underneath it.
|
|
||||||
this.showStickyButton = true;
|
|
||||||
const moveCallback = (mousemoveEvent: MouseEvent) => {
|
|
||||||
if (buttonsWrapper) {
|
|
||||||
const wrapperBounds = buttonsWrapper.getBoundingClientRect();
|
|
||||||
const wrapperH = wrapperBounds.height;
|
|
||||||
const wrapperW = wrapperBounds.width;
|
|
||||||
const wrapperLeftNear = wrapperBounds.left;
|
|
||||||
const wrapperLeftFar = wrapperLeftNear + wrapperW;
|
|
||||||
const wrapperTopNear = wrapperBounds.top;
|
|
||||||
const wrapperTopFar = wrapperTopNear + wrapperH;
|
|
||||||
const inside =
|
|
||||||
mousemoveEvent.pageX > wrapperLeftNear &&
|
|
||||||
mousemoveEvent.pageX < wrapperLeftFar &&
|
|
||||||
mousemoveEvent.pageY > wrapperTopNear &&
|
|
||||||
mousemoveEvent.pageY < wrapperTopFar;
|
|
||||||
if (!inside) {
|
|
||||||
this.showStickyButton = false;
|
|
||||||
document.removeEventListener('mousemove', moveCallback, false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
document.addEventListener('mousemove', moveCallback, false);
|
|
||||||
},
|
|
||||||
openNodeCreator() {
|
|
||||||
this.$emit('toggleNodeCreator', {
|
|
||||||
source: NODE_CREATOR_OPEN_SOURCES.ADD_NODE_BUTTON,
|
|
||||||
createNodeActive: true,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
addStickyNote() {
|
|
||||||
if (document.activeElement) {
|
|
||||||
(document.activeElement as HTMLElement).blur();
|
|
||||||
}
|
|
||||||
|
|
||||||
const offset: [number, number] = [...this.uiStore.nodeViewOffsetPosition];
|
|
||||||
|
|
||||||
const position = getMidCanvasPosition(this.nodeViewScale, offset);
|
|
||||||
position[0] -= DEFAULT_STICKY_WIDTH / 2;
|
|
||||||
position[1] -= DEFAULT_STICKY_HEIGHT / 2;
|
|
||||||
|
|
||||||
this.$emit('addNode', [
|
|
||||||
{
|
|
||||||
nodeTypeName: STICKY_NODE_TYPE,
|
|
||||||
position,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
},
|
|
||||||
closeNodeCreator() {
|
|
||||||
this.$emit('toggleNodeCreator', { createNodeActive: false });
|
|
||||||
},
|
|
||||||
nodeTypeSelected(nodeTypeNames: string[]) {
|
|
||||||
this.$emit(
|
|
||||||
'addNode',
|
|
||||||
nodeTypeNames.map((nodeTypeName) => ({ nodeTypeName })),
|
|
||||||
);
|
|
||||||
this.closeNodeCreator();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style lang="scss" module>
|
<style lang="scss" module>
|
||||||
.nodeButtonsWrapper {
|
.nodeButtonsWrapper {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|
|
@ -23,7 +23,7 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { reactive, computed, toRefs, getCurrentInstance } from 'vue';
|
import { reactive, computed, toRefs, getCurrentInstance } from 'vue';
|
||||||
import type { ActionTypeDescription, SimplifiedNodeType } from '@/Interface';
|
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 { getNewNodePosition, NODE_SIZE } from '@/utils/nodeViewUtils';
|
||||||
import NodeIcon from '@/components/NodeIcon.vue';
|
import NodeIcon from '@/components/NodeIcon.vue';
|
||||||
|
@ -41,7 +41,7 @@ const props = defineProps<Props>();
|
||||||
const instance = getCurrentInstance();
|
const instance = getCurrentInstance();
|
||||||
const telemetry = instance?.proxy.$telemetry;
|
const telemetry = instance?.proxy.$telemetry;
|
||||||
|
|
||||||
const { getActionData, getNodeTypesWithManualTrigger, setAddedNodeActionParameters } = useActions();
|
const { getActionData, getAddedNodesAndConnections, setAddedNodeActionParameters } = useActions();
|
||||||
const { activeViewStack } = useViewStacks();
|
const { activeViewStack } = useViewStacks();
|
||||||
|
|
||||||
const state = reactive({
|
const state = reactive({
|
||||||
|
@ -72,13 +72,13 @@ function onDragStart(event: DragEvent): void {
|
||||||
*/
|
*/
|
||||||
document.body.addEventListener('dragover', onDragOver);
|
document.body.addEventListener('dragover', onDragOver);
|
||||||
const { pageX: x, pageY: y } = event;
|
const { pageX: x, pageY: y } = event;
|
||||||
if (event.dataTransfer) {
|
if (event.dataTransfer && actionData.value.key) {
|
||||||
event.dataTransfer.effectAllowed = 'copy';
|
event.dataTransfer.effectAllowed = 'copy';
|
||||||
event.dataTransfer.dropEffect = 'copy';
|
event.dataTransfer.dropEffect = 'copy';
|
||||||
event.dataTransfer.setDragImage(state.draggableDataTransfer as Element, 0, 0);
|
event.dataTransfer.setDragImage(state.draggableDataTransfer as Element, 0, 0);
|
||||||
event.dataTransfer.setData(
|
event.dataTransfer.setData(
|
||||||
'nodeTypeName',
|
DRAG_EVENT_DATA_KEY,
|
||||||
getNodeTypesWithManualTrigger(actionData.value?.key).join(','),
|
JSON.stringify(getAddedNodesAndConnections([{ type: actionData.value.key }])),
|
||||||
);
|
);
|
||||||
if (telemetry) {
|
if (telemetry) {
|
||||||
state.storeWatcher = setAddedNodeActionParameters(
|
state.storeWatcher = setAddedNodeActionParameters(
|
||||||
|
|
|
@ -42,7 +42,11 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref } from 'vue';
|
import { computed, ref } from 'vue';
|
||||||
import type { SimplifiedNodeType } from '@/Interface';
|
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 { isCommunityPackageName } from '@/utils';
|
||||||
import { getNewNodePosition, NODE_SIZE } from '@/utils/nodeViewUtils';
|
import { getNewNodePosition, NODE_SIZE } from '@/utils/nodeViewUtils';
|
||||||
|
@ -67,7 +71,7 @@ const i18n = useI18n();
|
||||||
const telemetry = useTelemetry();
|
const telemetry = useTelemetry();
|
||||||
|
|
||||||
const { actions } = useNodeCreatorStore();
|
const { actions } = useNodeCreatorStore();
|
||||||
const { getNodeTypesWithManualTrigger } = useActions();
|
const { getAddedNodesAndConnections } = useActions();
|
||||||
|
|
||||||
const dragging = ref(false);
|
const dragging = ref(false);
|
||||||
const draggablePosition = ref({ x: -100, y: -100 });
|
const draggablePosition = ref({ x: -100, y: -100 });
|
||||||
|
@ -140,8 +144,8 @@ function onDragStart(event: DragEvent): void {
|
||||||
event.dataTransfer.dropEffect = 'copy';
|
event.dataTransfer.dropEffect = 'copy';
|
||||||
event.dataTransfer.setDragImage(draggableDataTransfer.value as Element, 0, 0);
|
event.dataTransfer.setDragImage(draggableDataTransfer.value as Element, 0, 0);
|
||||||
event.dataTransfer.setData(
|
event.dataTransfer.setData(
|
||||||
'nodeTypeName',
|
DRAG_EVENT_DATA_KEY,
|
||||||
getNodeTypesWithManualTrigger(props.nodeType.name).join(','),
|
JSON.stringify(getAddedNodesAndConnections([{ type: props.nodeType.name }])),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -147,7 +147,7 @@ function onSelected(actionCreateElement: INodeCreateElement) {
|
||||||
|
|
||||||
emit('nodeTypeSelected', [actionData.key as string, actionNode]);
|
emit('nodeTypeSelected', [actionData.key as string, actionNode]);
|
||||||
} else {
|
} else {
|
||||||
emit('nodeTypeSelected', getNodeTypesWithManualTrigger(actionData.key));
|
emit('nodeTypeSelected', [actionData.key as string]);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (telemetry) setAddedNodeActionParameters(actionData, telemetry, rootView.value);
|
if (telemetry) setAddedNodeActionParameters(actionData, telemetry, rootView.value);
|
||||||
|
|
|
@ -18,7 +18,6 @@ import { useNodeCreatorStore } from '@/stores/nodeCreator.store';
|
||||||
import { TriggerView, RegularView, AIView, AINodesView } from '../viewsData';
|
import { TriggerView, RegularView, AIView, AINodesView } from '../viewsData';
|
||||||
import { transformNodeType } from '../utils';
|
import { transformNodeType } from '../utils';
|
||||||
import { useViewStacks } from '../composables/useViewStacks';
|
import { useViewStacks } from '../composables/useViewStacks';
|
||||||
import { useActions } from '../composables/useActions';
|
|
||||||
import { useKeyboardNavigation } from '../composables/useKeyboardNavigation';
|
import { useKeyboardNavigation } from '../composables/useKeyboardNavigation';
|
||||||
import ItemsRenderer from '../Renderers/ItemsRenderer.vue';
|
import ItemsRenderer from '../Renderers/ItemsRenderer.vue';
|
||||||
import CategorizedItemsRenderer from '../Renderers/CategorizedItemsRenderer.vue';
|
import CategorizedItemsRenderer from '../Renderers/CategorizedItemsRenderer.vue';
|
||||||
|
@ -38,7 +37,6 @@ const telemetry = useTelemetry();
|
||||||
|
|
||||||
const { mergedNodes, actions } = useNodeCreatorStore();
|
const { mergedNodes, actions } = useNodeCreatorStore();
|
||||||
const { baseUrl } = useRootStore();
|
const { baseUrl } = useRootStore();
|
||||||
const { getNodeTypesWithManualTrigger } = useActions();
|
|
||||||
const { pushViewStack, popViewStack } = useViewStacks();
|
const { pushViewStack, popViewStack } = useViewStacks();
|
||||||
|
|
||||||
const { registerKeyHook } = useKeyboardNavigation();
|
const { registerKeyHook } = useKeyboardNavigation();
|
||||||
|
@ -47,10 +45,7 @@ const activeViewStack = computed(() => useViewStacks().activeViewStack);
|
||||||
const globalSearchItemsDiff = computed(() => useViewStacks().globalSearchItemsDiff);
|
const globalSearchItemsDiff = computed(() => useViewStacks().globalSearchItemsDiff);
|
||||||
|
|
||||||
function selectNodeType(nodeTypes: string[]) {
|
function selectNodeType(nodeTypes: string[]) {
|
||||||
emit(
|
emit('nodeTypeSelected', nodeTypes);
|
||||||
'nodeTypeSelected',
|
|
||||||
nodeTypes.length === 1 ? getNodeTypesWithManualTrigger(nodeTypes[0]) : nodeTypes,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function onSelected(item: INodeCreateElement) {
|
function onSelected(item: INodeCreateElement) {
|
||||||
|
|
|
@ -31,10 +31,11 @@ import { useKeyboardNavigation } from './composables/useKeyboardNavigation';
|
||||||
import { useActionsGenerator } from './composables/useActionsGeneration';
|
import { useActionsGenerator } from './composables/useActionsGeneration';
|
||||||
import NodesListPanel from './Panel/NodesListPanel.vue';
|
import NodesListPanel from './Panel/NodesListPanel.vue';
|
||||||
import { useUIStore } from '@/stores';
|
import { useUIStore } from '@/stores';
|
||||||
|
import { DRAG_EVENT_DATA_KEY } from '@/constants';
|
||||||
|
|
||||||
export interface Props {
|
export interface Props {
|
||||||
active?: boolean;
|
active?: boolean;
|
||||||
onNodeTypeSelected?: (nodeType: string) => void;
|
onNodeTypeSelected?: (nodeType: string[]) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps<Props>();
|
const props = defineProps<Props>();
|
||||||
|
@ -93,12 +94,12 @@ function onDrop(event: DragEvent) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const nodeTypeName = event.dataTransfer.getData('nodeTypeName');
|
const dragData = event.dataTransfer.getData(DRAG_EVENT_DATA_KEY);
|
||||||
const nodeCreatorBoundingRect = (state.nodeCreator as Element).getBoundingClientRect();
|
const nodeCreatorBoundingRect = (state.nodeCreator as Element).getBoundingClientRect();
|
||||||
|
|
||||||
// Abort drag end event propagation if dropped inside nodes panel
|
// Abort drag end event propagation if dropped inside nodes panel
|
||||||
if (
|
if (
|
||||||
nodeTypeName &&
|
dragData &&
|
||||||
event.pageX >= nodeCreatorBoundingRect.x &&
|
event.pageX >= nodeCreatorBoundingRect.x &&
|
||||||
event.pageY >= nodeCreatorBoundingRect.y
|
event.pageY >= nodeCreatorBoundingRect.y
|
||||||
) {
|
) {
|
||||||
|
|
|
@ -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 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -2,6 +2,9 @@ import { getCurrentInstance, computed } from 'vue';
|
||||||
import type { IDataObject, INodeParameters } from 'n8n-workflow';
|
import type { IDataObject, INodeParameters } from 'n8n-workflow';
|
||||||
import type {
|
import type {
|
||||||
ActionTypeDescription,
|
ActionTypeDescription,
|
||||||
|
AddedNode,
|
||||||
|
AddedNodeConnection,
|
||||||
|
AddedNodesAndConnections,
|
||||||
INodeCreateElement,
|
INodeCreateElement,
|
||||||
IUpdateInformation,
|
IUpdateInformation,
|
||||||
LabelCreateElement,
|
LabelCreateElement,
|
||||||
|
@ -9,11 +12,14 @@ import type {
|
||||||
import {
|
import {
|
||||||
MANUAL_TRIGGER_NODE_TYPE,
|
MANUAL_TRIGGER_NODE_TYPE,
|
||||||
NODE_CREATOR_OPEN_SOURCES,
|
NODE_CREATOR_OPEN_SOURCES,
|
||||||
|
NO_OP_NODE_TYPE,
|
||||||
SCHEDULE_TRIGGER_NODE_TYPE,
|
SCHEDULE_TRIGGER_NODE_TYPE,
|
||||||
|
SPLIT_IN_BATCHES_NODE_TYPE,
|
||||||
STICKY_NODE_TYPE,
|
STICKY_NODE_TYPE,
|
||||||
TRIGGER_NODE_CREATOR_VIEW,
|
TRIGGER_NODE_CREATOR_VIEW,
|
||||||
WEBHOOK_NODE_TYPE,
|
WEBHOOK_NODE_TYPE,
|
||||||
} from '@/constants';
|
} from '@/constants';
|
||||||
|
import { i18n } from '@/plugins/i18n';
|
||||||
|
|
||||||
import type { BaseTextKey } from '@/plugins/i18n';
|
import type { BaseTextKey } from '@/plugins/i18n';
|
||||||
import type { Telemetry } from '@/plugins/telemetry';
|
import type { Telemetry } from '@/plugins/telemetry';
|
||||||
|
@ -144,15 +150,13 @@ export const useActions = () => {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function getNodeTypesWithManualTrigger(nodeType?: string): string[] {
|
function shouldPrependManualTrigger(addedNodes: AddedNode[]): boolean {
|
||||||
if (!nodeType) return [];
|
|
||||||
|
|
||||||
const { selectedView, openSource } = useNodeCreatorStore();
|
const { selectedView, openSource } = useNodeCreatorStore();
|
||||||
const { workflowTriggerNodes } = useWorkflowsStore();
|
const { workflowTriggerNodes } = useWorkflowsStore();
|
||||||
const isTrigger = useNodeTypesStore().isTriggerNode(nodeType);
|
const hasTrigger = addedNodes.some((node) => useNodeTypesStore().isTriggerNode(node.type));
|
||||||
const workflowContainsTrigger = workflowTriggerNodes.length > 0;
|
const workflowContainsTrigger = workflowTriggerNodes.length > 0;
|
||||||
const isTriggerPanel = selectedView === TRIGGER_NODE_CREATOR_VIEW;
|
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 = [
|
const singleNodeOpenSources = [
|
||||||
NODE_CREATOR_OPEN_SOURCES.PLUS_ENDPOINT,
|
NODE_CREATOR_OPEN_SOURCES.PLUS_ENDPOINT,
|
||||||
NODE_CREATOR_OPEN_SOURCES.NODE_CONNECTION_ACTION,
|
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
|
// 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
|
// then we do not want to append the manual trigger
|
||||||
const isSingleNodeOpenSource = singleNodeOpenSources.includes(openSource);
|
const isSingleNodeOpenSource = singleNodeOpenSources.includes(openSource);
|
||||||
const shouldAppendManualTrigger =
|
return (
|
||||||
!isSingleNodeOpenSource &&
|
!isSingleNodeOpenSource &&
|
||||||
!isTrigger &&
|
!hasTrigger &&
|
||||||
!workflowContainsTrigger &&
|
!workflowContainsTrigger &&
|
||||||
isTriggerPanel &&
|
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
|
// Hook into addNode action to set the last node parameters & track the action selected
|
||||||
|
@ -211,7 +264,7 @@ export const useActions = () => {
|
||||||
actionsCategoryLocales,
|
actionsCategoryLocales,
|
||||||
getPlaceholderTriggerActions,
|
getPlaceholderTriggerActions,
|
||||||
parseCategoryActions,
|
parseCategoryActions,
|
||||||
getNodeTypesWithManualTrigger,
|
getAddedNodesAndConnections,
|
||||||
getActionData,
|
getActionData,
|
||||||
setAddedNodeActionParameters,
|
setAddedNodeActionParameters,
|
||||||
};
|
};
|
||||||
|
|
|
@ -628,3 +628,5 @@ export const ASK_AI_MIN_PROMPT_LENGTH = 15;
|
||||||
export const ASK_AI_LOADING_DURATION_MS = 12000;
|
export const ASK_AI_LOADING_DURATION_MS = 12000;
|
||||||
|
|
||||||
export const APPEND_ATTRIBUTION_DEFAULT_PATH = 'parameters.options.appendAttribution';
|
export const APPEND_ATTRIBUTION_DEFAULT_PATH = 'parameters.options.appendAttribution';
|
||||||
|
|
||||||
|
export const DRAG_EVENT_DATA_KEY = 'nodesAndConnections';
|
||||||
|
|
|
@ -1039,6 +1039,7 @@
|
||||||
"nodeView.zoomIn": "Zoom In",
|
"nodeView.zoomIn": "Zoom In",
|
||||||
"nodeView.zoomOut": "Zoom Out",
|
"nodeView.zoomOut": "Zoom Out",
|
||||||
"nodeView.zoomToFit": "Zoom to Fit",
|
"nodeView.zoomToFit": "Zoom to Fit",
|
||||||
|
"nodeView.replaceMe": "Replace Me",
|
||||||
"nodeWebhooks.clickToCopyWebhookUrls": "Click to copy webhook URLs",
|
"nodeWebhooks.clickToCopyWebhookUrls": "Click to copy webhook URLs",
|
||||||
"nodeWebhooks.clickToDisplayWebhookUrls": "Click to display webhook URLs",
|
"nodeWebhooks.clickToDisplayWebhookUrls": "Click to display webhook URLs",
|
||||||
"nodeWebhooks.clickToHideWebhookUrls": "Click to hide webhook URLs",
|
"nodeWebhooks.clickToHideWebhookUrls": "Click to hide webhook URLs",
|
||||||
|
|
|
@ -97,7 +97,7 @@
|
||||||
:create-node-active="createNodeActive"
|
:create-node-active="createNodeActive"
|
||||||
:node-view-scale="nodeViewScale"
|
:node-view-scale="nodeViewScale"
|
||||||
@toggleNodeCreator="onToggleNodeCreator"
|
@toggleNodeCreator="onToggleNodeCreator"
|
||||||
@addNode="onAddNode"
|
@addNodes="onAddNodes"
|
||||||
/>
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
<Suspense>
|
<Suspense>
|
||||||
|
@ -217,11 +217,11 @@ import {
|
||||||
TRIGGER_NODE_CREATOR_VIEW,
|
TRIGGER_NODE_CREATOR_VIEW,
|
||||||
EnterpriseEditionFeature,
|
EnterpriseEditionFeature,
|
||||||
REGULAR_NODE_CREATOR_VIEW,
|
REGULAR_NODE_CREATOR_VIEW,
|
||||||
MANUAL_TRIGGER_NODE_TYPE,
|
|
||||||
NODE_CREATOR_OPEN_SOURCES,
|
NODE_CREATOR_OPEN_SOURCES,
|
||||||
MANUAL_CHAT_TRIGGER_NODE_TYPE,
|
MANUAL_CHAT_TRIGGER_NODE_TYPE,
|
||||||
WORKFLOW_LM_CHAT_MODAL_KEY,
|
WORKFLOW_LM_CHAT_MODAL_KEY,
|
||||||
AI_NODE_CREATOR_VIEW,
|
AI_NODE_CREATOR_VIEW,
|
||||||
|
DRAG_EVENT_DATA_KEY,
|
||||||
} from '@/constants';
|
} from '@/constants';
|
||||||
import { copyPaste } from '@/mixins/copyPaste';
|
import { copyPaste } from '@/mixins/copyPaste';
|
||||||
import { externalHooks } from '@/mixins/externalHooks';
|
import { externalHooks } from '@/mixins/externalHooks';
|
||||||
|
@ -264,7 +264,13 @@ import type {
|
||||||
Workflow,
|
Workflow,
|
||||||
ConnectionTypes,
|
ConnectionTypes,
|
||||||
} from 'n8n-workflow';
|
} from 'n8n-workflow';
|
||||||
import { deepCopy, NodeConnectionType, NodeHelpers, TelemetryHelpers } from 'n8n-workflow';
|
import {
|
||||||
|
deepCopy,
|
||||||
|
jsonParse,
|
||||||
|
NodeConnectionType,
|
||||||
|
NodeHelpers,
|
||||||
|
TelemetryHelpers,
|
||||||
|
} from 'n8n-workflow';
|
||||||
import type {
|
import type {
|
||||||
ICredentialsResponse,
|
ICredentialsResponse,
|
||||||
IExecutionResponse,
|
IExecutionResponse,
|
||||||
|
@ -282,6 +288,7 @@ import type {
|
||||||
IUser,
|
IUser,
|
||||||
INodeUpdatePropertiesInformation,
|
INodeUpdatePropertiesInformation,
|
||||||
NodeCreatorOpenSource,
|
NodeCreatorOpenSource,
|
||||||
|
AddedNodesAndConnections,
|
||||||
} from '@/Interface';
|
} from '@/Interface';
|
||||||
|
|
||||||
import { debounceHelper } from '@/mixins/debounce';
|
import { debounceHelper } from '@/mixins/debounce';
|
||||||
|
@ -341,6 +348,7 @@ import { useViewStacks } from '@/components/Node/NodeCreator/composables/useView
|
||||||
interface AddNodeOptions {
|
interface AddNodeOptions {
|
||||||
position?: XYPosition;
|
position?: XYPosition;
|
||||||
dragAndDrop?: boolean;
|
dragAndDrop?: boolean;
|
||||||
|
name?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const NodeCreation = defineAsyncComponent(async () => import('@/components/Node/NodeCreation.vue'));
|
const NodeCreation = defineAsyncComponent(async () => import('@/components/Node/NodeCreation.vue'));
|
||||||
|
@ -1741,32 +1749,22 @@ export default defineComponent({
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
},
|
},
|
||||||
|
|
||||||
onDrop(event: DragEvent) {
|
async onDrop(event: DragEvent) {
|
||||||
if (!event.dataTransfer) {
|
if (!event.dataTransfer) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const nodeTypeNames = event.dataTransfer.getData('nodeTypeName').split(',');
|
const dropData = jsonParse<AddedNodesAndConnections>(
|
||||||
|
event.dataTransfer.getData(DRAG_EVENT_DATA_KEY),
|
||||||
if (nodeTypeNames) {
|
);
|
||||||
|
if (dropData) {
|
||||||
const mousePosition = this.getMousePositionWithinNodeView(event);
|
const mousePosition = this.getMousePositionWithinNodeView(event);
|
||||||
|
const insertNodePosition = [
|
||||||
|
mousePosition[0] - NodeViewUtils.NODE_SIZE / 2 + NodeViewUtils.GRID_SIZE,
|
||||||
|
mousePosition[1] - NodeViewUtils.NODE_SIZE / 2,
|
||||||
|
] as XYPosition;
|
||||||
|
|
||||||
const nodesToAdd = nodeTypeNames.map((nodeTypeName: string, index: number) => {
|
await this.onAddNodes(dropData, true, insertNodePosition);
|
||||||
return {
|
|
||||||
nodeTypeName,
|
|
||||||
position: [
|
|
||||||
// If adding more than one node, offset the X position
|
|
||||||
mousePosition[0] -
|
|
||||||
NodeViewUtils.NODE_SIZE / 2 +
|
|
||||||
NodeViewUtils.NODE_SIZE * index * 2 +
|
|
||||||
NodeViewUtils.GRID_SIZE,
|
|
||||||
mousePosition[1] - NodeViewUtils.NODE_SIZE / 2,
|
|
||||||
] as XYPosition,
|
|
||||||
dragAndDrop: true,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
this.onAddNode(nodesToAdd, true);
|
|
||||||
this.createNodeActive = false;
|
this.createNodeActive = false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
@ -1810,7 +1808,10 @@ export default defineComponent({
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
async getNewNodeWithDefaultCredential(nodeTypeData: INodeTypeDescription) {
|
async getNewNodeWithDefaultCredential(
|
||||||
|
nodeTypeData: INodeTypeDescription,
|
||||||
|
overrides: Partial<INodeUi>,
|
||||||
|
) {
|
||||||
let nodeVersion = nodeTypeData.defaultVersion;
|
let nodeVersion = nodeTypeData.defaultVersion;
|
||||||
|
|
||||||
if (nodeVersion === undefined) {
|
if (nodeVersion === undefined) {
|
||||||
|
@ -1821,7 +1822,7 @@ export default defineComponent({
|
||||||
|
|
||||||
const newNodeData: INodeUi = {
|
const newNodeData: INodeUi = {
|
||||||
id: uuid(),
|
id: uuid(),
|
||||||
name: nodeTypeData.defaults.name as string,
|
name: overrides.name ?? (nodeTypeData.defaults.name as string),
|
||||||
type: nodeTypeData.name,
|
type: nodeTypeData.name,
|
||||||
typeVersion: nodeVersion,
|
typeVersion: nodeVersion,
|
||||||
position: [0, 0],
|
position: [0, 0],
|
||||||
|
@ -1922,7 +1923,9 @@ export default defineComponent({
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const newNodeData = await this.getNewNodeWithDefaultCredential(nodeTypeData);
|
const newNodeData = await this.getNewNodeWithDefaultCredential(nodeTypeData, {
|
||||||
|
name: options.name,
|
||||||
|
});
|
||||||
|
|
||||||
// when pulling new connection from node or injecting into a connection
|
// when pulling new connection from node or injecting into a connection
|
||||||
const lastSelectedNode = this.lastSelectedNode;
|
const lastSelectedNode = this.lastSelectedNode;
|
||||||
|
@ -2193,7 +2196,7 @@ export default defineComponent({
|
||||||
const targetEndpoint = lastSelectedNodeEndpointUuid || '';
|
const targetEndpoint = lastSelectedNodeEndpointUuid || '';
|
||||||
|
|
||||||
// Handle connection of scoped_endpoint types
|
// Handle connection of scoped_endpoint types
|
||||||
if (lastSelectedNodeEndpointUuid) {
|
if (lastSelectedNodeEndpointUuid && !isAutoAdd) {
|
||||||
const lastSelectedEndpoint = this.instance.getEndpoint(lastSelectedNodeEndpointUuid);
|
const lastSelectedEndpoint = this.instance.getEndpoint(lastSelectedNodeEndpointUuid);
|
||||||
if (
|
if (
|
||||||
this.checkNodeConnectionAllowed(
|
this.checkNodeConnectionAllowed(
|
||||||
|
@ -2221,7 +2224,7 @@ export default defineComponent({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// If a node is last selected then connect between the active and its child ones
|
// If a node is last selected then connect between the active and its child ones
|
||||||
if (lastSelectedNode) {
|
if (lastSelectedNode && !isAutoAdd) {
|
||||||
await this.$nextTick();
|
await this.$nextTick();
|
||||||
|
|
||||||
if (lastSelectedConnection?.__meta) {
|
if (lastSelectedConnection?.__meta) {
|
||||||
|
@ -4153,15 +4156,7 @@ export default defineComponent({
|
||||||
this.instance.setSuspendDrawing(false, true);
|
this.instance.setSuspendDrawing(false, true);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onToggleNodeCreator({
|
onToggleNodeCreator({ source, createNodeActive, nodeCreatorView }: ToggleNodeCreatorOptions) {
|
||||||
source,
|
|
||||||
createNodeActive,
|
|
||||||
nodeCreatorView,
|
|
||||||
}: {
|
|
||||||
source?: NodeCreatorOpenSource;
|
|
||||||
createNodeActive: boolean;
|
|
||||||
nodeCreatorView?: string;
|
|
||||||
}) {
|
|
||||||
if (createNodeActive === this.createNodeActive) return;
|
if (createNodeActive === this.createNodeActive) return;
|
||||||
|
|
||||||
if (!nodeCreatorView) {
|
if (!nodeCreatorView) {
|
||||||
|
@ -4200,52 +4195,43 @@ export default defineComponent({
|
||||||
workflow_id: this.workflowsStore.workflowId,
|
workflow_id: this.workflowsStore.workflowId,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onAddNode(
|
async onAddNodes(
|
||||||
nodeTypes: Array<{ nodeTypeName: string; position: XYPosition }>,
|
{ nodes, connections }: AddedNodesAndConnections,
|
||||||
dragAndDrop: boolean,
|
dragAndDrop = false,
|
||||||
|
position?: XYPosition,
|
||||||
) {
|
) {
|
||||||
nodeTypes.forEach(({ nodeTypeName, position }, index) => {
|
let currentPosition = position;
|
||||||
const isManualTrigger = nodeTypeName === MANUAL_TRIGGER_NODE_TYPE;
|
for (const { type, isAutoAdd, name, openDetail, position: nodePosition } of nodes) {
|
||||||
const openNDV = !isManualTrigger && (nodeTypes.length === 1 || index > 0);
|
await this.addNode(
|
||||||
void this.addNode(
|
type,
|
||||||
nodeTypeName,
|
{ position: nodePosition ?? currentPosition, dragAndDrop, name },
|
||||||
{ position, dragAndDrop },
|
openDetail ?? false,
|
||||||
openNDV,
|
|
||||||
true,
|
true,
|
||||||
nodeTypes.length > 1 && index < 1,
|
isAutoAdd,
|
||||||
);
|
);
|
||||||
if (index === 0) return;
|
|
||||||
// If there's more than one node, we want to connect them
|
|
||||||
// this has to be done in mutation subscriber to make sure both nodes already
|
|
||||||
// exist
|
|
||||||
const actionWatcher = this.workflowsStore.$onAction(({ name, after, args }) => {
|
|
||||||
if (name === 'addNode' && args[0].type === nodeTypeName) {
|
|
||||||
after(async () => {
|
|
||||||
const lastAddedNode = this.nodes[this.nodes.length - 1];
|
|
||||||
const previouslyAddedNode = this.nodes[this.nodes.length - 2];
|
|
||||||
|
|
||||||
// Position the added node to the right side of the previously added one
|
const lastAddedNode = this.nodes[this.nodes.length - 1];
|
||||||
lastAddedNode.position = [
|
currentPosition = [
|
||||||
previouslyAddedNode.position[0] +
|
lastAddedNode.position[0] + NodeViewUtils.NODE_SIZE * 2 + NodeViewUtils.GRID_SIZE,
|
||||||
NodeViewUtils.NODE_SIZE * 2 +
|
lastAddedNode.position[1],
|
||||||
NodeViewUtils.GRID_SIZE,
|
];
|
||||||
previouslyAddedNode.position[1],
|
}
|
||||||
];
|
|
||||||
await this.$nextTick();
|
|
||||||
this.connectTwoNodes(
|
|
||||||
previouslyAddedNode.name,
|
|
||||||
0,
|
|
||||||
lastAddedNode.name,
|
|
||||||
0,
|
|
||||||
NodeConnectionType.Main,
|
|
||||||
);
|
|
||||||
|
|
||||||
actionWatcher();
|
const newNodesOffset = this.nodes.length - nodes.length;
|
||||||
});
|
for (const { from, to } of connections) {
|
||||||
}
|
const fromNode = this.nodes[newNodesOffset + from.nodeIndex];
|
||||||
});
|
const toNode = this.nodes[newNodesOffset + to.nodeIndex];
|
||||||
});
|
|
||||||
|
this.connectTwoNodes(
|
||||||
|
fromNode.name,
|
||||||
|
from.outputIndex ?? 0,
|
||||||
|
toNode.name,
|
||||||
|
to.inputIndex ?? 0,
|
||||||
|
NodeConnectionType.Main,
|
||||||
|
);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
async saveCurrentWorkflowExternal(callback: () => void) {
|
async saveCurrentWorkflowExternal(callback: () => void) {
|
||||||
await this.saveCurrentWorkflow();
|
await this.saveCurrentWorkflow();
|
||||||
callback?.();
|
callback?.();
|
||||||
|
|
|
@ -22,7 +22,7 @@
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"alias": ["Loop", "concatenate"],
|
"alias": ["Loop", "Concatenate", "Batch", "Split", "Split In Batches"],
|
||||||
"subcategories": {
|
"subcategories": {
|
||||||
"Core Nodes": ["Flow"]
|
"Core Nodes": ["Flow"]
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,6 +3,7 @@ import { VersionedNodeType } from 'n8n-workflow';
|
||||||
|
|
||||||
import { SplitInBatchesV1 } from './v1/SplitInBatchesV1.node';
|
import { SplitInBatchesV1 } from './v1/SplitInBatchesV1.node';
|
||||||
import { SplitInBatchesV2 } from './v2/SplitInBatchesV2.node';
|
import { SplitInBatchesV2 } from './v2/SplitInBatchesV2.node';
|
||||||
|
import { SplitInBatchesV3 } from './v3/SplitInBatchesV3.node';
|
||||||
|
|
||||||
export class SplitInBatches extends VersionedNodeType {
|
export class SplitInBatches extends VersionedNodeType {
|
||||||
constructor() {
|
constructor() {
|
||||||
|
@ -12,12 +13,13 @@ export class SplitInBatches extends VersionedNodeType {
|
||||||
icon: 'fa:th-large',
|
icon: 'fa:th-large',
|
||||||
group: ['organization'],
|
group: ['organization'],
|
||||||
description: 'Split data into batches and iterate over each batch',
|
description: 'Split data into batches and iterate over each batch',
|
||||||
defaultVersion: 2,
|
defaultVersion: 3,
|
||||||
};
|
};
|
||||||
|
|
||||||
const nodeVersions: IVersionedNodeType['nodeVersions'] = {
|
const nodeVersions: IVersionedNodeType['nodeVersions'] = {
|
||||||
1: new SplitInBatchesV1(),
|
1: new SplitInBatchesV1(),
|
||||||
2: new SplitInBatchesV2(),
|
2: new SplitInBatchesV2(),
|
||||||
|
3: new SplitInBatchesV3(),
|
||||||
};
|
};
|
||||||
|
|
||||||
super(nodeVersions, baseDescription);
|
super(nodeVersions, baseDescription);
|
||||||
|
|
|
@ -0,0 +1,164 @@
|
||||||
|
/* eslint-disable n8n-nodes-base/node-filename-against-convention */
|
||||||
|
import type {
|
||||||
|
IExecuteFunctions,
|
||||||
|
INodeExecutionData,
|
||||||
|
INodeType,
|
||||||
|
INodeTypeDescription,
|
||||||
|
IPairedItemData,
|
||||||
|
} from 'n8n-workflow';
|
||||||
|
import { deepCopy } from 'n8n-workflow';
|
||||||
|
|
||||||
|
export class SplitInBatchesV3 implements INodeType {
|
||||||
|
description: INodeTypeDescription = {
|
||||||
|
displayName: 'Loop Over Items (Split in Batches)',
|
||||||
|
name: 'splitInBatches',
|
||||||
|
icon: 'fa:sync',
|
||||||
|
group: ['organization'],
|
||||||
|
version: 3,
|
||||||
|
description: 'Split data into batches and iterate over each batch',
|
||||||
|
defaults: {
|
||||||
|
name: 'Loop Over Items',
|
||||||
|
color: '#007755',
|
||||||
|
},
|
||||||
|
inputs: ['main'],
|
||||||
|
// eslint-disable-next-line n8n-nodes-base/node-class-description-outputs-wrong
|
||||||
|
outputs: ['main', 'main'],
|
||||||
|
outputNames: ['done', 'loop'],
|
||||||
|
properties: [
|
||||||
|
{
|
||||||
|
displayName:
|
||||||
|
'You may not need this node — n8n nodes automatically run once for each input item. <a href="https://docs.n8n.io/getting-started/key-concepts/looping.html#using-loops-in-n8n" target="_blank">More info</a>',
|
||||||
|
name: 'splitInBatchesNotice',
|
||||||
|
type: 'notice',
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
displayName: 'Batch Size',
|
||||||
|
name: 'batchSize',
|
||||||
|
type: 'number',
|
||||||
|
typeOptions: {
|
||||||
|
minValue: 1,
|
||||||
|
},
|
||||||
|
default: 1,
|
||||||
|
description: 'The number of items to return with each call',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
displayName: 'Options',
|
||||||
|
name: 'options',
|
||||||
|
type: 'collection',
|
||||||
|
placeholder: 'Add Option',
|
||||||
|
default: {},
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
displayName: 'Reset',
|
||||||
|
name: 'reset',
|
||||||
|
type: 'boolean',
|
||||||
|
default: false,
|
||||||
|
description:
|
||||||
|
'Whether the node will be reset and so with the current input-data newly initialized',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][] | null> {
|
||||||
|
// Get the input data and create a new array so that we can remove
|
||||||
|
// items without a problem
|
||||||
|
const items = this.getInputData().slice();
|
||||||
|
|
||||||
|
const nodeContext = this.getContext('node');
|
||||||
|
|
||||||
|
const batchSize = this.getNodeParameter('batchSize', 0) as number;
|
||||||
|
|
||||||
|
const returnItems: INodeExecutionData[] = [];
|
||||||
|
|
||||||
|
const options = this.getNodeParameter('options', 0, {});
|
||||||
|
|
||||||
|
if (nodeContext.items === undefined || options.reset === true) {
|
||||||
|
// Is the first time the node runs
|
||||||
|
|
||||||
|
const sourceData = this.getInputSourceData();
|
||||||
|
|
||||||
|
nodeContext.currentRunIndex = 0;
|
||||||
|
nodeContext.maxRunIndex = Math.ceil(items.length / batchSize);
|
||||||
|
nodeContext.sourceData = deepCopy(sourceData);
|
||||||
|
|
||||||
|
// Get the items which should be returned
|
||||||
|
returnItems.push.apply(returnItems, items.splice(0, batchSize));
|
||||||
|
|
||||||
|
// Save the incoming items to be able to return them for later runs
|
||||||
|
nodeContext.items = [...items];
|
||||||
|
|
||||||
|
// Reset processedItems as they get only added starting from the first iteration
|
||||||
|
nodeContext.processedItems = [];
|
||||||
|
} else {
|
||||||
|
// The node has been called before. So return the next batch of items.
|
||||||
|
nodeContext.currentRunIndex += 1;
|
||||||
|
returnItems.push.apply(
|
||||||
|
returnItems,
|
||||||
|
(nodeContext.items as INodeExecutionData[]).splice(0, batchSize),
|
||||||
|
);
|
||||||
|
|
||||||
|
const addSourceOverwrite = (pairedItem: IPairedItemData | number): IPairedItemData => {
|
||||||
|
if (typeof pairedItem === 'number') {
|
||||||
|
return {
|
||||||
|
item: pairedItem,
|
||||||
|
sourceOverwrite: nodeContext.sourceData,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...pairedItem,
|
||||||
|
sourceOverwrite: nodeContext.sourceData,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
function getPairedItemInformation(
|
||||||
|
item: INodeExecutionData,
|
||||||
|
): IPairedItemData | IPairedItemData[] {
|
||||||
|
if (item.pairedItem === undefined) {
|
||||||
|
return {
|
||||||
|
item: 0,
|
||||||
|
sourceOverwrite: nodeContext.sourceData,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(item.pairedItem)) {
|
||||||
|
return item.pairedItem.map(addSourceOverwrite);
|
||||||
|
}
|
||||||
|
|
||||||
|
return addSourceOverwrite(item.pairedItem);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceOverwrite = this.getInputSourceData();
|
||||||
|
|
||||||
|
const newItems = items.map((item, index) => {
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
pairedItem: {
|
||||||
|
sourceOverwrite,
|
||||||
|
item: index,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
nodeContext.processedItems = [...nodeContext.processedItems, ...newItems];
|
||||||
|
|
||||||
|
returnItems.map((item) => {
|
||||||
|
item.pairedItem = getPairedItemInformation(item);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
nodeContext.noItemsLeft = nodeContext.items.length === 0;
|
||||||
|
|
||||||
|
if (returnItems.length === 0) {
|
||||||
|
nodeContext.done = true;
|
||||||
|
return [nodeContext.processedItems, []];
|
||||||
|
}
|
||||||
|
|
||||||
|
nodeContext.done = false;
|
||||||
|
|
||||||
|
return [[], returnItems];
|
||||||
|
}
|
||||||
|
}
|
|
@ -5,7 +5,8 @@
|
||||||
"cache": false
|
"cache": false
|
||||||
},
|
},
|
||||||
"build": {
|
"build": {
|
||||||
"dependsOn": ["^build"]
|
"dependsOn": ["^build"],
|
||||||
|
"outputs": ["dist/**"]
|
||||||
},
|
},
|
||||||
"typecheck": {},
|
"typecheck": {},
|
||||||
"format": {},
|
"format": {},
|
||||||
|
|
Loading…
Reference in a new issue