mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-18 02:21:13 +00:00
feat(editor): AI Floating Nodes (#8703)
Signed-off-by: Oleg Ivaniv <me@olegivaniv.com>
This commit is contained in:
@@ -1,7 +1,8 @@
|
|||||||
import { v4 as uuid } from 'uuid';
|
import { v4 as uuid } from 'uuid';
|
||||||
import { getVisibleSelect } from '../utils';
|
import { getVisibleSelect } from '../utils';
|
||||||
import { MANUAL_TRIGGER_NODE_DISPLAY_NAME } from '../constants';
|
import { MANUAL_TRIGGER_NODE_DISPLAY_NAME, AI_LANGUAGE_MODEL_OPENAI_CHAT_MODEL_NODE_NAME } from '../constants';
|
||||||
import { NDV, WorkflowPage } from '../pages';
|
import { NDV, WorkflowPage } from '../pages';
|
||||||
|
import { NodeCreator } from '../pages/features/node-creator';
|
||||||
|
|
||||||
const workflowPage = new WorkflowPage();
|
const workflowPage = new WorkflowPage();
|
||||||
const ndv = new NDV();
|
const ndv = new NDV();
|
||||||
@@ -386,14 +387,12 @@ describe('NDV', () => {
|
|||||||
) {
|
) {
|
||||||
return cy.get(`[data-node-placement=${position}]`);
|
return cy.get(`[data-node-placement=${position}]`);
|
||||||
}
|
}
|
||||||
beforeEach(() => {
|
|
||||||
|
it('should traverse floating nodes with mouse', () => {
|
||||||
cy.createFixtureWorkflow('Floating_Nodes.json', `Floating Nodes`);
|
cy.createFixtureWorkflow('Floating_Nodes.json', `Floating Nodes`);
|
||||||
workflowPage.getters.canvasNodes().first().dblclick();
|
workflowPage.getters.canvasNodes().first().dblclick();
|
||||||
getFloatingNodeByPosition('inputMain').should('not.exist');
|
getFloatingNodeByPosition('inputMain').should('not.exist');
|
||||||
getFloatingNodeByPosition('outputMain').should('exist');
|
getFloatingNodeByPosition('outputMain').should('exist');
|
||||||
});
|
|
||||||
|
|
||||||
it('should traverse floating nodes with mouse', () => {
|
|
||||||
// Traverse 4 connected node forwards
|
// Traverse 4 connected node forwards
|
||||||
Array.from(Array(4).keys()).forEach((i) => {
|
Array.from(Array(4).keys()).forEach((i) => {
|
||||||
getFloatingNodeByPosition('outputMain').click({ force: true });
|
getFloatingNodeByPosition('outputMain').click({ force: true });
|
||||||
@@ -411,19 +410,6 @@ describe('NDV', () => {
|
|||||||
|
|
||||||
getFloatingNodeByPosition('outputMain').click({ force: true });
|
getFloatingNodeByPosition('outputMain').click({ force: true });
|
||||||
ndv.getters.nodeNameContainer().should('contain', 'Chain');
|
ndv.getters.nodeNameContainer().should('contain', 'Chain');
|
||||||
getFloatingNodeByPosition('inputSub').should('exist');
|
|
||||||
getFloatingNodeByPosition('inputSub').click({ force: true });
|
|
||||||
ndv.getters.nodeNameContainer().should('contain', 'Model');
|
|
||||||
getFloatingNodeByPosition('inputSub').should('not.exist');
|
|
||||||
getFloatingNodeByPosition('inputMain').should('not.exist');
|
|
||||||
getFloatingNodeByPosition('outputMain').should('not.exist');
|
|
||||||
getFloatingNodeByPosition('outputSub').should('exist');
|
|
||||||
ndv.actions.close();
|
|
||||||
workflowPage.getters.selectedNodes().should('have.length', 1);
|
|
||||||
workflowPage.getters.selectedNodes().first().should('contain', 'Model');
|
|
||||||
workflowPage.getters.selectedNodes().first().dblclick();
|
|
||||||
getFloatingNodeByPosition('outputSub').click({ force: true });
|
|
||||||
ndv.getters.nodeNameContainer().should('contain', 'Chain');
|
|
||||||
|
|
||||||
// Traverse 4 connected node backwards
|
// Traverse 4 connected node backwards
|
||||||
Array.from(Array(4).keys()).forEach((i) => {
|
Array.from(Array(4).keys()).forEach((i) => {
|
||||||
@@ -448,7 +434,11 @@ describe('NDV', () => {
|
|||||||
.should('contain', MANUAL_TRIGGER_NODE_DISPLAY_NAME);
|
.should('contain', MANUAL_TRIGGER_NODE_DISPLAY_NAME);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should traverse floating nodes with mouse', () => {
|
it('should traverse floating nodes with keyboard', () => {
|
||||||
|
cy.createFixtureWorkflow('Floating_Nodes.json', `Floating Nodes`);
|
||||||
|
workflowPage.getters.canvasNodes().first().dblclick();
|
||||||
|
getFloatingNodeByPosition('inputMain').should('not.exist');
|
||||||
|
getFloatingNodeByPosition('outputMain').should('exist');
|
||||||
// Traverse 4 connected node forwards
|
// Traverse 4 connected node forwards
|
||||||
Array.from(Array(4).keys()).forEach((i) => {
|
Array.from(Array(4).keys()).forEach((i) => {
|
||||||
cy.realPress(['ShiftLeft', 'Meta', 'AltLeft', 'ArrowRight']);
|
cy.realPress(['ShiftLeft', 'Meta', 'AltLeft', 'ArrowRight']);
|
||||||
@@ -466,19 +456,6 @@ describe('NDV', () => {
|
|||||||
|
|
||||||
cy.realPress(['ShiftLeft', 'Meta', 'AltLeft', 'ArrowRight']);
|
cy.realPress(['ShiftLeft', 'Meta', 'AltLeft', 'ArrowRight']);
|
||||||
ndv.getters.nodeNameContainer().should('contain', 'Chain');
|
ndv.getters.nodeNameContainer().should('contain', 'Chain');
|
||||||
getFloatingNodeByPosition('inputSub').should('exist');
|
|
||||||
cy.realPress(['ShiftLeft', 'Meta', 'AltLeft', 'ArrowDown']);
|
|
||||||
ndv.getters.nodeNameContainer().should('contain', 'Model');
|
|
||||||
getFloatingNodeByPosition('inputSub').should('not.exist');
|
|
||||||
getFloatingNodeByPosition('inputMain').should('not.exist');
|
|
||||||
getFloatingNodeByPosition('outputMain').should('not.exist');
|
|
||||||
getFloatingNodeByPosition('outputSub').should('exist');
|
|
||||||
ndv.actions.close();
|
|
||||||
workflowPage.getters.selectedNodes().should('have.length', 1);
|
|
||||||
workflowPage.getters.selectedNodes().first().should('contain', 'Model');
|
|
||||||
workflowPage.getters.selectedNodes().first().dblclick();
|
|
||||||
cy.realPress(['ShiftLeft', 'Meta', 'AltLeft', 'ArrowUp']);
|
|
||||||
ndv.getters.nodeNameContainer().should('contain', 'Chain');
|
|
||||||
|
|
||||||
// Traverse 4 connected node backwards
|
// Traverse 4 connected node backwards
|
||||||
Array.from(Array(4).keys()).forEach((i) => {
|
Array.from(Array(4).keys()).forEach((i) => {
|
||||||
@@ -502,6 +479,47 @@ describe('NDV', () => {
|
|||||||
.first()
|
.first()
|
||||||
.should('contain', MANUAL_TRIGGER_NODE_DISPLAY_NAME);
|
.should('contain', MANUAL_TRIGGER_NODE_DISPLAY_NAME);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should connect floating sub-nodes', () => {
|
||||||
|
const nodeCreator = new NodeCreator();
|
||||||
|
const connectionGroups = [
|
||||||
|
{
|
||||||
|
title: 'Language Models',
|
||||||
|
id: 'ai_languageModel'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Tools',
|
||||||
|
id: 'ai_tool'
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
workflowPage.actions.addInitialNodeToCanvas('AI Agent', { keepNdvOpen: true });
|
||||||
|
|
||||||
|
connectionGroups.forEach((group) => {
|
||||||
|
cy.getByTestId(`add-subnode-${group.id}`).should('exist');
|
||||||
|
cy.getByTestId(`add-subnode-${group.id}`).click();
|
||||||
|
|
||||||
|
cy.getByTestId('nodes-list-header').contains(group.title).should('exist');
|
||||||
|
nodeCreator.getters.getNthCreatorItem(1).click();
|
||||||
|
getFloatingNodeByPosition('outputSub').should('exist');
|
||||||
|
getFloatingNodeByPosition('outputSub').click({ force: true });
|
||||||
|
|
||||||
|
if (group.id === 'ai_languageModel') {
|
||||||
|
cy.getByTestId(`add-subnode-${group.id}`).should('not.exist');
|
||||||
|
} else {
|
||||||
|
cy.getByTestId(`add-subnode-${group.id}`).should('exist');
|
||||||
|
// Expand the subgroup
|
||||||
|
cy.getByTestId('subnode-connection-group-ai_tool').click();
|
||||||
|
cy.getByTestId(`add-subnode-${group.id}`).click();
|
||||||
|
nodeCreator.getters.getNthCreatorItem(1).click();
|
||||||
|
getFloatingNodeByPosition('outputSub').click({ force: true });
|
||||||
|
cy.getByTestId('subnode-connection-group-ai_tool').findChildByTestId('floating-subnode').should('have.length', 2);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Since language model has no credentials set, it should show an error
|
||||||
|
cy.get('[class*=hasIssues]').should('have.length', 1);
|
||||||
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should show node name and version in settings', () => {
|
it('should show node name and version in settings', () => {
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
<NDVFloatingNodes
|
<NDVFloatingNodes
|
||||||
v-if="activeNode"
|
v-if="activeNode"
|
||||||
:root-node="activeNode"
|
:root-node="activeNode"
|
||||||
type="input"
|
|
||||||
@switchSelectedNode="onSwitchSelectedNode"
|
@switchSelectedNode="onSwitchSelectedNode"
|
||||||
/>
|
/>
|
||||||
<div v-if="!hideInputAndOutput" :class="$style.inputPanel" :style="inputPanelStyles">
|
<div v-if="!hideInputAndOutput" :class="$style.inputPanel" :style="inputPanelStyles">
|
||||||
|
|||||||
@@ -46,12 +46,10 @@ import type { INodeTypeDescription } from 'n8n-workflow';
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
rootNode: INodeUi;
|
rootNode: INodeUi;
|
||||||
type: 'input' | 'sub-input' | 'sub-output' | 'output';
|
|
||||||
}
|
}
|
||||||
const enum FloatingNodePosition {
|
const enum FloatingNodePosition {
|
||||||
top = 'outputSub',
|
top = 'outputSub',
|
||||||
right = 'outputMain',
|
right = 'outputMain',
|
||||||
bottom = 'inputSub',
|
|
||||||
left = 'inputMain',
|
left = 'inputMain',
|
||||||
}
|
}
|
||||||
const props = defineProps<Props>();
|
const props = defineProps<Props>();
|
||||||
@@ -77,7 +75,6 @@ function onKeyDown(e: KeyboardEvent) {
|
|||||||
const mapper = {
|
const mapper = {
|
||||||
ArrowUp: FloatingNodePosition.top,
|
ArrowUp: FloatingNodePosition.top,
|
||||||
ArrowRight: FloatingNodePosition.right,
|
ArrowRight: FloatingNodePosition.right,
|
||||||
ArrowDown: FloatingNodePosition.bottom,
|
|
||||||
ArrowLeft: FloatingNodePosition.left,
|
ArrowLeft: FloatingNodePosition.left,
|
||||||
};
|
};
|
||||||
/* eslint-enable @typescript-eslint/naming-convention */
|
/* eslint-enable @typescript-eslint/naming-convention */
|
||||||
@@ -111,9 +108,6 @@ const connectedNodes = computed<
|
|||||||
workflow.getChildNodes(rootName, 'ALL_NON_MAIN'),
|
workflow.getChildNodes(rootName, 'ALL_NON_MAIN'),
|
||||||
),
|
),
|
||||||
[FloatingNodePosition.right]: getINodesFromNames(workflow.getChildNodes(rootName, 'main', 1)),
|
[FloatingNodePosition.right]: getINodesFromNames(workflow.getChildNodes(rootName, 'main', 1)),
|
||||||
[FloatingNodePosition.bottom]: getINodesFromNames(
|
|
||||||
workflow.getParentNodes(rootName, 'ALL_NON_MAIN'),
|
|
||||||
),
|
|
||||||
[FloatingNodePosition.left]: getINodesFromNames(workflow.getParentNodes(rootName, 'main', 1)),
|
[FloatingNodePosition.left]: getINodesFromNames(workflow.getParentNodes(rootName, 'main', 1)),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -121,13 +115,11 @@ const connectedNodes = computed<
|
|||||||
const connectionGroups = [
|
const connectionGroups = [
|
||||||
FloatingNodePosition.top,
|
FloatingNodePosition.top,
|
||||||
FloatingNodePosition.right,
|
FloatingNodePosition.right,
|
||||||
FloatingNodePosition.bottom,
|
|
||||||
FloatingNodePosition.left,
|
FloatingNodePosition.left,
|
||||||
];
|
];
|
||||||
const tooltipPositionMapper = {
|
const tooltipPositionMapper = {
|
||||||
[FloatingNodePosition.top]: 'bottom',
|
[FloatingNodePosition.top]: 'bottom',
|
||||||
[FloatingNodePosition.right]: 'left',
|
[FloatingNodePosition.right]: 'left',
|
||||||
[FloatingNodePosition.bottom]: 'top',
|
|
||||||
[FloatingNodePosition.left]: 'right',
|
[FloatingNodePosition.left]: 'right',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
458
packages/editor-ui/src/components/NDVSubConnections.vue
Normal file
458
packages/editor-ui/src/components/NDVSubConnections.vue
Normal file
@@ -0,0 +1,458 @@
|
|||||||
|
<template>
|
||||||
|
<div :class="$style.container">
|
||||||
|
<div
|
||||||
|
:class="$style.connections"
|
||||||
|
:style="`--possible-connections: ${possibleConnections.length}`"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-for="connection in possibleConnections"
|
||||||
|
:key="connection.type"
|
||||||
|
:data-test-id="`subnode-connection-group-${connection.type}`"
|
||||||
|
>
|
||||||
|
<div :class="$style.connectionType">
|
||||||
|
<span
|
||||||
|
:class="{
|
||||||
|
[$style.connectionLabel]: true,
|
||||||
|
[$style.hasIssues]: hasInputIssues(connection.type),
|
||||||
|
}"
|
||||||
|
v-text="`${connection.displayName}${connection.required ? ' *' : ''}`"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
v-on-click-outside="() => expandConnectionGroup(connection.type, false)"
|
||||||
|
:class="{
|
||||||
|
[$style.connectedNodesWrapper]: true,
|
||||||
|
[$style.connectedNodesWrapperExpanded]: expandedGroups.includes(connection.type),
|
||||||
|
}"
|
||||||
|
:style="`--nodes-length: ${connectedNodes[connection.type].length}`"
|
||||||
|
@click="expandConnectionGroup(connection.type, true)"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-if="
|
||||||
|
connectedNodes[connection.type].length >= 1 ? connection.maxConnections !== 1 : true
|
||||||
|
"
|
||||||
|
:class="{
|
||||||
|
[$style.plusButton]: true,
|
||||||
|
[$style.hasIssues]: hasInputIssues(connection.type),
|
||||||
|
}"
|
||||||
|
@click="onPlusClick(connection.type)"
|
||||||
|
>
|
||||||
|
<n8n-tooltip
|
||||||
|
placement="top"
|
||||||
|
:teleported="true"
|
||||||
|
:offset="10"
|
||||||
|
:show-after="300"
|
||||||
|
:disabled="
|
||||||
|
shouldShowConnectionTooltip(connection.type) &&
|
||||||
|
connectedNodes[connection.type].length >= 1
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<template #content>
|
||||||
|
Add {{ connection.displayName }}
|
||||||
|
<template v-if="hasInputIssues(connection.type)">
|
||||||
|
<TitledList
|
||||||
|
:title="`${$locale.baseText('node.issues')}:`"
|
||||||
|
:items="nodeInputIssues[connection.type]"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
<n8n-icon-button
|
||||||
|
size="medium"
|
||||||
|
icon="plus"
|
||||||
|
type="tertiary"
|
||||||
|
:data-test-id="`add-subnode-${connection.type}`"
|
||||||
|
/>
|
||||||
|
</n8n-tooltip>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="connectedNodes[connection.type].length > 0"
|
||||||
|
:class="{
|
||||||
|
[$style.connectedNodes]: true,
|
||||||
|
[$style.connectedNodesMultiple]: connectedNodes[connection.type].length > 1,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-for="(node, index) in connectedNodes[connection.type]"
|
||||||
|
:key="node.node.name"
|
||||||
|
:class="{ [$style.nodeWrapper]: true, [$style.hasIssues]: node.issues }"
|
||||||
|
data-test-id="floating-subnode"
|
||||||
|
:data-node-name="node.node.name"
|
||||||
|
:style="`--node-index: ${index}`"
|
||||||
|
>
|
||||||
|
<n8n-tooltip
|
||||||
|
:key="node.node.name"
|
||||||
|
placement="top"
|
||||||
|
:teleported="true"
|
||||||
|
:offset="10"
|
||||||
|
:show-after="300"
|
||||||
|
:disabled="shouldShowConnectionTooltip(connection.type)"
|
||||||
|
>
|
||||||
|
<template #content>
|
||||||
|
{{ node.node.name }}
|
||||||
|
<template v-if="node.issues">
|
||||||
|
<TitledList
|
||||||
|
:title="`${$locale.baseText('node.issues')}:`"
|
||||||
|
:items="node.issues"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div
|
||||||
|
:class="$style.connectedNode"
|
||||||
|
@click="onNodeClick(node.node.name, connection.type)"
|
||||||
|
>
|
||||||
|
<NodeIcon
|
||||||
|
:node-type="node.nodeType"
|
||||||
|
:node-name="node.node.name"
|
||||||
|
tooltip-position="top"
|
||||||
|
:size="20"
|
||||||
|
circle
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</n8n-tooltip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { INodeUi } from '@/Interface';
|
||||||
|
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
|
||||||
|
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||||
|
import { computed, ref, watch } from 'vue';
|
||||||
|
import { NodeHelpers } from 'n8n-workflow';
|
||||||
|
import { useNodeHelpers } from '@/composables/useNodeHelpers';
|
||||||
|
import NodeIcon from '@/components/NodeIcon.vue';
|
||||||
|
import TitledList from '@/components/TitledList.vue';
|
||||||
|
import type { ConnectionTypes, INodeInputConfiguration, INodeTypeDescription } from 'n8n-workflow';
|
||||||
|
import { useDebounce } from '@/composables/useDebounce';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
rootNode: INodeUi;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>();
|
||||||
|
const workflowsStore = useWorkflowsStore();
|
||||||
|
const nodeTypesStore = useNodeTypesStore();
|
||||||
|
const nodeHelpers = useNodeHelpers();
|
||||||
|
const { debounce } = useDebounce();
|
||||||
|
const emit = defineEmits(['switchSelectedNode', 'openConnectionNodeCreator']);
|
||||||
|
|
||||||
|
interface NodeConfig {
|
||||||
|
node: INodeUi;
|
||||||
|
nodeType: INodeTypeDescription;
|
||||||
|
issues: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const possibleConnections = ref<INodeInputConfiguration[]>([]);
|
||||||
|
|
||||||
|
const expandedGroups = ref<ConnectionTypes[]>([]);
|
||||||
|
const shouldShowNodeInputIssues = ref(false);
|
||||||
|
|
||||||
|
const nodeType = computed(() =>
|
||||||
|
nodeTypesStore.getNodeType(props.rootNode.type, props.rootNode.typeVersion),
|
||||||
|
);
|
||||||
|
|
||||||
|
const nodeData = computed(() => workflowsStore.getNodeByName(props.rootNode.name));
|
||||||
|
|
||||||
|
const workflow = computed(() => workflowsStore.getCurrentWorkflow());
|
||||||
|
|
||||||
|
const nodeInputIssues = computed(() => {
|
||||||
|
const issues = nodeHelpers.getNodeIssues(nodeType.value, props.rootNode, workflow.value, [
|
||||||
|
'typeUnknown',
|
||||||
|
'parameters',
|
||||||
|
'credentials',
|
||||||
|
'execution',
|
||||||
|
]);
|
||||||
|
return issues?.input ?? {};
|
||||||
|
});
|
||||||
|
|
||||||
|
const connectedNodes = computed<Record<ConnectionTypes, NodeConfig[]>>(() => {
|
||||||
|
return possibleConnections.value.reduce(
|
||||||
|
(acc, connection) => {
|
||||||
|
const nodes = getINodesFromNames(
|
||||||
|
workflow.value.getParentNodes(props.rootNode.name, connection.type),
|
||||||
|
);
|
||||||
|
return { ...acc, [connection.type]: nodes };
|
||||||
|
},
|
||||||
|
{} as Record<ConnectionTypes, NodeConfig[]>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
function getConnectionConfig(connectionType: ConnectionTypes) {
|
||||||
|
return possibleConnections.value.find((c) => c.type === connectionType);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isMultiConnection(connectionType: ConnectionTypes) {
|
||||||
|
const connectionConfig = getConnectionConfig(connectionType);
|
||||||
|
return connectionConfig?.maxConnections !== 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldShowConnectionTooltip(connectionType: ConnectionTypes) {
|
||||||
|
return isMultiConnection(connectionType) && !expandedGroups.value.includes(connectionType);
|
||||||
|
}
|
||||||
|
|
||||||
|
function expandConnectionGroup(connectionType: ConnectionTypes, isExpanded: boolean) {
|
||||||
|
// If the connection is a single connection, we don't need to expand the group
|
||||||
|
if (!isMultiConnection(connectionType)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isExpanded) {
|
||||||
|
expandedGroups.value = [...expandedGroups.value, connectionType];
|
||||||
|
} else {
|
||||||
|
expandedGroups.value = expandedGroups.value.filter((g) => g !== connectionType);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getINodesFromNames(names: string[]): NodeConfig[] {
|
||||||
|
return names
|
||||||
|
.map((name) => {
|
||||||
|
const node = workflowsStore.getNodeByName(name);
|
||||||
|
if (node) {
|
||||||
|
const matchedNodeType = nodeTypesStore.getNodeType(node.type);
|
||||||
|
if (matchedNodeType) {
|
||||||
|
const issues = nodeHelpers.getNodeIssues(matchedNodeType, node, workflow.value);
|
||||||
|
const stringifiedIssues = issues ? NodeHelpers.nodeIssuesToString(issues, node) : '';
|
||||||
|
return { node, nodeType: matchedNodeType, issues: stringifiedIssues };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
})
|
||||||
|
.filter((n): n is NodeConfig => n !== null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasInputIssues(connectionType: ConnectionTypes) {
|
||||||
|
return (
|
||||||
|
shouldShowNodeInputIssues.value && (nodeInputIssues.value[connectionType] ?? []).length > 0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isNodeInputConfiguration(
|
||||||
|
connectionConfig: ConnectionTypes | INodeInputConfiguration,
|
||||||
|
): connectionConfig is INodeInputConfiguration {
|
||||||
|
if (typeof connectionConfig === 'string') return false;
|
||||||
|
|
||||||
|
return 'type' in connectionConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPossibleSubInputConnections(): INodeInputConfiguration[] {
|
||||||
|
if (!nodeType.value || !props.rootNode) return [];
|
||||||
|
|
||||||
|
const inputs = NodeHelpers.getNodeInputs(workflow.value, props.rootNode, nodeType.value);
|
||||||
|
|
||||||
|
const nonMainInputs = inputs.filter((input): input is INodeInputConfiguration => {
|
||||||
|
if (!isNodeInputConfiguration(input)) return false;
|
||||||
|
|
||||||
|
return input.type !== 'main';
|
||||||
|
});
|
||||||
|
|
||||||
|
return nonMainInputs;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onNodeClick(nodeName: string, connectionType: ConnectionTypes) {
|
||||||
|
if (isMultiConnection(connectionType) && !expandedGroups.value.includes(connectionType)) {
|
||||||
|
expandConnectionGroup(connectionType, true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
emit('switchSelectedNode', nodeName);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPlusClick(connectionType: ConnectionTypes) {
|
||||||
|
const connectionNodes = connectedNodes.value[connectionType];
|
||||||
|
if (
|
||||||
|
isMultiConnection(connectionType) &&
|
||||||
|
!expandedGroups.value.includes(connectionType) &&
|
||||||
|
connectionNodes.length >= 1
|
||||||
|
) {
|
||||||
|
expandConnectionGroup(connectionType, true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
emit('openConnectionNodeCreator', props.rootNode.name, connectionType);
|
||||||
|
}
|
||||||
|
|
||||||
|
function showNodeInputsIssues() {
|
||||||
|
console.log('showNodeInputsIssues');
|
||||||
|
shouldShowNodeInputIssues.value = false;
|
||||||
|
// Reset animation
|
||||||
|
setTimeout(() => {
|
||||||
|
shouldShowNodeInputIssues.value = true;
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
nodeData,
|
||||||
|
debounce(
|
||||||
|
() =>
|
||||||
|
setTimeout(() => {
|
||||||
|
expandedGroups.value = [];
|
||||||
|
possibleConnections.value = getPossibleSubInputConnections();
|
||||||
|
}, 0),
|
||||||
|
{ debounceTime: 1000 },
|
||||||
|
),
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
showNodeInputsIssues,
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" module>
|
||||||
|
@keyframes horizontal-shake {
|
||||||
|
0% {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
25% {
|
||||||
|
transform: translateX(5px);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: translateX(-5px);
|
||||||
|
}
|
||||||
|
75% {
|
||||||
|
transform: translateX(5px);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.container {
|
||||||
|
--node-size: 45px;
|
||||||
|
--plus-button-size: 30px;
|
||||||
|
--animation-duration: 150ms;
|
||||||
|
--collapsed-offset: 10px;
|
||||||
|
padding-top: calc(var(--node-size) + var(--spacing-3xs));
|
||||||
|
}
|
||||||
|
.connections {
|
||||||
|
// Make sure container has matching height if there's no connections
|
||||||
|
// since the plus button is absolutely positioned
|
||||||
|
min-height: calc(var(--node-size) + var(--spacing-m));
|
||||||
|
position: absolute;
|
||||||
|
bottom: calc((var(--node-size) / 2) * -1);
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
user-select: none;
|
||||||
|
justify-content: space-between;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(var(--possible-connections), 1fr);
|
||||||
|
}
|
||||||
|
.connectionType {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
transition: all calc((var(--animation-duration) - 50ms)) ease;
|
||||||
|
}
|
||||||
|
.connectionLabel {
|
||||||
|
margin-bottom: var(--spacing-2xs);
|
||||||
|
font-size: var(--font-size-2xs);
|
||||||
|
user-select: none;
|
||||||
|
text-wrap: nowrap;
|
||||||
|
|
||||||
|
&.hasIssues {
|
||||||
|
color: var(--color-danger);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.connectedNodesWrapper {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.plusButton {
|
||||||
|
transition: all var(--animation-duration) ease;
|
||||||
|
position: absolute;
|
||||||
|
top: var(--spacing-2xs);
|
||||||
|
|
||||||
|
&.hasIssues {
|
||||||
|
animation: horizontal-shake 500ms;
|
||||||
|
button {
|
||||||
|
--button-font-color: var(--color-danger);
|
||||||
|
--button-border-color: var(--color-danger);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:not(:last-child) {
|
||||||
|
z-index: 1;
|
||||||
|
right: 100%;
|
||||||
|
margin-right: calc((var(--plus-button-size) * -1) * 0.9);
|
||||||
|
pointer-events: none;
|
||||||
|
|
||||||
|
.connectedNodesWrapperExpanded & {
|
||||||
|
// left: 100%;
|
||||||
|
margin-right: var(--spacing-2xs);
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: all;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.connectedNodesMultiple {
|
||||||
|
transition: all var(--animation-duration) ease;
|
||||||
|
}
|
||||||
|
.connectedNodesWrapperExpanded {
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
// Hide all other connection groups when one is expanded
|
||||||
|
.connections:has(.connectedNodesWrapperExpanded)
|
||||||
|
.connectionType:not(:has(.connectedNodesWrapperExpanded)) {
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
.connectedNode {
|
||||||
|
border: var(--border-base);
|
||||||
|
background-color: var(--color-canvas-node-background);
|
||||||
|
border-radius: 100%;
|
||||||
|
padding: var(--spacing-xs);
|
||||||
|
cursor: pointer;
|
||||||
|
pointer-events: all;
|
||||||
|
transition: all var(--animation-duration) ease;
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
justify-self: center;
|
||||||
|
align-self: center;
|
||||||
|
}
|
||||||
|
.connectedNodes {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
margin-right: calc(
|
||||||
|
(var(--nodes-length) - 1) * (-1 * (var(--node-size) - var(--collapsed-offset)))
|
||||||
|
);
|
||||||
|
.connectedNodesWrapperExpanded & {
|
||||||
|
margin-right: 0;
|
||||||
|
// Negative margin to offset the absolutely positioned plus button
|
||||||
|
// when the nodes are expanded to center the nodes
|
||||||
|
margin-right: calc((var(--spacing-2xs) + var(--plus-button-size)) * -1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.nodeWrapper {
|
||||||
|
transition: all var(--animation-duration) ease;
|
||||||
|
transform-origin: center;
|
||||||
|
z-index: 1;
|
||||||
|
.connectedNodesWrapperExpanded &:not(:first-child) {
|
||||||
|
margin-left: var(--spacing-2xs);
|
||||||
|
}
|
||||||
|
&.hasIssues {
|
||||||
|
.connectedNode {
|
||||||
|
border-width: calc(var(--border-width-base) * 2);
|
||||||
|
border-color: var(--color-danger);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:not(:first-child) {
|
||||||
|
transform: translateX(
|
||||||
|
calc(var(--node-index) * (-1 * (var(--node-size) - var(--collapsed-offset))))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.connectedNodesWrapperExpanded & {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -45,6 +45,7 @@
|
|||||||
:has-double-width="activeNodeType?.parameterPane === 'wide'"
|
:has-double-width="activeNodeType?.parameterPane === 'wide'"
|
||||||
:node-type="activeNodeType"
|
:node-type="activeNodeType"
|
||||||
@switchSelectedNode="onSwitchSelectedNode"
|
@switchSelectedNode="onSwitchSelectedNode"
|
||||||
|
@openConnectionNodeCreator="onOpenConnectionNodeCreator"
|
||||||
@close="close"
|
@close="close"
|
||||||
@init="onPanelsInit"
|
@init="onPanelsInit"
|
||||||
@dragstart="onDragStart"
|
@dragstart="onDragStart"
|
||||||
@@ -117,6 +118,8 @@
|
|||||||
@stopExecution="onStopExecution"
|
@stopExecution="onStopExecution"
|
||||||
@redrawRequired="redrawRequired = true"
|
@redrawRequired="redrawRequired = true"
|
||||||
@activate="onWorkflowActivate"
|
@activate="onWorkflowActivate"
|
||||||
|
@switchSelectedNode="onSwitchSelectedNode"
|
||||||
|
@openConnectionNodeCreator="onOpenConnectionNodeCreator"
|
||||||
/>
|
/>
|
||||||
<a
|
<a
|
||||||
v-if="featureRequestUrl"
|
v-if="featureRequestUrl"
|
||||||
@@ -143,6 +146,7 @@ import type {
|
|||||||
IRunData,
|
IRunData,
|
||||||
IRunExecutionData,
|
IRunExecutionData,
|
||||||
Workflow,
|
Workflow,
|
||||||
|
ConnectionTypes,
|
||||||
} from 'n8n-workflow';
|
} from 'n8n-workflow';
|
||||||
import { jsonParse, NodeHelpers, NodeConnectionType } from 'n8n-workflow';
|
import { jsonParse, NodeHelpers, NodeConnectionType } from 'n8n-workflow';
|
||||||
import type { IExecutionResponse, INodeUi, IUpdateInformation, TargetItem } from '@/Interface';
|
import type { IExecutionResponse, INodeUi, IUpdateInformation, TargetItem } from '@/Interface';
|
||||||
@@ -664,9 +668,12 @@ export default defineComponent({
|
|||||||
nodeTypeSelected(nodeTypeName: string) {
|
nodeTypeSelected(nodeTypeName: string) {
|
||||||
this.$emit('nodeTypeSelected', nodeTypeName);
|
this.$emit('nodeTypeSelected', nodeTypeName);
|
||||||
},
|
},
|
||||||
async onSwitchSelectedNode(nodeTypeName: string) {
|
onSwitchSelectedNode(nodeTypeName: string) {
|
||||||
this.$emit('switchSelectedNode', nodeTypeName);
|
this.$emit('switchSelectedNode', nodeTypeName);
|
||||||
},
|
},
|
||||||
|
onOpenConnectionNodeCreator(nodeTypeName: string, connectionType: ConnectionTypes) {
|
||||||
|
this.$emit('openConnectionNodeCreator', nodeTypeName, connectionType);
|
||||||
|
},
|
||||||
async close() {
|
async close() {
|
||||||
if (this.isDragging) {
|
if (this.isDragging) {
|
||||||
return;
|
return;
|
||||||
@@ -780,8 +787,9 @@ export default defineComponent({
|
|||||||
}
|
}
|
||||||
|
|
||||||
.data-display-wrapper {
|
.data-display-wrapper {
|
||||||
height: calc(100% - var(--spacing-2xl));
|
height: calc(100% - var(--spacing-l)) !important;
|
||||||
margin-top: var(--spacing-xl) !important;
|
margin-top: var(--spacing-xl) !important;
|
||||||
|
margin-bottom: var(--spacing-xl) !important;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
|
|||||||
@@ -165,6 +165,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<NDVSubConnections
|
||||||
|
v-if="node"
|
||||||
|
ref="subConnections"
|
||||||
|
:root-node="node"
|
||||||
|
@switchSelectedNode="onSwitchSelectedNode"
|
||||||
|
@openConnectionNodeCreator="onOpenConnectionNodeCreator"
|
||||||
|
/>
|
||||||
<n8n-block-ui :show="blockUI" />
|
<n8n-block-ui :show="blockUI" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -178,6 +185,7 @@ import type {
|
|||||||
INodeParameters,
|
INodeParameters,
|
||||||
INodeProperties,
|
INodeProperties,
|
||||||
NodeParameterValue,
|
NodeParameterValue,
|
||||||
|
ConnectionTypes,
|
||||||
} from 'n8n-workflow';
|
} from 'n8n-workflow';
|
||||||
import { NodeHelpers, NodeConnectionType, deepCopy } from 'n8n-workflow';
|
import { NodeHelpers, NodeConnectionType, deepCopy } from 'n8n-workflow';
|
||||||
import type {
|
import type {
|
||||||
@@ -199,6 +207,7 @@ import ParameterInputList from '@/components/ParameterInputList.vue';
|
|||||||
import NodeCredentials from '@/components/NodeCredentials.vue';
|
import NodeCredentials from '@/components/NodeCredentials.vue';
|
||||||
import NodeSettingsTabs from '@/components/NodeSettingsTabs.vue';
|
import NodeSettingsTabs from '@/components/NodeSettingsTabs.vue';
|
||||||
import NodeWebhooks from '@/components/NodeWebhooks.vue';
|
import NodeWebhooks from '@/components/NodeWebhooks.vue';
|
||||||
|
import NDVSubConnections from '@/components/NDVSubConnections.vue';
|
||||||
import { get, set, unset } from 'lodash-es';
|
import { get, set, unset } from 'lodash-es';
|
||||||
|
|
||||||
import NodeExecuteButton from './NodeExecuteButton.vue';
|
import NodeExecuteButton from './NodeExecuteButton.vue';
|
||||||
@@ -223,6 +232,7 @@ export default defineComponent({
|
|||||||
ParameterInputList,
|
ParameterInputList,
|
||||||
NodeSettingsTabs,
|
NodeSettingsTabs,
|
||||||
NodeWebhooks,
|
NodeWebhooks,
|
||||||
|
NDVSubConnections,
|
||||||
NodeExecuteButton,
|
NodeExecuteButton,
|
||||||
},
|
},
|
||||||
setup() {
|
setup() {
|
||||||
@@ -467,6 +477,12 @@ export default defineComponent({
|
|||||||
this.eventBus?.off('openSettings', this.openSettings);
|
this.eventBus?.off('openSettings', this.openSettings);
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
onSwitchSelectedNode(node: string) {
|
||||||
|
this.$emit('switchSelectedNode', node);
|
||||||
|
},
|
||||||
|
onOpenConnectionNodeCreator(node: string, connectionType: ConnectionTypes) {
|
||||||
|
this.$emit('openConnectionNodeCreator', node, connectionType);
|
||||||
|
},
|
||||||
populateHiddenIssuesSet() {
|
populateHiddenIssuesSet() {
|
||||||
if (!this.node || !this.workflowsStore.isNodePristine(this.node.name)) return;
|
if (!this.node || !this.workflowsStore.isNodePristine(this.node.name)) return;
|
||||||
|
|
||||||
@@ -612,6 +628,7 @@ export default defineComponent({
|
|||||||
},
|
},
|
||||||
onNodeExecute() {
|
onNodeExecute() {
|
||||||
this.hiddenIssuesInputs = [];
|
this.hiddenIssuesInputs = [];
|
||||||
|
(this.$refs.subConnections as InstanceType<typeof NDVSubConnections>)?.showNodeInputsIssues();
|
||||||
this.$emit('execute');
|
this.$emit('execute');
|
||||||
},
|
},
|
||||||
setValue(name: string, value: NodeParameterValue) {
|
setValue(name: string, value: NodeParameterValue) {
|
||||||
|
|||||||
@@ -94,6 +94,7 @@
|
|||||||
:is-production-execution-preview="isProductionExecutionPreview"
|
:is-production-execution-preview="isProductionExecutionPreview"
|
||||||
@redrawNode="redrawNode"
|
@redrawNode="redrawNode"
|
||||||
@switchSelectedNode="onSwitchSelectedNode"
|
@switchSelectedNode="onSwitchSelectedNode"
|
||||||
|
@openConnectionNodeCreator="onOpenConnectionNodeCreator"
|
||||||
@valueChanged="valueChanged"
|
@valueChanged="valueChanged"
|
||||||
@stopExecution="stopExecution"
|
@stopExecution="stopExecution"
|
||||||
@saveKeyboardShortcut="onSaveKeyboardShortcut"
|
@saveKeyboardShortcut="onSaveKeyboardShortcut"
|
||||||
@@ -900,38 +901,7 @@ export default defineComponent({
|
|||||||
|
|
||||||
this.registerCustomAction({
|
this.registerCustomAction({
|
||||||
key: 'openSelectiveNodeCreator',
|
key: 'openSelectiveNodeCreator',
|
||||||
action: async ({
|
action: this.openSelectiveNodeCreator,
|
||||||
connectiontype,
|
|
||||||
node,
|
|
||||||
creatorview,
|
|
||||||
}: {
|
|
||||||
connectiontype: NodeConnectionType;
|
|
||||||
node: string;
|
|
||||||
creatorview?: string;
|
|
||||||
}) => {
|
|
||||||
const nodeName = node ?? this.ndvStore.activeNodeName;
|
|
||||||
const nodeData = nodeName ? this.workflowsStore.getNodeByName(nodeName) : null;
|
|
||||||
|
|
||||||
this.ndvStore.activeNodeName = null;
|
|
||||||
await this.redrawNode(node);
|
|
||||||
// Wait for UI to update
|
|
||||||
setTimeout(() => {
|
|
||||||
if (creatorview) {
|
|
||||||
this.onToggleNodeCreator({
|
|
||||||
createNodeActive: true,
|
|
||||||
nodeCreatorView: creatorview,
|
|
||||||
});
|
|
||||||
} else if (connectiontype && nodeData) {
|
|
||||||
this.insertNodeAfterSelected({
|
|
||||||
index: 0,
|
|
||||||
endpointUuid: `${nodeData.id}-input${connectiontype}0`,
|
|
||||||
eventSource: NODE_CREATOR_OPEN_SOURCES.NOTICE_ERROR_MESSAGE,
|
|
||||||
outputType: connectiontype,
|
|
||||||
sourceId: nodeData.id,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, 0);
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
this.readOnlyEnvRouteCheck();
|
this.readOnlyEnvRouteCheck();
|
||||||
@@ -1022,6 +992,38 @@ export default defineComponent({
|
|||||||
sourceControlEventBus.off('pull', this.onSourceControlPull);
|
sourceControlEventBus.off('pull', this.onSourceControlPull);
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
async openSelectiveNodeCreator({
|
||||||
|
connectiontype,
|
||||||
|
node,
|
||||||
|
creatorview,
|
||||||
|
}: {
|
||||||
|
connectiontype: ConnectionTypes;
|
||||||
|
node: string;
|
||||||
|
creatorview?: string;
|
||||||
|
}) {
|
||||||
|
const nodeName = node ?? this.ndvStore.activeNodeName;
|
||||||
|
const nodeData = nodeName ? this.workflowsStore.getNodeByName(nodeName) : null;
|
||||||
|
|
||||||
|
this.ndvStore.activeNodeName = null;
|
||||||
|
await this.redrawNode(node);
|
||||||
|
// Wait for UI to update
|
||||||
|
setTimeout(() => {
|
||||||
|
if (creatorview) {
|
||||||
|
this.onToggleNodeCreator({
|
||||||
|
createNodeActive: true,
|
||||||
|
nodeCreatorView: creatorview,
|
||||||
|
});
|
||||||
|
} else if (connectiontype && nodeData) {
|
||||||
|
this.insertNodeAfterSelected({
|
||||||
|
index: 0,
|
||||||
|
endpointUuid: `${nodeData.id}-input${connectiontype}0`,
|
||||||
|
eventSource: NODE_CREATOR_OPEN_SOURCES.NOTICE_ERROR_MESSAGE,
|
||||||
|
outputType: connectiontype,
|
||||||
|
sourceId: nodeData.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
editAllowedCheck(): boolean {
|
editAllowedCheck(): boolean {
|
||||||
if (this.readOnlyNotification?.visible) {
|
if (this.readOnlyNotification?.visible) {
|
||||||
return;
|
return;
|
||||||
@@ -3966,6 +3968,12 @@ export default defineComponent({
|
|||||||
async onSwitchSelectedNode(nodeName: string) {
|
async onSwitchSelectedNode(nodeName: string) {
|
||||||
this.nodeSelectedByName(nodeName, true, true);
|
this.nodeSelectedByName(nodeName, true, true);
|
||||||
},
|
},
|
||||||
|
async onOpenConnectionNodeCreator(node: string, connectionType: ConnectionTypes) {
|
||||||
|
await this.openSelectiveNodeCreator({
|
||||||
|
connectiontype: connectionType,
|
||||||
|
node,
|
||||||
|
});
|
||||||
|
},
|
||||||
async redrawNode(nodeName: string) {
|
async redrawNode(nodeName: string) {
|
||||||
// TODO: Improve later
|
// TODO: Improve later
|
||||||
// For now we redraw the node by simply renaming it. Can for sure be
|
// For now we redraw the node by simply renaming it. Can for sure be
|
||||||
|
|||||||
Reference in New Issue
Block a user