mirror of
https://github.com/n8n-io/n8n.git
synced 2024-12-25 20:54:07 -08:00
refactor(editor): Migrate nodeBase mixin to composable (no-changelog) (#9742)
This commit is contained in:
parent
c58621ab79
commit
b0d7cfa2ab
|
@ -10,7 +10,7 @@ import aiNodeTypesJson from '../../../@n8n/nodes-langchain/dist/types/nodes.json
|
||||||
|
|
||||||
const allNodeTypes = [...nodeTypesJson, ...aiNodeTypesJson];
|
const allNodeTypes = [...nodeTypesJson, ...aiNodeTypesJson];
|
||||||
|
|
||||||
function findNodeWithName(name: string): INodeTypeDescription {
|
export function findNodeTypeDescriptionByName(name: string): INodeTypeDescription {
|
||||||
return allNodeTypes.find((node) => node.name === name) as INodeTypeDescription;
|
return allNodeTypes.find((node) => node.name === name) as INodeTypeDescription;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -18,25 +18,25 @@ export const testingNodeTypes: INodeTypeData = {
|
||||||
[MANUAL_TRIGGER_NODE_TYPE]: {
|
[MANUAL_TRIGGER_NODE_TYPE]: {
|
||||||
sourcePath: '',
|
sourcePath: '',
|
||||||
type: {
|
type: {
|
||||||
description: findNodeWithName(MANUAL_TRIGGER_NODE_TYPE),
|
description: findNodeTypeDescriptionByName(MANUAL_TRIGGER_NODE_TYPE),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
[SET_NODE_TYPE]: {
|
[SET_NODE_TYPE]: {
|
||||||
sourcePath: '',
|
sourcePath: '',
|
||||||
type: {
|
type: {
|
||||||
description: findNodeWithName(SET_NODE_TYPE),
|
description: findNodeTypeDescriptionByName(SET_NODE_TYPE),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
[CHAT_TRIGGER_NODE_TYPE]: {
|
[CHAT_TRIGGER_NODE_TYPE]: {
|
||||||
sourcePath: '',
|
sourcePath: '',
|
||||||
type: {
|
type: {
|
||||||
description: findNodeWithName(CHAT_TRIGGER_NODE_TYPE),
|
description: findNodeTypeDescriptionByName(CHAT_TRIGGER_NODE_TYPE),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
[AGENT_NODE_TYPE]: {
|
[AGENT_NODE_TYPE]: {
|
||||||
sourcePath: '',
|
sourcePath: '',
|
||||||
type: {
|
type: {
|
||||||
description: findNodeWithName(AGENT_NODE_TYPE),
|
description: findNodeTypeDescriptionByName(AGENT_NODE_TYPE),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
@ -178,7 +178,8 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { type CSSProperties, defineComponent } from 'vue';
|
import { defineComponent } from 'vue';
|
||||||
|
import type { PropType, CSSProperties } from 'vue';
|
||||||
import { mapStores } from 'pinia';
|
import { mapStores } from 'pinia';
|
||||||
import xss from 'xss';
|
import xss from 'xss';
|
||||||
import { useStorage } from '@/composables/useStorage';
|
import { useStorage } from '@/composables/useStorage';
|
||||||
|
@ -192,7 +193,7 @@ import {
|
||||||
SIMULATE_TRIGGER_NODE_TYPE,
|
SIMULATE_TRIGGER_NODE_TYPE,
|
||||||
WAIT_TIME_UNLIMITED,
|
WAIT_TIME_UNLIMITED,
|
||||||
} from '@/constants';
|
} from '@/constants';
|
||||||
import { nodeBase } from '@/mixins/nodeBase';
|
import { NodeConnectionType, NodeHelpers } from 'n8n-workflow';
|
||||||
import type {
|
import type {
|
||||||
ConnectionTypes,
|
ConnectionTypes,
|
||||||
ExecutionSummary,
|
ExecutionSummary,
|
||||||
|
@ -201,8 +202,8 @@ import type {
|
||||||
INodeTypeDescription,
|
INodeTypeDescription,
|
||||||
ITaskData,
|
ITaskData,
|
||||||
NodeOperationError,
|
NodeOperationError,
|
||||||
|
Workflow,
|
||||||
} from 'n8n-workflow';
|
} from 'n8n-workflow';
|
||||||
import { NodeConnectionType, NodeHelpers } from 'n8n-workflow';
|
|
||||||
|
|
||||||
import NodeIcon from '@/components/NodeIcon.vue';
|
import NodeIcon from '@/components/NodeIcon.vue';
|
||||||
import TitledList from '@/components/TitledList.vue';
|
import TitledList from '@/components/TitledList.vue';
|
||||||
|
@ -222,6 +223,10 @@ import { useExternalHooks } from '@/composables/useExternalHooks';
|
||||||
import { usePinnedData } from '@/composables/usePinnedData';
|
import { usePinnedData } from '@/composables/usePinnedData';
|
||||||
import { useDeviceSupport } from 'n8n-design-system';
|
import { useDeviceSupport } from 'n8n-design-system';
|
||||||
import { useDebounce } from '@/composables/useDebounce';
|
import { useDebounce } from '@/composables/useDebounce';
|
||||||
|
import type { BrowserJsPlumbInstance } from '@jsplumb/browser-ui';
|
||||||
|
import { useCanvasStore } from '@/stores/canvas.store';
|
||||||
|
import { useHistoryStore } from '@/stores/history.store';
|
||||||
|
import { useNodeBase } from '@/composables/useNodeBase';
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineComponent({
|
||||||
name: 'Node',
|
name: 'Node',
|
||||||
|
@ -230,7 +235,6 @@ export default defineComponent({
|
||||||
FontAwesomeIcon,
|
FontAwesomeIcon,
|
||||||
NodeIcon,
|
NodeIcon,
|
||||||
},
|
},
|
||||||
mixins: [nodeBase],
|
|
||||||
props: {
|
props: {
|
||||||
isProductionExecutionPreview: {
|
isProductionExecutionPreview: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
|
@ -244,6 +248,33 @@ export default defineComponent({
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
|
name: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
instance: {
|
||||||
|
type: Object as PropType<BrowserJsPlumbInstance>,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
isReadOnly: {
|
||||||
|
type: Boolean,
|
||||||
|
},
|
||||||
|
isActive: {
|
||||||
|
type: Boolean,
|
||||||
|
},
|
||||||
|
hideActions: {
|
||||||
|
type: Boolean,
|
||||||
|
},
|
||||||
|
disableSelecting: {
|
||||||
|
type: Boolean,
|
||||||
|
},
|
||||||
|
showCustomTooltip: {
|
||||||
|
type: Boolean,
|
||||||
|
},
|
||||||
|
workflow: {
|
||||||
|
type: Object as PropType<Workflow>,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
emits: {
|
emits: {
|
||||||
run: null,
|
run: null,
|
||||||
|
@ -251,7 +282,7 @@ export default defineComponent({
|
||||||
removeNode: null,
|
removeNode: null,
|
||||||
toggleDisableNode: null,
|
toggleDisableNode: null,
|
||||||
},
|
},
|
||||||
setup(props) {
|
setup(props, { emit }) {
|
||||||
const workflowsStore = useWorkflowsStore();
|
const workflowsStore = useWorkflowsStore();
|
||||||
const contextMenu = useContextMenu();
|
const contextMenu = useContextMenu();
|
||||||
const externalHooks = useExternalHooks();
|
const externalHooks = useExternalHooks();
|
||||||
|
@ -261,12 +292,21 @@ export default defineComponent({
|
||||||
const deviceSupport = useDeviceSupport();
|
const deviceSupport = useDeviceSupport();
|
||||||
const { callDebounced } = useDebounce();
|
const { callDebounced } = useDebounce();
|
||||||
|
|
||||||
|
const nodeBase = useNodeBase({
|
||||||
|
name: props.name,
|
||||||
|
instance: props.instance,
|
||||||
|
workflowObject: props.workflow,
|
||||||
|
isReadOnly: props.isReadOnly,
|
||||||
|
emit: emit as (event: string, ...args: unknown[]) => void,
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
contextMenu,
|
contextMenu,
|
||||||
externalHooks,
|
externalHooks,
|
||||||
nodeHelpers,
|
nodeHelpers,
|
||||||
pinnedData,
|
pinnedData,
|
||||||
deviceSupport,
|
deviceSupport,
|
||||||
|
...nodeBase,
|
||||||
callDebounced,
|
callDebounced,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
@ -281,7 +321,20 @@ export default defineComponent({
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
...mapStores(useNodeTypesStore, useNDVStore, useUIStore, useWorkflowsStore),
|
...mapStores(
|
||||||
|
useNodeTypesStore,
|
||||||
|
useCanvasStore,
|
||||||
|
useNDVStore,
|
||||||
|
useUIStore,
|
||||||
|
useWorkflowsStore,
|
||||||
|
useHistoryStore,
|
||||||
|
),
|
||||||
|
data(): INodeUi | null {
|
||||||
|
return this.workflowsStore.getNodeByName(this.name);
|
||||||
|
},
|
||||||
|
nodeId(): string {
|
||||||
|
return this.data?.id || '';
|
||||||
|
},
|
||||||
showPinnedDataInfo(): boolean {
|
showPinnedDataInfo(): boolean {
|
||||||
return this.pinnedData.hasData.value && !this.isProductionExecutionPreview;
|
return this.pinnedData.hasData.value && !this.isProductionExecutionPreview;
|
||||||
},
|
},
|
||||||
|
@ -678,6 +731,16 @@ export default defineComponent({
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
|
// Initialize the node
|
||||||
|
if (this.data !== null) {
|
||||||
|
try {
|
||||||
|
this.addNode(this.data);
|
||||||
|
} catch (error) {
|
||||||
|
// This breaks when new nodes are loaded into store but workflow tab is not currently active
|
||||||
|
// Shouldn't affect anything
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
this.setSubtitle();
|
this.setSubtitle();
|
||||||
}, 0);
|
}, 0);
|
||||||
|
|
|
@ -103,10 +103,10 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { defineComponent, ref, type StyleValue } from 'vue';
|
import { defineComponent, ref } from 'vue';
|
||||||
|
import type { PropType, StyleValue } from 'vue';
|
||||||
import { mapStores } from 'pinia';
|
import { mapStores } from 'pinia';
|
||||||
|
|
||||||
import { nodeBase } from '@/mixins/nodeBase';
|
|
||||||
import { isNumber, isString } from '@/utils/typeGuards';
|
import { isNumber, isString } from '@/utils/typeGuards';
|
||||||
import type {
|
import type {
|
||||||
INodeUi,
|
INodeUi,
|
||||||
|
@ -115,7 +115,7 @@ import type {
|
||||||
XYPosition,
|
XYPosition,
|
||||||
} from '@/Interface';
|
} from '@/Interface';
|
||||||
|
|
||||||
import type { INodeTypeDescription } from 'n8n-workflow';
|
import type { INodeTypeDescription, Workflow } from 'n8n-workflow';
|
||||||
import { QUICKSTART_NOTE_NAME } from '@/constants';
|
import { QUICKSTART_NOTE_NAME } from '@/constants';
|
||||||
import { useUIStore } from '@/stores/ui.store';
|
import { useUIStore } from '@/stores/ui.store';
|
||||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||||
|
@ -126,10 +126,13 @@ import { useDeviceSupport } from 'n8n-design-system';
|
||||||
import { GRID_SIZE } from '@/utils/nodeViewUtils';
|
import { GRID_SIZE } from '@/utils/nodeViewUtils';
|
||||||
import { useToast } from '@/composables/useToast';
|
import { useToast } from '@/composables/useToast';
|
||||||
import { assert } from '@/utils/assert';
|
import { assert } from '@/utils/assert';
|
||||||
|
import type { BrowserJsPlumbInstance } from '@jsplumb/browser-ui';
|
||||||
|
import { useCanvasStore } from '@/stores/canvas.store';
|
||||||
|
import { useHistoryStore } from '@/stores/history.store';
|
||||||
|
import { useNodeBase } from '@/composables/useNodeBase';
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineComponent({
|
||||||
name: 'Sticky',
|
name: 'Sticky',
|
||||||
mixins: [nodeBase],
|
|
||||||
props: {
|
props: {
|
||||||
nodeViewScale: {
|
nodeViewScale: {
|
||||||
type: Number,
|
type: Number,
|
||||||
|
@ -139,9 +142,36 @@ export default defineComponent({
|
||||||
type: Number,
|
type: Number,
|
||||||
default: GRID_SIZE,
|
default: GRID_SIZE,
|
||||||
},
|
},
|
||||||
|
name: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
instance: {
|
||||||
|
type: Object as PropType<BrowserJsPlumbInstance>,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
isReadOnly: {
|
||||||
|
type: Boolean,
|
||||||
|
},
|
||||||
|
isActive: {
|
||||||
|
type: Boolean,
|
||||||
|
},
|
||||||
|
hideActions: {
|
||||||
|
type: Boolean,
|
||||||
|
},
|
||||||
|
disableSelecting: {
|
||||||
|
type: Boolean,
|
||||||
|
},
|
||||||
|
showCustomTooltip: {
|
||||||
|
type: Boolean,
|
||||||
|
},
|
||||||
|
workflow: {
|
||||||
|
type: Object as PropType<Workflow>,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
emits: { removeNode: null, nodeSelected: null },
|
emits: { removeNode: null, nodeSelected: null },
|
||||||
setup() {
|
setup(props, { emit }) {
|
||||||
const deviceSupport = useDeviceSupport();
|
const deviceSupport = useDeviceSupport();
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const colorPopoverTrigger = ref<HTMLDivElement>();
|
const colorPopoverTrigger = ref<HTMLDivElement>();
|
||||||
|
@ -156,12 +186,21 @@ export default defineComponent({
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const nodeBase = useNodeBase({
|
||||||
|
name: props.name,
|
||||||
|
instance: props.instance,
|
||||||
|
workflowObject: props.workflow,
|
||||||
|
isReadOnly: props.isReadOnly,
|
||||||
|
emit: emit as (event: string, ...args: unknown[]) => void,
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
deviceSupport,
|
deviceSupport,
|
||||||
toast,
|
toast,
|
||||||
colorPopoverTrigger,
|
colorPopoverTrigger,
|
||||||
contextMenu,
|
contextMenu,
|
||||||
forceActions,
|
forceActions,
|
||||||
|
...nodeBase,
|
||||||
setForceActions,
|
setForceActions,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
@ -172,7 +211,20 @@ export default defineComponent({
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
...mapStores(useNodeTypesStore, useNDVStore, useUIStore, useWorkflowsStore),
|
...mapStores(
|
||||||
|
useNodeTypesStore,
|
||||||
|
useUIStore,
|
||||||
|
useNDVStore,
|
||||||
|
useCanvasStore,
|
||||||
|
useWorkflowsStore,
|
||||||
|
useHistoryStore,
|
||||||
|
),
|
||||||
|
data(): INodeUi | null {
|
||||||
|
return this.workflowsStore.getNodeByName(this.name);
|
||||||
|
},
|
||||||
|
nodeId(): string {
|
||||||
|
return this.data?.id || '';
|
||||||
|
},
|
||||||
defaultText(): string {
|
defaultText(): string {
|
||||||
if (!this.nodeType) {
|
if (!this.nodeType) {
|
||||||
return '';
|
return '';
|
||||||
|
@ -239,6 +291,17 @@ export default defineComponent({
|
||||||
return this.uiStore.isActionActive('workflowRunning');
|
return this.uiStore.isActionActive('workflowRunning');
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
mounted() {
|
||||||
|
// Initialize the node
|
||||||
|
if (this.data !== null) {
|
||||||
|
try {
|
||||||
|
this.addNode(this.data);
|
||||||
|
} catch (error) {
|
||||||
|
// This breaks when new nodes are loaded into store but workflow tab is not currently active
|
||||||
|
// Shouldn't affect anything
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
methods: {
|
methods: {
|
||||||
onShowPopover() {
|
onShowPopover() {
|
||||||
this.setForceActions(true);
|
this.setForceActions(true);
|
||||||
|
@ -274,7 +337,7 @@ export default defineComponent({
|
||||||
onMarkdownClick(link: HTMLAnchorElement) {
|
onMarkdownClick(link: HTMLAnchorElement) {
|
||||||
if (link) {
|
if (link) {
|
||||||
const isOnboardingNote = this.name === QUICKSTART_NOTE_NAME;
|
const isOnboardingNote = this.name === QUICKSTART_NOTE_NAME;
|
||||||
const isWelcomeVideo = link.querySelector('img[alt="n8n quickstart video"');
|
const isWelcomeVideo = link.querySelector('img[alt="n8n quickstart video"]');
|
||||||
const type =
|
const type =
|
||||||
isOnboardingNote && isWelcomeVideo
|
isOnboardingNote && isWelcomeVideo
|
||||||
? 'welcome_video'
|
? 'welcome_video'
|
||||||
|
|
198
packages/editor-ui/src/composables/useNodeBase.spec.ts
Normal file
198
packages/editor-ui/src/composables/useNodeBase.spec.ts
Normal file
|
@ -0,0 +1,198 @@
|
||||||
|
import { useNodeBase } from '@/composables/useNodeBase';
|
||||||
|
import type { BrowserJsPlumbInstance } from '@jsplumb/browser-ui';
|
||||||
|
import { createTestNode, createTestWorkflowObject } from '@/__tests__/mocks';
|
||||||
|
import { createPinia, setActivePinia } from 'pinia';
|
||||||
|
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||||
|
import { findNodeTypeDescriptionByName } from '@/__tests__/defaults';
|
||||||
|
import { SET_NODE_TYPE } from '@/constants';
|
||||||
|
import { useUIStore } from '@/stores/ui.store';
|
||||||
|
import type { INodeTypeDescription, Workflow } from 'n8n-workflow';
|
||||||
|
import type { INodeUi } from '@/Interface';
|
||||||
|
import type { Mock } from '@vitest/spy';
|
||||||
|
|
||||||
|
describe('useNodeBase', () => {
|
||||||
|
let pinia: ReturnType<typeof createPinia>;
|
||||||
|
let workflowsStore: ReturnType<typeof useWorkflowsStore>;
|
||||||
|
let uiStore: ReturnType<typeof useUIStore>;
|
||||||
|
let instance: Record<string, Mock>;
|
||||||
|
let workflowObject: Workflow;
|
||||||
|
let emit: (event: string, ...args: unknown[]) => void;
|
||||||
|
let node: INodeUi;
|
||||||
|
let nodeTypeDescription: INodeTypeDescription;
|
||||||
|
let nodeBase: ReturnType<typeof useNodeBase>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
pinia = createPinia();
|
||||||
|
setActivePinia(pinia);
|
||||||
|
|
||||||
|
workflowsStore = useWorkflowsStore();
|
||||||
|
uiStore = useUIStore();
|
||||||
|
|
||||||
|
instance = {
|
||||||
|
addEndpoint: vi.fn().mockReturnValue({}),
|
||||||
|
};
|
||||||
|
workflowObject = createTestWorkflowObject({ nodes: [], connections: {} });
|
||||||
|
node = createTestNode();
|
||||||
|
nodeTypeDescription = findNodeTypeDescriptionByName(SET_NODE_TYPE);
|
||||||
|
emit = vi.fn();
|
||||||
|
|
||||||
|
nodeBase = useNodeBase({
|
||||||
|
instance: instance as unknown as BrowserJsPlumbInstance,
|
||||||
|
name: node.name,
|
||||||
|
workflowObject,
|
||||||
|
isReadOnly: false,
|
||||||
|
emit,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should initialize correctly', () => {
|
||||||
|
const { inputs, outputs } = nodeBase;
|
||||||
|
|
||||||
|
expect(inputs.value).toEqual([]);
|
||||||
|
expect(outputs.value).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('addInputEndpoints', () => {
|
||||||
|
it('should add input endpoints correctly', () => {
|
||||||
|
const { addInputEndpoints } = nodeBase;
|
||||||
|
|
||||||
|
vi.spyOn(workflowsStore, 'getNodeByName').mockReturnValue(node);
|
||||||
|
|
||||||
|
addInputEndpoints(node, nodeTypeDescription);
|
||||||
|
|
||||||
|
const addEndpointCall = instance.addEndpoint.mock.calls[0][1];
|
||||||
|
|
||||||
|
expect(workflowsStore.getNodeByName).toHaveBeenCalledWith(node.name);
|
||||||
|
expect(addEndpointCall.anchor).toEqual([0.01, 0.5, -1, 0]);
|
||||||
|
expect(addEndpointCall.cssClass).toEqual('rect-input-endpoint');
|
||||||
|
expect(addEndpointCall.dragAllowedWhenFull).toEqual(true);
|
||||||
|
expect(addEndpointCall.enabled).toEqual(true);
|
||||||
|
expect(addEndpointCall.endpoint).toEqual('Rectangle');
|
||||||
|
expect(addEndpointCall.hoverClass).toEqual('rect-input-endpoint-hover');
|
||||||
|
expect(addEndpointCall.hoverPaintStyle).toMatchObject({
|
||||||
|
fill: 'var(--color-primary)',
|
||||||
|
height: 20,
|
||||||
|
lineWidth: 0,
|
||||||
|
stroke: 'var(--color-primary)',
|
||||||
|
width: 8,
|
||||||
|
});
|
||||||
|
expect(addEndpointCall.maxConnections).toEqual(-1);
|
||||||
|
expect(addEndpointCall.paintStyle).toMatchObject({
|
||||||
|
fill: 'var(--node-type-main-color)',
|
||||||
|
height: 20,
|
||||||
|
lineWidth: 0,
|
||||||
|
stroke: 'var(--node-type-main-color)',
|
||||||
|
width: 8,
|
||||||
|
});
|
||||||
|
expect(addEndpointCall.parameters).toMatchObject({
|
||||||
|
connection: 'target',
|
||||||
|
index: 0,
|
||||||
|
type: 'main',
|
||||||
|
});
|
||||||
|
expect(addEndpointCall.scope).toBeUndefined();
|
||||||
|
expect(addEndpointCall.source).toBeFalsy();
|
||||||
|
expect(addEndpointCall.target).toBeFalsy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('addOutputEndpoints', () => {
|
||||||
|
it('should add output endpoints correctly', () => {
|
||||||
|
const { addOutputEndpoints } = nodeBase;
|
||||||
|
const getNodeByNameSpy = vi.spyOn(workflowsStore, 'getNodeByName').mockReturnValue(node);
|
||||||
|
|
||||||
|
addOutputEndpoints(node, nodeTypeDescription);
|
||||||
|
|
||||||
|
const addEndpointCall = instance.addEndpoint.mock.calls[0][1];
|
||||||
|
|
||||||
|
expect(getNodeByNameSpy).toHaveBeenCalledWith(node.name);
|
||||||
|
expect(addEndpointCall.anchor).toEqual([0.99, 0.5, 1, 0]);
|
||||||
|
expect(addEndpointCall.cssClass).toEqual('dot-output-endpoint');
|
||||||
|
expect(addEndpointCall.dragAllowedWhenFull).toEqual(false);
|
||||||
|
expect(addEndpointCall.enabled).toEqual(true);
|
||||||
|
expect(addEndpointCall.endpoint).toEqual({
|
||||||
|
options: {
|
||||||
|
radius: 9,
|
||||||
|
},
|
||||||
|
type: 'Dot',
|
||||||
|
});
|
||||||
|
expect(addEndpointCall.hoverClass).toEqual('dot-output-endpoint-hover');
|
||||||
|
expect(addEndpointCall.hoverPaintStyle).toMatchObject({
|
||||||
|
fill: 'var(--color-primary)',
|
||||||
|
});
|
||||||
|
expect(addEndpointCall.maxConnections).toEqual(-1);
|
||||||
|
expect(addEndpointCall.paintStyle).toMatchObject({
|
||||||
|
fill: 'var(--node-type-main-color)',
|
||||||
|
});
|
||||||
|
expect(addEndpointCall.parameters).toMatchObject({
|
||||||
|
connection: 'source',
|
||||||
|
index: 0,
|
||||||
|
type: 'main',
|
||||||
|
});
|
||||||
|
expect(addEndpointCall.scope).toBeUndefined();
|
||||||
|
expect(addEndpointCall.source).toBeTruthy();
|
||||||
|
expect(addEndpointCall.target).toBeFalsy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('mouseLeftClick', () => {
|
||||||
|
it('should handle mouse left click correctly', () => {
|
||||||
|
const { mouseLeftClick } = nodeBase;
|
||||||
|
|
||||||
|
const isActionActiveFn = vi.fn().mockReturnValue(false);
|
||||||
|
|
||||||
|
// @ts-expect-error Pinia has a known issue when mocking getters, will be solved when migrating the uiStore to composition api
|
||||||
|
vi.spyOn(uiStore, 'isActionActive', 'get').mockReturnValue(isActionActiveFn);
|
||||||
|
// @ts-expect-error Pinia has a known issue when mocking getters, will be solved when migrating the uiStore to composition api
|
||||||
|
vi.spyOn(uiStore, 'isNodeSelected', 'get').mockReturnValue(() => false);
|
||||||
|
|
||||||
|
const event = new MouseEvent('click', {
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
mouseLeftClick(event);
|
||||||
|
|
||||||
|
expect(isActionActiveFn).toHaveBeenCalledWith('dragActive');
|
||||||
|
expect(emit).toHaveBeenCalledWith('deselectAllNodes');
|
||||||
|
expect(emit).toHaveBeenCalledWith('nodeSelected', node.name);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getSpacerIndexes', () => {
|
||||||
|
it('should return spacer indexes when left and right group have items and spacer between groups is true', () => {
|
||||||
|
const { getSpacerIndexes } = nodeBase;
|
||||||
|
const result = getSpacerIndexes(3, 3, true);
|
||||||
|
expect(result).toEqual([3]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return spacer indexes to meet the min items count if there are less items in both groups', () => {
|
||||||
|
const { getSpacerIndexes } = nodeBase;
|
||||||
|
const result = getSpacerIndexes(1, 1, false, 5);
|
||||||
|
expect(result).toEqual([1, 2, 3]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return spacer indexes for left group when only left group has items and less than min items count', () => {
|
||||||
|
const { getSpacerIndexes } = nodeBase;
|
||||||
|
const result = getSpacerIndexes(2, 0, false, 4);
|
||||||
|
expect(result).toEqual([2, 3]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return spacer indexes for right group when only right group has items and less than min items count', () => {
|
||||||
|
const { getSpacerIndexes } = nodeBase;
|
||||||
|
const result = getSpacerIndexes(0, 3, false, 5);
|
||||||
|
expect(result).toEqual([0, 1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return empty array when both groups have items more than min items count and spacer between groups is false', () => {
|
||||||
|
const { getSpacerIndexes } = nodeBase;
|
||||||
|
const result = getSpacerIndexes(3, 3, false, 5);
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return empty array when left and right group have items and spacer between groups is false', () => {
|
||||||
|
const { getSpacerIndexes } = nodeBase;
|
||||||
|
const result = getSpacerIndexes(2, 2, false, 4);
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
673
packages/editor-ui/src/composables/useNodeBase.ts
Normal file
673
packages/editor-ui/src/composables/useNodeBase.ts
Normal file
|
@ -0,0 +1,673 @@
|
||||||
|
import { computed, getCurrentInstance, ref } from 'vue';
|
||||||
|
|
||||||
|
import type { INodeUi } from '@/Interface';
|
||||||
|
import {
|
||||||
|
NO_OP_NODE_TYPE,
|
||||||
|
NODE_CONNECTION_TYPE_ALLOW_MULTIPLE,
|
||||||
|
NODE_INSERT_SPACER_BETWEEN_INPUT_GROUPS,
|
||||||
|
NODE_MIN_INPUT_ITEMS_COUNT,
|
||||||
|
} from '@/constants';
|
||||||
|
|
||||||
|
import { NodeHelpers, NodeConnectionType } from 'n8n-workflow';
|
||||||
|
import type {
|
||||||
|
ConnectionTypes,
|
||||||
|
INodeInputConfiguration,
|
||||||
|
INodeTypeDescription,
|
||||||
|
INodeOutputConfiguration,
|
||||||
|
Workflow,
|
||||||
|
} from 'n8n-workflow';
|
||||||
|
import { useUIStore } from '@/stores/ui.store';
|
||||||
|
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||||
|
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
|
||||||
|
import type { BrowserJsPlumbInstance } from '@jsplumb/browser-ui';
|
||||||
|
import type { Endpoint, EndpointOptions } from '@jsplumb/core';
|
||||||
|
import * as NodeViewUtils from '@/utils/nodeViewUtils';
|
||||||
|
import type { EndpointSpec } from '@jsplumb/common';
|
||||||
|
import { useDeviceSupport } from 'n8n-design-system';
|
||||||
|
import type { N8nEndpointLabelLength } from '@/plugins/jsplumb/N8nPlusEndpointType';
|
||||||
|
import { isValidNodeConnectionType } from '@/utils/typeGuards';
|
||||||
|
import { useI18n } from '@/composables/useI18n';
|
||||||
|
|
||||||
|
export function useNodeBase({
|
||||||
|
name,
|
||||||
|
instance,
|
||||||
|
workflowObject,
|
||||||
|
isReadOnly,
|
||||||
|
emit,
|
||||||
|
}: {
|
||||||
|
name: string;
|
||||||
|
instance: BrowserJsPlumbInstance;
|
||||||
|
workflowObject: Workflow;
|
||||||
|
isReadOnly: boolean;
|
||||||
|
emit: (event: string, ...args: unknown[]) => void;
|
||||||
|
}) {
|
||||||
|
const uiStore = useUIStore();
|
||||||
|
const deviceSupport = useDeviceSupport();
|
||||||
|
const workflowsStore = useWorkflowsStore();
|
||||||
|
const nodeTypesStore = useNodeTypesStore();
|
||||||
|
|
||||||
|
const i18n = useI18n();
|
||||||
|
|
||||||
|
// @TODO Remove this when Node.vue and Sticky.vue are migrated to composition API and pass refs instead
|
||||||
|
const refs = computed(() => getCurrentInstance()?.refs ?? {});
|
||||||
|
|
||||||
|
const data = computed<INodeUi | null>(() => {
|
||||||
|
return workflowsStore.getNodeByName(name);
|
||||||
|
});
|
||||||
|
|
||||||
|
const nodeId = computed<string>(() => data.value?.id ?? '');
|
||||||
|
|
||||||
|
const inputs = ref<Array<ConnectionTypes | INodeInputConfiguration>>([]);
|
||||||
|
const outputs = ref<Array<ConnectionTypes | INodeOutputConfiguration>>([]);
|
||||||
|
|
||||||
|
const createAddInputEndpointSpec = (
|
||||||
|
connectionName: NodeConnectionType,
|
||||||
|
color: string,
|
||||||
|
): EndpointSpec => {
|
||||||
|
const multiple = NODE_CONNECTION_TYPE_ALLOW_MULTIPLE.includes(connectionName);
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'N8nAddInput',
|
||||||
|
options: {
|
||||||
|
width: 24,
|
||||||
|
height: 72,
|
||||||
|
color,
|
||||||
|
multiple,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const createDiamondOutputEndpointSpec = (): EndpointSpec => ({
|
||||||
|
type: 'Rectangle',
|
||||||
|
options: {
|
||||||
|
height: 10,
|
||||||
|
width: 10,
|
||||||
|
cssClass: 'diamond-output-endpoint',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const getEndpointLabelLength = (length: number): N8nEndpointLabelLength => {
|
||||||
|
if (length <= 2) return 'small';
|
||||||
|
else if (length <= 6) return 'medium';
|
||||||
|
return 'large';
|
||||||
|
};
|
||||||
|
|
||||||
|
function addEndpointTestingData(endpoint: Endpoint, type: string, inputIndex: number) {
|
||||||
|
if (window?.Cypress && 'canvas' in endpoint.endpoint && instance) {
|
||||||
|
const canvas = endpoint.endpoint.canvas;
|
||||||
|
instance.setAttribute(canvas, 'data-endpoint-name', data.value?.name ?? '');
|
||||||
|
instance.setAttribute(canvas, 'data-input-index', inputIndex.toString());
|
||||||
|
instance.setAttribute(canvas, 'data-endpoint-type', type);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addInputEndpoints(node: INodeUi, nodeTypeData: INodeTypeDescription) {
|
||||||
|
// Add Inputs
|
||||||
|
const rootTypeIndexData: {
|
||||||
|
[key: string]: number;
|
||||||
|
} = {};
|
||||||
|
const typeIndexData: {
|
||||||
|
[key: string]: number;
|
||||||
|
} = {};
|
||||||
|
|
||||||
|
inputs.value = NodeHelpers.getNodeInputs(workflowObject, data.value!, nodeTypeData) || [];
|
||||||
|
|
||||||
|
const sortedInputs = [...inputs.value];
|
||||||
|
sortedInputs.sort((a, b) => {
|
||||||
|
if (typeof a === 'string') {
|
||||||
|
return 1;
|
||||||
|
} else if (typeof b === 'string') {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (a.required && !b.required) {
|
||||||
|
return -1;
|
||||||
|
} else if (!a.required && b.required) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
sortedInputs.forEach((value, i) => {
|
||||||
|
let inputConfiguration: INodeInputConfiguration;
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
inputConfiguration = {
|
||||||
|
type: value,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
inputConfiguration = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputName: ConnectionTypes = inputConfiguration.type;
|
||||||
|
|
||||||
|
const rootCategoryInputName =
|
||||||
|
inputName === NodeConnectionType.Main ? NodeConnectionType.Main : 'other';
|
||||||
|
|
||||||
|
// Increment the index for inputs with current name
|
||||||
|
if (rootTypeIndexData.hasOwnProperty(rootCategoryInputName)) {
|
||||||
|
rootTypeIndexData[rootCategoryInputName]++;
|
||||||
|
} else {
|
||||||
|
rootTypeIndexData[rootCategoryInputName] = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeIndexData.hasOwnProperty(inputName)) {
|
||||||
|
typeIndexData[inputName]++;
|
||||||
|
} else {
|
||||||
|
typeIndexData[inputName] = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rootTypeIndex = rootTypeIndexData[rootCategoryInputName];
|
||||||
|
const typeIndex = typeIndexData[inputName];
|
||||||
|
|
||||||
|
const inputsOfSameRootType = inputs.value.filter((inputData) => {
|
||||||
|
const thisInputName: string = typeof inputData === 'string' ? inputData : inputData.type;
|
||||||
|
return inputName === NodeConnectionType.Main
|
||||||
|
? thisInputName === NodeConnectionType.Main
|
||||||
|
: thisInputName !== NodeConnectionType.Main;
|
||||||
|
});
|
||||||
|
|
||||||
|
const nonMainInputs = inputsOfSameRootType.filter((inputData) => {
|
||||||
|
return inputData !== NodeConnectionType.Main;
|
||||||
|
});
|
||||||
|
const requiredNonMainInputs = nonMainInputs.filter((inputData) => {
|
||||||
|
return typeof inputData !== 'string' && inputData.required;
|
||||||
|
});
|
||||||
|
const optionalNonMainInputs = nonMainInputs.filter((inputData) => {
|
||||||
|
return typeof inputData !== 'string' && !inputData.required;
|
||||||
|
});
|
||||||
|
const spacerIndexes = getSpacerIndexes(
|
||||||
|
requiredNonMainInputs.length,
|
||||||
|
optionalNonMainInputs.length,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get the position of the anchor depending on how many it has
|
||||||
|
const anchorPosition = NodeViewUtils.getAnchorPosition(
|
||||||
|
inputName,
|
||||||
|
'input',
|
||||||
|
inputsOfSameRootType.length,
|
||||||
|
spacerIndexes,
|
||||||
|
)[rootTypeIndex];
|
||||||
|
|
||||||
|
if (!isValidNodeConnectionType(inputName)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const scope = NodeViewUtils.getEndpointScope(inputName);
|
||||||
|
|
||||||
|
const newEndpointData: EndpointOptions = {
|
||||||
|
uuid: NodeViewUtils.getInputEndpointUUID(nodeId.value, inputName, typeIndex),
|
||||||
|
anchor: anchorPosition,
|
||||||
|
// We potentially want to change that in the future to allow people to dynamically
|
||||||
|
// activate and deactivate connected nodes
|
||||||
|
maxConnections: inputConfiguration.maxConnections ?? -1,
|
||||||
|
endpoint: 'Rectangle',
|
||||||
|
paintStyle: NodeViewUtils.getInputEndpointStyle(
|
||||||
|
nodeTypeData,
|
||||||
|
'--color-foreground-xdark',
|
||||||
|
inputName,
|
||||||
|
),
|
||||||
|
hoverPaintStyle: NodeViewUtils.getInputEndpointStyle(
|
||||||
|
nodeTypeData,
|
||||||
|
'--color-primary',
|
||||||
|
inputName,
|
||||||
|
),
|
||||||
|
scope: NodeViewUtils.getScope(scope),
|
||||||
|
source: inputName !== NodeConnectionType.Main,
|
||||||
|
target: !isReadOnly && inputs.value.length > 1, // only enabled for nodes with multiple inputs.. otherwise attachment handled by connectionDrag event in NodeView,
|
||||||
|
parameters: {
|
||||||
|
connection: 'target',
|
||||||
|
nodeId: nodeId.value,
|
||||||
|
type: inputName,
|
||||||
|
index: typeIndex,
|
||||||
|
},
|
||||||
|
enabled: !isReadOnly, // enabled in default case to allow dragging
|
||||||
|
cssClass: 'rect-input-endpoint',
|
||||||
|
dragAllowedWhenFull: true,
|
||||||
|
hoverClass: 'rect-input-endpoint-hover',
|
||||||
|
...getInputConnectionStyle(inputName, nodeTypeData),
|
||||||
|
};
|
||||||
|
|
||||||
|
const endpoint = instance?.addEndpoint(
|
||||||
|
refs.value[data.value?.name ?? ''] as Element,
|
||||||
|
newEndpointData,
|
||||||
|
) as Endpoint;
|
||||||
|
addEndpointTestingData(endpoint, 'input', typeIndex);
|
||||||
|
if (inputConfiguration.displayName ?? nodeTypeData.inputNames?.[i]) {
|
||||||
|
// Apply input names if they got set
|
||||||
|
endpoint.addOverlay(
|
||||||
|
NodeViewUtils.getInputNameOverlay(
|
||||||
|
inputConfiguration.displayName ?? nodeTypeData.inputNames?.[i] ?? '',
|
||||||
|
inputName,
|
||||||
|
inputConfiguration.required,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!Array.isArray(endpoint)) {
|
||||||
|
endpoint.__meta = {
|
||||||
|
nodeName: node.name,
|
||||||
|
nodeId: nodeId.value,
|
||||||
|
index: typeIndex,
|
||||||
|
totalEndpoints: inputsOfSameRootType.length,
|
||||||
|
nodeType: node.type,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Activate again if it makes sense. Currently makes problems when removing
|
||||||
|
// connection on which the input has a name. It does not get hidden because
|
||||||
|
// the endpoint to which it connects when letting it go over the node is
|
||||||
|
// different to the regular one (have different ids). So that seems to make
|
||||||
|
// problems when hiding the input-name.
|
||||||
|
|
||||||
|
// if (index === 0 && inputName === NodeConnectionType.Main) {
|
||||||
|
// // Make the first main-input the default one to connect to when connection gets dropped on node
|
||||||
|
// instance.makeTarget(nodeId.value, newEndpointData);
|
||||||
|
// }
|
||||||
|
});
|
||||||
|
if (sortedInputs.length === 0) {
|
||||||
|
instance?.manage(refs.value[data.value?.name ?? ''] as Element);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSpacerIndexes(
|
||||||
|
leftGroupItemsCount: number,
|
||||||
|
rightGroupItemsCount: number,
|
||||||
|
insertSpacerBetweenGroups = NODE_INSERT_SPACER_BETWEEN_INPUT_GROUPS,
|
||||||
|
minItemsCount = NODE_MIN_INPUT_ITEMS_COUNT,
|
||||||
|
): number[] {
|
||||||
|
const spacerIndexes = [];
|
||||||
|
|
||||||
|
if (leftGroupItemsCount > 0 && rightGroupItemsCount > 0) {
|
||||||
|
if (insertSpacerBetweenGroups) {
|
||||||
|
spacerIndexes.push(leftGroupItemsCount);
|
||||||
|
} else if (leftGroupItemsCount + rightGroupItemsCount < minItemsCount) {
|
||||||
|
for (
|
||||||
|
let spacerIndex = leftGroupItemsCount;
|
||||||
|
spacerIndex < minItemsCount - rightGroupItemsCount;
|
||||||
|
spacerIndex++
|
||||||
|
) {
|
||||||
|
spacerIndexes.push(spacerIndex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (
|
||||||
|
leftGroupItemsCount > 0 &&
|
||||||
|
leftGroupItemsCount < minItemsCount &&
|
||||||
|
rightGroupItemsCount === 0
|
||||||
|
) {
|
||||||
|
for (
|
||||||
|
let spacerIndex = 0;
|
||||||
|
spacerIndex < minItemsCount - leftGroupItemsCount;
|
||||||
|
spacerIndex++
|
||||||
|
) {
|
||||||
|
spacerIndexes.push(spacerIndex + leftGroupItemsCount);
|
||||||
|
}
|
||||||
|
} else if (
|
||||||
|
leftGroupItemsCount === 0 &&
|
||||||
|
rightGroupItemsCount > 0 &&
|
||||||
|
rightGroupItemsCount < minItemsCount
|
||||||
|
) {
|
||||||
|
for (
|
||||||
|
let spacerIndex = 0;
|
||||||
|
spacerIndex < minItemsCount - rightGroupItemsCount;
|
||||||
|
spacerIndex++
|
||||||
|
) {
|
||||||
|
spacerIndexes.push(spacerIndex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return spacerIndexes;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addOutputEndpoints(node: INodeUi, nodeTypeData: INodeTypeDescription) {
|
||||||
|
const rootTypeIndexData: {
|
||||||
|
[key: string]: number;
|
||||||
|
} = {};
|
||||||
|
const typeIndexData: {
|
||||||
|
[key: string]: number;
|
||||||
|
} = {};
|
||||||
|
|
||||||
|
if (!data.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
outputs.value = NodeHelpers.getNodeOutputs(workflowObject, data.value, nodeTypeData) || [];
|
||||||
|
|
||||||
|
// TODO: There are still a lot of references of "main" in NodesView and
|
||||||
|
// other locations. So assume there will be more problems
|
||||||
|
let maxLabelLength = 0;
|
||||||
|
const outputConfigurations: INodeOutputConfiguration[] = [];
|
||||||
|
outputs.value.forEach((value, i) => {
|
||||||
|
let outputConfiguration: INodeOutputConfiguration;
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
outputConfiguration = {
|
||||||
|
type: value,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
outputConfiguration = value;
|
||||||
|
}
|
||||||
|
if (nodeTypeData.outputNames?.[i]) {
|
||||||
|
outputConfiguration.displayName = nodeTypeData.outputNames[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (outputConfiguration.displayName) {
|
||||||
|
maxLabelLength =
|
||||||
|
outputConfiguration.displayName.length > maxLabelLength
|
||||||
|
? outputConfiguration.displayName.length
|
||||||
|
: maxLabelLength;
|
||||||
|
}
|
||||||
|
|
||||||
|
outputConfigurations.push(outputConfiguration);
|
||||||
|
});
|
||||||
|
|
||||||
|
const endpointLabelLength = getEndpointLabelLength(maxLabelLength);
|
||||||
|
|
||||||
|
outputs.value.forEach((_value, i) => {
|
||||||
|
const outputConfiguration = outputConfigurations[i];
|
||||||
|
|
||||||
|
const outputName: ConnectionTypes = outputConfiguration.type;
|
||||||
|
|
||||||
|
const rootCategoryOutputName =
|
||||||
|
outputName === NodeConnectionType.Main ? NodeConnectionType.Main : 'other';
|
||||||
|
|
||||||
|
// Increment the index for outputs with current name
|
||||||
|
if (rootTypeIndexData.hasOwnProperty(rootCategoryOutputName)) {
|
||||||
|
rootTypeIndexData[rootCategoryOutputName]++;
|
||||||
|
} else {
|
||||||
|
rootTypeIndexData[rootCategoryOutputName] = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeIndexData.hasOwnProperty(outputName)) {
|
||||||
|
typeIndexData[outputName]++;
|
||||||
|
} else {
|
||||||
|
typeIndexData[outputName] = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rootTypeIndex = rootTypeIndexData[rootCategoryOutputName];
|
||||||
|
const typeIndex = typeIndexData[outputName];
|
||||||
|
|
||||||
|
const outputsOfSameRootType = outputs.value.filter((outputData) => {
|
||||||
|
const thisOutputName: string =
|
||||||
|
typeof outputData === 'string' ? outputData : outputData.type;
|
||||||
|
return outputName === NodeConnectionType.Main
|
||||||
|
? thisOutputName === NodeConnectionType.Main
|
||||||
|
: thisOutputName !== NodeConnectionType.Main;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get the position of the anchor depending on how many it has
|
||||||
|
const anchorPosition = NodeViewUtils.getAnchorPosition(
|
||||||
|
outputName,
|
||||||
|
'output',
|
||||||
|
outputsOfSameRootType.length,
|
||||||
|
)[rootTypeIndex];
|
||||||
|
|
||||||
|
if (!isValidNodeConnectionType(outputName)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const scope = NodeViewUtils.getEndpointScope(outputName);
|
||||||
|
|
||||||
|
const newEndpointData: EndpointOptions = {
|
||||||
|
uuid: NodeViewUtils.getOutputEndpointUUID(nodeId.value, outputName, typeIndex),
|
||||||
|
anchor: anchorPosition,
|
||||||
|
maxConnections: -1,
|
||||||
|
endpoint: {
|
||||||
|
type: 'Dot',
|
||||||
|
options: {
|
||||||
|
radius: nodeTypeData && outputsOfSameRootType.length > 2 ? 7 : 9,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
hoverPaintStyle: NodeViewUtils.getOutputEndpointStyle(nodeTypeData, '--color-primary'),
|
||||||
|
scope,
|
||||||
|
source: true,
|
||||||
|
target: outputName !== NodeConnectionType.Main,
|
||||||
|
enabled: !isReadOnly,
|
||||||
|
parameters: {
|
||||||
|
connection: 'source',
|
||||||
|
nodeId: nodeId.value,
|
||||||
|
type: outputName,
|
||||||
|
index: typeIndex,
|
||||||
|
},
|
||||||
|
hoverClass: 'dot-output-endpoint-hover',
|
||||||
|
connectionsDirected: true,
|
||||||
|
dragAllowedWhenFull: false,
|
||||||
|
...getOutputConnectionStyle(outputName, outputConfiguration, nodeTypeData),
|
||||||
|
};
|
||||||
|
|
||||||
|
const endpoint = instance?.addEndpoint(
|
||||||
|
refs.value[data.value?.name ?? ''] as Element,
|
||||||
|
newEndpointData,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!endpoint) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
addEndpointTestingData(endpoint, 'output', typeIndex);
|
||||||
|
if (outputConfiguration.displayName && isValidNodeConnectionType(outputName)) {
|
||||||
|
// Apply output names if they got set
|
||||||
|
const overlaySpec = NodeViewUtils.getOutputNameOverlay(
|
||||||
|
outputConfiguration.displayName,
|
||||||
|
outputName,
|
||||||
|
outputConfiguration?.category,
|
||||||
|
);
|
||||||
|
endpoint.addOverlay(overlaySpec);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Array.isArray(endpoint)) {
|
||||||
|
endpoint.__meta = {
|
||||||
|
nodeName: node.name,
|
||||||
|
nodeId: nodeId.value,
|
||||||
|
index: typeIndex,
|
||||||
|
totalEndpoints: outputsOfSameRootType.length,
|
||||||
|
endpointLabelLength,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isReadOnly && outputName === NodeConnectionType.Main) {
|
||||||
|
const plusEndpointData: EndpointOptions = {
|
||||||
|
uuid: NodeViewUtils.getOutputEndpointUUID(nodeId.value, outputName, typeIndex),
|
||||||
|
anchor: anchorPosition,
|
||||||
|
maxConnections: -1,
|
||||||
|
endpoint: {
|
||||||
|
type: 'N8nPlus',
|
||||||
|
options: {
|
||||||
|
dimensions: 24,
|
||||||
|
connectedEndpoint: endpoint,
|
||||||
|
showOutputLabel: outputs.value.length === 1,
|
||||||
|
size: outputs.value.length >= 3 ? 'small' : 'medium',
|
||||||
|
endpointLabelLength,
|
||||||
|
hoverMessage: i18n.baseText('nodeBase.clickToAddNodeOrDragToConnect'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
source: true,
|
||||||
|
target: false,
|
||||||
|
enabled: !isReadOnly,
|
||||||
|
paintStyle: {
|
||||||
|
outlineStroke: 'none',
|
||||||
|
},
|
||||||
|
hoverPaintStyle: {
|
||||||
|
outlineStroke: 'none',
|
||||||
|
},
|
||||||
|
parameters: {
|
||||||
|
connection: 'source',
|
||||||
|
nodeId: nodeId.value,
|
||||||
|
type: outputName,
|
||||||
|
index: typeIndex,
|
||||||
|
category: outputConfiguration?.category,
|
||||||
|
},
|
||||||
|
cssClass: 'plus-draggable-endpoint',
|
||||||
|
dragAllowedWhenFull: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (outputConfiguration?.category) {
|
||||||
|
plusEndpointData.cssClass = `${plusEndpointData.cssClass} ${outputConfiguration?.category}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!instance || !data.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const plusEndpoint = instance.addEndpoint(
|
||||||
|
refs.value[data.value.name] as Element,
|
||||||
|
plusEndpointData,
|
||||||
|
);
|
||||||
|
addEndpointTestingData(plusEndpoint, 'plus', typeIndex);
|
||||||
|
|
||||||
|
if (!Array.isArray(plusEndpoint)) {
|
||||||
|
plusEndpoint.__meta = {
|
||||||
|
nodeName: node.name,
|
||||||
|
nodeId: nodeId.value,
|
||||||
|
index: typeIndex,
|
||||||
|
nodeType: node.type,
|
||||||
|
totalEndpoints: outputsOfSameRootType.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function addNode(node: INodeUi) {
|
||||||
|
const nodeTypeData = (nodeTypesStore.getNodeType(node.type, node.typeVersion) ??
|
||||||
|
nodeTypesStore.getNodeType(NO_OP_NODE_TYPE)) as INodeTypeDescription;
|
||||||
|
|
||||||
|
addInputEndpoints(node, nodeTypeData);
|
||||||
|
addOutputEndpoints(node, nodeTypeData);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getEndpointColor(connectionType: ConnectionTypes) {
|
||||||
|
return `--node-type-${connectionType}-color`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getInputConnectionStyle(
|
||||||
|
connectionType: ConnectionTypes,
|
||||||
|
nodeTypeData: INodeTypeDescription,
|
||||||
|
): EndpointOptions {
|
||||||
|
if (connectionType === NodeConnectionType.Main) {
|
||||||
|
return {
|
||||||
|
paintStyle: NodeViewUtils.getInputEndpointStyle(
|
||||||
|
nodeTypeData,
|
||||||
|
getEndpointColor(NodeConnectionType.Main),
|
||||||
|
connectionType,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isValidNodeConnectionType(connectionType)) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const createSupplementalConnectionType = (
|
||||||
|
connectionName: ConnectionTypes,
|
||||||
|
): EndpointOptions => ({
|
||||||
|
endpoint: createAddInputEndpointSpec(
|
||||||
|
connectionName as NodeConnectionType,
|
||||||
|
getEndpointColor(connectionName),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
return createSupplementalConnectionType(connectionType);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getOutputConnectionStyle(
|
||||||
|
connectionType: ConnectionTypes,
|
||||||
|
outputConfiguration: INodeOutputConfiguration,
|
||||||
|
nodeTypeData: INodeTypeDescription,
|
||||||
|
): EndpointOptions {
|
||||||
|
const createSupplementalConnectionType = (
|
||||||
|
connectionName: ConnectionTypes,
|
||||||
|
): EndpointOptions => ({
|
||||||
|
endpoint: createDiamondOutputEndpointSpec(),
|
||||||
|
paintStyle: NodeViewUtils.getOutputEndpointStyle(
|
||||||
|
nodeTypeData,
|
||||||
|
getEndpointColor(connectionName),
|
||||||
|
),
|
||||||
|
hoverPaintStyle: NodeViewUtils.getOutputEndpointStyle(
|
||||||
|
nodeTypeData,
|
||||||
|
getEndpointColor(connectionName),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
const type = 'output';
|
||||||
|
|
||||||
|
if (connectionType === NodeConnectionType.Main) {
|
||||||
|
if (outputConfiguration.category === 'error') {
|
||||||
|
return {
|
||||||
|
paintStyle: {
|
||||||
|
...NodeViewUtils.getOutputEndpointStyle(
|
||||||
|
nodeTypeData,
|
||||||
|
getEndpointColor(NodeConnectionType.Main),
|
||||||
|
),
|
||||||
|
fill: 'var(--color-danger)',
|
||||||
|
},
|
||||||
|
cssClass: `dot-${type}-endpoint`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
paintStyle: NodeViewUtils.getOutputEndpointStyle(
|
||||||
|
nodeTypeData,
|
||||||
|
getEndpointColor(NodeConnectionType.Main),
|
||||||
|
),
|
||||||
|
cssClass: `dot-${type}-endpoint`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isValidNodeConnectionType(connectionType)) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
return createSupplementalConnectionType(connectionType);
|
||||||
|
}
|
||||||
|
|
||||||
|
function touchEnd(_e: MouseEvent) {
|
||||||
|
if (deviceSupport.isTouchDevice && uiStore.isActionActive('dragActive')) {
|
||||||
|
uiStore.removeActiveAction('dragActive');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mouseLeftClick(e: MouseEvent) {
|
||||||
|
// @ts-expect-error path is not defined in MouseEvent on all browsers
|
||||||
|
const path = e.path || e.composedPath?.();
|
||||||
|
for (let index = 0; index < path.length; index++) {
|
||||||
|
if (
|
||||||
|
path[index].className &&
|
||||||
|
typeof path[index].className === 'string' &&
|
||||||
|
path[index].className.includes('no-select-on-click')
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!deviceSupport.isTouchDevice) {
|
||||||
|
if (uiStore.isActionActive('dragActive')) {
|
||||||
|
uiStore.removeActiveAction('dragActive');
|
||||||
|
} else {
|
||||||
|
if (!deviceSupport.isCtrlKeyPressed(e)) {
|
||||||
|
emit('deselectAllNodes');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (uiStore.isNodeSelected(data.value?.name ?? '')) {
|
||||||
|
emit('deselectNode', name);
|
||||||
|
} else {
|
||||||
|
emit('nodeSelected', name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
getSpacerIndexes,
|
||||||
|
addInputEndpoints,
|
||||||
|
addOutputEndpoints,
|
||||||
|
addEndpointTestingData,
|
||||||
|
addNode,
|
||||||
|
getEndpointColor,
|
||||||
|
getInputConnectionStyle,
|
||||||
|
getOutputConnectionStyle,
|
||||||
|
mouseLeftClick,
|
||||||
|
touchEnd,
|
||||||
|
inputs,
|
||||||
|
outputs,
|
||||||
|
};
|
||||||
|
}
|
|
@ -1,684 +0,0 @@
|
||||||
import { defineComponent } from 'vue';
|
|
||||||
import type { PropType } from 'vue';
|
|
||||||
import { mapStores } from 'pinia';
|
|
||||||
|
|
||||||
import type { INodeUi } from '@/Interface';
|
|
||||||
import {
|
|
||||||
NO_OP_NODE_TYPE,
|
|
||||||
NODE_CONNECTION_TYPE_ALLOW_MULTIPLE,
|
|
||||||
NODE_INSERT_SPACER_BETWEEN_INPUT_GROUPS,
|
|
||||||
NODE_MIN_INPUT_ITEMS_COUNT,
|
|
||||||
} from '@/constants';
|
|
||||||
|
|
||||||
import { NodeHelpers, NodeConnectionType } from 'n8n-workflow';
|
|
||||||
import type {
|
|
||||||
ConnectionTypes,
|
|
||||||
INodeInputConfiguration,
|
|
||||||
INodeTypeDescription,
|
|
||||||
INodeOutputConfiguration,
|
|
||||||
Workflow,
|
|
||||||
} from 'n8n-workflow';
|
|
||||||
import { useUIStore } from '@/stores/ui.store';
|
|
||||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
|
||||||
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
|
|
||||||
import type { BrowserJsPlumbInstance } from '@jsplumb/browser-ui';
|
|
||||||
import type { Endpoint, EndpointOptions } from '@jsplumb/core';
|
|
||||||
import * as NodeViewUtils from '@/utils/nodeViewUtils';
|
|
||||||
import { useHistoryStore } from '@/stores/history.store';
|
|
||||||
import { useCanvasStore } from '@/stores/canvas.store';
|
|
||||||
import type { EndpointSpec } from '@jsplumb/common';
|
|
||||||
import { useDeviceSupport } from 'n8n-design-system';
|
|
||||||
import type { N8nEndpointLabelLength } from '@/plugins/jsplumb/N8nPlusEndpointType';
|
|
||||||
import { isValidNodeConnectionType } from '@/utils/typeGuards';
|
|
||||||
|
|
||||||
const createAddInputEndpointSpec = (
|
|
||||||
connectionName: NodeConnectionType,
|
|
||||||
color: string,
|
|
||||||
): EndpointSpec => {
|
|
||||||
const multiple = NODE_CONNECTION_TYPE_ALLOW_MULTIPLE.includes(connectionName);
|
|
||||||
|
|
||||||
return {
|
|
||||||
type: 'N8nAddInput',
|
|
||||||
options: {
|
|
||||||
width: 24,
|
|
||||||
height: 72,
|
|
||||||
color,
|
|
||||||
multiple,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const createDiamondOutputEndpointSpec = (): EndpointSpec => ({
|
|
||||||
type: 'Rectangle',
|
|
||||||
options: {
|
|
||||||
height: 10,
|
|
||||||
width: 10,
|
|
||||||
cssClass: 'diamond-output-endpoint',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const getEndpointLabelLength = (length: number): N8nEndpointLabelLength => {
|
|
||||||
if (length <= 2) return 'small';
|
|
||||||
else if (length <= 6) return 'medium';
|
|
||||||
return 'large';
|
|
||||||
};
|
|
||||||
|
|
||||||
export const nodeBase = defineComponent({
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
inputs: [] as Array<ConnectionTypes | INodeInputConfiguration>,
|
|
||||||
outputs: [] as Array<ConnectionTypes | INodeOutputConfiguration>,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
mounted() {
|
|
||||||
// Initialize the node
|
|
||||||
if (this.data !== null) {
|
|
||||||
try {
|
|
||||||
this.__addNode(this.data);
|
|
||||||
} catch (error) {
|
|
||||||
// This breaks when new nodes are loaded into store but workflow tab is not currently active
|
|
||||||
// Shouldn't affect anything
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
computed: {
|
|
||||||
...mapStores(useNodeTypesStore, useUIStore, useCanvasStore, useWorkflowsStore, useHistoryStore),
|
|
||||||
data(): INodeUi | null {
|
|
||||||
return this.workflowsStore.getNodeByName(this.name);
|
|
||||||
},
|
|
||||||
nodeId(): string {
|
|
||||||
return this.data?.id || '';
|
|
||||||
},
|
|
||||||
},
|
|
||||||
props: {
|
|
||||||
name: {
|
|
||||||
type: String,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
instance: {
|
|
||||||
type: Object as PropType<BrowserJsPlumbInstance>,
|
|
||||||
},
|
|
||||||
isReadOnly: {
|
|
||||||
type: Boolean,
|
|
||||||
},
|
|
||||||
isActive: {
|
|
||||||
type: Boolean,
|
|
||||||
},
|
|
||||||
hideActions: {
|
|
||||||
type: Boolean,
|
|
||||||
},
|
|
||||||
disableSelecting: {
|
|
||||||
type: Boolean,
|
|
||||||
},
|
|
||||||
showCustomTooltip: {
|
|
||||||
type: Boolean,
|
|
||||||
},
|
|
||||||
workflow: {
|
|
||||||
type: Object as () => Workflow,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
__addEndpointTestingData(endpoint: Endpoint, type: string, inputIndex: number) {
|
|
||||||
if (window?.Cypress && 'canvas' in endpoint.endpoint && this.instance) {
|
|
||||||
const canvas = endpoint.endpoint.canvas;
|
|
||||||
this.instance.setAttribute(canvas, 'data-endpoint-name', this.data?.name ?? '');
|
|
||||||
this.instance.setAttribute(canvas, 'data-input-index', inputIndex.toString());
|
|
||||||
this.instance.setAttribute(canvas, 'data-endpoint-type', type);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
__addInputEndpoints(node: INodeUi, nodeTypeData: INodeTypeDescription) {
|
|
||||||
// Add Inputs
|
|
||||||
const rootTypeIndexData: {
|
|
||||||
[key: string]: number;
|
|
||||||
} = {};
|
|
||||||
const typeIndexData: {
|
|
||||||
[key: string]: number;
|
|
||||||
} = {};
|
|
||||||
|
|
||||||
const inputs: Array<ConnectionTypes | INodeInputConfiguration> =
|
|
||||||
NodeHelpers.getNodeInputs(this.workflow, this.data!, nodeTypeData) || [];
|
|
||||||
this.inputs = inputs;
|
|
||||||
|
|
||||||
const sortedInputs = [...inputs];
|
|
||||||
sortedInputs.sort((a, b) => {
|
|
||||||
if (typeof a === 'string') {
|
|
||||||
return 1;
|
|
||||||
} else if (typeof b === 'string') {
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (a.required && !b.required) {
|
|
||||||
return -1;
|
|
||||||
} else if (!a.required && b.required) {
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
return 0;
|
|
||||||
});
|
|
||||||
|
|
||||||
sortedInputs.forEach((value, i) => {
|
|
||||||
let inputConfiguration: INodeInputConfiguration;
|
|
||||||
if (typeof value === 'string') {
|
|
||||||
inputConfiguration = {
|
|
||||||
type: value,
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
inputConfiguration = value;
|
|
||||||
}
|
|
||||||
|
|
||||||
const inputName: ConnectionTypes = inputConfiguration.type;
|
|
||||||
|
|
||||||
const rootCategoryInputName =
|
|
||||||
inputName === NodeConnectionType.Main ? NodeConnectionType.Main : 'other';
|
|
||||||
|
|
||||||
// Increment the index for inputs with current name
|
|
||||||
if (rootTypeIndexData.hasOwnProperty(rootCategoryInputName)) {
|
|
||||||
rootTypeIndexData[rootCategoryInputName]++;
|
|
||||||
} else {
|
|
||||||
rootTypeIndexData[rootCategoryInputName] = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeIndexData.hasOwnProperty(inputName)) {
|
|
||||||
typeIndexData[inputName]++;
|
|
||||||
} else {
|
|
||||||
typeIndexData[inputName] = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
const rootTypeIndex = rootTypeIndexData[rootCategoryInputName];
|
|
||||||
const typeIndex = typeIndexData[inputName];
|
|
||||||
|
|
||||||
const inputsOfSameRootType = inputs.filter((inputData) => {
|
|
||||||
const thisInputName: string = typeof inputData === 'string' ? inputData : inputData.type;
|
|
||||||
return inputName === NodeConnectionType.Main
|
|
||||||
? thisInputName === NodeConnectionType.Main
|
|
||||||
: thisInputName !== NodeConnectionType.Main;
|
|
||||||
});
|
|
||||||
|
|
||||||
const nonMainInputs = inputsOfSameRootType.filter((inputData) => {
|
|
||||||
return inputData !== NodeConnectionType.Main;
|
|
||||||
});
|
|
||||||
const requiredNonMainInputs = nonMainInputs.filter((inputData) => {
|
|
||||||
return typeof inputData !== 'string' && inputData.required;
|
|
||||||
});
|
|
||||||
const optionalNonMainInputs = nonMainInputs.filter((inputData) => {
|
|
||||||
return typeof inputData !== 'string' && !inputData.required;
|
|
||||||
});
|
|
||||||
const spacerIndexes = this.getSpacerIndexes(
|
|
||||||
requiredNonMainInputs.length,
|
|
||||||
optionalNonMainInputs.length,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Get the position of the anchor depending on how many it has
|
|
||||||
const anchorPosition = NodeViewUtils.getAnchorPosition(
|
|
||||||
inputName,
|
|
||||||
'input',
|
|
||||||
inputsOfSameRootType.length,
|
|
||||||
spacerIndexes,
|
|
||||||
)[rootTypeIndex];
|
|
||||||
|
|
||||||
if (!isValidNodeConnectionType(inputName)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const scope = NodeViewUtils.getEndpointScope(inputName);
|
|
||||||
|
|
||||||
const newEndpointData: EndpointOptions = {
|
|
||||||
uuid: NodeViewUtils.getInputEndpointUUID(this.nodeId, inputName, typeIndex),
|
|
||||||
anchor: anchorPosition,
|
|
||||||
// We potentially want to change that in the future to allow people to dynamically
|
|
||||||
// activate and deactivate connected nodes
|
|
||||||
maxConnections: inputConfiguration.maxConnections ?? -1,
|
|
||||||
endpoint: 'Rectangle',
|
|
||||||
paintStyle: NodeViewUtils.getInputEndpointStyle(
|
|
||||||
nodeTypeData,
|
|
||||||
'--color-foreground-xdark',
|
|
||||||
inputName,
|
|
||||||
),
|
|
||||||
hoverPaintStyle: NodeViewUtils.getInputEndpointStyle(
|
|
||||||
nodeTypeData,
|
|
||||||
'--color-primary',
|
|
||||||
inputName,
|
|
||||||
),
|
|
||||||
scope: NodeViewUtils.getScope(scope),
|
|
||||||
source: inputName !== NodeConnectionType.Main,
|
|
||||||
target: !this.isReadOnly && inputs.length > 1, // only enabled for nodes with multiple inputs.. otherwise attachment handled by connectionDrag event in NodeView,
|
|
||||||
parameters: {
|
|
||||||
connection: 'target',
|
|
||||||
nodeId: this.nodeId,
|
|
||||||
type: inputName,
|
|
||||||
index: typeIndex,
|
|
||||||
},
|
|
||||||
enabled: !this.isReadOnly, // enabled in default case to allow dragging
|
|
||||||
cssClass: 'rect-input-endpoint',
|
|
||||||
dragAllowedWhenFull: true,
|
|
||||||
hoverClass: 'rect-input-endpoint-hover',
|
|
||||||
...this.__getInputConnectionStyle(inputName, nodeTypeData),
|
|
||||||
};
|
|
||||||
|
|
||||||
const endpoint = this.instance?.addEndpoint(
|
|
||||||
this.$refs[this.data?.name ?? ''] as Element,
|
|
||||||
newEndpointData,
|
|
||||||
) as Endpoint;
|
|
||||||
this.__addEndpointTestingData(endpoint, 'input', typeIndex);
|
|
||||||
if (inputConfiguration.displayName ?? nodeTypeData.inputNames?.[i]) {
|
|
||||||
// Apply input names if they got set
|
|
||||||
endpoint.addOverlay(
|
|
||||||
NodeViewUtils.getInputNameOverlay(
|
|
||||||
inputConfiguration.displayName ?? nodeTypeData.inputNames?.[i] ?? '',
|
|
||||||
inputName,
|
|
||||||
inputConfiguration.required,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (!Array.isArray(endpoint)) {
|
|
||||||
endpoint.__meta = {
|
|
||||||
nodeName: node.name,
|
|
||||||
nodeId: this.nodeId,
|
|
||||||
index: typeIndex,
|
|
||||||
totalEndpoints: inputsOfSameRootType.length,
|
|
||||||
nodeType: node.type,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: Activate again if it makes sense. Currently makes problems when removing
|
|
||||||
// connection on which the input has a name. It does not get hidden because
|
|
||||||
// the endpoint to which it connects when letting it go over the node is
|
|
||||||
// different to the regular one (have different ids). So that seems to make
|
|
||||||
// problems when hiding the input-name.
|
|
||||||
|
|
||||||
// if (index === 0 && inputName === NodeConnectionType.Main) {
|
|
||||||
// // Make the first main-input the default one to connect to when connection gets dropped on node
|
|
||||||
// this.instance.makeTarget(this.nodeId, newEndpointData);
|
|
||||||
// }
|
|
||||||
});
|
|
||||||
if (sortedInputs.length === 0) {
|
|
||||||
this.instance?.manage(this.$refs[this.data?.name ?? ''] as Element);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
getSpacerIndexes(
|
|
||||||
leftGroupItemsCount: number,
|
|
||||||
rightGroupItemsCount: number,
|
|
||||||
insertSpacerBetweenGroups = NODE_INSERT_SPACER_BETWEEN_INPUT_GROUPS,
|
|
||||||
minItemsCount = NODE_MIN_INPUT_ITEMS_COUNT,
|
|
||||||
): number[] {
|
|
||||||
const spacerIndexes = [];
|
|
||||||
|
|
||||||
if (leftGroupItemsCount > 0 && rightGroupItemsCount > 0) {
|
|
||||||
if (insertSpacerBetweenGroups) {
|
|
||||||
spacerIndexes.push(leftGroupItemsCount);
|
|
||||||
} else if (leftGroupItemsCount + rightGroupItemsCount < minItemsCount) {
|
|
||||||
for (
|
|
||||||
let spacerIndex = leftGroupItemsCount;
|
|
||||||
spacerIndex < minItemsCount - rightGroupItemsCount;
|
|
||||||
spacerIndex++
|
|
||||||
) {
|
|
||||||
spacerIndexes.push(spacerIndex);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (
|
|
||||||
leftGroupItemsCount > 0 &&
|
|
||||||
leftGroupItemsCount < minItemsCount &&
|
|
||||||
rightGroupItemsCount === 0
|
|
||||||
) {
|
|
||||||
for (
|
|
||||||
let spacerIndex = 0;
|
|
||||||
spacerIndex < minItemsCount - leftGroupItemsCount;
|
|
||||||
spacerIndex++
|
|
||||||
) {
|
|
||||||
spacerIndexes.push(spacerIndex + leftGroupItemsCount);
|
|
||||||
}
|
|
||||||
} else if (
|
|
||||||
leftGroupItemsCount === 0 &&
|
|
||||||
rightGroupItemsCount > 0 &&
|
|
||||||
rightGroupItemsCount < minItemsCount
|
|
||||||
) {
|
|
||||||
for (
|
|
||||||
let spacerIndex = 0;
|
|
||||||
spacerIndex < minItemsCount - rightGroupItemsCount;
|
|
||||||
spacerIndex++
|
|
||||||
) {
|
|
||||||
spacerIndexes.push(spacerIndex);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return spacerIndexes;
|
|
||||||
},
|
|
||||||
__addOutputEndpoints(node: INodeUi, nodeTypeData: INodeTypeDescription) {
|
|
||||||
const rootTypeIndexData: {
|
|
||||||
[key: string]: number;
|
|
||||||
} = {};
|
|
||||||
const typeIndexData: {
|
|
||||||
[key: string]: number;
|
|
||||||
} = {};
|
|
||||||
|
|
||||||
if (!this.data) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.outputs = NodeHelpers.getNodeOutputs(this.workflow, this.data, nodeTypeData) || [];
|
|
||||||
|
|
||||||
// TODO: There are still a lot of references of "main" in NodesView and
|
|
||||||
// other locations. So assume there will be more problems
|
|
||||||
let maxLabelLength = 0;
|
|
||||||
const outputConfigurations: INodeOutputConfiguration[] = [];
|
|
||||||
this.outputs.forEach((value, i) => {
|
|
||||||
let outputConfiguration: INodeOutputConfiguration;
|
|
||||||
if (typeof value === 'string') {
|
|
||||||
outputConfiguration = {
|
|
||||||
type: value,
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
outputConfiguration = value;
|
|
||||||
}
|
|
||||||
if (nodeTypeData.outputNames?.[i]) {
|
|
||||||
outputConfiguration.displayName = nodeTypeData.outputNames[i];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (outputConfiguration.displayName) {
|
|
||||||
maxLabelLength =
|
|
||||||
outputConfiguration.displayName.length > maxLabelLength
|
|
||||||
? outputConfiguration.displayName.length
|
|
||||||
: maxLabelLength;
|
|
||||||
}
|
|
||||||
|
|
||||||
outputConfigurations.push(outputConfiguration);
|
|
||||||
});
|
|
||||||
|
|
||||||
const endpointLabelLength = getEndpointLabelLength(maxLabelLength);
|
|
||||||
|
|
||||||
this.outputs.forEach((_value, i) => {
|
|
||||||
const outputConfiguration = outputConfigurations[i];
|
|
||||||
|
|
||||||
const outputName: ConnectionTypes = outputConfiguration.type;
|
|
||||||
|
|
||||||
const rootCategoryOutputName =
|
|
||||||
outputName === NodeConnectionType.Main ? NodeConnectionType.Main : 'other';
|
|
||||||
|
|
||||||
// Increment the index for outputs with current name
|
|
||||||
if (rootTypeIndexData.hasOwnProperty(rootCategoryOutputName)) {
|
|
||||||
rootTypeIndexData[rootCategoryOutputName]++;
|
|
||||||
} else {
|
|
||||||
rootTypeIndexData[rootCategoryOutputName] = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeIndexData.hasOwnProperty(outputName)) {
|
|
||||||
typeIndexData[outputName]++;
|
|
||||||
} else {
|
|
||||||
typeIndexData[outputName] = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
const rootTypeIndex = rootTypeIndexData[rootCategoryOutputName];
|
|
||||||
const typeIndex = typeIndexData[outputName];
|
|
||||||
|
|
||||||
const outputsOfSameRootType = this.outputs.filter((outputData) => {
|
|
||||||
const thisOutputName: string =
|
|
||||||
typeof outputData === 'string' ? outputData : outputData.type;
|
|
||||||
return outputName === NodeConnectionType.Main
|
|
||||||
? thisOutputName === NodeConnectionType.Main
|
|
||||||
: thisOutputName !== NodeConnectionType.Main;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Get the position of the anchor depending on how many it has
|
|
||||||
const anchorPosition = NodeViewUtils.getAnchorPosition(
|
|
||||||
outputName,
|
|
||||||
'output',
|
|
||||||
outputsOfSameRootType.length,
|
|
||||||
)[rootTypeIndex];
|
|
||||||
|
|
||||||
if (!isValidNodeConnectionType(outputName)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const scope = NodeViewUtils.getEndpointScope(outputName);
|
|
||||||
|
|
||||||
const newEndpointData: EndpointOptions = {
|
|
||||||
uuid: NodeViewUtils.getOutputEndpointUUID(this.nodeId, outputName, typeIndex),
|
|
||||||
anchor: anchorPosition,
|
|
||||||
maxConnections: -1,
|
|
||||||
endpoint: {
|
|
||||||
type: 'Dot',
|
|
||||||
options: {
|
|
||||||
radius: nodeTypeData && outputsOfSameRootType.length > 2 ? 7 : 9,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
hoverPaintStyle: NodeViewUtils.getOutputEndpointStyle(nodeTypeData, '--color-primary'),
|
|
||||||
scope,
|
|
||||||
source: true,
|
|
||||||
target: outputName !== NodeConnectionType.Main,
|
|
||||||
enabled: !this.isReadOnly,
|
|
||||||
parameters: {
|
|
||||||
connection: 'source',
|
|
||||||
nodeId: this.nodeId,
|
|
||||||
type: outputName,
|
|
||||||
index: typeIndex,
|
|
||||||
},
|
|
||||||
hoverClass: 'dot-output-endpoint-hover',
|
|
||||||
connectionsDirected: true,
|
|
||||||
dragAllowedWhenFull: false,
|
|
||||||
...this.__getOutputConnectionStyle(outputName, outputConfiguration, nodeTypeData),
|
|
||||||
};
|
|
||||||
|
|
||||||
const endpoint = this.instance?.addEndpoint(
|
|
||||||
this.$refs[this.data?.name ?? ''] as Element,
|
|
||||||
newEndpointData,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!endpoint) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.__addEndpointTestingData(endpoint, 'output', typeIndex);
|
|
||||||
if (outputConfiguration.displayName && isValidNodeConnectionType(outputName)) {
|
|
||||||
// Apply output names if they got set
|
|
||||||
const overlaySpec = NodeViewUtils.getOutputNameOverlay(
|
|
||||||
outputConfiguration.displayName,
|
|
||||||
outputName,
|
|
||||||
outputConfiguration?.category,
|
|
||||||
);
|
|
||||||
endpoint.addOverlay(overlaySpec);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!Array.isArray(endpoint)) {
|
|
||||||
endpoint.__meta = {
|
|
||||||
nodeName: node.name,
|
|
||||||
nodeId: this.nodeId,
|
|
||||||
index: typeIndex,
|
|
||||||
totalEndpoints: outputsOfSameRootType.length,
|
|
||||||
endpointLabelLength,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!this.isReadOnly && outputName === NodeConnectionType.Main) {
|
|
||||||
const plusEndpointData: EndpointOptions = {
|
|
||||||
uuid: NodeViewUtils.getOutputEndpointUUID(this.nodeId, outputName, typeIndex),
|
|
||||||
anchor: anchorPosition,
|
|
||||||
maxConnections: -1,
|
|
||||||
endpoint: {
|
|
||||||
type: 'N8nPlus',
|
|
||||||
options: {
|
|
||||||
dimensions: 24,
|
|
||||||
connectedEndpoint: endpoint,
|
|
||||||
showOutputLabel: this.outputs.length === 1,
|
|
||||||
size: this.outputs.length >= 3 ? 'small' : 'medium',
|
|
||||||
endpointLabelLength,
|
|
||||||
hoverMessage: this.$locale.baseText('nodeBase.clickToAddNodeOrDragToConnect'),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
source: true,
|
|
||||||
target: false,
|
|
||||||
enabled: !this.isReadOnly,
|
|
||||||
paintStyle: {
|
|
||||||
outlineStroke: 'none',
|
|
||||||
},
|
|
||||||
hoverPaintStyle: {
|
|
||||||
outlineStroke: 'none',
|
|
||||||
},
|
|
||||||
parameters: {
|
|
||||||
connection: 'source',
|
|
||||||
nodeId: this.nodeId,
|
|
||||||
type: outputName,
|
|
||||||
index: typeIndex,
|
|
||||||
category: outputConfiguration?.category,
|
|
||||||
},
|
|
||||||
cssClass: 'plus-draggable-endpoint',
|
|
||||||
dragAllowedWhenFull: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (outputConfiguration?.category) {
|
|
||||||
plusEndpointData.cssClass = `${plusEndpointData.cssClass} ${outputConfiguration?.category}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!this.instance || !this.data) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const plusEndpoint = this.instance.addEndpoint(
|
|
||||||
this.$refs[this.data.name] as Element,
|
|
||||||
plusEndpointData,
|
|
||||||
);
|
|
||||||
this.__addEndpointTestingData(plusEndpoint, 'plus', typeIndex);
|
|
||||||
|
|
||||||
if (!Array.isArray(plusEndpoint)) {
|
|
||||||
plusEndpoint.__meta = {
|
|
||||||
nodeName: node.name,
|
|
||||||
nodeId: this.nodeId,
|
|
||||||
index: typeIndex,
|
|
||||||
nodeType: node.type,
|
|
||||||
totalEndpoints: outputsOfSameRootType.length,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
|
||||||
__addNode(node: INodeUi) {
|
|
||||||
const nodeTypeData = (this.nodeTypesStore.getNodeType(node.type, node.typeVersion) ??
|
|
||||||
this.nodeTypesStore.getNodeType(NO_OP_NODE_TYPE)) as INodeTypeDescription;
|
|
||||||
|
|
||||||
this.__addInputEndpoints(node, nodeTypeData);
|
|
||||||
this.__addOutputEndpoints(node, nodeTypeData);
|
|
||||||
},
|
|
||||||
__getEndpointColor(connectionType: ConnectionTypes) {
|
|
||||||
return `--node-type-${connectionType}-color`;
|
|
||||||
},
|
|
||||||
__getInputConnectionStyle(
|
|
||||||
connectionType: ConnectionTypes,
|
|
||||||
nodeTypeData: INodeTypeDescription,
|
|
||||||
): EndpointOptions {
|
|
||||||
if (connectionType === NodeConnectionType.Main) {
|
|
||||||
return {
|
|
||||||
paintStyle: NodeViewUtils.getInputEndpointStyle(
|
|
||||||
nodeTypeData,
|
|
||||||
this.__getEndpointColor(NodeConnectionType.Main),
|
|
||||||
connectionType,
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isValidNodeConnectionType(connectionType)) {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
|
|
||||||
const createSupplementalConnectionType = (
|
|
||||||
connectionName: ConnectionTypes,
|
|
||||||
): EndpointOptions => ({
|
|
||||||
endpoint: createAddInputEndpointSpec(
|
|
||||||
connectionName as NodeConnectionType,
|
|
||||||
this.__getEndpointColor(connectionName),
|
|
||||||
),
|
|
||||||
});
|
|
||||||
|
|
||||||
return createSupplementalConnectionType(connectionType);
|
|
||||||
},
|
|
||||||
__getOutputConnectionStyle(
|
|
||||||
connectionType: ConnectionTypes,
|
|
||||||
outputConfiguration: INodeOutputConfiguration,
|
|
||||||
nodeTypeData: INodeTypeDescription,
|
|
||||||
): EndpointOptions {
|
|
||||||
const type = 'output';
|
|
||||||
|
|
||||||
const createSupplementalConnectionType = (
|
|
||||||
connectionName: ConnectionTypes,
|
|
||||||
): EndpointOptions => ({
|
|
||||||
endpoint: createDiamondOutputEndpointSpec(),
|
|
||||||
paintStyle: NodeViewUtils.getOutputEndpointStyle(
|
|
||||||
nodeTypeData,
|
|
||||||
this.__getEndpointColor(connectionName),
|
|
||||||
),
|
|
||||||
hoverPaintStyle: NodeViewUtils.getOutputEndpointStyle(
|
|
||||||
nodeTypeData,
|
|
||||||
this.__getEndpointColor(connectionName),
|
|
||||||
),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (connectionType === NodeConnectionType.Main) {
|
|
||||||
if (outputConfiguration.category === 'error') {
|
|
||||||
return {
|
|
||||||
paintStyle: {
|
|
||||||
...NodeViewUtils.getOutputEndpointStyle(
|
|
||||||
nodeTypeData,
|
|
||||||
this.__getEndpointColor(NodeConnectionType.Main),
|
|
||||||
),
|
|
||||||
fill: 'var(--color-danger)',
|
|
||||||
},
|
|
||||||
cssClass: `dot-${type}-endpoint`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
paintStyle: NodeViewUtils.getOutputEndpointStyle(
|
|
||||||
nodeTypeData,
|
|
||||||
this.__getEndpointColor(NodeConnectionType.Main),
|
|
||||||
),
|
|
||||||
cssClass: `dot-${type}-endpoint`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isValidNodeConnectionType(connectionType)) {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
|
|
||||||
return createSupplementalConnectionType(connectionType);
|
|
||||||
},
|
|
||||||
touchEnd(_e: MouseEvent) {
|
|
||||||
const deviceSupport = useDeviceSupport();
|
|
||||||
if (deviceSupport.isTouchDevice) {
|
|
||||||
if (this.uiStore.isActionActive('dragActive')) {
|
|
||||||
this.uiStore.removeActiveAction('dragActive');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
mouseLeftClick(e: MouseEvent) {
|
|
||||||
const deviceSupport = useDeviceSupport();
|
|
||||||
|
|
||||||
// @ts-ignore
|
|
||||||
const path = e.path || (e.composedPath && e.composedPath());
|
|
||||||
for (let index = 0; index < path.length; index++) {
|
|
||||||
if (
|
|
||||||
path[index].className &&
|
|
||||||
typeof path[index].className === 'string' &&
|
|
||||||
path[index].className.includes('no-select-on-click')
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!deviceSupport.isTouchDevice) {
|
|
||||||
if (this.uiStore.isActionActive('dragActive')) {
|
|
||||||
this.uiStore.removeActiveAction('dragActive');
|
|
||||||
} else {
|
|
||||||
if (!deviceSupport.isCtrlKeyPressed(e)) {
|
|
||||||
this.$emit('deselectAllNodes');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.uiStore.isNodeSelected(this.data?.name ?? '')) {
|
|
||||||
this.$emit('deselectNode', this.name);
|
|
||||||
} else {
|
|
||||||
this.$emit('nodeSelected', this.name);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
Loading…
Reference in a new issue