mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-16 17:46:45 +00:00
feat(editor): Add ability to extract sub-workflows to canvas context menu (#15538)
This commit is contained in:
@@ -1523,6 +1523,7 @@
|
||||
"contextMenu.deselectAll": "Clear selection",
|
||||
"contextMenu.tidyUpWorkflow": "Tidy up workflow",
|
||||
"contextMenu.tidyUpSelection": "Tidy up selection",
|
||||
"contextMenu.extract": "Extract node into sub-workflow | Extract {count} nodes into sub-workflow",
|
||||
"contextMenu.duplicate": "Duplicate | Duplicate {count} {subject}",
|
||||
"contextMenu.open": "Open...",
|
||||
"contextMenu.test": "Execute step",
|
||||
@@ -2370,6 +2371,21 @@
|
||||
"workflowDetails.active": "Active",
|
||||
"workflowDetails.addTag": "Add tag",
|
||||
"workflowDetails.chooseOrCreateATag": "Choose or create a tag",
|
||||
"workflowExtraction.error.failure": "Sub-workflow extraction failed",
|
||||
"workflowExtraction.error.selectionGraph.inputEdgeToNonRoot": "Non-input node '{node}' has a connection from a node outside the current selection.",
|
||||
"workflowExtraction.error.selectionGraph.outputEdgeFromNonLeaf": "Non-output node '{node}' has a connection to a node outside the current selection.",
|
||||
"workflowExtraction.error.selectionGraph.multipleInputNodes": "Multiple nodes [{nodes}] have inputs from outside the selection.",
|
||||
"workflowExtraction.error.selectionGraph.multipleOutputNodes": "Multiple nodes [{nodes}] have outputs to outside the selection.",
|
||||
"workflowExtraction.error.selectionGraph.noContinuousPathFromRootToLeaf": "First node '{start}' has no selected path to last node '{end}'.",
|
||||
"workflowExtraction.error.selectionGraph.listHeader": "Selection is invalid because of these errors:<br><br>{body}<br><br><a href=\"https://docs.n8n.io/workflows/subworkflow-extraction/\" target=\"_blank\">See docs for more info.</a>",
|
||||
"workflowExtraction.error.inputNodeHasMultipleInputBranches": "First node '{node}' has multiple input branches, which sub-workflows do not support.",
|
||||
"workflowExtraction.error.outputNodeHasMultipleOutputBranches": "Last node '{node}' has multiple output branches, which sub-workflows do not support.",
|
||||
"workflowExtraction.error.triggerSelected": "Triggers cannot be extracted into a sub-workflow. Please unselect {nodes}.",
|
||||
"workflowExtraction.error.subworkflowCreationFailed": "Sub-workflow creation failed, aborting conversion.",
|
||||
"workflowExtraction.success.title": "Created sub-workflow",
|
||||
"workflowExtraction.success.message": "<a href=\"{url}\" target=\"_blank\">Open in new Tab</a>",
|
||||
"workflowExtraction.modal.title": "Move to sub-workflow",
|
||||
"workflowExtraction.modal.description": "Extract {nodeCount} node into a new sub-workflow | Extract {nodeCount} nodes into a new sub-workflow",
|
||||
"workflowHelpers.showMessage.title": "Problem saving workflow",
|
||||
"workflowOpen.active": "Active",
|
||||
"workflowOpen.couldNotLoadActiveWorkflows": "Could not load active workflows",
|
||||
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
WORKFLOW_ACTIVATION_CONFLICTING_WEBHOOK_MODAL_KEY,
|
||||
FROM_AI_PARAMETERS_MODAL_KEY,
|
||||
IMPORT_WORKFLOW_URL_MODAL_KEY,
|
||||
WORKFLOW_EXTRACTION_NAME_MODAL_KEY,
|
||||
} from '@/constants';
|
||||
|
||||
import AboutModal from '@/components/AboutModal.vue';
|
||||
@@ -316,5 +317,11 @@ import type { EventBus } from '@n8n/utils/event-bus';
|
||||
<FromAiParametersModal :modal-name="modalName" :data="data" />
|
||||
</template>
|
||||
</ModalRoot>
|
||||
|
||||
<ModalRoot :name="WORKFLOW_EXTRACTION_NAME_MODAL_KEY">
|
||||
<template #default="{ modalName, data }">
|
||||
<WorkflowExtractionNameModal :modal-name="modalName" :data="data" />
|
||||
</template>
|
||||
</ModalRoot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import WorkflowExtractionNameModal from '@/components/WorkflowExtractionNameModal.vue';
|
||||
import { WORKFLOW_EXTRACTION_NAME_MODAL_KEY } from '@/constants';
|
||||
import type { INodeUi } from '@/Interface';
|
||||
import type { ExtractableSubgraphData } from 'n8n-workflow';
|
||||
import { cloneDeep } from 'lodash-es';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { createTestingPinia } from '@pinia/testing';
|
||||
import { createComponentRenderer } from '@/__tests__/render';
|
||||
|
||||
const extractNodesIntoSubworkflow = vi.fn();
|
||||
vi.mock('@/composables/useWorkflowExtraction', () => {
|
||||
return {
|
||||
useWorkflowExtraction: () => ({
|
||||
extractNodesIntoSubworkflow,
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
const ModalStub = {
|
||||
template: `
|
||||
<div>
|
||||
<slot name="header" />
|
||||
<slot name="title" />
|
||||
<slot name="content" />
|
||||
<slot name="footer" />
|
||||
</div>
|
||||
`,
|
||||
};
|
||||
|
||||
const global = {
|
||||
stubs: {
|
||||
Modal: ModalStub,
|
||||
},
|
||||
};
|
||||
|
||||
const renderModal = createComponentRenderer(WorkflowExtractionNameModal);
|
||||
let pinia: ReturnType<typeof createTestingPinia>;
|
||||
|
||||
const DEFAULT_PROPS = {
|
||||
modalName: WORKFLOW_EXTRACTION_NAME_MODAL_KEY,
|
||||
data: {
|
||||
subGraph: Symbol() as unknown as INodeUi[],
|
||||
selection: Symbol() as ExtractableSubgraphData,
|
||||
},
|
||||
};
|
||||
|
||||
describe('WorkflowExtractionNameModal.vue', () => {
|
||||
let props = DEFAULT_PROPS;
|
||||
beforeEach(() => {
|
||||
pinia = createTestingPinia();
|
||||
props = cloneDeep(DEFAULT_PROPS);
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
it('emits "close" event when the cancel button is clicked', async () => {
|
||||
const { getByTestId } = renderModal({
|
||||
props,
|
||||
global,
|
||||
pinia,
|
||||
});
|
||||
await userEvent.click(getByTestId('cancel-button'));
|
||||
expect(extractNodesIntoSubworkflow).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('emits "submit" event with the correct name when the form is submitted', async () => {
|
||||
const { getByTestId, getByRole } = renderModal({
|
||||
props,
|
||||
global,
|
||||
pinia,
|
||||
});
|
||||
|
||||
const input = getByRole('textbox');
|
||||
// The auto-select isn't working for the test, so this doesn't clear the input
|
||||
await userEvent.type(input, ' 2');
|
||||
await userEvent.click(getByTestId('submit-button'));
|
||||
|
||||
expect(extractNodesIntoSubworkflow).toHaveBeenCalledWith(
|
||||
DEFAULT_PROPS.data.selection,
|
||||
DEFAULT_PROPS.data.subGraph,
|
||||
'My Sub-workflow 2',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
import { useWorkflowExtraction } from '@/composables/useWorkflowExtraction';
|
||||
import { WORKFLOW_EXTRACTION_NAME_MODAL_KEY } from '@/constants';
|
||||
import type { INodeUi } from '@/Interface';
|
||||
import { N8nFormInput } from '@n8n/design-system';
|
||||
import { createEventBus } from '@n8n/utils/event-bus';
|
||||
import type { ExtractableSubgraphData } from 'n8n-workflow';
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
|
||||
const props = defineProps<{
|
||||
modalName: string;
|
||||
data: {
|
||||
subGraph: INodeUi[];
|
||||
selection: ExtractableSubgraphData;
|
||||
};
|
||||
}>();
|
||||
|
||||
const DEFAULT_WORKFLOW_NAME = 'My Sub-workflow';
|
||||
|
||||
const i18n = useI18n();
|
||||
const modalBus = createEventBus();
|
||||
|
||||
const workflowExtraction = useWorkflowExtraction();
|
||||
const workflowName = ref(DEFAULT_WORKFLOW_NAME);
|
||||
|
||||
const workflowNameOrDefault = computed(() => {
|
||||
if (workflowName.value) return workflowName.value;
|
||||
|
||||
return DEFAULT_WORKFLOW_NAME;
|
||||
});
|
||||
|
||||
const onSubmit = async () => {
|
||||
const { selection, subGraph } = props.data;
|
||||
await workflowExtraction.extractNodesIntoSubworkflow(
|
||||
selection,
|
||||
subGraph,
|
||||
workflowNameOrDefault.value,
|
||||
);
|
||||
modalBus.emit('close');
|
||||
};
|
||||
|
||||
const inputRef = ref<InstanceType<typeof N8nFormInput> | null>(null);
|
||||
|
||||
onMounted(() => {
|
||||
// With modals normal focusing via `props.focus-initially` on N8nFormInput does not work
|
||||
setTimeout(() => {
|
||||
inputRef.value?.inputRef?.select();
|
||||
inputRef.value?.inputRef?.focus();
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal
|
||||
max-width="540px"
|
||||
:title="
|
||||
i18n.baseText('workflowExtraction.modal.description', {
|
||||
interpolate: { nodeCount: props.data.subGraph.length },
|
||||
})
|
||||
"
|
||||
:event-bus="modalBus"
|
||||
:name="WORKFLOW_EXTRACTION_NAME_MODAL_KEY"
|
||||
:center="true"
|
||||
:close-on-click-modal="false"
|
||||
>
|
||||
<template #content>
|
||||
<N8nFormInput
|
||||
ref="inputRef"
|
||||
v-model="workflowName"
|
||||
name="key"
|
||||
label=""
|
||||
max-length="128"
|
||||
focus-initially
|
||||
@enter="onSubmit"
|
||||
/>
|
||||
</template>
|
||||
<template #footer="{ close }">
|
||||
<div :class="$style.footer">
|
||||
<n8n-button
|
||||
type="secondary"
|
||||
:label="i18n.baseText('generic.cancel')"
|
||||
float="right"
|
||||
data-test-id="cancel-button"
|
||||
@click="close"
|
||||
/>
|
||||
<n8n-button
|
||||
:label="i18n.baseText('generic.confirm')"
|
||||
float="right"
|
||||
:disabled="!workflowName"
|
||||
data-test-id="submit-button"
|
||||
@click="onSubmit"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.row {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.container {
|
||||
h1 {
|
||||
max-width: 90%;
|
||||
}
|
||||
}
|
||||
|
||||
.description {
|
||||
font-size: var(--font-size-s);
|
||||
margin: var(--spacing-s) 0;
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
gap: var(--spacing-2xs);
|
||||
justify-content: flex-end;
|
||||
}
|
||||
</style>
|
||||
@@ -105,6 +105,7 @@ const emit = defineEmits<{
|
||||
'selection:end': [position: XYPosition];
|
||||
'open:sub-workflow': [nodeId: string];
|
||||
'start-chat': [];
|
||||
'extract-workflow': [ids: string[]];
|
||||
}>();
|
||||
|
||||
const props = withDefaults(
|
||||
@@ -314,6 +315,7 @@ const keyMap = computed(() => {
|
||||
ctrl_enter: () => emit('run:workflow'),
|
||||
ctrl_s: () => emit('save:workflow'),
|
||||
shift_alt_t: async () => await onTidyUp({ source: 'keyboard-shortcut' }),
|
||||
alt_x: emitWithSelectedNodes((ids) => emit('extract-workflow', ids)),
|
||||
c: () => emit('start-chat'),
|
||||
};
|
||||
return fullKeymap;
|
||||
@@ -695,6 +697,8 @@ async function onContextMenuAction(action: ContextMenuAction, nodeIds: string[])
|
||||
return props.eventBus.emit('nodes:action', { ids: nodeIds, action: 'update:sticky:color' });
|
||||
case 'tidy_up':
|
||||
return await onTidyUp({ source: 'context-menu' });
|
||||
case 'extract_sub_workflow':
|
||||
return emit('extract-workflow', nodeIds);
|
||||
case 'open_sub_workflow': {
|
||||
return emit('open:sub-workflow', nodeIds[0]);
|
||||
}
|
||||
|
||||
@@ -79,6 +79,18 @@ exports[`useContextMenu > Read-only mode > should return the correct actions whe
|
||||
"shiftKey": true,
|
||||
},
|
||||
},
|
||||
{
|
||||
"disabled": true,
|
||||
"divided": true,
|
||||
"id": "extract_sub_workflow",
|
||||
"label": "Extract node into sub-workflow",
|
||||
"shortcut": {
|
||||
"altKey": true,
|
||||
"keys": [
|
||||
"X",
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
"disabled": false,
|
||||
"divided": true,
|
||||
@@ -160,6 +172,18 @@ exports[`useContextMenu > Read-only mode > should return the correct actions whe
|
||||
"shiftKey": true,
|
||||
},
|
||||
},
|
||||
{
|
||||
"disabled": true,
|
||||
"divided": true,
|
||||
"id": "extract_sub_workflow",
|
||||
"label": "Extract node into sub-workflow",
|
||||
"shortcut": {
|
||||
"altKey": true,
|
||||
"keys": [
|
||||
"X",
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
"disabled": false,
|
||||
"divided": true,
|
||||
@@ -270,6 +294,18 @@ exports[`useContextMenu > should return the correct actions opening the menu fro
|
||||
"shiftKey": true,
|
||||
},
|
||||
},
|
||||
{
|
||||
"disabled": false,
|
||||
"divided": true,
|
||||
"id": "extract_sub_workflow",
|
||||
"label": "Extract node into sub-workflow",
|
||||
"shortcut": {
|
||||
"altKey": true,
|
||||
"keys": [
|
||||
"X",
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
"disabled": false,
|
||||
"divided": true,
|
||||
@@ -380,6 +416,18 @@ exports[`useContextMenu > should return the correct actions when right clicking
|
||||
"shiftKey": true,
|
||||
},
|
||||
},
|
||||
{
|
||||
"disabled": false,
|
||||
"divided": true,
|
||||
"id": "extract_sub_workflow",
|
||||
"label": "Extract node into sub-workflow",
|
||||
"shortcut": {
|
||||
"altKey": true,
|
||||
"keys": [
|
||||
"X",
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
"disabled": false,
|
||||
"divided": true,
|
||||
@@ -461,6 +509,18 @@ exports[`useContextMenu > should return the correct actions when right clicking
|
||||
"shiftKey": true,
|
||||
},
|
||||
},
|
||||
{
|
||||
"disabled": false,
|
||||
"divided": true,
|
||||
"id": "extract_sub_workflow",
|
||||
"label": "Extract node into sub-workflow",
|
||||
"shortcut": {
|
||||
"altKey": true,
|
||||
"keys": [
|
||||
"X",
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
"disabled": false,
|
||||
"divided": true,
|
||||
@@ -583,6 +643,18 @@ exports[`useContextMenu > should show "Go to Sub-workflow" action (disabled) whe
|
||||
"shiftKey": true,
|
||||
},
|
||||
},
|
||||
{
|
||||
"disabled": false,
|
||||
"divided": true,
|
||||
"id": "extract_sub_workflow",
|
||||
"label": "Extract node into sub-workflow",
|
||||
"shortcut": {
|
||||
"altKey": true,
|
||||
"keys": [
|
||||
"X",
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
"disabled": false,
|
||||
"divided": true,
|
||||
@@ -705,6 +777,18 @@ exports[`useContextMenu > should show "Go to Sub-workflow" action (disabled) whe
|
||||
"shiftKey": true,
|
||||
},
|
||||
},
|
||||
{
|
||||
"disabled": false,
|
||||
"divided": true,
|
||||
"id": "extract_sub_workflow",
|
||||
"label": "Extract node into sub-workflow",
|
||||
"shortcut": {
|
||||
"altKey": true,
|
||||
"keys": [
|
||||
"X",
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
"disabled": false,
|
||||
"divided": true,
|
||||
@@ -827,6 +911,18 @@ exports[`useContextMenu > should show "Go to Sub-workflow" action (enabled) when
|
||||
"shiftKey": true,
|
||||
},
|
||||
},
|
||||
{
|
||||
"disabled": false,
|
||||
"divided": true,
|
||||
"id": "extract_sub_workflow",
|
||||
"label": "Extract node into sub-workflow",
|
||||
"shortcut": {
|
||||
"altKey": true,
|
||||
"keys": [
|
||||
"X",
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
"disabled": false,
|
||||
"divided": true,
|
||||
@@ -949,6 +1045,18 @@ exports[`useContextMenu > should show "Go to Sub-workflow" action (enabled) when
|
||||
"shiftKey": true,
|
||||
},
|
||||
},
|
||||
{
|
||||
"disabled": false,
|
||||
"divided": true,
|
||||
"id": "extract_sub_workflow",
|
||||
"label": "Extract node into sub-workflow",
|
||||
"shortcut": {
|
||||
"altKey": true,
|
||||
"keys": [
|
||||
"X",
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
"disabled": false,
|
||||
"divided": true,
|
||||
@@ -1035,6 +1143,18 @@ exports[`useContextMenu > should support opening and closing (default = right cl
|
||||
"shiftKey": true,
|
||||
},
|
||||
},
|
||||
{
|
||||
"disabled": false,
|
||||
"divided": true,
|
||||
"id": "extract_sub_workflow",
|
||||
"label": "Extract 2 nodes into sub-workflow",
|
||||
"shortcut": {
|
||||
"altKey": true,
|
||||
"keys": [
|
||||
"X",
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
"disabled": false,
|
||||
"divided": true,
|
||||
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
RemoveConnectionCommand,
|
||||
RemoveNodeCommand,
|
||||
RenameNodeCommand,
|
||||
ReplaceNodeParametersCommand,
|
||||
} from '@/models/history';
|
||||
import { useCanvasStore } from '@/stores/canvas.store';
|
||||
import { useCredentialsStore } from '@/stores/credentials.store';
|
||||
@@ -97,6 +98,7 @@ import type {
|
||||
NodeParameterValueType,
|
||||
Workflow,
|
||||
NodeConnectionType,
|
||||
INodeParameters,
|
||||
} from 'n8n-workflow';
|
||||
import { deepCopy, NodeConnectionTypes, NodeHelpers, TelemetryHelpers } from 'n8n-workflow';
|
||||
import { computed, nextTick, ref } from 'vue';
|
||||
@@ -124,6 +126,7 @@ type AddNodesBaseOptions = {
|
||||
trackHistory?: boolean;
|
||||
keepPristine?: boolean;
|
||||
telemetry?: boolean;
|
||||
forcePosition?: boolean;
|
||||
viewport?: ViewportBoundaries;
|
||||
};
|
||||
|
||||
@@ -245,6 +248,42 @@ export function useCanvasOperations({ router }: { router: ReturnType<typeof useR
|
||||
updateNodePosition(node.id, position);
|
||||
}
|
||||
|
||||
function replaceNodeParameters(
|
||||
nodeId: string,
|
||||
currentParameters: INodeParameters,
|
||||
newParameters: INodeParameters,
|
||||
{ trackHistory = false, trackBulk = true } = {},
|
||||
) {
|
||||
const node = workflowsStore.getNodeById(nodeId);
|
||||
if (!node) return;
|
||||
|
||||
if (trackHistory && trackBulk) {
|
||||
historyStore.startRecordingUndo();
|
||||
}
|
||||
workflowsStore.setNodeParameters({
|
||||
name: node.name,
|
||||
value: newParameters,
|
||||
});
|
||||
|
||||
if (trackHistory) {
|
||||
historyStore.pushCommandToUndo(
|
||||
new ReplaceNodeParametersCommand(nodeId, currentParameters, newParameters, Date.now()),
|
||||
);
|
||||
}
|
||||
|
||||
if (trackHistory && trackBulk) {
|
||||
historyStore.stopRecordingUndo();
|
||||
}
|
||||
}
|
||||
|
||||
async function revertReplaceNodeParameters(
|
||||
nodeId: string,
|
||||
currentParameters: INodeParameters,
|
||||
newParameters: INodeParameters,
|
||||
) {
|
||||
replaceNodeParameters(nodeId, newParameters, currentParameters);
|
||||
}
|
||||
|
||||
async function renameNode(
|
||||
currentName: string,
|
||||
newName: string,
|
||||
@@ -419,6 +458,63 @@ export function useCanvasOperations({ router }: { router: ReturnType<typeof useR
|
||||
}
|
||||
}
|
||||
|
||||
function replaceNodeConnections(
|
||||
previousId: string,
|
||||
newId: string,
|
||||
{ trackHistory = false, trackBulk = true, replaceInputs = true, replaceOutputs = true } = {},
|
||||
) {
|
||||
const previousNode = workflowsStore.getNodeById(previousId);
|
||||
const newNode = workflowsStore.getNodeById(newId);
|
||||
|
||||
if (!previousNode || !newNode) {
|
||||
return;
|
||||
}
|
||||
|
||||
const wf = workflowsStore.getCurrentWorkflow();
|
||||
|
||||
const inputNodeNames = replaceInputs ? wf.getParentNodes(previousNode.name, 'main', 1) : [];
|
||||
const outputNodeNames = replaceOutputs ? wf.getChildNodes(previousNode.name, 'main', 1) : [];
|
||||
const connectionPairs = [
|
||||
...wf.getConnectionsBetweenNodes(inputNodeNames, [previousNode.name]),
|
||||
...wf.getConnectionsBetweenNodes([previousNode.name], outputNodeNames),
|
||||
];
|
||||
|
||||
if (trackHistory && trackBulk) {
|
||||
historyStore.startRecordingUndo();
|
||||
}
|
||||
for (const pair of connectionPairs) {
|
||||
const sourceNode = workflowsStore.getNodeByName(pair[0].node);
|
||||
const targetNode = workflowsStore.getNodeByName(pair[1].node);
|
||||
if (!sourceNode || !targetNode) continue;
|
||||
const oldCanvasConnection = mapLegacyConnectionToCanvasConnection(
|
||||
sourceNode,
|
||||
targetNode,
|
||||
pair,
|
||||
);
|
||||
deleteConnection(oldCanvasConnection, { trackHistory, trackBulk: false });
|
||||
|
||||
const newCanvasConnection = mapLegacyConnectionToCanvasConnection(
|
||||
sourceNode.name === previousNode.name ? newNode : sourceNode,
|
||||
targetNode.name === previousNode.name ? newNode : targetNode,
|
||||
[
|
||||
{
|
||||
...pair[0],
|
||||
node: pair[0].node === previousNode.name ? newNode.name : pair[0].node,
|
||||
},
|
||||
{
|
||||
...pair[1],
|
||||
node: pair[1].node === previousNode.name ? newNode.name : pair[1].node,
|
||||
},
|
||||
],
|
||||
);
|
||||
createConnection(newCanvasConnection, { trackHistory });
|
||||
}
|
||||
|
||||
if (trackHistory && trackBulk) {
|
||||
historyStore.stopRecordingUndo();
|
||||
}
|
||||
}
|
||||
|
||||
function setNodeActive(id: string) {
|
||||
const node = workflowsStore.getNodeById(id);
|
||||
if (!node) {
|
||||
@@ -710,7 +806,6 @@ export function useCanvasOperations({ router }: { router: ReturnType<typeof useR
|
||||
const lastInteractedWithNodeId = lastInteractedWithNode.id;
|
||||
const lastInteractedWithNodeConnection = uiStore.lastInteractedWithNodeConnection;
|
||||
const lastInteractedWithNodeHandle = uiStore.lastInteractedWithNodeHandle;
|
||||
|
||||
// If we have a specific endpoint to connect to
|
||||
if (lastInteractedWithNodeHandle) {
|
||||
const { type: connectionType, mode } = parseCanvasConnectionHandleString(
|
||||
@@ -813,15 +908,18 @@ export function useCanvasOperations({ router }: { router: ReturnType<typeof useR
|
||||
function resolveNodeData(
|
||||
node: AddNodeDataWithTypeVersion,
|
||||
nodeTypeDescription: INodeTypeDescription,
|
||||
options: { viewport?: ViewportBoundaries } = {},
|
||||
options: { viewport?: ViewportBoundaries; forcePosition?: boolean } = {},
|
||||
) {
|
||||
const id = node.id ?? nodeHelpers.assignNodeId(node as INodeUi);
|
||||
const name = node.name ?? (nodeTypeDescription.defaults.name as string);
|
||||
const type = nodeTypeDescription.name;
|
||||
const typeVersion = node.typeVersion;
|
||||
const position = resolveNodePosition(node as INodeUi, nodeTypeDescription, {
|
||||
viewport: options.viewport,
|
||||
});
|
||||
const position =
|
||||
options.forcePosition && node.position
|
||||
? node.position
|
||||
: resolveNodePosition(node as INodeUi, nodeTypeDescription, {
|
||||
viewport: options.viewport,
|
||||
});
|
||||
const disabled = node.disabled ?? false;
|
||||
const parameters = node.parameters ?? {};
|
||||
|
||||
@@ -2110,6 +2208,8 @@ export function useCanvasOperations({ router }: { router: ReturnType<typeof useR
|
||||
setNodeParameters,
|
||||
renameNode,
|
||||
revertRenameNode,
|
||||
replaceNodeParameters,
|
||||
revertReplaceNodeParameters,
|
||||
deleteNode,
|
||||
deleteNodes,
|
||||
copyNodes,
|
||||
@@ -2136,6 +2236,7 @@ export function useCanvasOperations({ router }: { router: ReturnType<typeof useR
|
||||
openExecution,
|
||||
startChat,
|
||||
importTemplate,
|
||||
replaceNodeConnections,
|
||||
tryToOpenSubworkflowInNewTab,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -34,7 +34,8 @@ export type ContextMenuAction =
|
||||
| 'add_sticky'
|
||||
| 'change_color'
|
||||
| 'open_sub_workflow'
|
||||
| 'tidy_up';
|
||||
| 'tidy_up'
|
||||
| 'extract_sub_workflow';
|
||||
|
||||
const position = ref<XYPosition>([0, 0]);
|
||||
const isOpen = ref(false);
|
||||
@@ -47,7 +48,6 @@ export const useContextMenu = (onAction: ContextMenuActionCallback = () => {}) =
|
||||
const nodeTypesStore = useNodeTypesStore();
|
||||
const workflowsStore = useWorkflowsStore();
|
||||
const sourceControlStore = useSourceControlStore();
|
||||
|
||||
const i18n = useI18n();
|
||||
|
||||
const workflowPermissions = computed(
|
||||
@@ -164,6 +164,16 @@ export const useContextMenu = (onAction: ContextMenuActionCallback = () => {}) =
|
||||
},
|
||||
];
|
||||
|
||||
const extractionActions: ActionDropdownItem[] = [
|
||||
{
|
||||
id: 'extract_sub_workflow',
|
||||
divided: true,
|
||||
label: i18n.baseText('contextMenu.extract', { adjustToNumber: nodes.length }),
|
||||
shortcut: { altKey: true, keys: ['X'] },
|
||||
disabled: isReadOnly.value,
|
||||
},
|
||||
];
|
||||
|
||||
const layoutActions: ActionDropdownItem[] = [
|
||||
{
|
||||
id: 'tidy_up',
|
||||
@@ -222,6 +232,7 @@ export const useContextMenu = (onAction: ContextMenuActionCallback = () => {}) =
|
||||
disabled: isReadOnly.value || !nodes.every(canDuplicateNode),
|
||||
},
|
||||
...layoutActions,
|
||||
...extractionActions,
|
||||
...selectionActions,
|
||||
{
|
||||
id: 'delete',
|
||||
|
||||
@@ -0,0 +1,549 @@
|
||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||
import {
|
||||
buildAdjacencyList,
|
||||
parseExtractableSubgraphSelection,
|
||||
type ExtractableSubgraphData,
|
||||
type ExtractableErrorResult,
|
||||
extractReferencesInNodeExpressions,
|
||||
type IConnections,
|
||||
type INode,
|
||||
EXECUTE_WORKFLOW_TRIGGER_NODE_TYPE,
|
||||
NodeHelpers,
|
||||
} from 'n8n-workflow';
|
||||
import { computed } from 'vue';
|
||||
import { useToast } from './useToast';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { VIEWS, WORKFLOW_EXTRACTION_NAME_MODAL_KEY } from '@/constants';
|
||||
import { useHistoryStore } from '@/stores/history.store';
|
||||
import { useCanvasOperations } from './useCanvasOperations';
|
||||
|
||||
import type { AddedNode, INodeUi, IWorkflowDataCreate, IWorkflowDb } from '@/Interface';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
import { PUSH_NODES_OFFSET } from '@/utils/nodeViewUtils';
|
||||
import { useUIStore } from '@/stores/ui.store';
|
||||
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
|
||||
import { useTelemetry } from './useTelemetry';
|
||||
import { isEqual } from 'lodash-es';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
const CANVAS_HISTORY_OPTIONS = {
|
||||
trackBulk: false,
|
||||
trackHistory: true,
|
||||
};
|
||||
|
||||
export function useWorkflowExtraction() {
|
||||
const uiStore = useUIStore();
|
||||
const workflowsStore = useWorkflowsStore();
|
||||
const toast = useToast();
|
||||
const router = useRouter();
|
||||
const historyStore = useHistoryStore();
|
||||
const canvasOperations = useCanvasOperations({ router });
|
||||
const i18n = useI18n();
|
||||
const telemetry = useTelemetry();
|
||||
|
||||
const adjacencyList = computed(() => buildAdjacencyList(workflowsStore.workflow.connections));
|
||||
|
||||
function showError(message: string) {
|
||||
toast.showMessage({
|
||||
type: 'error',
|
||||
message,
|
||||
title: i18n.baseText('workflowExtraction.error.failure'),
|
||||
duration: 15 * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
function extractableErrorResultToMessage(result: ExtractableErrorResult) {
|
||||
switch (result.errorCode) {
|
||||
case 'Input Edge To Non-Root Node':
|
||||
return i18n.baseText('workflowExtraction.error.selectionGraph.inputEdgeToNonRoot', {
|
||||
interpolate: { node: result.node },
|
||||
});
|
||||
case 'Output Edge From Non-Leaf Node':
|
||||
return i18n.baseText('workflowExtraction.error.selectionGraph.outputEdgeFromNonLeaf', {
|
||||
interpolate: { node: result.node },
|
||||
});
|
||||
case 'Multiple Input Nodes':
|
||||
return i18n.baseText('workflowExtraction.error.selectionGraph.multipleInputNodes', {
|
||||
interpolate: { nodes: [...result.nodes].map((x) => `'${x}'`).join(', ') },
|
||||
});
|
||||
case 'Multiple Output Nodes':
|
||||
return i18n.baseText('workflowExtraction.error.selectionGraph.multipleOutputNodes', {
|
||||
interpolate: { nodes: [...result.nodes].map((x) => `'${x}'`).join(', ') },
|
||||
});
|
||||
case 'No Continuous Path From Root To Leaf In Selection':
|
||||
return i18n.baseText(
|
||||
'workflowExtraction.error.selectionGraph.noContinuousPathFromRootToLeaf',
|
||||
{ interpolate: { start: result.start, end: result.end } },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function makeExecuteWorkflowNode(
|
||||
workflowId: string,
|
||||
name: string,
|
||||
position: [number, number],
|
||||
variables: Map<string, string>,
|
||||
): Omit<INode, 'id'> {
|
||||
return {
|
||||
parameters: {
|
||||
workflowId: {
|
||||
__rl: true,
|
||||
value: workflowId,
|
||||
mode: 'list',
|
||||
},
|
||||
workflowInputs: {
|
||||
mappingMode: 'defineBelow',
|
||||
value: Object.fromEntries(variables.entries().map(([k, v]) => [k, `={{ ${v} }}`])),
|
||||
matchingColumns: [...variables.keys()],
|
||||
schema: [
|
||||
...variables.keys().map((x) => ({
|
||||
id: x,
|
||||
displayName: x,
|
||||
required: false,
|
||||
defaultMatch: false,
|
||||
display: true,
|
||||
canBeUsedToMatch: true,
|
||||
removed: false,
|
||||
// omitted type implicitly uses our `any` type
|
||||
})),
|
||||
],
|
||||
attemptToConvertTypes: false,
|
||||
convertFieldsToString: true,
|
||||
},
|
||||
options: {},
|
||||
},
|
||||
type: 'n8n-nodes-base.executeWorkflow',
|
||||
typeVersion: 1.2,
|
||||
position,
|
||||
name,
|
||||
};
|
||||
}
|
||||
|
||||
function makeSubworkflow(
|
||||
newWorkflowName: string,
|
||||
{ start, end }: ExtractableSubgraphData,
|
||||
nodes: INodeUi[],
|
||||
connections: IConnections,
|
||||
selectionVariables: Map<string, string>,
|
||||
selectionChildrenVariables: Map<string, string>,
|
||||
startNodeName: string,
|
||||
returnNodeName: string,
|
||||
): IWorkflowDataCreate {
|
||||
const newConnections = Object.fromEntries(
|
||||
Object.entries(connections).filter(([k]) => nodes.some((x) => x.name === k)),
|
||||
);
|
||||
if (end) {
|
||||
// this is necessary because the new workflow may crash looking for the
|
||||
// nodes in these connections
|
||||
delete newConnections[end];
|
||||
}
|
||||
|
||||
const startNodeTarget = nodes.find((x) => x.name === start);
|
||||
const firstNode = startNodeTarget ?? nodes.sort((a, b) => a.position[1] - b.position[1])[0];
|
||||
const startNodePosition: [number, number] = [
|
||||
firstNode.position[0] - PUSH_NODES_OFFSET,
|
||||
firstNode.position[1],
|
||||
];
|
||||
|
||||
const endNodeTarget = nodes.find((x) => x.name === end);
|
||||
const lastNode = endNodeTarget ?? nodes.sort((a, b) => b.position[1] - a.position[1])[0];
|
||||
const endNodePosition: [number, number] = [
|
||||
lastNode.position[0] + PUSH_NODES_OFFSET,
|
||||
lastNode.position[1],
|
||||
];
|
||||
|
||||
const shouldInsertReturnNode = selectionChildrenVariables.size > 0;
|
||||
|
||||
const startNodeConnection = startNodeTarget
|
||||
? ({
|
||||
[startNodeName]: {
|
||||
main: [
|
||||
[
|
||||
{
|
||||
node: startNodeTarget.name,
|
||||
type: 'main',
|
||||
index: 0,
|
||||
},
|
||||
],
|
||||
],
|
||||
},
|
||||
} satisfies IConnections)
|
||||
: {};
|
||||
|
||||
const endNodeConnection =
|
||||
endNodeTarget && shouldInsertReturnNode
|
||||
? ({
|
||||
[endNodeTarget.name]: {
|
||||
main: [
|
||||
[
|
||||
{
|
||||
node: returnNodeName,
|
||||
type: 'main',
|
||||
index: 0,
|
||||
},
|
||||
],
|
||||
],
|
||||
},
|
||||
} satisfies IConnections)
|
||||
: {};
|
||||
|
||||
const returnNode = shouldInsertReturnNode
|
||||
? [
|
||||
{
|
||||
parameters: {
|
||||
assignments: {
|
||||
assignments: [
|
||||
...selectionChildrenVariables.entries().map((x) => ({
|
||||
id: uuidv4(),
|
||||
name: x[0],
|
||||
value: `={{ ${x[1]} }}`,
|
||||
type: 'string',
|
||||
})),
|
||||
],
|
||||
},
|
||||
options: {},
|
||||
},
|
||||
type: 'n8n-nodes-base.set',
|
||||
typeVersion: 3.4,
|
||||
position: endNodePosition,
|
||||
id: uuidv4(),
|
||||
name: returnNodeName,
|
||||
} satisfies INode,
|
||||
]
|
||||
: [];
|
||||
const triggerParameters =
|
||||
selectionVariables.size > 0
|
||||
? {
|
||||
workflowInputs: {
|
||||
values: [...selectionVariables.keys().map((k) => ({ name: k, type: 'any' }))],
|
||||
},
|
||||
}
|
||||
: {
|
||||
inputSource: 'passthrough',
|
||||
};
|
||||
|
||||
const triggerNode: INode = {
|
||||
id: uuidv4(),
|
||||
typeVersion: 1.1,
|
||||
name: startNodeName,
|
||||
type: EXECUTE_WORKFLOW_TRIGGER_NODE_TYPE,
|
||||
position: startNodePosition,
|
||||
parameters: triggerParameters,
|
||||
};
|
||||
|
||||
return {
|
||||
name: newWorkflowName,
|
||||
nodes: [...nodes, ...returnNode, triggerNode],
|
||||
connections: {
|
||||
...newConnections,
|
||||
...startNodeConnection,
|
||||
...endNodeConnection,
|
||||
},
|
||||
settings: { executionOrder: 'v1' },
|
||||
projectId: workflowsStore.workflow.homeProject?.id,
|
||||
parentFolderId: workflowsStore.workflow.parentFolder?.id ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function computeAveragePosition(nodes: INode[]): [number, number] {
|
||||
const summedUp = nodes.reduce(
|
||||
(acc, v) => [acc[0] + v.position[0], acc[1] + v.position[1], acc[2] + 1],
|
||||
[0, 0, 0],
|
||||
);
|
||||
return [summedUp[0] / summedUp[2], summedUp[1] / summedUp[2]];
|
||||
}
|
||||
|
||||
async function tryCreateWorkflow(workflowData: IWorkflowDataCreate): Promise<IWorkflowDb | null> {
|
||||
try {
|
||||
const createdWorkflow = await workflowsStore.createNewWorkflow(workflowData);
|
||||
|
||||
const { href } = router.resolve({
|
||||
name: VIEWS.WORKFLOW,
|
||||
params: {
|
||||
name: createdWorkflow.id,
|
||||
},
|
||||
});
|
||||
|
||||
toast.showMessage({
|
||||
title: i18n.baseText('workflowExtraction.success.title'),
|
||||
message: i18n.baseText('workflowExtraction.success.message', {
|
||||
interpolate: { url: href },
|
||||
}),
|
||||
type: 'success',
|
||||
duration: 10 * 1000,
|
||||
});
|
||||
return createdWorkflow;
|
||||
} catch (e) {
|
||||
toast.showError(e, i18n.baseText('workflowExtraction.error.subworkflowCreationFailed'));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function checkExtractableSelectionValidity(
|
||||
selection: ReturnType<typeof parseExtractableSubgraphSelection>,
|
||||
): selection is ExtractableSubgraphData {
|
||||
if (Array.isArray(selection)) {
|
||||
showError(
|
||||
i18n.baseText('workflowExtraction.error.selectionGraph.listHeader', {
|
||||
interpolate: {
|
||||
body: selection
|
||||
.map(extractableErrorResultToMessage)
|
||||
.map((x) => `- ${x}`)
|
||||
.join('<br>'),
|
||||
},
|
||||
}),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
const { start, end } = selection;
|
||||
|
||||
const isSingleIO = (
|
||||
nodeName: string,
|
||||
getIOs: (
|
||||
...x: Parameters<typeof NodeHelpers.getNodeInputs>
|
||||
) => ReturnType<typeof NodeHelpers.getNodeInputs>,
|
||||
) => {
|
||||
const node = workflowsStore.getNodeByName(nodeName);
|
||||
if (!node) return true; // invariant broken -> abort onto error path
|
||||
const nodeType = useNodeTypesStore().getNodeType(node.type, node.typeVersion);
|
||||
if (!nodeType) return true; // invariant broken -> abort onto error path
|
||||
|
||||
const ios = getIOs(workflowsStore.getCurrentWorkflow(), node, nodeType);
|
||||
return (
|
||||
ios.filter((x) => (typeof x === 'string' ? x === 'main' : x.type === 'main')).length <= 1
|
||||
);
|
||||
};
|
||||
|
||||
if (start && !isSingleIO(start, NodeHelpers.getNodeInputs)) {
|
||||
showError(
|
||||
i18n.baseText('workflowExtraction.error.inputNodeHasMultipleInputBranches', {
|
||||
interpolate: { node: start },
|
||||
}),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
if (end && !isSingleIO(end, NodeHelpers.getNodeOutputs)) {
|
||||
showError(
|
||||
i18n.baseText('workflowExtraction.error.outputNodeHasMultipleOutputBranches', {
|
||||
interpolate: { node: end },
|
||||
}),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Returns an array of errors
|
||||
return !Array.isArray(selection);
|
||||
}
|
||||
|
||||
async function replaceSelectionWithNode(
|
||||
executeWorkflowNodeData: AddedNode,
|
||||
startId: string | undefined,
|
||||
endId: string | undefined,
|
||||
selection: INode[],
|
||||
selectionChildNodes: INode[],
|
||||
) {
|
||||
historyStore.startRecordingUndo();
|
||||
|
||||
// In most cases we're about to move the selection anyway
|
||||
// One remarkable edge case is when a single node is right-clicked on
|
||||
// This allows extraction, but does not necessarily select the node
|
||||
uiStore.resetLastInteractedWith();
|
||||
|
||||
const executeWorkflowNode = (
|
||||
await canvasOperations.addNodes([executeWorkflowNodeData], {
|
||||
...CANVAS_HISTORY_OPTIONS,
|
||||
forcePosition: true,
|
||||
})
|
||||
)[0];
|
||||
|
||||
if (endId)
|
||||
canvasOperations.replaceNodeConnections(endId, executeWorkflowNode.id, {
|
||||
...CANVAS_HISTORY_OPTIONS,
|
||||
replaceInputs: false,
|
||||
});
|
||||
|
||||
if (startId)
|
||||
canvasOperations.replaceNodeConnections(startId, executeWorkflowNode.id, {
|
||||
...CANVAS_HISTORY_OPTIONS,
|
||||
replaceOutputs: false,
|
||||
});
|
||||
|
||||
canvasOperations.deleteNodes(
|
||||
selection.map((x) => x.id),
|
||||
CANVAS_HISTORY_OPTIONS,
|
||||
);
|
||||
|
||||
for (const node of selectionChildNodes) {
|
||||
const currentNode = workflowsStore.workflow.nodes.find((x) => x.id === node.id);
|
||||
|
||||
if (isEqual(node, currentNode)) continue;
|
||||
|
||||
canvasOperations.replaceNodeParameters(
|
||||
node.id,
|
||||
{ ...currentNode?.parameters },
|
||||
{ ...node.parameters },
|
||||
CANVAS_HISTORY_OPTIONS,
|
||||
);
|
||||
}
|
||||
|
||||
uiStore.stateIsDirty = true;
|
||||
historyStore.stopRecordingUndo();
|
||||
}
|
||||
|
||||
function tryExtractNodesIntoSubworkflow(nodeIds: string[]): boolean {
|
||||
const subGraph = nodeIds.map(workflowsStore.getNodeById).filter((x) => x !== undefined);
|
||||
|
||||
const triggers = subGraph.filter((x) =>
|
||||
useNodeTypesStore().getNodeType(x.type, x.typeVersion)?.group.includes('trigger'),
|
||||
);
|
||||
if (triggers.length > 0) {
|
||||
showError(
|
||||
i18n.baseText('workflowExtraction.error.triggerSelected', {
|
||||
interpolate: { nodes: triggers.map((x) => `'${x.name}'`).join(', ') },
|
||||
}),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
const selection = parseExtractableSubgraphSelection(
|
||||
new Set(subGraph.map((x) => x.name)),
|
||||
adjacencyList.value,
|
||||
);
|
||||
|
||||
if (!checkExtractableSelectionValidity(selection)) return false;
|
||||
|
||||
uiStore.openModalWithData({
|
||||
name: WORKFLOW_EXTRACTION_NAME_MODAL_KEY,
|
||||
data: { subGraph, selection },
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
async function doExtractNodesIntoSubworkflow(
|
||||
selection: ExtractableSubgraphData,
|
||||
subGraph: INodeUi[],
|
||||
newWorkflowName: string,
|
||||
) {
|
||||
const { start, end } = selection;
|
||||
|
||||
const currentWorkflow = workflowsStore.getCurrentWorkflow();
|
||||
const allNodeNames = workflowsStore.workflow.nodes.map((x) => x.name);
|
||||
|
||||
let startNodeName = 'Start';
|
||||
const subGraphNames = subGraph.map((x) => x.name);
|
||||
while (subGraphNames.includes(startNodeName)) startNodeName += '_1';
|
||||
|
||||
let returnNodeName = 'Return';
|
||||
while (subGraphNames.includes(returnNodeName)) returnNodeName += '_1';
|
||||
|
||||
const directAfterEndNodeNames = end
|
||||
? currentWorkflow
|
||||
.getChildNodes(end, 'main', 1)
|
||||
.map((x) => currentWorkflow.getNode(x)?.name)
|
||||
.filter((x) => x !== undefined)
|
||||
: [];
|
||||
|
||||
const allAfterEndNodes = end
|
||||
? currentWorkflow
|
||||
.getChildNodes(end, 'ALL')
|
||||
.map((x) => currentWorkflow.getNode(x))
|
||||
.filter((x) => x !== null)
|
||||
: [];
|
||||
|
||||
const { nodes, variables } = extractReferencesInNodeExpressions(
|
||||
subGraph,
|
||||
allNodeNames,
|
||||
startNodeName,
|
||||
start ? [start] : undefined,
|
||||
);
|
||||
|
||||
let executeWorkflowNodeName = `Call ${newWorkflowName}`;
|
||||
while (allNodeNames.includes(executeWorkflowNodeName)) executeWorkflowNodeName += '_1';
|
||||
|
||||
const { nodes: afterNodes, variables: afterVariables } = extractReferencesInNodeExpressions(
|
||||
allAfterEndNodes,
|
||||
allAfterEndNodes
|
||||
.map((x) => x.name)
|
||||
.concat(subGraphNames), // this excludes nodes that will remain in the parent workflow
|
||||
executeWorkflowNodeName,
|
||||
directAfterEndNodeNames,
|
||||
);
|
||||
|
||||
const workflowData = makeSubworkflow(
|
||||
newWorkflowName,
|
||||
selection,
|
||||
nodes,
|
||||
workflowsStore.workflow.connections,
|
||||
variables,
|
||||
afterVariables,
|
||||
startNodeName,
|
||||
returnNodeName,
|
||||
);
|
||||
const createdWorkflow = await tryCreateWorkflow(workflowData);
|
||||
if (createdWorkflow === null) return false;
|
||||
|
||||
const executeWorkflowPosition = computeAveragePosition(subGraph);
|
||||
const executeWorkflowNode = makeExecuteWorkflowNode(
|
||||
createdWorkflow.id,
|
||||
executeWorkflowNodeName,
|
||||
executeWorkflowPosition,
|
||||
variables,
|
||||
);
|
||||
await replaceSelectionWithNode(
|
||||
executeWorkflowNode,
|
||||
subGraph.find((x) => x.name === start)?.id,
|
||||
subGraph.find((x) => x.name === end)?.id,
|
||||
subGraph,
|
||||
afterNodes,
|
||||
);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* This mutates the current workflow and creates a new one.
|
||||
* Intended to be called from @WorkflowExtractionNameModal spawned
|
||||
* by @tryExtractNodesIntoSubworkflow
|
||||
*/
|
||||
async function extractNodesIntoSubworkflow(
|
||||
selection: ExtractableSubgraphData,
|
||||
subGraph: INodeUi[],
|
||||
newWorkflowName: string,
|
||||
) {
|
||||
const success = await doExtractNodesIntoSubworkflow(selection, subGraph, newWorkflowName);
|
||||
trackExtractWorkflow(subGraph.length, success);
|
||||
}
|
||||
|
||||
/**
|
||||
* This starts the extraction process by checking whether the selection is extractable
|
||||
* and spawning a pop up asking for a sub-workflow name.
|
||||
* If confirmed, the modal calls @extractNodesIntoSubworkflow to handle the actual mutation
|
||||
*
|
||||
* @param nodeIds the ids to be extracted from the current workflow into a sub-workflow
|
||||
*/
|
||||
async function extractWorkflow(nodeIds: string[]) {
|
||||
const success = tryExtractNodesIntoSubworkflow(nodeIds);
|
||||
trackStartExtractWorkflow(nodeIds.length, success);
|
||||
}
|
||||
|
||||
function trackStartExtractWorkflow(nodeCount: number, success: boolean) {
|
||||
telemetry.track('User started nodes to sub-workflow extraction', {
|
||||
node_count: nodeCount,
|
||||
success,
|
||||
});
|
||||
}
|
||||
|
||||
function trackExtractWorkflow(nodeCount: number, success: boolean) {
|
||||
telemetry.track('User extracted nodes to sub-workflow', {
|
||||
node_count: nodeCount,
|
||||
success,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
adjacencyList,
|
||||
extractWorkflow,
|
||||
tryExtractNodesIntoSubworkflow,
|
||||
extractNodesIntoSubworkflow,
|
||||
};
|
||||
}
|
||||
@@ -82,6 +82,7 @@ export const MOVE_FOLDER_MODAL_KEY = 'moveFolder';
|
||||
export const WORKFLOW_ACTIVATION_CONFLICTING_WEBHOOK_MODAL_KEY =
|
||||
'workflowActivationConflictingWebhook';
|
||||
export const FROM_AI_PARAMETERS_MODAL_KEY = 'fromAiParameters';
|
||||
export const WORKFLOW_EXTRACTION_NAME_MODAL_KEY = 'workflowExtractionName';
|
||||
|
||||
export const COMMUNITY_PACKAGE_MANAGE_ACTIONS = {
|
||||
UNINSTALL: 'uninstall',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { INodeUi, XYPosition } from '@/Interface';
|
||||
import type { IConnection } from 'n8n-workflow';
|
||||
import type { IConnection, INodeParameters } from 'n8n-workflow';
|
||||
import { createEventBus } from '@n8n/utils/event-bus';
|
||||
|
||||
// Command names don't serve any particular purpose in the app
|
||||
@@ -13,6 +13,7 @@ export const enum COMMANDS {
|
||||
REMOVE_CONNECTION = 'removeConnection',
|
||||
ENABLE_NODE_TOGGLE = 'enableNodeToggle',
|
||||
RENAME_NODE = 'renameNode',
|
||||
REPLACE_NODE_PARAMETERS = 'replaceNodeParameters',
|
||||
}
|
||||
|
||||
// Triggering multiple canvas actions in sequence leaves
|
||||
@@ -281,3 +282,43 @@ export class RenameNodeCommand extends Command {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class ReplaceNodeParametersCommand extends Command {
|
||||
constructor(
|
||||
private nodeId: string,
|
||||
private currentParameters: INodeParameters,
|
||||
private newParameters: INodeParameters,
|
||||
timestamp: number,
|
||||
) {
|
||||
super(COMMANDS.REPLACE_NODE_PARAMETERS, timestamp);
|
||||
}
|
||||
|
||||
getReverseCommand(timestamp: number): Command {
|
||||
return new ReplaceNodeParametersCommand(
|
||||
this.nodeId,
|
||||
this.newParameters,
|
||||
this.currentParameters,
|
||||
timestamp,
|
||||
);
|
||||
}
|
||||
|
||||
isEqualTo(anotherCommand: Command): boolean {
|
||||
return (
|
||||
anotherCommand instanceof ReplaceNodeParametersCommand &&
|
||||
anotherCommand.nodeId === this.nodeId &&
|
||||
anotherCommand.currentParameters === this.currentParameters &&
|
||||
anotherCommand.newParameters === this.newParameters
|
||||
);
|
||||
}
|
||||
|
||||
async revert(): Promise<void> {
|
||||
return await new Promise<void>((resolve) => {
|
||||
historyBus.emit('revertReplaceNodeParameters', {
|
||||
nodeId: this.nodeId,
|
||||
currentProperties: this.currentParameters,
|
||||
newProperties: this.newParameters,
|
||||
});
|
||||
resolve();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,6 +41,7 @@ import {
|
||||
WORKFLOW_ACTIVATION_CONFLICTING_WEBHOOK_MODAL_KEY,
|
||||
FROM_AI_PARAMETERS_MODAL_KEY,
|
||||
IMPORT_WORKFLOW_URL_MODAL_KEY,
|
||||
WORKFLOW_EXTRACTION_NAME_MODAL_KEY,
|
||||
} from '@/constants';
|
||||
import { STORES } from '@n8n/stores';
|
||||
import type {
|
||||
@@ -199,6 +200,12 @@ export const useUIStore = defineStore(STORES.UI, () => {
|
||||
url: '',
|
||||
},
|
||||
},
|
||||
[WORKFLOW_EXTRACTION_NAME_MODAL_KEY]: {
|
||||
open: false,
|
||||
data: {
|
||||
workflowName: '',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const modalStack = ref<string[]>([]);
|
||||
|
||||
@@ -390,6 +390,35 @@ describe('useWorkflowsStore', () => {
|
||||
const result = workflowsStore.isNodeInOutgoingNodeConnections('RootNode', 'SearchNode');
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true if connection is indirect within `depth`', () => {
|
||||
workflowsStore.workflow.connections = {
|
||||
RootNode: { main: [[{ node: 'IntermediateNode' } as IConnection]] },
|
||||
IntermediateNode: { main: [[{ node: 'SearchNode' } as IConnection]] },
|
||||
};
|
||||
|
||||
const result = workflowsStore.isNodeInOutgoingNodeConnections('RootNode', 'SearchNode', 2);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false if connection is indirect beyond `depth`', () => {
|
||||
workflowsStore.workflow.connections = {
|
||||
RootNode: { main: [[{ node: 'IntermediateNode' } as IConnection]] },
|
||||
IntermediateNode: { main: [[{ node: 'SearchNode' } as IConnection]] },
|
||||
};
|
||||
|
||||
const result = workflowsStore.isNodeInOutgoingNodeConnections('RootNode', 'SearchNode', 1);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false if depth is 0', () => {
|
||||
workflowsStore.workflow.connections = {
|
||||
RootNode: { main: [[{ node: 'SearchNode' } as IConnection]] },
|
||||
};
|
||||
|
||||
const result = workflowsStore.isNodeInOutgoingNodeConnections('RootNode', 'SearchNode', 0);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPinDataSize()', () => {
|
||||
|
||||
@@ -334,14 +334,21 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
|
||||
return workflow.value.connections.hasOwnProperty(nodeName);
|
||||
}
|
||||
|
||||
function isNodeInOutgoingNodeConnections(rootNodeName: string, searchNodeName: string): boolean {
|
||||
function isNodeInOutgoingNodeConnections(
|
||||
rootNodeName: string,
|
||||
searchNodeName: string,
|
||||
depth = -1,
|
||||
): boolean {
|
||||
if (depth === 0) return false;
|
||||
const firstNodeConnections = outgoingConnectionsByNodeName(rootNodeName);
|
||||
if (!firstNodeConnections?.main?.[0]) return false;
|
||||
|
||||
const connections = firstNodeConnections.main[0];
|
||||
if (connections.some((node) => node.node === searchNodeName)) return true;
|
||||
|
||||
return connections.some((node) => isNodeInOutgoingNodeConnections(node.node, searchNodeName));
|
||||
return connections.some((node) =>
|
||||
isNodeInOutgoingNodeConnections(node.node, searchNodeName, depth - 1),
|
||||
);
|
||||
}
|
||||
|
||||
function getWorkflowById(id: string): IWorkflowDb {
|
||||
|
||||
@@ -76,7 +76,13 @@ import {
|
||||
EVALUATION_TRIGGER_NODE_TYPE,
|
||||
EVALUATION_NODE_TYPE,
|
||||
} from 'n8n-workflow';
|
||||
import type { NodeConnectionType, IDataObject, ExecutionSummary, IConnection } from 'n8n-workflow';
|
||||
import type {
|
||||
NodeConnectionType,
|
||||
IDataObject,
|
||||
ExecutionSummary,
|
||||
IConnection,
|
||||
INodeParameters,
|
||||
} from 'n8n-workflow';
|
||||
import { useToast } from '@/composables/useToast';
|
||||
import { useSettingsStore } from '@/stores/settings.store';
|
||||
import { useCredentialsStore } from '@/stores/credentials.store';
|
||||
@@ -123,6 +129,7 @@ import { useWorkflowSaving } from '@/composables/useWorkflowSaving';
|
||||
import { useBuilderStore } from '@/stores/builder.store';
|
||||
import { useFoldersStore } from '@/stores/folders.store';
|
||||
import KeyboardShortcutTooltip from '@/components/KeyboardShortcutTooltip.vue';
|
||||
import { useWorkflowExtraction } from '@/composables/useWorkflowExtraction';
|
||||
import { useAgentRequestStore } from '@n8n/stores/useAgentRequestStore';
|
||||
import { needsAgentInput } from '@/utils/nodes/nodeTransforms';
|
||||
import { useLogsStore } from '@/stores/logs.store';
|
||||
@@ -195,6 +202,7 @@ const {
|
||||
revertUpdateNodePosition,
|
||||
renameNode,
|
||||
revertRenameNode,
|
||||
revertReplaceNodeParameters,
|
||||
setNodeActive,
|
||||
setNodeSelected,
|
||||
toggleNodesDisabled,
|
||||
@@ -230,6 +238,7 @@ const {
|
||||
lastClickPosition,
|
||||
startChat,
|
||||
} = useCanvasOperations({ router });
|
||||
const { extractWorkflow } = useWorkflowExtraction();
|
||||
const { applyExecutionData } = useExecutionDebugging();
|
||||
useClipboard({ onPaste: onClipboardPaste });
|
||||
|
||||
@@ -635,6 +644,10 @@ function onTidyUp(event: CanvasLayoutEvent) {
|
||||
tidyUp(event);
|
||||
}
|
||||
|
||||
function onExtractWorkflow(nodeIds: string[]) {
|
||||
void extractWorkflow(nodeIds);
|
||||
}
|
||||
|
||||
function onUpdateNodesPosition(events: CanvasNodeMoveEvent[]) {
|
||||
updateNodesPosition(events, { trackHistory: true });
|
||||
}
|
||||
@@ -891,6 +904,18 @@ async function onRevertRenameNode({
|
||||
await revertRenameNode(currentName, newName);
|
||||
}
|
||||
|
||||
async function onRevertReplaceNodeParameters({
|
||||
nodeId,
|
||||
currentProperties,
|
||||
newProperties,
|
||||
}: {
|
||||
nodeId: string;
|
||||
currentProperties: INodeParameters;
|
||||
newProperties: INodeParameters;
|
||||
}) {
|
||||
await revertReplaceNodeParameters(nodeId, currentProperties, newProperties);
|
||||
}
|
||||
|
||||
function onUpdateNodeParameters(id: string, parameters: Record<string, unknown>) {
|
||||
setNodeParameters(id, parameters);
|
||||
}
|
||||
@@ -1400,6 +1425,7 @@ function addUndoRedoEventBindings() {
|
||||
historyBus.on('revertAddConnection', onRevertCreateConnection);
|
||||
historyBus.on('revertRemoveConnection', onRevertDeleteConnection);
|
||||
historyBus.on('revertRenameNode', onRevertRenameNode);
|
||||
historyBus.on('revertReplaceNodeParameters', onRevertReplaceNodeParameters);
|
||||
historyBus.on('enableNodeToggle', onRevertToggleNodeDisabled);
|
||||
}
|
||||
|
||||
@@ -1410,6 +1436,7 @@ function removeUndoRedoEventBindings() {
|
||||
historyBus.off('revertAddConnection', onRevertCreateConnection);
|
||||
historyBus.off('revertRemoveConnection', onRevertDeleteConnection);
|
||||
historyBus.off('revertRenameNode', onRevertRenameNode);
|
||||
historyBus.off('revertReplaceNodeParameters', onRevertReplaceNodeParameters);
|
||||
historyBus.off('enableNodeToggle', onRevertToggleNodeDisabled);
|
||||
}
|
||||
|
||||
@@ -1977,6 +2004,7 @@ onBeforeUnmount(() => {
|
||||
@selection:end="onSelectionEnd"
|
||||
@drag-and-drop="onDragAndDrop"
|
||||
@tidy-up="onTidyUp"
|
||||
@extract-workflow="onExtractWorkflow"
|
||||
@start-chat="startChat()"
|
||||
>
|
||||
<Suspense>
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { IConnection, IConnections } from '../interfaces';
|
||||
|
||||
type MultipleInputNodesError = {
|
||||
errorCode: 'Multiple Input Nodes';
|
||||
nodes: Set<string>;
|
||||
@@ -31,21 +33,21 @@ export type ExtractableErrorResult =
|
||||
| OutputEdgeFromNonLeafNode
|
||||
| NoContinuousPathFromRootToLeaf;
|
||||
|
||||
type AdjacencyList = Map<string, Set<string>>;
|
||||
export type IConnectionAdjacencyList = Map<string, Set<IConnection>>;
|
||||
|
||||
/**
|
||||
* Find all edges leading into the graph described in `graphIds`.
|
||||
*/
|
||||
export function getInputEdges(
|
||||
graphIds: Set<string>,
|
||||
adjacencyList: AdjacencyList,
|
||||
): Array<[string, string]> {
|
||||
const result: Array<[string, string]> = [];
|
||||
adjacencyList: IConnectionAdjacencyList,
|
||||
): Array<[string, IConnection]> {
|
||||
const result: Array<[string, IConnection]> = [];
|
||||
for (const [from, tos] of adjacencyList.entries()) {
|
||||
if (graphIds.has(from)) continue;
|
||||
|
||||
for (const to of tos) {
|
||||
if (graphIds.has(to)) {
|
||||
if (graphIds.has(to.node)) {
|
||||
result.push([from, to]);
|
||||
}
|
||||
}
|
||||
@@ -59,14 +61,14 @@ export function getInputEdges(
|
||||
*/
|
||||
export function getOutputEdges(
|
||||
graphIds: Set<string>,
|
||||
adjacencyList: AdjacencyList,
|
||||
): Array<[string, string]> {
|
||||
const result: Array<[string, string]> = [];
|
||||
adjacencyList: IConnectionAdjacencyList,
|
||||
): Array<[string, IConnection]> {
|
||||
const result: Array<[string, IConnection]> = [];
|
||||
for (const [from, tos] of adjacencyList.entries()) {
|
||||
if (!graphIds.has(from)) continue;
|
||||
|
||||
for (const to of tos) {
|
||||
if (!graphIds.has(to)) {
|
||||
if (!graphIds.has(to.node)) {
|
||||
result.push([from, to]);
|
||||
}
|
||||
}
|
||||
@@ -98,27 +100,49 @@ function difference<T>(minuend: Set<T>, subtrahend: Set<T>): Set<T> {
|
||||
return result;
|
||||
}
|
||||
|
||||
export function getRootNodes(graphIds: Set<string>, adjacencyList: AdjacencyList): Set<string> {
|
||||
export function getRootNodes(
|
||||
graphIds: Set<string>,
|
||||
adjacencyList: IConnectionAdjacencyList,
|
||||
): Set<string> {
|
||||
// Inner nodes are all nodes with an incoming edge from another node in the graph
|
||||
let innerNodes = new Set<string>();
|
||||
for (const nodeId of graphIds) {
|
||||
innerNodes = union(innerNodes, adjacencyList.get(nodeId) ?? new Set());
|
||||
innerNodes = union(
|
||||
innerNodes,
|
||||
new Set(
|
||||
[...(adjacencyList.get(nodeId) ?? [])]
|
||||
.filter((x) => x.type === 'main' && x.node !== nodeId)
|
||||
.map((x) => x.node),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return difference(graphIds, innerNodes);
|
||||
}
|
||||
|
||||
export function getLeafNodes(graphIds: Set<string>, adjacencyList: AdjacencyList): Set<string> {
|
||||
export function getLeafNodes(
|
||||
graphIds: Set<string>,
|
||||
adjacencyList: IConnectionAdjacencyList,
|
||||
): Set<string> {
|
||||
const result = new Set<string>();
|
||||
for (const nodeId of graphIds) {
|
||||
if (intersection(adjacencyList.get(nodeId) ?? new Set(), graphIds).size === 0) {
|
||||
if (
|
||||
intersection(
|
||||
new Set(
|
||||
[...(adjacencyList.get(nodeId) ?? [])]
|
||||
.filter((x) => x.type === 'main' && x.node !== nodeId)
|
||||
.map((x) => x.node),
|
||||
),
|
||||
graphIds,
|
||||
).size === 0
|
||||
) {
|
||||
result.add(nodeId);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function hasPath(start: string, end: string, adjacencyList: AdjacencyList) {
|
||||
export function hasPath(start: string, end: string, adjacencyList: IConnectionAdjacencyList) {
|
||||
const seen = new Set<string>();
|
||||
const paths: string[] = [start];
|
||||
while (true) {
|
||||
@@ -127,7 +151,14 @@ export function hasPath(start: string, end: string, adjacencyList: AdjacencyList
|
||||
if (next === undefined) return false;
|
||||
seen.add(next);
|
||||
|
||||
paths.push(...difference(adjacencyList.get(next) ?? new Set<string>(), seen));
|
||||
paths.push(
|
||||
...difference(
|
||||
new Set(
|
||||
[...(adjacencyList.get(next) ?? [])].filter((x) => x.type === 'main').map((x) => x.node),
|
||||
),
|
||||
seen,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,6 +167,31 @@ export type ExtractableSubgraphData = {
|
||||
end?: string;
|
||||
};
|
||||
|
||||
export function buildAdjacencyList(
|
||||
connectionsBySourceNode: IConnections,
|
||||
): IConnectionAdjacencyList {
|
||||
const result = new Map<string, Set<IConnection>>();
|
||||
const addOrCreate = (k: string, v: IConnection) =>
|
||||
result.set(k, union(result.get(k) ?? new Set(), new Set([v])));
|
||||
|
||||
for (const sourceNode of Object.keys(connectionsBySourceNode)) {
|
||||
for (const type of Object.keys(connectionsBySourceNode[sourceNode])) {
|
||||
for (const sourceIndex of Object.keys(connectionsBySourceNode[sourceNode][type])) {
|
||||
for (const connectionIndex of Object.keys(
|
||||
connectionsBySourceNode[sourceNode][type][parseInt(sourceIndex, 10)] ?? [],
|
||||
)) {
|
||||
const connection =
|
||||
connectionsBySourceNode[sourceNode][type][parseInt(sourceIndex, 10)]?.[
|
||||
parseInt(connectionIndex, 10)
|
||||
];
|
||||
if (connection) addOrCreate(sourceNode, connection);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* A subgraph is considered extractable if the following properties hold:
|
||||
* - 0-1 input nodes from outside the subgraph, to a root node
|
||||
@@ -152,14 +208,18 @@ export type ExtractableSubgraphData = {
|
||||
*/
|
||||
export function parseExtractableSubgraphSelection(
|
||||
graphIds: Set<string>,
|
||||
adjacencyList: AdjacencyList,
|
||||
adjacencyList: IConnectionAdjacencyList,
|
||||
): ExtractableSubgraphData | ExtractableErrorResult[] {
|
||||
const errors: ExtractableErrorResult[] = [];
|
||||
|
||||
// 0-1 Input nodes
|
||||
const inputEdges = getInputEdges(graphIds, adjacencyList);
|
||||
const inputNodes = new Set(inputEdges.map((x) => x[1]));
|
||||
const rootNodes = getRootNodes(graphIds, adjacencyList);
|
||||
// This filters out e.g. sub-nodes, which are technically parents
|
||||
const inputNodes = new Set(inputEdges.filter((x) => x[1].type === 'main').map((x) => x[1].node));
|
||||
let rootNodes = getRootNodes(graphIds, adjacencyList);
|
||||
|
||||
// this enables supporting cases where we have one input and a loop back to it from within the selection
|
||||
if (rootNodes.size === 0 && inputNodes.size === 1) rootNodes = inputNodes;
|
||||
for (const inputNode of difference(inputNodes, rootNodes).values()) {
|
||||
errors.push({
|
||||
errorCode: 'Input Edge To Non-Root Node',
|
||||
@@ -176,8 +236,13 @@ export function parseExtractableSubgraphSelection(
|
||||
|
||||
// 0-1 Output nodes
|
||||
const outputEdges = getOutputEdges(graphIds, adjacencyList);
|
||||
const outputNodes = new Set(outputEdges.map((x) => x[0]));
|
||||
const leafNodes = getLeafNodes(graphIds, adjacencyList);
|
||||
const outputNodes = new Set(outputEdges.filter((x) => x[1].type === 'main').map((x) => x[0]));
|
||||
let leafNodes = getLeafNodes(graphIds, adjacencyList);
|
||||
// If we have no leaf nodes, and only one output node, we can tolerate this output node
|
||||
// and connect to it.
|
||||
// Note that this is fairly theoretical, as return semantics in this case are not well-defined.
|
||||
if (leafNodes.size === 0 && outputNodes.size === 1) leafNodes = outputNodes;
|
||||
|
||||
for (const outputNode of difference(outputNodes, leafNodes).values()) {
|
||||
errors.push({
|
||||
errorCode: 'Output Edge From Non-Leaf Node',
|
||||
|
||||
@@ -14,6 +14,7 @@ export * from './execution-status';
|
||||
export * from './expression';
|
||||
export * from './from-ai-parse-utils';
|
||||
export * from './node-helpers';
|
||||
export * from './node-reference-parser-utils';
|
||||
export * from './metadata-utils';
|
||||
export * from './workflow';
|
||||
export * from './workflow-data-proxy';
|
||||
@@ -50,6 +51,13 @@ export {
|
||||
isFilterValue,
|
||||
} from './type-guards';
|
||||
|
||||
export {
|
||||
parseExtractableSubgraphSelection,
|
||||
buildAdjacencyList,
|
||||
type ExtractableErrorResult,
|
||||
type ExtractableSubgraphData,
|
||||
type IConnectionAdjacencyList as AdjacencyList,
|
||||
} from './graph/graph-utils';
|
||||
export { ExpressionExtensions } from './extensions';
|
||||
export * as ExpressionParser from './extensions/expression-parser';
|
||||
export { NativeMethods } from './native-methods';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { escapeRegExp, mapValues, isEqual, cloneDeep } from 'lodash';
|
||||
|
||||
import { OperationalError } from './errors';
|
||||
import type { INode, NodeParameterValueType } from './interfaces';
|
||||
import type { INode, INodeParameters, NodeParameterValueType } from './interfaces';
|
||||
|
||||
class LazyRegExp {
|
||||
private regExp?: RegExp;
|
||||
@@ -176,7 +176,6 @@ function parseExpressionMapping(
|
||||
for (; partsIdx < parts.length; ++partsIdx) {
|
||||
if (!DOT_REFERENCEABLE_JS_VARIABLE.test(parts[partsIdx])) break;
|
||||
}
|
||||
|
||||
return {
|
||||
nodeNameInExpression: null,
|
||||
originalExpression: `${exprStart}.${parts.slice(0, partsIdx + 1).join('.')}`, // $json.valid.until, but not ['x'] after
|
||||
@@ -304,8 +303,12 @@ function parseCandidateMatch(
|
||||
|
||||
// Handle matches of form `$json.path.to.value`, which is necessary for the selection input node
|
||||
function parse$jsonMatch(match: RegExpExecArray, expression: string, startNodeName: string) {
|
||||
const candidate = extractExpressionCandidate(expression, match.index, match[0].length);
|
||||
if (candidate === null) return;
|
||||
const candidate = extractExpressionCandidate(
|
||||
expression,
|
||||
match.index,
|
||||
match.index + match[0].length + 1,
|
||||
);
|
||||
if (candidate === null) return null;
|
||||
return parseExpressionMapping(candidate, null, null, startNodeName);
|
||||
}
|
||||
|
||||
@@ -439,6 +442,10 @@ function applyExtractMappingToNode(node: INode, parameterExtractMapping: Paramet
|
||||
return parameters;
|
||||
}
|
||||
|
||||
if (Array.isArray(parameters) && typeof mapping === 'object' && !Array.isArray(mapping)) {
|
||||
return parameters.map((x, i) => applyMapping(x, mapping[i]) as INodeParameters);
|
||||
}
|
||||
|
||||
return mapValues(parameters, (v, k) => applyMapping(v, mapping[k])) as NodeParameterValueType;
|
||||
};
|
||||
|
||||
@@ -477,17 +484,16 @@ export function extractReferencesInNodeExpressions(
|
||||
subGraph: INode[],
|
||||
nodeNames: string[],
|
||||
insertedStartName: string,
|
||||
graphInputNodeName?: string,
|
||||
graphInputNodeNames?: string[],
|
||||
) {
|
||||
////
|
||||
// STEP 1 - Validate input invariants
|
||||
////
|
||||
if (nodeNames.includes(insertedStartName))
|
||||
throw new OperationalError(
|
||||
`StartNodeName ${insertedStartName} already exists in nodeNames: ${JSON.stringify(nodeNames)}`,
|
||||
);
|
||||
|
||||
const subGraphNames = subGraph.map((x) => x.name);
|
||||
if (subGraphNames.includes(insertedStartName))
|
||||
throw new OperationalError(
|
||||
`StartNodeName ${insertedStartName} already exists in nodeNames: ${JSON.stringify(subGraphNames)}`,
|
||||
);
|
||||
|
||||
if (subGraphNames.some((x) => !nodeNames.includes(x))) {
|
||||
throw new OperationalError(
|
||||
@@ -516,7 +522,8 @@ export function extractReferencesInNodeExpressions(
|
||||
////
|
||||
|
||||
// This map is used to change the actual expressions once resolved
|
||||
const recMapByNode = new Map<string, ParameterExtractMapping>();
|
||||
// The value represents fields in the actual parameters object which require change
|
||||
const parameterTreeMappingByNode = new Map<string, ParameterExtractMapping>();
|
||||
// This is used to track all candidates for change, necessary for deduplication
|
||||
const allData = [];
|
||||
|
||||
@@ -527,10 +534,10 @@ export function extractReferencesInNodeExpressions(
|
||||
nodeRegexps,
|
||||
nodeNames,
|
||||
insertedStartName,
|
||||
node.name === graphInputNodeName,
|
||||
graphInputNodeNames?.includes(node.name) ?? false,
|
||||
),
|
||||
);
|
||||
recMapByNode.set(node.name, parameterMapping);
|
||||
parameterTreeMappingByNode.set(node.name, parameterMapping);
|
||||
allData.push(...allMappings);
|
||||
}
|
||||
|
||||
@@ -560,8 +567,8 @@ export function extractReferencesInNodeExpressions(
|
||||
return triggerArgumentMap.get(key);
|
||||
};
|
||||
|
||||
for (const [key, value] of recMapByNode.entries()) {
|
||||
recMapByNode.set(key, applyCanonicalMapping(value, getCanonicalData));
|
||||
for (const [key, value] of parameterTreeMappingByNode.entries()) {
|
||||
parameterTreeMappingByNode.set(key, applyCanonicalMapping(value, getCanonicalData));
|
||||
}
|
||||
|
||||
const allUsedMappings = [];
|
||||
@@ -569,7 +576,7 @@ export function extractReferencesInNodeExpressions(
|
||||
for (const node of subGraph) {
|
||||
const { result, usedMappings } = applyExtractMappingToNode(
|
||||
cloneDeep(node),
|
||||
recMapByNode.get(node.name),
|
||||
parameterTreeMappingByNode.get(node.name),
|
||||
);
|
||||
allUsedMappings.push(...usedMappings);
|
||||
output.push(result);
|
||||
|
||||
@@ -927,4 +927,38 @@ export class Workflow {
|
||||
|
||||
return this.__getStartNode(Object.keys(this.nodes));
|
||||
}
|
||||
|
||||
getConnectionsBetweenNodes(
|
||||
sources: string[],
|
||||
targets: string[],
|
||||
): Array<[IConnection, IConnection]> {
|
||||
const result: Array<[IConnection, IConnection]> = [];
|
||||
|
||||
for (const source of sources) {
|
||||
for (const type of Object.keys(this.connectionsBySourceNode[source] ?? {})) {
|
||||
for (const sourceIndex of Object.keys(this.connectionsBySourceNode[source][type])) {
|
||||
for (const connectionIndex of Object.keys(
|
||||
this.connectionsBySourceNode[source][type][parseInt(sourceIndex, 10)] ?? [],
|
||||
)) {
|
||||
const targetConnectionData =
|
||||
this.connectionsBySourceNode[source][type][parseInt(sourceIndex, 10)]?.[
|
||||
parseInt(connectionIndex, 10)
|
||||
];
|
||||
if (targetConnectionData && targets.includes(targetConnectionData?.node)) {
|
||||
result.push([
|
||||
{
|
||||
node: source,
|
||||
index: parseInt(sourceIndex, 10),
|
||||
type: type as NodeConnectionType,
|
||||
},
|
||||
targetConnectionData,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,323 +0,0 @@
|
||||
import {
|
||||
getInputEdges,
|
||||
getOutputEdges,
|
||||
getRootNodes,
|
||||
getLeafNodes,
|
||||
parseExtractableSubgraphSelection,
|
||||
hasPath,
|
||||
} from '@/graph/graph-utils';
|
||||
|
||||
describe('graphUtils', () => {
|
||||
describe('getInputEdges', () => {
|
||||
it('should return edges leading into the graph', () => {
|
||||
const graphIds = new Set(['B', 'C']);
|
||||
const adjacencyList = new Map<string, Set<string>>([
|
||||
['A', new Set(['B'])],
|
||||
['B', new Set(['C'])],
|
||||
]);
|
||||
|
||||
const result = getInputEdges(graphIds, adjacencyList);
|
||||
expect(result).toEqual([['A', 'B']]);
|
||||
});
|
||||
|
||||
it('should return an empty array if there are no input edges', () => {
|
||||
const graphIds = new Set(['A', 'B']);
|
||||
const adjacencyList = new Map<string, Set<string>>([
|
||||
['A', new Set(['B'])],
|
||||
['B', new Set()],
|
||||
]);
|
||||
|
||||
const result = getInputEdges(graphIds, adjacencyList);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getOutputEdges', () => {
|
||||
it('should return edges leading out of the graph', () => {
|
||||
const graphIds = new Set(['A', 'B']);
|
||||
const adjacencyList = new Map<string, Set<string>>([
|
||||
['A', new Set(['B'])],
|
||||
['B', new Set(['C'])],
|
||||
['C', new Set()],
|
||||
]);
|
||||
|
||||
const result = getOutputEdges(graphIds, adjacencyList);
|
||||
expect(result).toEqual([['B', 'C']]);
|
||||
});
|
||||
|
||||
it('should return an empty array if there are no output edges', () => {
|
||||
const graphIds = new Set(['A', 'B']);
|
||||
const adjacencyList = new Map<string, Set<string>>([['A', new Set(['B'])]]);
|
||||
|
||||
const result = getOutputEdges(graphIds, adjacencyList);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getRootNodes', () => {
|
||||
it('should return root nodes of the graph', () => {
|
||||
const graphIds = new Set(['A', 'B', 'C']);
|
||||
const adjacencyList = new Map<string, Set<string>>([['A', new Set(['B'])]]);
|
||||
|
||||
const result = getRootNodes(graphIds, adjacencyList);
|
||||
expect(result).toEqual(new Set(['A', 'C']));
|
||||
});
|
||||
|
||||
it('should return all nodes if there are no incoming edges', () => {
|
||||
const graphIds = new Set(['A', 'B']);
|
||||
const adjacencyList = new Map<string, Set<string>>();
|
||||
|
||||
const result = getRootNodes(graphIds, adjacencyList);
|
||||
expect(result).toEqual(new Set(['A', 'B']));
|
||||
});
|
||||
});
|
||||
|
||||
describe('getLeafNodes', () => {
|
||||
it('should return leaf nodes of the graph', () => {
|
||||
const graphIds = new Set(['A', 'B', 'C']);
|
||||
const adjacencyList = new Map<string, Set<string>>([
|
||||
['A', new Set(['B'])],
|
||||
['B', new Set(['C'])],
|
||||
['C', new Set()],
|
||||
]);
|
||||
|
||||
const result = getLeafNodes(graphIds, adjacencyList);
|
||||
expect(result).toEqual(new Set(['C']));
|
||||
});
|
||||
|
||||
it('should return all nodes if there are no outgoing edges', () => {
|
||||
const graphIds = new Set(['A', 'B']);
|
||||
const adjacencyList = new Map<string, Set<string>>([
|
||||
['A', new Set()],
|
||||
['B', new Set()],
|
||||
]);
|
||||
|
||||
const result = getLeafNodes(graphIds, adjacencyList);
|
||||
expect(result).toEqual(new Set(['A', 'B']));
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseExtractableSubgraphSelection', () => {
|
||||
it('should return successfully for a valid extractable subgraph', () => {
|
||||
const graphIds = new Set(['A', 'B']);
|
||||
const adjacencyList = new Map<string, Set<string>>([
|
||||
['C', new Set(['A'])],
|
||||
['A', new Set(['B'])],
|
||||
]);
|
||||
|
||||
const result = parseExtractableSubgraphSelection(graphIds, adjacencyList);
|
||||
expect(result).toMatchObject({ start: 'A' });
|
||||
});
|
||||
|
||||
it('should return successfully for multiple edges into single input node', () => {
|
||||
const graphIds = new Set(['A', 'B']);
|
||||
const adjacencyList = new Map<string, Set<string>>([
|
||||
['X', new Set(['A'])],
|
||||
['Y', new Set(['A'])],
|
||||
['A', new Set(['B'])],
|
||||
['B', new Set()],
|
||||
]);
|
||||
|
||||
const result = parseExtractableSubgraphSelection(graphIds, adjacencyList);
|
||||
expect(result).toMatchObject({ start: 'A' });
|
||||
});
|
||||
|
||||
it('should return successfully for multiple edges from single output nodes', () => {
|
||||
const graphIds = new Set(['A', 'B']);
|
||||
const adjacencyList = new Map<string, Set<string>>([
|
||||
['A', new Set(['B'])],
|
||||
['B', new Set(['X', 'Y'])],
|
||||
]);
|
||||
|
||||
const result = parseExtractableSubgraphSelection(graphIds, adjacencyList);
|
||||
expect(result).toMatchObject({});
|
||||
});
|
||||
|
||||
it('should return errors for input edge to non-root node', () => {
|
||||
const graphIds = new Set(['A', 'B']);
|
||||
const adjacencyList = new Map<string, Set<string>>([
|
||||
['X', new Set(['B'])],
|
||||
['A', new Set(['B'])],
|
||||
['B', new Set()],
|
||||
]);
|
||||
|
||||
const result = parseExtractableSubgraphSelection(graphIds, adjacencyList);
|
||||
expect(result).toEqual([
|
||||
{
|
||||
errorCode: 'Input Edge To Non-Root Node',
|
||||
node: 'B',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should return errors for output edge from non-leaf node', () => {
|
||||
const graphIds = new Set(['A', 'B']);
|
||||
const adjacencyList = new Map<string, Set<string>>([['A', new Set(['B', 'X'])]]);
|
||||
|
||||
const result = parseExtractableSubgraphSelection(graphIds, adjacencyList);
|
||||
expect(result).toEqual([
|
||||
{
|
||||
errorCode: 'Output Edge From Non-Leaf Node',
|
||||
node: 'A',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should return successfully for multiple root nodes with 1 input', () => {
|
||||
const graphIds = new Set(['A', 'B', 'C']);
|
||||
const adjacencyList = new Map<string, Set<string>>([
|
||||
['A', new Set(['C'])],
|
||||
['B', new Set(['C'])],
|
||||
['X', new Set(['A'])],
|
||||
]);
|
||||
|
||||
const result = parseExtractableSubgraphSelection(graphIds, adjacencyList);
|
||||
expect(result).toMatchObject({});
|
||||
});
|
||||
|
||||
it('should return an error for multiple root nodes with inputs', () => {
|
||||
const graphIds = new Set(['A', 'B', 'C']);
|
||||
const adjacencyList = new Map<string, Set<string>>([
|
||||
['A', new Set(['C'])],
|
||||
['B', new Set(['C'])],
|
||||
['X', new Set(['A'])],
|
||||
['Y', new Set(['B'])],
|
||||
]);
|
||||
|
||||
const result = parseExtractableSubgraphSelection(graphIds, adjacencyList);
|
||||
expect(result).toEqual([
|
||||
{
|
||||
errorCode: 'Multiple Input Nodes',
|
||||
nodes: new Set(['A', 'B']),
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should return successfully for multiple leaf nodes with 1 output', () => {
|
||||
const graphIds = new Set(['A', 'B', 'C']);
|
||||
const adjacencyList = new Map<string, Set<string>>([
|
||||
['A', new Set(['B', 'C'])],
|
||||
['C', new Set(['X'])],
|
||||
]);
|
||||
|
||||
const result = parseExtractableSubgraphSelection(graphIds, adjacencyList);
|
||||
expect(result).toMatchObject({});
|
||||
});
|
||||
|
||||
it('should return an error for multiple leaf nodes with outputs', () => {
|
||||
const graphIds = new Set(['A', 'B', 'C']);
|
||||
const adjacencyList = new Map<string, Set<string>>([
|
||||
['A', new Set(['B', 'C'])],
|
||||
['B', new Set(['X'])],
|
||||
['C', new Set(['X'])],
|
||||
]);
|
||||
|
||||
const result = parseExtractableSubgraphSelection(graphIds, adjacencyList);
|
||||
expect(result).toEqual([
|
||||
{
|
||||
errorCode: 'Multiple Output Nodes',
|
||||
nodes: new Set(['B', 'C']),
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should return an error for a non-continuous selection', () => {
|
||||
const graphIds = new Set(['A', 'D']);
|
||||
const adjacencyList = new Map<string, Set<string>>([
|
||||
['A', new Set(['B'])],
|
||||
['B', new Set(['C'])],
|
||||
['C', new Set(['D'])],
|
||||
]);
|
||||
|
||||
const result = parseExtractableSubgraphSelection(graphIds, adjacencyList);
|
||||
expect(result).toEqual([
|
||||
{
|
||||
errorCode: 'No Continuous Path From Root To Leaf In Selection',
|
||||
start: 'D',
|
||||
end: 'A',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
describe('hasPath', () => {
|
||||
it('should return true for a direct path between start and end', () => {
|
||||
const adjacencyList = new Map<string, Set<string>>([
|
||||
['A', new Set(['B'])],
|
||||
['B', new Set(['C'])],
|
||||
]);
|
||||
|
||||
const result = hasPath('A', 'C', adjacencyList);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false if there is no path between start and end', () => {
|
||||
const adjacencyList = new Map<string, Set<string>>([
|
||||
['A', new Set(['B'])],
|
||||
['C', new Set(['D'])],
|
||||
]);
|
||||
|
||||
const result = hasPath('A', 'D', adjacencyList);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true for a path with multiple intermediate nodes', () => {
|
||||
const adjacencyList = new Map<string, Set<string>>([
|
||||
['A', new Set(['B'])],
|
||||
['B', new Set(['C'])],
|
||||
['C', new Set(['D'])],
|
||||
]);
|
||||
|
||||
const result = hasPath('A', 'D', adjacencyList);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false if the start node is not in the adjacency list', () => {
|
||||
const adjacencyList = new Map<string, Set<string>>([
|
||||
['B', new Set(['C'])],
|
||||
['C', new Set(['D'])],
|
||||
]);
|
||||
|
||||
const result = hasPath('A', 'D', adjacencyList);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false if the end node is not in the adjacency list', () => {
|
||||
const adjacencyList = new Map<string, Set<string>>([
|
||||
['A', new Set(['B'])],
|
||||
['B', new Set(['C'])],
|
||||
]);
|
||||
|
||||
const result = hasPath('A', 'D', adjacencyList);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true for a cyclic graph where a path exists', () => {
|
||||
const adjacencyList = new Map<string, Set<string>>([
|
||||
['A', new Set(['B'])],
|
||||
['B', new Set(['C'])],
|
||||
['C', new Set(['A'])],
|
||||
]);
|
||||
|
||||
const result = hasPath('A', 'C', adjacencyList);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for a cyclic graph where no path exists', () => {
|
||||
const adjacencyList = new Map<string, Set<string>>([
|
||||
['A', new Set(['B'])],
|
||||
['B', new Set(['A'])],
|
||||
['C', new Set(['D'])],
|
||||
]);
|
||||
|
||||
const result = hasPath('A', 'D', adjacencyList);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true for a self-loop', () => {
|
||||
const adjacencyList = new Map<string, Set<string>>([['A', new Set(['A'])]]);
|
||||
|
||||
const result = hasPath('A', 'A', adjacencyList);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
484
packages/workflow/test/graph/graph-utils.test.ts
Normal file
484
packages/workflow/test/graph/graph-utils.test.ts
Normal file
@@ -0,0 +1,484 @@
|
||||
import {
|
||||
getInputEdges,
|
||||
getOutputEdges,
|
||||
getRootNodes,
|
||||
getLeafNodes,
|
||||
parseExtractableSubgraphSelection,
|
||||
hasPath,
|
||||
buildAdjacencyList,
|
||||
} from '@/graph/graph-utils';
|
||||
import type { IConnection, IConnections, NodeConnectionType } from '@/index';
|
||||
|
||||
function makeConnection(
|
||||
node: string,
|
||||
index: number = 0,
|
||||
type: NodeConnectionType = 'main',
|
||||
): IConnection {
|
||||
return {
|
||||
node,
|
||||
index,
|
||||
type,
|
||||
};
|
||||
}
|
||||
|
||||
describe('graphUtils', () => {
|
||||
describe('getInputEdges', () => {
|
||||
it('should return edges leading into the graph', () => {
|
||||
const graphIds = new Set(['B', 'C']);
|
||||
const adjacencyList = new Map<string, Set<IConnection>>([
|
||||
['A', new Set([makeConnection('B')])],
|
||||
['B', new Set([makeConnection('C')])],
|
||||
]);
|
||||
|
||||
const result = getInputEdges(graphIds, adjacencyList);
|
||||
expect(result).toEqual([['A', makeConnection('B')]]);
|
||||
});
|
||||
|
||||
it('should return an empty array if there are no input edges', () => {
|
||||
const graphIds = new Set(['A', 'B']);
|
||||
const adjacencyList = new Map<string, Set<IConnection>>([
|
||||
['A', new Set([makeConnection('B')])],
|
||||
['B', new Set()],
|
||||
]);
|
||||
|
||||
const result = getInputEdges(graphIds, adjacencyList);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getOutputEdges', () => {
|
||||
it('should return edges leading out of the graph', () => {
|
||||
const graphIds = new Set(['A', 'B']);
|
||||
const adjacencyList = new Map<string, Set<IConnection>>([
|
||||
['A', new Set([makeConnection('B')])],
|
||||
['B', new Set([makeConnection('C')])],
|
||||
['C', new Set()],
|
||||
]);
|
||||
|
||||
const result = getOutputEdges(graphIds, adjacencyList);
|
||||
expect(result).toEqual([['B', makeConnection('C')]]);
|
||||
});
|
||||
|
||||
it('should return an empty array if there are no output edges', () => {
|
||||
const graphIds = new Set(['A', 'B']);
|
||||
const adjacencyList = new Map<string, Set<IConnection>>([
|
||||
['A', new Set([makeConnection('B')])],
|
||||
]);
|
||||
|
||||
const result = getOutputEdges(graphIds, adjacencyList);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getRootNodes', () => {
|
||||
it('should return root nodes of the graph', () => {
|
||||
const graphIds = new Set(['A', 'B', 'C']);
|
||||
const adjacencyList = new Map<string, Set<IConnection>>([
|
||||
['A', new Set([makeConnection('B')])],
|
||||
]);
|
||||
|
||||
const result = getRootNodes(graphIds, adjacencyList);
|
||||
expect(result).toEqual(new Set(['A', 'C']));
|
||||
});
|
||||
|
||||
it('should return all nodes if there are no incoming edges', () => {
|
||||
const graphIds = new Set(['A', 'B']);
|
||||
const adjacencyList = new Map<string, Set<IConnection>>();
|
||||
|
||||
const result = getRootNodes(graphIds, adjacencyList);
|
||||
expect(result).toEqual(new Set(['A', 'B']));
|
||||
});
|
||||
});
|
||||
|
||||
describe('getLeafNodes', () => {
|
||||
it('should return leaf nodes of the graph', () => {
|
||||
const graphIds = new Set(['A', 'B', 'C']);
|
||||
const adjacencyList = new Map<string, Set<IConnection>>([
|
||||
['A', new Set([makeConnection('B')])],
|
||||
['B', new Set([makeConnection('C')])],
|
||||
['C', new Set()],
|
||||
]);
|
||||
|
||||
const result = getLeafNodes(graphIds, adjacencyList);
|
||||
expect(result).toEqual(new Set(['C']));
|
||||
});
|
||||
|
||||
it('should return all nodes if there are no outgoing edges', () => {
|
||||
const graphIds = new Set(['A', 'B']);
|
||||
const adjacencyList = new Map<string, Set<IConnection>>([
|
||||
['A', new Set()],
|
||||
['B', new Set()],
|
||||
]);
|
||||
|
||||
const result = getLeafNodes(graphIds, adjacencyList);
|
||||
expect(result).toEqual(new Set(['A', 'B']));
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseExtractableSubgraphSelection', () => {
|
||||
it('should return successfully for a valid extractable subgraph', () => {
|
||||
const graphIds = new Set(['A', 'B']);
|
||||
const adjacencyList = new Map<string, Set<IConnection>>([
|
||||
['C', new Set([makeConnection('A')])],
|
||||
['A', new Set([makeConnection('B')])],
|
||||
]);
|
||||
|
||||
const result = parseExtractableSubgraphSelection(graphIds, adjacencyList);
|
||||
expect(result).toEqual({ start: 'A', end: undefined });
|
||||
});
|
||||
|
||||
it('should return successfully for multiple edges into single input node', () => {
|
||||
const graphIds = new Set(['A', 'B']);
|
||||
const adjacencyList = new Map<string, Set<IConnection>>([
|
||||
['X', new Set([makeConnection('A')])],
|
||||
['Y', new Set([makeConnection('A')])],
|
||||
['A', new Set([makeConnection('B')])],
|
||||
['B', new Set()],
|
||||
]);
|
||||
|
||||
const result = parseExtractableSubgraphSelection(graphIds, adjacencyList);
|
||||
expect(result).toEqual({ start: 'A', end: undefined });
|
||||
});
|
||||
|
||||
it('should return successfully for multiple edges from single output nodes', () => {
|
||||
const graphIds = new Set(['A', 'B']);
|
||||
const adjacencyList = new Map<string, Set<IConnection>>([
|
||||
['A', new Set([makeConnection('B')])],
|
||||
['B', new Set([makeConnection('X'), makeConnection('Y')])],
|
||||
]);
|
||||
|
||||
const result = parseExtractableSubgraphSelection(graphIds, adjacencyList);
|
||||
expect(result).toEqual({ start: undefined, end: 'B' });
|
||||
});
|
||||
|
||||
it('should return errors for input edge to non-root node', () => {
|
||||
const graphIds = new Set(['A', 'B']);
|
||||
const adjacencyList = new Map<string, Set<IConnection>>([
|
||||
['X', new Set([makeConnection('B')])],
|
||||
['A', new Set([makeConnection('B')])],
|
||||
['B', new Set()],
|
||||
]);
|
||||
|
||||
const result = parseExtractableSubgraphSelection(graphIds, adjacencyList);
|
||||
expect(result).toEqual([
|
||||
{
|
||||
errorCode: 'Input Edge To Non-Root Node',
|
||||
node: 'B',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should return errors for output edge from non-leaf node', () => {
|
||||
const graphIds = new Set(['A', 'B']);
|
||||
const adjacencyList = new Map<string, Set<IConnection>>([
|
||||
['A', new Set([makeConnection('B'), makeConnection('X')])],
|
||||
]);
|
||||
|
||||
const result = parseExtractableSubgraphSelection(graphIds, adjacencyList);
|
||||
expect(result).toEqual([
|
||||
{
|
||||
errorCode: 'Output Edge From Non-Leaf Node',
|
||||
node: 'A',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should return successfully for multiple root nodes with 1 input', () => {
|
||||
const graphIds = new Set(['A', 'B', 'C']);
|
||||
const adjacencyList = new Map<string, Set<IConnection>>([
|
||||
['A', new Set([makeConnection('C')])],
|
||||
['B', new Set([makeConnection('C')])],
|
||||
['X', new Set([makeConnection('A')])],
|
||||
]);
|
||||
|
||||
const result = parseExtractableSubgraphSelection(graphIds, adjacencyList);
|
||||
expect(result).toEqual({ start: 'A', end: undefined });
|
||||
});
|
||||
|
||||
it('should return an error for multiple root nodes with inputs', () => {
|
||||
const graphIds = new Set(['A', 'B', 'C']);
|
||||
const adjacencyList = new Map<string, Set<IConnection>>([
|
||||
['A', new Set([makeConnection('C')])],
|
||||
['B', new Set([makeConnection('C')])],
|
||||
['X', new Set([makeConnection('A')])],
|
||||
['Y', new Set([makeConnection('B')])],
|
||||
]);
|
||||
|
||||
const result = parseExtractableSubgraphSelection(graphIds, adjacencyList);
|
||||
expect(result).toEqual([
|
||||
{
|
||||
errorCode: 'Multiple Input Nodes',
|
||||
nodes: new Set(['A', 'B']),
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should return successfully for multiple leaf nodes with 1 output', () => {
|
||||
const graphIds = new Set(['A', 'B', 'C']);
|
||||
const adjacencyList = new Map<string, Set<IConnection>>([
|
||||
['A', new Set([makeConnection('B'), makeConnection('C')])],
|
||||
['C', new Set([makeConnection('X')])],
|
||||
]);
|
||||
|
||||
const result = parseExtractableSubgraphSelection(graphIds, adjacencyList);
|
||||
expect(result).toEqual({ start: undefined, end: 'C' });
|
||||
});
|
||||
|
||||
it('should return an error for multiple leaf nodes with outputs', () => {
|
||||
const graphIds = new Set(['A', 'B', 'C']);
|
||||
const adjacencyList = new Map<string, Set<IConnection>>([
|
||||
['A', new Set([makeConnection('B'), makeConnection('C')])],
|
||||
['B', new Set([makeConnection('X')])],
|
||||
['C', new Set([makeConnection('X')])],
|
||||
]);
|
||||
|
||||
const result = parseExtractableSubgraphSelection(graphIds, adjacencyList);
|
||||
expect(result).toEqual([
|
||||
{
|
||||
errorCode: 'Multiple Output Nodes',
|
||||
nodes: new Set(['B', 'C']),
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should return an error for a non-continuous selection', () => {
|
||||
const graphIds = new Set(['A', 'D']);
|
||||
const adjacencyList = new Map<string, Set<IConnection>>([
|
||||
['A', new Set([makeConnection('B')])],
|
||||
['B', new Set([makeConnection('C')])],
|
||||
['C', new Set([makeConnection('D')])],
|
||||
]);
|
||||
|
||||
const result = parseExtractableSubgraphSelection(graphIds, adjacencyList);
|
||||
expect(result).toEqual([
|
||||
{
|
||||
errorCode: 'No Continuous Path From Root To Leaf In Selection',
|
||||
start: 'D',
|
||||
end: 'A',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should allow loop with node itself', () => {
|
||||
const graphIds = new Set(['A']);
|
||||
const adjacencyList = new Map<string, Set<IConnection>>([
|
||||
['A', new Set([makeConnection('A')])],
|
||||
]);
|
||||
|
||||
const result = parseExtractableSubgraphSelection(graphIds, adjacencyList);
|
||||
expect(result).toEqual({ start: undefined, end: undefined });
|
||||
});
|
||||
it('should allow loop with node itself with input and output', () => {
|
||||
const graphIds = new Set(['B']);
|
||||
const adjacencyList = new Map<string, Set<IConnection>>([
|
||||
['A', new Set([makeConnection('B')])],
|
||||
['B', new Set([makeConnection('B'), makeConnection('C')])],
|
||||
]);
|
||||
|
||||
const result = parseExtractableSubgraphSelection(graphIds, adjacencyList);
|
||||
expect(result).toEqual({ start: 'B', end: 'B' });
|
||||
});
|
||||
it('should allow loop within selection', () => {
|
||||
const graphIds = new Set(['A', 'B', 'C']);
|
||||
const adjacencyList = new Map<string, Set<IConnection>>([
|
||||
['A', new Set([makeConnection('B')])],
|
||||
['B', new Set([makeConnection('C')])],
|
||||
['C', new Set([makeConnection('A')])],
|
||||
]);
|
||||
|
||||
const result = parseExtractableSubgraphSelection(graphIds, adjacencyList);
|
||||
expect(result).toEqual({ start: undefined, end: undefined });
|
||||
});
|
||||
it('should allow loop within selection with input', () => {
|
||||
const graphIds = new Set(['A', 'B', 'C']);
|
||||
const adjacencyList = new Map<string, Set<IConnection>>([
|
||||
['A', new Set([makeConnection('B')])],
|
||||
['B', new Set([makeConnection('C')])],
|
||||
['C', new Set([makeConnection('A')])],
|
||||
['D', new Set([makeConnection('B')])],
|
||||
]);
|
||||
|
||||
const result = parseExtractableSubgraphSelection(graphIds, adjacencyList);
|
||||
expect(result).toEqual({ start: 'B', end: undefined });
|
||||
});
|
||||
it('should allow loop within selection with two inputs', () => {
|
||||
const graphIds = new Set(['A', 'B', 'C']);
|
||||
const adjacencyList = new Map<string, Set<IConnection>>([
|
||||
['A', new Set([makeConnection('B')])],
|
||||
['B', new Set([makeConnection('C')])],
|
||||
['C', new Set([makeConnection('A')])],
|
||||
['D', new Set([makeConnection('B')])],
|
||||
['E', new Set([makeConnection('B')])],
|
||||
]);
|
||||
|
||||
const result = parseExtractableSubgraphSelection(graphIds, adjacencyList);
|
||||
expect(result).toEqual({ start: 'B', end: undefined });
|
||||
});
|
||||
it('should not allow loop within selection with inputs to different nodes', () => {
|
||||
const graphIds = new Set(['A', 'B', 'C']);
|
||||
const adjacencyList = new Map<string, Set<IConnection>>([
|
||||
['A', new Set([makeConnection('B')])],
|
||||
['B', new Set([makeConnection('C')])],
|
||||
['C', new Set([makeConnection('A')])],
|
||||
['D', new Set([makeConnection('B')])],
|
||||
['E', new Set([makeConnection('C')])],
|
||||
]);
|
||||
|
||||
const result = parseExtractableSubgraphSelection(graphIds, adjacencyList);
|
||||
expect(result).toEqual([
|
||||
{ errorCode: 'Input Edge To Non-Root Node', node: 'B' },
|
||||
{ errorCode: 'Input Edge To Non-Root Node', node: 'C' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
describe('hasPath', () => {
|
||||
it('should return true for a direct path between start and end', () => {
|
||||
const adjacencyList = new Map<string, Set<IConnection>>([
|
||||
['A', new Set([makeConnection('B')])],
|
||||
['B', new Set([makeConnection('C')])],
|
||||
]);
|
||||
|
||||
const result = hasPath('A', 'C', adjacencyList);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false if there is no path between start and end', () => {
|
||||
const adjacencyList = new Map<string, Set<IConnection>>([
|
||||
['A', new Set([makeConnection('B')])],
|
||||
['C', new Set([makeConnection('D')])],
|
||||
]);
|
||||
|
||||
const result = hasPath('A', 'D', adjacencyList);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true for a path with multiple intermediate nodes', () => {
|
||||
const adjacencyList = new Map<string, Set<IConnection>>([
|
||||
['A', new Set([makeConnection('B')])],
|
||||
['B', new Set([makeConnection('C')])],
|
||||
['C', new Set([makeConnection('D')])],
|
||||
]);
|
||||
|
||||
const result = hasPath('A', 'D', adjacencyList);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false if the start node is not in the adjacency list', () => {
|
||||
const adjacencyList = new Map<string, Set<IConnection>>([
|
||||
['B', new Set([makeConnection('C')])],
|
||||
['C', new Set([makeConnection('D')])],
|
||||
]);
|
||||
|
||||
const result = hasPath('A', 'D', adjacencyList);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false if the end node is not in the adjacency list', () => {
|
||||
const adjacencyList = new Map<string, Set<IConnection>>([
|
||||
['A', new Set([makeConnection('B')])],
|
||||
['B', new Set([makeConnection('C')])],
|
||||
]);
|
||||
|
||||
const result = hasPath('A', 'D', adjacencyList);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true for a cyclic graph where a path exists', () => {
|
||||
const adjacencyList = new Map<string, Set<IConnection>>([
|
||||
['A', new Set([makeConnection('B')])],
|
||||
['B', new Set([makeConnection('C')])],
|
||||
['C', new Set([makeConnection('A')])],
|
||||
]);
|
||||
|
||||
const result = hasPath('A', 'C', adjacencyList);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for a cyclic graph where no path exists', () => {
|
||||
const adjacencyList = new Map<string, Set<IConnection>>([
|
||||
['A', new Set([makeConnection('B')])],
|
||||
['B', new Set([makeConnection('A')])],
|
||||
['C', new Set([makeConnection('D')])],
|
||||
]);
|
||||
|
||||
const result = hasPath('A', 'D', adjacencyList);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true for a self-loop', () => {
|
||||
const adjacencyList = new Map<string, Set<IConnection>>([
|
||||
['A', new Set([makeConnection('A')])],
|
||||
]);
|
||||
|
||||
const result = hasPath('A', 'A', adjacencyList);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
describe('buildAdjacencyList', () => {
|
||||
it('should build an adjacency list from connections by source node', () => {
|
||||
const connectionsBySourceNode: IConnections = {
|
||||
A: {
|
||||
main: [
|
||||
[
|
||||
{ node: 'B', index: 0, type: 'main' },
|
||||
{ node: 'C', index: 1, type: 'main' },
|
||||
],
|
||||
],
|
||||
},
|
||||
B: {
|
||||
main: [[{ node: 'D', index: 0, type: 'main' }]],
|
||||
},
|
||||
};
|
||||
|
||||
const result = buildAdjacencyList(connectionsBySourceNode);
|
||||
|
||||
expect(result).toEqual(
|
||||
new Map<string, Set<IConnection>>([
|
||||
['A', new Set([makeConnection('B', 0), makeConnection('C', 1)])],
|
||||
['B', new Set([makeConnection('D', 0)])],
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle an empty connections object', () => {
|
||||
const connectionsBySourceNode = {};
|
||||
|
||||
const result = buildAdjacencyList(connectionsBySourceNode);
|
||||
|
||||
expect(result).toEqual(new Map());
|
||||
});
|
||||
|
||||
it('should handle connections with multiple types', () => {
|
||||
const connectionsBySourceNode: IConnections = {
|
||||
A: {
|
||||
main: [[{ node: 'B', index: 0, type: 'main' }]],
|
||||
ai_tool: [[{ node: 'C', index: 1, type: 'ai_tool' }]],
|
||||
},
|
||||
};
|
||||
|
||||
const result = buildAdjacencyList(connectionsBySourceNode);
|
||||
|
||||
expect(result).toEqual(
|
||||
new Map<string, Set<IConnection>>([
|
||||
['A', new Set([makeConnection('B', 0, 'main'), makeConnection('C', 1, 'ai_tool')])],
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle connections with multiple indices', () => {
|
||||
const connectionsBySourceNode: IConnections = {
|
||||
A: {
|
||||
main: [[{ node: 'B', index: 0, type: 'main' }], [{ node: 'C', index: 1, type: 'main' }]],
|
||||
},
|
||||
};
|
||||
|
||||
const result = buildAdjacencyList(connectionsBySourceNode);
|
||||
|
||||
expect(result).toEqual(
|
||||
new Map<string, Set<IConnection>>([
|
||||
['A', new Set([makeConnection('B', 0), makeConnection('C', 1)])],
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -196,7 +196,7 @@ describe('NodeReferenceParserUtils', () => {
|
||||
nodes = [makeNode('B', ['$("D")'])];
|
||||
nodeNames = ['B', 'D'];
|
||||
|
||||
const result = extractReferencesInNodeExpressions(nodes, nodeNames, startNodeName, 'B');
|
||||
const result = extractReferencesInNodeExpressions(nodes, nodeNames, startNodeName, ['B']);
|
||||
expect([...result.variables.entries()]).toEqual([]);
|
||||
expect(result.nodes).toEqual([
|
||||
{
|
||||
@@ -210,7 +210,7 @@ describe('NodeReferenceParserUtils', () => {
|
||||
nodes = [makeNode('B', ['$("E").item.json.x'])];
|
||||
nodeNames = ['B'];
|
||||
|
||||
const result = extractReferencesInNodeExpressions(nodes, nodeNames, startNodeName, 'B');
|
||||
const result = extractReferencesInNodeExpressions(nodes, nodeNames, startNodeName, ['B']);
|
||||
expect([...result.variables.entries()]).toEqual([]);
|
||||
expect(result.nodes).toEqual([
|
||||
{
|
||||
@@ -249,7 +249,7 @@ describe('NodeReferenceParserUtils', () => {
|
||||
nodes = [makeNode('B', ['$json.a.b.c_d["e"]["f"]']), makeNode('C', ['$json.x.y.z'])];
|
||||
nodeNames = ['A', 'B', 'C'];
|
||||
|
||||
const result = extractReferencesInNodeExpressions(nodes, nodeNames, startNodeName, 'B');
|
||||
const result = extractReferencesInNodeExpressions(nodes, nodeNames, startNodeName, ['B']);
|
||||
expect([...result.variables.entries()]).toEqual([['a_b_c_d', '$json.a.b.c_d']]);
|
||||
expect(result.nodes).toEqual([
|
||||
{
|
||||
@@ -262,6 +262,31 @@ describe('NodeReferenceParserUtils', () => {
|
||||
},
|
||||
]);
|
||||
});
|
||||
it('should handle complex $json case for first node', () => {
|
||||
nodes = [
|
||||
{
|
||||
parameters: {
|
||||
p0: '=https://raw.githubusercontent.com/{{ $json.org }}/{{ $json.repo }}/refs/heads/master/package.json',
|
||||
},
|
||||
name: 'A',
|
||||
} as unknown as INode,
|
||||
];
|
||||
nodeNames = ['A', 'B'];
|
||||
|
||||
const result = extractReferencesInNodeExpressions(nodes, nodeNames, startNodeName, ['A']);
|
||||
expect([...result.variables.entries()]).toEqual([
|
||||
['repo', '$json.repo'],
|
||||
['org', '$json.org'],
|
||||
]);
|
||||
expect(result.nodes).toEqual([
|
||||
{
|
||||
name: 'A',
|
||||
parameters: {
|
||||
p0: '=https://raw.githubusercontent.com/{{ $json.org }}/{{ $json.repo }}/refs/heads/master/package.json',
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
it('should support different node accessor patterns', () => {
|
||||
nodes = [
|
||||
makeNode('N', ['$("A").item.json.myField']),
|
||||
@@ -621,6 +646,56 @@ describe('NodeReferenceParserUtils', () => {
|
||||
},
|
||||
]);
|
||||
});
|
||||
it('should handle assignments format of Set node correctly', () => {
|
||||
nodes = [
|
||||
{
|
||||
parameters: {
|
||||
assignments: {
|
||||
assignments: [
|
||||
{
|
||||
id: 'cf8bd6cb-f28a-4a73-b141-02e5c22cfe74',
|
||||
name: 'ghApiBaseUrl',
|
||||
value: '={{ $("A").item.json.x.y.z }}',
|
||||
type: 'string',
|
||||
},
|
||||
],
|
||||
},
|
||||
options: {},
|
||||
},
|
||||
type: 'n8n-nodes-base.set',
|
||||
typeVersion: 3.4,
|
||||
position: [80, 80],
|
||||
id: '6e2fd284-2aba-4dee-8921-18be9a291484',
|
||||
name: 'Params',
|
||||
},
|
||||
];
|
||||
nodeNames = ['A', 'Params'];
|
||||
const result = extractReferencesInNodeExpressions(nodes, nodeNames, startNodeName);
|
||||
expect([...result.variables.entries()]).toEqual([['x_y_z', '$("A").item.json.x.y.z']]);
|
||||
expect(result.nodes).toEqual([
|
||||
{
|
||||
parameters: {
|
||||
assignments: {
|
||||
assignments: [
|
||||
{
|
||||
id: 'cf8bd6cb-f28a-4a73-b141-02e5c22cfe74',
|
||||
name: 'ghApiBaseUrl',
|
||||
value: "={{ $('Start').item.json.x_y_z }}",
|
||||
type: 'string',
|
||||
},
|
||||
],
|
||||
},
|
||||
options: {},
|
||||
},
|
||||
type: 'n8n-nodes-base.set',
|
||||
typeVersion: 3.4,
|
||||
position: [80, 80],
|
||||
id: '6e2fd284-2aba-4dee-8921-18be9a291484',
|
||||
name: 'Params',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should carry over unrelated properties', () => {
|
||||
nodes = [
|
||||
{
|
||||
|
||||
@@ -2418,4 +2418,227 @@ describe('Workflow', () => {
|
||||
expect(nodes).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
describe('getConnectionsBetweenNodes', () => {
|
||||
test('should return empty array if no connections exist between sources and targets', () => {
|
||||
const result = SIMPLE_WORKFLOW.getConnectionsBetweenNodes(['Start'], ['Set1']);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
test('should return connections between a single source and target', () => {
|
||||
const result = SIMPLE_WORKFLOW.getConnectionsBetweenNodes(['Start'], ['Set']);
|
||||
expect(result).toEqual([
|
||||
[
|
||||
{ node: 'Start', index: 0, type: NodeConnectionTypes.Main },
|
||||
{ node: 'Set', type: NodeConnectionTypes.Main, index: 0 },
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
test('should return connections between multiple sources and a single target', () => {
|
||||
const connections: IConnections = {
|
||||
Node1: {
|
||||
main: [[{ node: 'TargetNode', type: NodeConnectionTypes.Main, index: 0 }]],
|
||||
},
|
||||
Node2: {
|
||||
main: [[{ node: 'TargetNode', type: NodeConnectionTypes.Main, index: 0 }]],
|
||||
},
|
||||
};
|
||||
|
||||
const workflow = new Workflow({
|
||||
id: 'test',
|
||||
nodes: [
|
||||
{
|
||||
id: 'Node1',
|
||||
name: 'Node1',
|
||||
type: 'test.set',
|
||||
typeVersion: 1,
|
||||
position: [0, 0],
|
||||
parameters: {},
|
||||
},
|
||||
{
|
||||
id: 'Node2',
|
||||
name: 'Node2',
|
||||
type: 'test.set',
|
||||
typeVersion: 1,
|
||||
position: [0, 0],
|
||||
parameters: {},
|
||||
},
|
||||
{
|
||||
id: 'TargetNode',
|
||||
name: 'TargetNode',
|
||||
type: 'test.set',
|
||||
typeVersion: 1,
|
||||
position: [0, 0],
|
||||
parameters: {},
|
||||
},
|
||||
],
|
||||
connections,
|
||||
active: false,
|
||||
nodeTypes,
|
||||
});
|
||||
|
||||
const result = workflow.getConnectionsBetweenNodes(['Node1', 'Node2'], ['TargetNode']);
|
||||
expect(result).toEqual([
|
||||
[
|
||||
{ node: 'Node1', index: 0, type: NodeConnectionTypes.Main },
|
||||
{ node: 'TargetNode', type: NodeConnectionTypes.Main, index: 0 },
|
||||
],
|
||||
[
|
||||
{ node: 'Node2', index: 0, type: NodeConnectionTypes.Main },
|
||||
{ node: 'TargetNode', type: NodeConnectionTypes.Main, index: 0 },
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
test('should return connections between a single source and multiple targets', () => {
|
||||
const connections: IConnections = {
|
||||
Node1: {
|
||||
main: [
|
||||
[
|
||||
{ node: 'TargetNode1', type: NodeConnectionTypes.Main, index: 0 },
|
||||
{ node: 'TargetNode2', type: NodeConnectionTypes.Main, index: 0 },
|
||||
],
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const workflow = new Workflow({
|
||||
id: 'test',
|
||||
nodes: [
|
||||
{
|
||||
id: 'Node1',
|
||||
name: 'Node1',
|
||||
type: 'test.set',
|
||||
typeVersion: 1,
|
||||
position: [0, 0],
|
||||
parameters: {},
|
||||
},
|
||||
{
|
||||
id: 'TargetNode1',
|
||||
name: 'TargetNode1',
|
||||
type: 'test.set',
|
||||
typeVersion: 1,
|
||||
position: [0, 0],
|
||||
parameters: {},
|
||||
},
|
||||
{
|
||||
id: 'TargetNode2',
|
||||
name: 'TargetNode2',
|
||||
type: 'test.set',
|
||||
typeVersion: 1,
|
||||
position: [0, 0],
|
||||
parameters: {},
|
||||
},
|
||||
],
|
||||
connections,
|
||||
active: false,
|
||||
nodeTypes,
|
||||
});
|
||||
|
||||
const result = workflow.getConnectionsBetweenNodes(['Node1'], ['TargetNode1', 'TargetNode2']);
|
||||
expect(result).toEqual([
|
||||
[
|
||||
{ node: 'Node1', index: 0, type: NodeConnectionTypes.Main },
|
||||
{ node: 'TargetNode1', type: NodeConnectionTypes.Main, index: 0 },
|
||||
],
|
||||
[
|
||||
{ node: 'Node1', index: 0, type: NodeConnectionTypes.Main },
|
||||
{ node: 'TargetNode2', type: NodeConnectionTypes.Main, index: 0 },
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
test('should handle workflows with multiple connection types', () => {
|
||||
const connections: IConnections = {
|
||||
Node1: {
|
||||
main: [
|
||||
[
|
||||
{ node: 'TargetNode', type: NodeConnectionTypes.Main, index: 0 },
|
||||
{ node: 'TargetNode', type: NodeConnectionTypes.Main, index: 1 },
|
||||
],
|
||||
],
|
||||
[NodeConnectionTypes.AiAgent]: [
|
||||
[{ node: 'TargetNode', type: NodeConnectionTypes.AiAgent, index: 0 }],
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const workflow = new Workflow({
|
||||
id: 'test',
|
||||
nodes: [
|
||||
{
|
||||
id: 'Node1',
|
||||
name: 'Node1',
|
||||
type: 'test.set',
|
||||
typeVersion: 1,
|
||||
position: [0, 0],
|
||||
parameters: {},
|
||||
},
|
||||
{
|
||||
id: 'TargetNode',
|
||||
name: 'TargetNode',
|
||||
type: 'test.set',
|
||||
typeVersion: 1,
|
||||
position: [0, 0],
|
||||
parameters: {},
|
||||
},
|
||||
],
|
||||
connections,
|
||||
active: false,
|
||||
nodeTypes,
|
||||
});
|
||||
|
||||
const result = workflow.getConnectionsBetweenNodes(['Node1'], ['TargetNode']);
|
||||
expect(result).toEqual([
|
||||
[
|
||||
{ node: 'Node1', index: 0, type: NodeConnectionTypes.Main },
|
||||
{ node: 'TargetNode', type: NodeConnectionTypes.Main, index: 0 },
|
||||
],
|
||||
[
|
||||
{ node: 'Node1', index: 0, type: NodeConnectionTypes.Main },
|
||||
{ node: 'TargetNode', type: NodeConnectionTypes.Main, index: 1 },
|
||||
],
|
||||
[
|
||||
{ node: 'Node1', index: 0, type: NodeConnectionTypes.AiAgent },
|
||||
{ node: 'TargetNode', type: NodeConnectionTypes.AiAgent, index: 0 },
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
test('should handle nodes with no connections', () => {
|
||||
const connections: IConnections = {
|
||||
Node1: {
|
||||
main: [[]],
|
||||
},
|
||||
};
|
||||
|
||||
const workflow = new Workflow({
|
||||
id: 'test',
|
||||
nodes: [
|
||||
{
|
||||
id: 'Node1',
|
||||
name: 'Node1',
|
||||
type: 'test.set',
|
||||
typeVersion: 1,
|
||||
position: [0, 0],
|
||||
parameters: {},
|
||||
},
|
||||
{
|
||||
id: 'TargetNode',
|
||||
name: 'TargetNode',
|
||||
type: 'test.set',
|
||||
typeVersion: 1,
|
||||
position: [0, 0],
|
||||
parameters: {},
|
||||
},
|
||||
],
|
||||
connections,
|
||||
active: false,
|
||||
nodeTypes,
|
||||
});
|
||||
|
||||
const result = workflow.getConnectionsBetweenNodes(['Node1'], ['TargetNode']);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user