mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-17 18:12:04 +00:00
refactor(editor): Upgrade to jsPlumb 5 (#4989)
* WIP: Nodeview * Replace types * Finish N8nPlus endpoint type * Working on connector * Apply prettier * Fixed prettier issues * Debugging rendering * Fixed connectorrs position recalc * Fix snapping and output labels, WIP dragging * Fix N8nPlus endpoint rendering issues * Cleanup * Fix undo/redo and canvas add button position, cleanup * Cleanup * Revert accidental CLI changes * Fix pnpm-lock * Address bugs that came up during review * Reset CLI package from master * Various fixes * Fix run items label toggling * Linter fixes * Fix stalk size for larger run items label * Remove comment * Correctly reset workspace after renaming the node * Fix canvas e2e tests * Fix undo/redo tests * Fix stalk positioning and triggering of endpoint overlays * Repaint connections on pin removal * Limit repaintings * Unbind jsPlumb events on deactivation * Fix jsPlumb managment of Sticky and minor memort managment improvments * Address rest of PR points * Lint fix * Copy patches folder to docker * Fix e2e tests * set allowNonAppliedPatches to allow build * fix(editor): Handling router errors when navigation is canceled by user (#5271) * 🔨 Handling router errors in main sidebar, removing unused code * 🔨 Handling router errors in modals * ci(core): Fix docker nightly/custom image build (no-changelog) (#5284) * ci(core): Copy patches dir to Docker (no-changelog) * Update patch * Update package-lock * reapply the patch * skip patchedDependencies after the frontend is built --------- Co-authored-by: कारतोफ्फेलस्क्रिप्ट™ <aditya@netroy.in> * Fix connector hover state on success * Remove allowNonAppliedPatches from package.json --------- Co-authored-by: Milorad FIlipović <milorad@n8n.io> Co-authored-by: कारतोफ्फेलस्क्रिप्ट™ <aditya@netroy.in>
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div :class="$style['content']">
|
||||
<div
|
||||
class="node-view-root"
|
||||
class="node-view-root do-not-select"
|
||||
id="node-view-root"
|
||||
data-test-id="node-view-root"
|
||||
@dragover="onDragOver"
|
||||
@@ -37,10 +37,11 @@
|
||||
v-show="showCanvasAddButton"
|
||||
:showTooltip="!containsTrigger && showTriggerMissingTooltip"
|
||||
:position="canvasStore.canvasAddButtonPosition"
|
||||
ref="canvasAddButton"
|
||||
@hook:mounted="canvasStore.setRecenteredCanvasAddButtonPosition"
|
||||
data-test-id="canvas-add-button"
|
||||
/>
|
||||
<div v-for="nodeData in nodes" :key="nodeData.id">
|
||||
<template v-for="nodeData in nodes">
|
||||
<node
|
||||
v-if="nodeData.type !== STICKY_NODE_TYPE"
|
||||
@duplicateNode="duplicateNode"
|
||||
@@ -51,6 +52,7 @@
|
||||
@runWorkflow="onRunNode"
|
||||
@moved="onNodeMoved"
|
||||
@run="onNodeRun"
|
||||
:ref="`node-${nodeData.id}`"
|
||||
:key="`${nodeData.id}_node`"
|
||||
:name="nodeData.name"
|
||||
:isReadOnly="isReadOnly"
|
||||
@@ -74,6 +76,7 @@
|
||||
@nodeSelected="nodeSelectedByName"
|
||||
@removeNode="(name) => removeNode(name, true)"
|
||||
:key="`${nodeData.id}_sticky`"
|
||||
:ref="`node-${nodeData.id}`"
|
||||
:name="nodeData.name"
|
||||
:isReadOnly="isReadOnly"
|
||||
:instance="instance"
|
||||
@@ -82,7 +85,7 @@
|
||||
:gridSize="GRID_SIZE"
|
||||
:hideActions="pullConnActive"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<node-details-view
|
||||
@@ -157,15 +160,21 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import Vue, { ComponentInstance } from 'vue';
|
||||
import { mapStores } from 'pinia';
|
||||
import type {
|
||||
OnConnectionBindInfo,
|
||||
Connection,
|
||||
|
||||
import {
|
||||
Endpoint,
|
||||
N8nPlusEndpoint,
|
||||
jsPlumbInstance,
|
||||
} from 'jsplumb';
|
||||
Connection,
|
||||
EVENT_CONNECTION,
|
||||
ConnectionEstablishedParams,
|
||||
EVENT_CONNECTION_DETACHED,
|
||||
EVENT_CONNECTION_MOVED,
|
||||
INTERCEPT_BEFORE_DROP,
|
||||
BeforeDropParams,
|
||||
ConnectionDetachedParams,
|
||||
ConnectionMovedParams,
|
||||
} from '@jsplumb/core';
|
||||
import type { MessageBoxInputData } from 'element-ui/types/message-box';
|
||||
|
||||
import {
|
||||
@@ -206,7 +215,6 @@ import Node from '@/components/Node.vue';
|
||||
import NodeSettings from '@/components/NodeSettings.vue';
|
||||
import Sticky from '@/components/Sticky.vue';
|
||||
import CanvasAddButton from './CanvasAddButton.vue';
|
||||
|
||||
import mixins from 'vue-typed-mixins';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import {
|
||||
@@ -275,7 +283,20 @@ import {
|
||||
RemoveConnectionCommand,
|
||||
RemoveNodeCommand,
|
||||
RenameNodeCommand,
|
||||
historyBus,
|
||||
} from '@/models/history';
|
||||
import {
|
||||
EVENT_ENDPOINT_MOUSEOVER,
|
||||
EVENT_ENDPOINT_MOUSEOUT,
|
||||
EVENT_DRAG_MOVE,
|
||||
EVENT_CONNECTION_DRAG,
|
||||
EVENT_CONNECTION_ABORT,
|
||||
EVENT_CONNECTION_MOUSEOUT,
|
||||
EVENT_CONNECTION_MOUSEOVER,
|
||||
BrowserJsPlumbInstance,
|
||||
ready,
|
||||
} from '@jsplumb/browser-ui';
|
||||
import { N8nPlusEndpoint } from '@/plugins/endpoints/N8nPlusEndpointType';
|
||||
|
||||
interface AddNodeOptions {
|
||||
position?: XYPosition;
|
||||
@@ -313,7 +334,6 @@ export default mixins(
|
||||
},
|
||||
setup() {
|
||||
const { registerCustomAction, unregisterCustomAction } = useGlobalLinkActions();
|
||||
|
||||
return {
|
||||
registerCustomAction,
|
||||
unregisterCustomAction,
|
||||
@@ -563,7 +583,7 @@ export default mixins(
|
||||
nodeViewScale(): number {
|
||||
return this.canvasStore.nodeViewScale;
|
||||
},
|
||||
instance(): jsPlumbInstance {
|
||||
instance(): BrowserJsPlumbInstance {
|
||||
return this.canvasStore.jsPlumbInstance;
|
||||
},
|
||||
},
|
||||
@@ -587,7 +607,10 @@ export default mixins(
|
||||
isExecutionPreview: false,
|
||||
showTriggerMissingTooltip: false,
|
||||
workflowData: null as INewWorkflowData | null,
|
||||
activeConnection: null as null | Connection,
|
||||
isProductionExecutionPreview: false,
|
||||
enterTimer: undefined as undefined | ReturnType<typeof setTimeout>,
|
||||
exitTimer: undefined as undefined | ReturnType<typeof setTimeout>,
|
||||
// jsplumb automatically deletes all loose connections which is in turn recorded
|
||||
// in undo history as a user action.
|
||||
// This should prevent automatically removed connections from populating undo stack
|
||||
@@ -1488,7 +1511,7 @@ export default mixins(
|
||||
}
|
||||
}
|
||||
|
||||
return this.importWorkflowData(workflowData!, false, 'paste');
|
||||
return this.importWorkflowData(workflowData!, 'paste', false);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1516,9 +1539,8 @@ export default mixins(
|
||||
// Imports the given workflow data into the current workflow
|
||||
async importWorkflowData(
|
||||
workflowData: IWorkflowToShare,
|
||||
// eslint-disable-next-line @typescript-eslint/default-param-last
|
||||
importTags = true,
|
||||
source: string,
|
||||
importTags = true,
|
||||
): Promise<void> {
|
||||
// eslint-disable-line @typescript-eslint/default-param-last
|
||||
// If it is JSON check if it looks on the first look like data we can use
|
||||
@@ -2032,14 +2054,14 @@ export default mixins(
|
||||
this.historyStore.stopRecordingUndo();
|
||||
},
|
||||
initNodeView() {
|
||||
this.instance.importDefaults({
|
||||
Connector: NodeViewUtils.CONNECTOR_FLOWCHART_TYPE,
|
||||
Endpoint: ['Dot', { radius: 5 }],
|
||||
DragOptions: { cursor: 'pointer', zIndex: 5000 },
|
||||
PaintStyle: NodeViewUtils.CONNECTOR_PAINT_STYLE_DEFAULT,
|
||||
HoverPaintStyle: NodeViewUtils.CONNECTOR_PAINT_STYLE_PRIMARY,
|
||||
ConnectionOverlays: NodeViewUtils.CONNECTOR_ARROW_OVERLAYS,
|
||||
Container: '#node-view',
|
||||
this.instance?.importDefaults({
|
||||
endpoint: {
|
||||
type: 'Dot',
|
||||
options: { radius: 5 },
|
||||
},
|
||||
paintStyle: NodeViewUtils.CONNECTOR_PAINT_STYLE_DEFAULT,
|
||||
hoverPaintStyle: NodeViewUtils.CONNECTOR_PAINT_STYLE_PRIMARY,
|
||||
connectionOverlays: NodeViewUtils.CONNECTOR_ARROW_OVERLAYS,
|
||||
});
|
||||
|
||||
const insertNodeAfterSelected = (info: {
|
||||
@@ -2067,7 +2089,7 @@ export default mixins(
|
||||
this.onToggleNodeCreator({ source: info.eventSource, createNodeActive: true });
|
||||
};
|
||||
|
||||
this.instance.bind('connectionAborted', (connection) => {
|
||||
this.instance.bind(EVENT_CONNECTION_ABORT, (connection: Connection) => {
|
||||
try {
|
||||
if (this.dropPrevented) {
|
||||
this.dropPrevented = false;
|
||||
@@ -2075,10 +2097,10 @@ export default mixins(
|
||||
}
|
||||
|
||||
if (this.pullConnActiveNodeName) {
|
||||
const sourceNode = this.workflowsStore.getNodeById(connection.sourceId);
|
||||
const sourceNode = this.workflowsStore.getNodeById(connection.parameters.nodeId);
|
||||
if (sourceNode) {
|
||||
const sourceNodeName = sourceNode.name;
|
||||
const outputIndex = connection.getParameters().index;
|
||||
const outputIndex = connection.parameters.index;
|
||||
|
||||
this.connectTwoNodes(
|
||||
sourceNodeName,
|
||||
@@ -2093,8 +2115,8 @@ export default mixins(
|
||||
}
|
||||
|
||||
insertNodeAfterSelected({
|
||||
sourceId: connection.sourceId,
|
||||
index: connection.getParameters().index,
|
||||
sourceId: connection.parameters.nodeId,
|
||||
index: connection.parameters.index,
|
||||
eventSource: 'node_connection_drop',
|
||||
});
|
||||
} catch (e) {
|
||||
@@ -2102,11 +2124,10 @@ export default mixins(
|
||||
}
|
||||
});
|
||||
|
||||
this.instance.bind('beforeDrop', (info) => {
|
||||
this.instance.bind(INTERCEPT_BEFORE_DROP, (info: BeforeDropParams) => {
|
||||
try {
|
||||
const sourceInfo = info.connection.endpoints[0].getParameters();
|
||||
// @ts-ignore
|
||||
const targetInfo = info.dropEndpoint.getParameters();
|
||||
const sourceInfo = info.connection.endpoints[0].parameters;
|
||||
const targetInfo = info.dropEndpoint.parameters;
|
||||
|
||||
const sourceNodeName = this.workflowsStore.getNodeById(sourceInfo.nodeId)?.name || '';
|
||||
const targetNodeName = this.workflowsStore.getNodeById(targetInfo.nodeId)?.name || '';
|
||||
@@ -2127,13 +2148,10 @@ export default mixins(
|
||||
}
|
||||
});
|
||||
|
||||
// only one set of visible actions should be visible at the same time
|
||||
let activeConnection: null | Connection = null;
|
||||
|
||||
this.instance.bind('connection', (info: OnConnectionBindInfo) => {
|
||||
this.instance.bind(EVENT_CONNECTION, (info: ConnectionEstablishedParams) => {
|
||||
try {
|
||||
const sourceInfo = info.sourceEndpoint.getParameters();
|
||||
const targetInfo = info.targetEndpoint.getParameters();
|
||||
const sourceInfo = info.sourceEndpoint.parameters;
|
||||
const targetInfo = info.targetEndpoint.parameters;
|
||||
|
||||
const sourceNodeName = this.workflowsStore.getNodeById(sourceInfo.nodeId)?.name;
|
||||
const targetNodeName = this.workflowsStore.getNodeById(targetInfo.nodeId)?.name;
|
||||
@@ -2148,86 +2166,6 @@ export default mixins(
|
||||
}
|
||||
|
||||
NodeViewUtils.resetConnection(info.connection);
|
||||
|
||||
if (!this.isReadOnly) {
|
||||
let exitTimer: NodeJS.Timeout | undefined;
|
||||
let enterTimer: NodeJS.Timeout | undefined;
|
||||
info.connection.bind('mouseover', (connection: Connection) => {
|
||||
try {
|
||||
if (exitTimer !== undefined) {
|
||||
clearTimeout(exitTimer);
|
||||
exitTimer = undefined;
|
||||
}
|
||||
|
||||
if (enterTimer) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!info.connection || info.connection === activeConnection) {
|
||||
return;
|
||||
}
|
||||
|
||||
NodeViewUtils.hideConnectionActions(activeConnection);
|
||||
|
||||
enterTimer = setTimeout(() => {
|
||||
enterTimer = undefined;
|
||||
if (info.connection) {
|
||||
activeConnection = info.connection;
|
||||
NodeViewUtils.showConnectionActions(info.connection);
|
||||
}
|
||||
}, 150);
|
||||
} catch (e) {
|
||||
console.error(e); // eslint-disable-line no-console
|
||||
}
|
||||
});
|
||||
|
||||
info.connection.bind('mouseout', (connection: Connection) => {
|
||||
try {
|
||||
if (exitTimer) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (enterTimer) {
|
||||
clearTimeout(enterTimer);
|
||||
enterTimer = undefined;
|
||||
}
|
||||
|
||||
if (!info.connection || activeConnection !== info.connection) {
|
||||
return;
|
||||
}
|
||||
|
||||
exitTimer = setTimeout(() => {
|
||||
exitTimer = undefined;
|
||||
|
||||
if (info.connection && activeConnection === info.connection) {
|
||||
NodeViewUtils.hideConnectionActions(activeConnection);
|
||||
activeConnection = null;
|
||||
}
|
||||
}, 500);
|
||||
} catch (e) {
|
||||
console.error(e); // eslint-disable-line no-console
|
||||
}
|
||||
});
|
||||
|
||||
NodeViewUtils.addConnectionActionsOverlay(
|
||||
info.connection,
|
||||
() => {
|
||||
activeConnection = null;
|
||||
this.__deleteJSPlumbConnection(info.connection);
|
||||
},
|
||||
() => {
|
||||
setTimeout(() => {
|
||||
insertNodeAfterSelected({
|
||||
sourceId: info.sourceId,
|
||||
index: sourceInfo.index,
|
||||
connection: info.connection,
|
||||
eventSource: 'node_connection_action',
|
||||
});
|
||||
}, 150);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
NodeViewUtils.moveBackInputLabelPosition(info.targetEndpoint);
|
||||
|
||||
const connectionData: [IConnection, IConnection] = [
|
||||
@@ -2247,26 +2185,105 @@ export default mixins(
|
||||
connection: connectionData,
|
||||
setStateDirty: true,
|
||||
});
|
||||
this.dropPrevented = true;
|
||||
if (!this.suspendRecordingDetachedConnections) {
|
||||
this.historyStore.pushCommandToUndo(new AddConnectionCommand(connectionData, this));
|
||||
this.historyStore.pushCommandToUndo(new AddConnectionCommand(connectionData));
|
||||
}
|
||||
if (!this.isReadOnly) {
|
||||
NodeViewUtils.addConnectionActionsOverlay(
|
||||
info.connection,
|
||||
() => {
|
||||
this.activeConnection = null;
|
||||
this.__deleteJSPlumbConnection(info.connection);
|
||||
},
|
||||
() => {
|
||||
insertNodeAfterSelected({
|
||||
sourceId: info.sourceEndpoint.parameters.nodeId,
|
||||
index: sourceInfo.index,
|
||||
connection: info.connection,
|
||||
eventSource: 'node_connection_action',
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e); // eslint-disable-line no-console
|
||||
}
|
||||
});
|
||||
|
||||
this.instance.bind('connectionMoved', (info) => {
|
||||
this.instance.bind(EVENT_DRAG_MOVE, () => {
|
||||
this.instance?.connections.forEach((connection) => {
|
||||
NodeViewUtils.showOrHideItemsLabel(connection);
|
||||
NodeViewUtils.showOrHideMidpointArrow(connection);
|
||||
|
||||
Object.values(connection.overlays).forEach((overlay) => {
|
||||
if (!overlay.canvas) return;
|
||||
this.instance?.repaint(overlay.canvas);
|
||||
});
|
||||
});
|
||||
});
|
||||
this.instance.bind(EVENT_CONNECTION_MOUSEOVER, (connection: Connection) => {
|
||||
try {
|
||||
if (this.exitTimer !== undefined) {
|
||||
clearTimeout(this.exitTimer);
|
||||
this.exitTimer = undefined;
|
||||
}
|
||||
|
||||
if (
|
||||
this.isReadOnly ||
|
||||
this.enterTimer ||
|
||||
!connection ||
|
||||
connection === this.activeConnection
|
||||
)
|
||||
return;
|
||||
|
||||
if (this.activeConnection) NodeViewUtils.hideConnectionActions(this.activeConnection);
|
||||
|
||||
this.enterTimer = setTimeout(() => {
|
||||
this.enterTimer = undefined;
|
||||
if (connection) {
|
||||
NodeViewUtils.showConnectionActions(connection);
|
||||
this.activeConnection = connection;
|
||||
}
|
||||
}, 150);
|
||||
} catch (e) {
|
||||
console.error(e); // eslint-disable-line no-console
|
||||
}
|
||||
});
|
||||
this.instance.bind(EVENT_CONNECTION_MOUSEOUT, (connection: Connection) => {
|
||||
try {
|
||||
if (this.exitTimer) return;
|
||||
|
||||
if (this.enterTimer) {
|
||||
clearTimeout(this.enterTimer);
|
||||
this.enterTimer = undefined;
|
||||
}
|
||||
|
||||
if (this.isReadOnly || !connection || this.activeConnection?.id !== connection.id) return;
|
||||
|
||||
this.exitTimer = setTimeout(() => {
|
||||
this.exitTimer = undefined;
|
||||
|
||||
if (connection && this.activeConnection === connection) {
|
||||
NodeViewUtils.hideConnectionActions(this.activeConnection);
|
||||
this.activeConnection = null;
|
||||
}
|
||||
}, 500);
|
||||
} catch (e) {
|
||||
console.error(e); // eslint-disable-line no-console
|
||||
}
|
||||
});
|
||||
|
||||
this.instance.bind(EVENT_CONNECTION_MOVED, (info: ConnectionMovedParams) => {
|
||||
try {
|
||||
// When a connection gets moved from one node to another it for some reason
|
||||
// calls the "connection" event but not the "connectionDetached" one. So we listen
|
||||
// additionally to the "connectionMoved" event and then only delete the existing connection.
|
||||
|
||||
NodeViewUtils.resetInputLabelPosition(info.originalTargetEndpoint);
|
||||
NodeViewUtils.resetInputLabelPosition(info.connection);
|
||||
|
||||
// @ts-ignore
|
||||
const sourceInfo = info.originalSourceEndpoint.getParameters();
|
||||
// @ts-ignore
|
||||
const targetInfo = info.originalTargetEndpoint.getParameters();
|
||||
const sourceInfo = info.connection.parameters;
|
||||
const targetInfo = info.originalEndpoint.parameters;
|
||||
|
||||
const connectionInfo = [
|
||||
{
|
||||
@@ -2286,8 +2303,18 @@ export default mixins(
|
||||
console.error(e); // eslint-disable-line no-console
|
||||
}
|
||||
});
|
||||
|
||||
this.instance.bind('connectionDetached', async (info) => {
|
||||
this.instance.bind(EVENT_ENDPOINT_MOUSEOVER, (endpoint: Endpoint, mouse) => {
|
||||
// This event seems bugged. It gets called constantly even when the mouse is not over the endpoint
|
||||
// if the endpoint has a connection attached to it. So we need to check if the mouse is actually over
|
||||
// the endpoint.
|
||||
if (!endpoint.isTarget || mouse.target !== endpoint.endpoint.canvas) return;
|
||||
this.instance.setHover(endpoint, true);
|
||||
});
|
||||
this.instance.bind(EVENT_ENDPOINT_MOUSEOUT, (endpoint: Endpoint) => {
|
||||
if (!endpoint.isTarget) return;
|
||||
this.instance.setHover(endpoint, false);
|
||||
});
|
||||
this.instance.bind(EVENT_CONNECTION_DETACHED, async (info: ConnectionDetachedParams) => {
|
||||
try {
|
||||
const connectionInfo: [IConnection, IConnection] | null = getConnectionInfo(info);
|
||||
NodeViewUtils.resetInputLabelPosition(info.targetEndpoint);
|
||||
@@ -2297,14 +2324,12 @@ export default mixins(
|
||||
if (this.pullConnActiveNodeName) {
|
||||
// establish new connection when dragging connection from one node to another
|
||||
this.historyStore.startRecordingUndo();
|
||||
const sourceNode = this.workflowsStore.getNodeById(info.connection.sourceId);
|
||||
const sourceNode = this.workflowsStore.getNodeById(info.connection.parameters.nodeId);
|
||||
const sourceNodeName = sourceNode.name;
|
||||
const outputIndex = info.connection.getParameters().index;
|
||||
const outputIndex = info.connection.parameters.index;
|
||||
|
||||
if (connectionInfo) {
|
||||
this.historyStore.pushCommandToUndo(
|
||||
new RemoveConnectionCommand(connectionInfo, this),
|
||||
);
|
||||
this.historyStore.pushCommandToUndo(new RemoveConnectionCommand(connectionInfo));
|
||||
}
|
||||
this.connectTwoNodes(sourceNodeName, outputIndex, this.pullConnActiveNodeName, 0, true);
|
||||
this.pullConnActiveNodeName = null;
|
||||
@@ -2324,26 +2349,27 @@ export default mixins(
|
||||
console.error(e); // eslint-disable-line no-console
|
||||
}
|
||||
});
|
||||
|
||||
// @ts-ignore
|
||||
this.instance.bind('connectionDrag', (connection: Connection) => {
|
||||
this.instance.bind(EVENT_CONNECTION_DRAG, (connection: Connection) => {
|
||||
// The overlays are visible by default so we need to hide the midpoint arrow
|
||||
// manually
|
||||
connection.overlays['midpoint-arrow']?.setVisible(false);
|
||||
try {
|
||||
this.pullConnActiveNodeName = null;
|
||||
this.pullConnActive = true;
|
||||
this.newNodeInsertPosition = null;
|
||||
NodeViewUtils.resetConnection(connection);
|
||||
|
||||
const nodes = [...document.querySelectorAll('.node-default')];
|
||||
const nodes = [...document.querySelectorAll('.node-wrapper')];
|
||||
|
||||
const onMouseMove = (e: MouseEvent | TouchEvent) => {
|
||||
if (!connection) {
|
||||
return;
|
||||
}
|
||||
|
||||
const element = document.querySelector('.jtk-endpoint.dropHover');
|
||||
const element = document.querySelector('.jtk-endpoint.jtk-drag-hover');
|
||||
if (element) {
|
||||
// @ts-ignore
|
||||
NodeViewUtils.showDropConnectionState(connection, element._jsPlumb);
|
||||
const endpoint = element.jtk.endpoint;
|
||||
NodeViewUtils.showDropConnectionState(connection, endpoint);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2360,7 +2386,7 @@ export default mixins(
|
||||
this.pullConnActiveNodeName = node.name;
|
||||
const endpointUUID = this.getInputEndpointUUID(nodeName, 0);
|
||||
if (endpointUUID) {
|
||||
const endpoint = this.instance.getEndpoint(endpointUUID);
|
||||
const endpoint = this.instance?.getEndpoint(endpointUUID);
|
||||
|
||||
NodeViewUtils.showDropConnectionState(connection, endpoint);
|
||||
|
||||
@@ -2395,8 +2421,17 @@ export default mixins(
|
||||
console.error(e); // eslint-disable-line no-console
|
||||
}
|
||||
});
|
||||
|
||||
// @ts-ignore
|
||||
this.instance.bind(
|
||||
[EVENT_CONNECTION_DRAG, EVENT_CONNECTION_ABORT, EVENT_CONNECTION_DETACHED],
|
||||
(connection: Connection) => {
|
||||
Object.values(this.instance?.endpointsByElement)
|
||||
.flatMap((endpoints) => Object.values(endpoints))
|
||||
.filter((endpoint) => endpoint.endpoint.type === 'N8nPlus')
|
||||
.forEach((endpoint) =>
|
||||
setTimeout(() => endpoint.instance.revalidate(endpoint.element), 0),
|
||||
);
|
||||
},
|
||||
);
|
||||
this.instance.bind('plusEndpointClick', (endpoint: Endpoint) => {
|
||||
if (endpoint && endpoint.__meta) {
|
||||
insertNodeAfterSelected({
|
||||
@@ -2555,10 +2590,8 @@ export default mixins(
|
||||
}
|
||||
|
||||
const uuid: [string, string] = [outputUuid, inputUuid];
|
||||
|
||||
// Create connections in DOM
|
||||
// @ts-ignore
|
||||
this.instance.connect({
|
||||
this.instance?.connect({
|
||||
uuids: uuid,
|
||||
detachable: !this.isReadOnly,
|
||||
});
|
||||
@@ -2581,15 +2614,12 @@ export default mixins(
|
||||
if (!sourceNode || !targetNode) {
|
||||
return;
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
const connections = this.instance.getConnections({
|
||||
const connections = this.instance?.getConnections({
|
||||
source: sourceNode.id,
|
||||
target: targetNode.id,
|
||||
});
|
||||
|
||||
// @ts-ignore
|
||||
connections.forEach((connectionInstance) => {
|
||||
connections.forEach((connectionInstance: Connection) => {
|
||||
if (connectionInstance.__meta) {
|
||||
// Only delete connections from specific indexes (if it can be determined by meta)
|
||||
if (
|
||||
@@ -2611,13 +2641,8 @@ export default mixins(
|
||||
// it visibly stays behind free floating without a connection.
|
||||
connection.removeOverlays();
|
||||
|
||||
const sourceEndpoint = connection.endpoints && connection.endpoints[0];
|
||||
this.pullConnActiveNodeName = null; // prevent new connections when connectionDetached is triggered
|
||||
this.instance.deleteConnection(connection); // on delete, triggers connectionDetached event which applies mutation to store
|
||||
if (sourceEndpoint) {
|
||||
const endpoints = this.instance.getEndpoints(sourceEndpoint.elementId);
|
||||
endpoints.forEach((endpoint: Endpoint) => endpoint.repaint()); // repaint both circle and plus endpoint
|
||||
}
|
||||
this.instance?.deleteConnection(connection); // on delete, triggers connectionDetached event which applies mutation to store
|
||||
if (trackHistory && connection.__meta) {
|
||||
const connectionData: [IConnection, IConnection] = [
|
||||
{
|
||||
@@ -2635,18 +2660,14 @@ export default mixins(
|
||||
this.historyStore.pushCommandToUndo(removeCommand);
|
||||
}
|
||||
},
|
||||
__removeConnectionByConnectionInfo(
|
||||
info: OnConnectionBindInfo,
|
||||
removeVisualConnection = false,
|
||||
trackHistory = false,
|
||||
) {
|
||||
__removeConnectionByConnectionInfo(info, removeVisualConnection = false, trackHistory = false) {
|
||||
const connectionInfo: [IConnection, IConnection] | null = getConnectionInfo(info);
|
||||
|
||||
if (connectionInfo) {
|
||||
if (removeVisualConnection) {
|
||||
this.__deleteJSPlumbConnection(info.connection, trackHistory);
|
||||
} else if (trackHistory) {
|
||||
this.historyStore.pushCommandToUndo(new RemoveConnectionCommand(connectionInfo, this));
|
||||
this.historyStore.pushCommandToUndo(new RemoveConnectionCommand(connectionInfo));
|
||||
}
|
||||
this.workflowsStore.removeConnection({ connection: connectionInfo });
|
||||
}
|
||||
@@ -2751,7 +2772,7 @@ export default mixins(
|
||||
const targetEndpoint = NodeViewUtils.getInputEndpointUUID(targetId, targetInputIndex);
|
||||
|
||||
// @ts-ignore
|
||||
const connections = this.instance.getConnections({
|
||||
const connections = this.instance?.getConnections({
|
||||
source: sourceId,
|
||||
target: targetId,
|
||||
}) as Connection[];
|
||||
@@ -2763,14 +2784,19 @@ export default mixins(
|
||||
},
|
||||
getJSPlumbEndpoints(nodeName: string): Endpoint[] {
|
||||
const node = this.workflowsStore.getNodeByName(nodeName);
|
||||
return this.instance.getEndpoints(node !== null ? node.id : '');
|
||||
const nodeEls: Element = (this.$refs[`node-${node?.id}`] as ComponentInstance[])[0]
|
||||
.$el as Element;
|
||||
|
||||
const endpoints = this.instance?.getEndpoints(nodeEls);
|
||||
|
||||
return endpoints as Endpoint[];
|
||||
},
|
||||
getPlusEndpoint(nodeName: string, outputIndex: number): Endpoint | undefined {
|
||||
const endpoints = this.getJSPlumbEndpoints(nodeName);
|
||||
// @ts-ignore
|
||||
return endpoints.find(
|
||||
(endpoint: Endpoint) =>
|
||||
endpoint.type === 'N8nPlus' && endpoint.__meta && endpoint.__meta.index === outputIndex,
|
||||
// @ts-ignore
|
||||
endpoint.endpoint.type === 'N8nPlus' && endpoint?.__meta?.index === outputIndex,
|
||||
);
|
||||
},
|
||||
getIncomingOutgoingConnections(nodeName: string): {
|
||||
@@ -2781,12 +2807,12 @@ export default mixins(
|
||||
|
||||
if (node) {
|
||||
// @ts-ignore
|
||||
const outgoing = this.instance.getConnections({
|
||||
const outgoing = this.instance?.getConnections({
|
||||
source: node.id,
|
||||
});
|
||||
|
||||
// @ts-ignore
|
||||
const incoming = this.instance.getConnections({
|
||||
const incoming = this.instance?.getConnections({
|
||||
target: node.id,
|
||||
}) as Connection[];
|
||||
|
||||
@@ -2823,8 +2849,7 @@ export default mixins(
|
||||
const sourceId = sourceNode !== null ? sourceNode.id : '';
|
||||
|
||||
if (data === null || data.length === 0 || waiting) {
|
||||
// @ts-ignore
|
||||
const outgoing = this.instance.getConnections({
|
||||
const outgoing = this.instance?.getConnections({
|
||||
source: sourceId,
|
||||
}) as Connection[];
|
||||
|
||||
@@ -2833,8 +2858,7 @@ export default mixins(
|
||||
});
|
||||
const endpoints = this.getJSPlumbEndpoints(sourceNodeName);
|
||||
endpoints.forEach((endpoint: Endpoint) => {
|
||||
// @ts-ignore
|
||||
if (endpoint.type === 'N8nPlus') {
|
||||
if (endpoint.endpoint.type === 'N8nPlus') {
|
||||
(endpoint.endpoint as N8nPlusEndpoint).clearSuccessOutput();
|
||||
}
|
||||
});
|
||||
@@ -2875,6 +2899,7 @@ export default mixins(
|
||||
);
|
||||
if (endpoint && endpoint.endpoint) {
|
||||
const output = outputMap[sourceOutputIndex][NODE_OUTPUT_DEFAULT_KEY][0];
|
||||
|
||||
if (output && output.total > 0) {
|
||||
(endpoint.endpoint as N8nPlusEndpoint).setSuccessOutput(
|
||||
NodeViewUtils.getRunItemsLabel(output),
|
||||
@@ -2963,7 +2988,7 @@ export default mixins(
|
||||
);
|
||||
|
||||
if (waitForNewConnection) {
|
||||
this.instance.setSuspendDrawing(false, true);
|
||||
this.instance?.setSuspendDrawing(false, true);
|
||||
waitForNewConnection = false;
|
||||
}
|
||||
}, 100); // just to make it clear to users that this is a new connection
|
||||
@@ -2973,14 +2998,10 @@ export default mixins(
|
||||
|
||||
setTimeout(() => {
|
||||
// Suspend drawing
|
||||
this.instance.setSuspendDrawing(true);
|
||||
|
||||
// Remove all endpoints and the connections in jsplumb
|
||||
this.instance.removeAllEndpoints(node.id);
|
||||
|
||||
// Remove the draggable
|
||||
// @ts-ignore
|
||||
this.instance.destroyDraggable(node.id);
|
||||
this.instance?.setSuspendDrawing(true);
|
||||
(this.instance?.endpointsByElement[node.id] || [])
|
||||
.flat()
|
||||
.forEach((endpoint) => this.instance?.deleteEndpoint(endpoint));
|
||||
|
||||
// Remove the connections in data
|
||||
this.workflowsStore.removeAllNodeConnection(node);
|
||||
@@ -2989,13 +3010,13 @@ export default mixins(
|
||||
|
||||
if (!waitForNewConnection) {
|
||||
// Now it can draw again
|
||||
this.instance.setSuspendDrawing(false, true);
|
||||
this.instance?.setSuspendDrawing(false, true);
|
||||
}
|
||||
|
||||
// Remove node from selected index if found in it
|
||||
this.uiStore.removeNodeFromSelection(node);
|
||||
if (trackHistory) {
|
||||
this.historyStore.pushCommandToUndo(new RemoveNodeCommand(node, this));
|
||||
this.historyStore.pushCommandToUndo(new RemoveNodeCommand(node));
|
||||
}
|
||||
}, 0); // allow other events to finish like drag stop
|
||||
if (trackHistory && trackBulk) {
|
||||
@@ -3069,7 +3090,7 @@ export default mixins(
|
||||
workflow.renameNode(currentName, newName);
|
||||
|
||||
if (trackHistory) {
|
||||
this.historyStore.pushCommandToUndo(new RenameNodeCommand(currentName, newName, this));
|
||||
this.historyStore.pushCommandToUndo(new RenameNodeCommand(currentName, newName));
|
||||
}
|
||||
|
||||
// Update also last selected node and execution data
|
||||
@@ -3104,18 +3125,12 @@ export default mixins(
|
||||
deleteEveryEndpoint() {
|
||||
// Check as it does not exist on first load
|
||||
if (this.instance) {
|
||||
const nodes = this.workflowsStore.allNodes;
|
||||
nodes.forEach((node: INodeUi) => {
|
||||
try {
|
||||
// important to prevent memory leak
|
||||
// @ts-ignore
|
||||
this.instance.destroyDraggable(node.id);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
});
|
||||
this.instance?.reset();
|
||||
Object.values(this.instance?.endpointsByElement)
|
||||
.flatMap((endpoint) => endpoint)
|
||||
.forEach((endpoint) => endpoint.destroy());
|
||||
|
||||
this.instance.deleteEveryEndpoint();
|
||||
this.instance.deleteEveryConnection({ fireEvent: true });
|
||||
}
|
||||
},
|
||||
matchCredentials(node: INodeUi) {
|
||||
@@ -3241,7 +3256,7 @@ export default mixins(
|
||||
|
||||
this.workflowsStore.addNode(node);
|
||||
if (trackHistory) {
|
||||
this.historyStore.pushCommandToUndo(new AddNodeCommand(node, this));
|
||||
this.historyStore.pushCommandToUndo(new AddNodeCommand(node));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -3249,7 +3264,7 @@ export default mixins(
|
||||
await Vue.nextTick();
|
||||
|
||||
// Suspend drawing
|
||||
this.instance.setSuspendDrawing(true);
|
||||
this.instance?.setSuspendDrawing(true);
|
||||
|
||||
// Load the connections
|
||||
if (connections !== undefined) {
|
||||
@@ -3285,9 +3300,8 @@ export default mixins(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Now it can draw again
|
||||
this.instance.setSuspendDrawing(false, true);
|
||||
this.instance?.setSuspendDrawing(false, true);
|
||||
},
|
||||
async addNodesToWorkflow(data: IWorkflowDataUpdate): Promise<IWorkflowDataUpdate> {
|
||||
// Because nodes with the same name maybe already exist, it could
|
||||
@@ -3412,6 +3426,7 @@ export default mixins(
|
||||
tempWorkflow.connectionsBySourceNode,
|
||||
true,
|
||||
);
|
||||
|
||||
this.historyStore.stopRecordingUndo();
|
||||
|
||||
this.uiStore.stateIsDirty = true;
|
||||
@@ -3458,7 +3473,6 @@ export default mixins(
|
||||
}
|
||||
|
||||
// Keep only the connection to node which get also exported
|
||||
// @ts-ignore
|
||||
typeConnections = {};
|
||||
for (type of Object.keys(connections)) {
|
||||
for (sourceIndex = 0; sourceIndex < connections[type].length; sourceIndex++) {
|
||||
@@ -3508,14 +3522,12 @@ export default mixins(
|
||||
// Ignore all errors
|
||||
});
|
||||
}
|
||||
|
||||
this.workflowsStore.removeAllConnections({ setStateDirty: false });
|
||||
this.workflowsStore.removeAllNodes({ setStateDirty: false, removePinData: true });
|
||||
|
||||
// Reset workflow execution data
|
||||
this.workflowsStore.setWorkflowExecutionData(null);
|
||||
this.workflowsStore.resetAllNodesIssues();
|
||||
// vm.$forceUpdate();
|
||||
|
||||
this.workflowsStore.setActive(false);
|
||||
this.workflowsStore.setWorkflowId(PLACEHOLDER_EMPTY_WORKFLOW_ID);
|
||||
@@ -3622,12 +3634,12 @@ export default mixins(
|
||||
} catch (e) {}
|
||||
},
|
||||
async onImportWorkflowDataEvent(data: IDataObject) {
|
||||
await this.importWorkflowData(data.data as IWorkflowDataUpdate, undefined, 'file');
|
||||
await this.importWorkflowData(data.data as IWorkflowDataUpdate, 'file');
|
||||
},
|
||||
async onImportWorkflowUrlEvent(data: IDataObject) {
|
||||
const workflowData = await this.getWorkflowDataFromUrl(data.url as string);
|
||||
if (workflowData !== undefined) {
|
||||
await this.importWorkflowData(workflowData, undefined, 'url');
|
||||
await this.importWorkflowData(workflowData, 'url');
|
||||
}
|
||||
},
|
||||
addPinDataConnections(pinData: IPinData) {
|
||||
@@ -3638,7 +3650,7 @@ export default mixins(
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
const connections = this.instance.getConnections({
|
||||
const connections = this.instance?.getConnections({
|
||||
source: node.id,
|
||||
}) as Connection[];
|
||||
|
||||
@@ -3658,11 +3670,13 @@ export default mixins(
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
const connections = this.instance.getConnections({
|
||||
const connections = this.instance?.getConnections({
|
||||
source: node.id,
|
||||
}) as Connection[];
|
||||
|
||||
this.instance.setSuspendDrawing(true);
|
||||
connections.forEach(NodeViewUtils.resetConnection);
|
||||
this.instance.setSuspendDrawing(false, true);
|
||||
});
|
||||
},
|
||||
onToggleNodeCreator({
|
||||
@@ -3738,10 +3752,10 @@ export default mixins(
|
||||
},
|
||||
onMoveNode({ nodeName, position }: { nodeName: string; position: XYPosition }): void {
|
||||
this.workflowsStore.updateNodeProperties({ name: nodeName, properties: { position } });
|
||||
const node = this.workflowsStore.getNodeByName(nodeName);
|
||||
setTimeout(() => {
|
||||
const node = this.workflowsStore.getNodeByName(nodeName);
|
||||
if (node) {
|
||||
this.instance.repaintEverything();
|
||||
this.instance?.repaintEverything();
|
||||
this.onNodeMoved(node);
|
||||
}
|
||||
}, 0);
|
||||
@@ -3780,12 +3794,12 @@ export default mixins(
|
||||
},
|
||||
},
|
||||
async mounted() {
|
||||
this.resetWorkspace();
|
||||
this.canvasStore.initInstance(this.$refs.nodeView as HTMLElement);
|
||||
this.$titleReset();
|
||||
window.addEventListener('message', this.onPostMessageReceived);
|
||||
|
||||
this.startLoading();
|
||||
this.resetWorkspace();
|
||||
|
||||
const loadPromises = [
|
||||
this.loadActiveWorkflows(),
|
||||
this.loadCredentials(),
|
||||
@@ -3806,8 +3820,7 @@ export default mixins(
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
this.instance.ready(async () => {
|
||||
ready(async () => {
|
||||
try {
|
||||
try {
|
||||
this.initNodeView();
|
||||
@@ -3879,6 +3892,7 @@ export default mixins(
|
||||
}
|
||||
this.uiStore.addFirstStepOnLoad = false;
|
||||
|
||||
this.initNodeView();
|
||||
document.addEventListener('keydown', this.keyDown);
|
||||
document.addEventListener('keyup', this.keyUp);
|
||||
window.addEventListener('message', this.onPostMessageReceived);
|
||||
@@ -3886,13 +3900,13 @@ export default mixins(
|
||||
this.$root.$on('newWorkflow', this.newWorkflow);
|
||||
this.$root.$on('importWorkflowData', this.onImportWorkflowDataEvent);
|
||||
this.$root.$on('importWorkflowUrl', this.onImportWorkflowUrlEvent);
|
||||
this.$root.$on('nodeMove', this.onMoveNode);
|
||||
this.$root.$on('revertAddNode', this.onRevertAddNode);
|
||||
this.$root.$on('revertRemoveNode', this.onRevertRemoveNode);
|
||||
this.$root.$on('revertAddConnection', this.onRevertAddConnection);
|
||||
this.$root.$on('revertRemoveConnection', this.onRevertRemoveConnection);
|
||||
this.$root.$on('revertRenameNode', this.onRevertNameChange);
|
||||
this.$root.$on('enableNodeToggle', this.onRevertEnableToggle);
|
||||
historyBus.$on('nodeMove', this.onMoveNode);
|
||||
historyBus.$on('revertAddNode', this.onRevertAddNode);
|
||||
historyBus.$on('revertRemoveNode', this.onRevertRemoveNode);
|
||||
historyBus.$on('revertAddConnection', this.onRevertAddConnection);
|
||||
historyBus.$on('revertRemoveConnection', this.onRevertRemoveConnection);
|
||||
historyBus.$on('revertRenameNode', this.onRevertNameChange);
|
||||
historyBus.$on('enableNodeToggle', this.onRevertEnableToggle);
|
||||
|
||||
dataPinningEventBus.$on('pin-data', this.addPinDataConnections);
|
||||
dataPinningEventBus.$on('unpin-data', this.removePinDataConnections);
|
||||
@@ -3908,20 +3922,23 @@ export default mixins(
|
||||
this.$root.$off('newWorkflow', this.newWorkflow);
|
||||
this.$root.$off('importWorkflowData', this.onImportWorkflowDataEvent);
|
||||
this.$root.$off('importWorkflowUrl', this.onImportWorkflowUrlEvent);
|
||||
this.$root.$off('nodeMove', this.onMoveNode);
|
||||
this.$root.$off('revertAddNode', this.onRevertAddNode);
|
||||
this.$root.$off('revertRemoveNode', this.onRevertRemoveNode);
|
||||
this.$root.$off('revertAddConnection', this.onRevertAddConnection);
|
||||
this.$root.$off('revertRemoveConnection', this.onRevertRemoveConnection);
|
||||
this.$root.$off('revertRenameNode', this.onRevertNameChange);
|
||||
this.$root.$off('enableNodeToggle', this.onRevertEnableToggle);
|
||||
historyBus.$off('nodeMove', this.onMoveNode);
|
||||
historyBus.$off('revertAddNode', this.onRevertAddNode);
|
||||
historyBus.$off('revertRemoveNode', this.onRevertRemoveNode);
|
||||
historyBus.$off('revertAddConnection', this.onRevertAddConnection);
|
||||
historyBus.$off('revertRemoveConnection', this.onRevertRemoveConnection);
|
||||
historyBus.$off('revertRenameNode', this.onRevertNameChange);
|
||||
historyBus.$off('enableNodeToggle', this.onRevertEnableToggle);
|
||||
|
||||
dataPinningEventBus.$off('pin-data', this.addPinDataConnections);
|
||||
dataPinningEventBus.$off('unpin-data', this.removePinDataConnections);
|
||||
nodeViewEventBus.$off('saveWorkflow', this.saveCurrentWorkflowExternal);
|
||||
this.instance.unbind();
|
||||
},
|
||||
destroyed() {
|
||||
this.resetWorkspace();
|
||||
this.instance.unbind();
|
||||
this.instance.destroy();
|
||||
this.uiStore.stateIsDirty = false;
|
||||
window.removeEventListener('message', this.onPostMessageReceived);
|
||||
this.$root.$off('newWorkflow', this.newWorkflow);
|
||||
@@ -4016,40 +4033,6 @@ export default mixins(
|
||||
</style>
|
||||
|
||||
<style lang="scss">
|
||||
.connection-run-items-label {
|
||||
span {
|
||||
border-radius: 7px;
|
||||
background-color: hsla(
|
||||
var(--color-canvas-background-h),
|
||||
var(--color-canvas-background-s),
|
||||
var(--color-canvas-background-l),
|
||||
0.85
|
||||
);
|
||||
line-height: 1.3em;
|
||||
padding: 0px 3px;
|
||||
white-space: nowrap;
|
||||
font-size: var(--font-size-s);
|
||||
font-weight: var(--font-weight-regular);
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.floating {
|
||||
position: absolute;
|
||||
top: -22px;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
}
|
||||
|
||||
.connection-input-name-label {
|
||||
position: relative;
|
||||
|
||||
span {
|
||||
position: absolute;
|
||||
top: -10px;
|
||||
left: -60px;
|
||||
}
|
||||
}
|
||||
|
||||
.drop-add-node-label {
|
||||
color: var(--color-text-dark);
|
||||
font-weight: 600;
|
||||
@@ -4058,30 +4041,12 @@ export default mixins(
|
||||
background-color: #ffffff55;
|
||||
}
|
||||
|
||||
.node-input-endpoint-label,
|
||||
.node-output-endpoint-label {
|
||||
background-color: hsla(
|
||||
var(--color-canvas-background-h),
|
||||
var(--color-canvas-background-s),
|
||||
var(--color-canvas-background-l),
|
||||
0.85
|
||||
);
|
||||
border-radius: 7px;
|
||||
font-size: 0.7em;
|
||||
padding: 2px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.node-input-endpoint-label {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.connection-actions {
|
||||
&:hover {
|
||||
display: block !important;
|
||||
}
|
||||
|
||||
> div {
|
||||
> button {
|
||||
color: var(--color-foreground-xdark);
|
||||
border: 2px solid var(--color-foreground-xdark);
|
||||
background-color: var(--color-background-xlight);
|
||||
|
||||
Reference in New Issue
Block a user