mirror of
https://github.com/n8n-io/n8n.git
synced 2025-01-12 13:27:31 -08:00
feat(editor): AI Floating Nodes (#8703)
Signed-off-by: Oleg Ivaniv <me@olegivaniv.com>
This commit is contained in:
parent
a29b41ec55
commit
41b191e055
|
@ -1,7 +1,8 @@
|
|||
import { v4 as uuid } from 'uuid';
|
||||
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 { NodeCreator } from '../pages/features/node-creator';
|
||||
|
||||
const workflowPage = new WorkflowPage();
|
||||
const ndv = new NDV();
|
||||
|
@ -386,14 +387,12 @@ describe('NDV', () => {
|
|||
) {
|
||||
return cy.get(`[data-node-placement=${position}]`);
|
||||
}
|
||||
beforeEach(() => {
|
||||
|
||||
it('should traverse floating nodes with mouse', () => {
|
||||
cy.createFixtureWorkflow('Floating_Nodes.json', `Floating Nodes`);
|
||||
workflowPage.getters.canvasNodes().first().dblclick();
|
||||
getFloatingNodeByPosition('inputMain').should('not.exist');
|
||||
getFloatingNodeByPosition('outputMain').should('exist');
|
||||
});
|
||||
|
||||
it('should traverse floating nodes with mouse', () => {
|
||||
// Traverse 4 connected node forwards
|
||||
Array.from(Array(4).keys()).forEach((i) => {
|
||||
getFloatingNodeByPosition('outputMain').click({ force: true });
|
||||
|
@ -411,19 +410,6 @@ describe('NDV', () => {
|
|||
|
||||
getFloatingNodeByPosition('outputMain').click({ force: true });
|
||||
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
|
||||
Array.from(Array(4).keys()).forEach((i) => {
|
||||
|
@ -448,7 +434,11 @@ describe('NDV', () => {
|
|||
.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
|
||||
Array.from(Array(4).keys()).forEach((i) => {
|
||||
cy.realPress(['ShiftLeft', 'Meta', 'AltLeft', 'ArrowRight']);
|
||||
|
@ -466,19 +456,6 @@ describe('NDV', () => {
|
|||
|
||||
cy.realPress(['ShiftLeft', 'Meta', 'AltLeft', 'ArrowRight']);
|
||||
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
|
||||
Array.from(Array(4).keys()).forEach((i) => {
|
||||
|
@ -502,6 +479,47 @@ describe('NDV', () => {
|
|||
.first()
|
||||
.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', () => {
|
||||
|
|
|
@ -3,7 +3,6 @@
|
|||
<NDVFloatingNodes
|
||||
v-if="activeNode"
|
||||
:root-node="activeNode"
|
||||
type="input"
|
||||
@switchSelectedNode="onSwitchSelectedNode"
|
||||
/>
|
||||
<div v-if="!hideInputAndOutput" :class="$style.inputPanel" :style="inputPanelStyles">
|
||||
|
|
|
@ -46,12 +46,10 @@ import type { INodeTypeDescription } from 'n8n-workflow';
|
|||
|
||||
interface Props {
|
||||
rootNode: INodeUi;
|
||||
type: 'input' | 'sub-input' | 'sub-output' | 'output';
|
||||
}
|
||||
const enum FloatingNodePosition {
|
||||
top = 'outputSub',
|
||||
right = 'outputMain',
|
||||
bottom = 'inputSub',
|
||||
left = 'inputMain',
|
||||
}
|
||||
const props = defineProps<Props>();
|
||||
|
@ -77,7 +75,6 @@ function onKeyDown(e: KeyboardEvent) {
|
|||
const mapper = {
|
||||
ArrowUp: FloatingNodePosition.top,
|
||||
ArrowRight: FloatingNodePosition.right,
|
||||
ArrowDown: FloatingNodePosition.bottom,
|
||||
ArrowLeft: FloatingNodePosition.left,
|
||||
};
|
||||
/* eslint-enable @typescript-eslint/naming-convention */
|
||||
|
@ -111,9 +108,6 @@ const connectedNodes = computed<
|
|||
workflow.getChildNodes(rootName, 'ALL_NON_MAIN'),
|
||||
),
|
||||
[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)),
|
||||
};
|
||||
});
|
||||
|
@ -121,13 +115,11 @@ const connectedNodes = computed<
|
|||
const connectionGroups = [
|
||||
FloatingNodePosition.top,
|
||||
FloatingNodePosition.right,
|
||||
FloatingNodePosition.bottom,
|
||||
FloatingNodePosition.left,
|
||||
];
|
||||
const tooltipPositionMapper = {
|
||||
[FloatingNodePosition.top]: 'bottom',
|
||||
[FloatingNodePosition.right]: 'left',
|
||||
[FloatingNodePosition.bottom]: 'top',
|
||||
[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'"
|
||||
:node-type="activeNodeType"
|
||||
@switchSelectedNode="onSwitchSelectedNode"
|
||||
@openConnectionNodeCreator="onOpenConnectionNodeCreator"
|
||||
@close="close"
|
||||
@init="onPanelsInit"
|
||||
@dragstart="onDragStart"
|
||||
|
@ -117,6 +118,8 @@
|
|||
@stopExecution="onStopExecution"
|
||||
@redrawRequired="redrawRequired = true"
|
||||
@activate="onWorkflowActivate"
|
||||
@switchSelectedNode="onSwitchSelectedNode"
|
||||
@openConnectionNodeCreator="onOpenConnectionNodeCreator"
|
||||
/>
|
||||
<a
|
||||
v-if="featureRequestUrl"
|
||||
|
@ -143,6 +146,7 @@ import type {
|
|||
IRunData,
|
||||
IRunExecutionData,
|
||||
Workflow,
|
||||
ConnectionTypes,
|
||||
} from 'n8n-workflow';
|
||||
import { jsonParse, NodeHelpers, NodeConnectionType } from 'n8n-workflow';
|
||||
import type { IExecutionResponse, INodeUi, IUpdateInformation, TargetItem } from '@/Interface';
|
||||
|
@ -664,9 +668,12 @@ export default defineComponent({
|
|||
nodeTypeSelected(nodeTypeName: string) {
|
||||
this.$emit('nodeTypeSelected', nodeTypeName);
|
||||
},
|
||||
async onSwitchSelectedNode(nodeTypeName: string) {
|
||||
onSwitchSelectedNode(nodeTypeName: string) {
|
||||
this.$emit('switchSelectedNode', nodeTypeName);
|
||||
},
|
||||
onOpenConnectionNodeCreator(nodeTypeName: string, connectionType: ConnectionTypes) {
|
||||
this.$emit('openConnectionNodeCreator', nodeTypeName, connectionType);
|
||||
},
|
||||
async close() {
|
||||
if (this.isDragging) {
|
||||
return;
|
||||
|
@ -780,8 +787,9 @@ export default defineComponent({
|
|||
}
|
||||
|
||||
.data-display-wrapper {
|
||||
height: calc(100% - var(--spacing-2xl));
|
||||
height: calc(100% - var(--spacing-l)) !important;
|
||||
margin-top: var(--spacing-xl) !important;
|
||||
margin-bottom: var(--spacing-xl) !important;
|
||||
width: 100%;
|
||||
background: none;
|
||||
border: none;
|
||||
|
|
|
@ -165,6 +165,13 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<NDVSubConnections
|
||||
v-if="node"
|
||||
ref="subConnections"
|
||||
:root-node="node"
|
||||
@switchSelectedNode="onSwitchSelectedNode"
|
||||
@openConnectionNodeCreator="onOpenConnectionNodeCreator"
|
||||
/>
|
||||
<n8n-block-ui :show="blockUI" />
|
||||
</div>
|
||||
</template>
|
||||
|
@ -178,6 +185,7 @@ import type {
|
|||
INodeParameters,
|
||||
INodeProperties,
|
||||
NodeParameterValue,
|
||||
ConnectionTypes,
|
||||
} from 'n8n-workflow';
|
||||
import { NodeHelpers, NodeConnectionType, deepCopy } from 'n8n-workflow';
|
||||
import type {
|
||||
|
@ -199,6 +207,7 @@ import ParameterInputList from '@/components/ParameterInputList.vue';
|
|||
import NodeCredentials from '@/components/NodeCredentials.vue';
|
||||
import NodeSettingsTabs from '@/components/NodeSettingsTabs.vue';
|
||||
import NodeWebhooks from '@/components/NodeWebhooks.vue';
|
||||
import NDVSubConnections from '@/components/NDVSubConnections.vue';
|
||||
import { get, set, unset } from 'lodash-es';
|
||||
|
||||
import NodeExecuteButton from './NodeExecuteButton.vue';
|
||||
|
@ -223,6 +232,7 @@ export default defineComponent({
|
|||
ParameterInputList,
|
||||
NodeSettingsTabs,
|
||||
NodeWebhooks,
|
||||
NDVSubConnections,
|
||||
NodeExecuteButton,
|
||||
},
|
||||
setup() {
|
||||
|
@ -467,6 +477,12 @@ export default defineComponent({
|
|||
this.eventBus?.off('openSettings', this.openSettings);
|
||||
},
|
||||
methods: {
|
||||
onSwitchSelectedNode(node: string) {
|
||||
this.$emit('switchSelectedNode', node);
|
||||
},
|
||||
onOpenConnectionNodeCreator(node: string, connectionType: ConnectionTypes) {
|
||||
this.$emit('openConnectionNodeCreator', node, connectionType);
|
||||
},
|
||||
populateHiddenIssuesSet() {
|
||||
if (!this.node || !this.workflowsStore.isNodePristine(this.node.name)) return;
|
||||
|
||||
|
@ -612,6 +628,7 @@ export default defineComponent({
|
|||
},
|
||||
onNodeExecute() {
|
||||
this.hiddenIssuesInputs = [];
|
||||
(this.$refs.subConnections as InstanceType<typeof NDVSubConnections>)?.showNodeInputsIssues();
|
||||
this.$emit('execute');
|
||||
},
|
||||
setValue(name: string, value: NodeParameterValue) {
|
||||
|
|
|
@ -94,6 +94,7 @@
|
|||
:is-production-execution-preview="isProductionExecutionPreview"
|
||||
@redrawNode="redrawNode"
|
||||
@switchSelectedNode="onSwitchSelectedNode"
|
||||
@openConnectionNodeCreator="onOpenConnectionNodeCreator"
|
||||
@valueChanged="valueChanged"
|
||||
@stopExecution="stopExecution"
|
||||
@saveKeyboardShortcut="onSaveKeyboardShortcut"
|
||||
|
@ -900,38 +901,7 @@ export default defineComponent({
|
|||
|
||||
this.registerCustomAction({
|
||||
key: 'openSelectiveNodeCreator',
|
||||
action: async ({
|
||||
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);
|
||||
},
|
||||
action: this.openSelectiveNodeCreator,
|
||||
});
|
||||
|
||||
this.readOnlyEnvRouteCheck();
|
||||
|
@ -1022,6 +992,38 @@ export default defineComponent({
|
|||
sourceControlEventBus.off('pull', this.onSourceControlPull);
|
||||
},
|
||||
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 {
|
||||
if (this.readOnlyNotification?.visible) {
|
||||
return;
|
||||
|
@ -3966,6 +3968,12 @@ export default defineComponent({
|
|||
async onSwitchSelectedNode(nodeName: string) {
|
||||
this.nodeSelectedByName(nodeName, true, true);
|
||||
},
|
||||
async onOpenConnectionNodeCreator(node: string, connectionType: ConnectionTypes) {
|
||||
await this.openSelectiveNodeCreator({
|
||||
connectiontype: connectionType,
|
||||
node,
|
||||
});
|
||||
},
|
||||
async redrawNode(nodeName: string) {
|
||||
// TODO: Improve later
|
||||
// For now we redraw the node by simply renaming it. Can for sure be
|
||||
|
|
Loading…
Reference in a new issue