mirror of
https://github.com/n8n-io/n8n.git
synced 2024-12-24 20:24:05 -08:00
feat(editor): Add duplicate and copy/paste to canvas v2 (no-changelog) (#10112)
This commit is contained in:
parent
fc4184773a
commit
2e5c548452
|
@ -7,6 +7,7 @@ import {
|
|||
MODAL_CONFIRM,
|
||||
PLACEHOLDER_EMPTY_WORKFLOW_ID,
|
||||
SOURCE_CONTROL_PUSH_MODAL_KEY,
|
||||
VALID_WORKFLOW_IMPORT_URL_REGEX,
|
||||
VIEWS,
|
||||
WORKFLOW_MENU_ACTIONS,
|
||||
WORKFLOW_SETTINGS_MODAL_KEY,
|
||||
|
@ -454,7 +455,7 @@ async function onWorkflowMenuSelect(action: WORKFLOW_MENU_ACTIONS): Promise<void
|
|||
confirmButtonText: locale.baseText('mainSidebar.prompt.import'),
|
||||
cancelButtonText: locale.baseText('mainSidebar.prompt.cancel'),
|
||||
inputErrorMessage: locale.baseText('mainSidebar.prompt.invalidUrl'),
|
||||
inputPattern: /^http[s]?:\/\/.*\.json$/i,
|
||||
inputPattern: VALID_WORKFLOW_IMPORT_URL_REGEX,
|
||||
},
|
||||
);
|
||||
|
||||
|
|
|
@ -15,6 +15,7 @@ import { useKeybindings } from '@/composables/useKeybindings';
|
|||
import ContextMenu from '@/components/ContextMenu/ContextMenu.vue';
|
||||
import type { NodeCreatorOpenSource } from '@/Interface';
|
||||
import type { PinDataSource } from '@/composables/usePinnedData';
|
||||
import { isPresent } from '@/utils/typesUtils';
|
||||
|
||||
const $style = useCssModule();
|
||||
|
||||
|
@ -75,6 +76,7 @@ const {
|
|||
project,
|
||||
nodes: graphNodes,
|
||||
onPaneReady,
|
||||
findNode,
|
||||
} = useVueFlow({ id: props.id, deleteKeyCode: null });
|
||||
|
||||
useKeybindings({
|
||||
|
@ -131,11 +133,20 @@ function onSetNodeActive(id: string) {
|
|||
emit('update:node:active', id);
|
||||
}
|
||||
|
||||
function clearSelectedNodes() {
|
||||
removeSelectedNodes(selectedNodes.value);
|
||||
}
|
||||
|
||||
function onSelectNode() {
|
||||
if (!lastSelectedNode.value) return;
|
||||
emit('update:node:selected', lastSelectedNode.value.id);
|
||||
}
|
||||
|
||||
function onSelectNodes(ids: string[]) {
|
||||
clearSelectedNodes();
|
||||
addSelectedNodes(ids.map(findNode).filter(isPresent));
|
||||
}
|
||||
|
||||
function onToggleNodeEnabled(id: string) {
|
||||
emit('update:node:enabled', id);
|
||||
}
|
||||
|
@ -288,7 +299,7 @@ function onContextMenuAction(action: ContextMenuAction, nodeIds: string[]) {
|
|||
case 'select_all':
|
||||
return addSelectedNodes(graphNodes.value);
|
||||
case 'deselect_all':
|
||||
return removeSelectedNodes(selectedNodes.value);
|
||||
return clearSelectedNodes();
|
||||
case 'duplicate':
|
||||
return emit('duplicate:nodes', nodeIds);
|
||||
case 'toggle_pin':
|
||||
|
@ -310,10 +321,12 @@ function onContextMenuAction(action: ContextMenuAction, nodeIds: string[]) {
|
|||
|
||||
onMounted(() => {
|
||||
props.eventBus.on('fitView', onFitView);
|
||||
props.eventBus.on('selectNodes', onSelectNodes);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
props.eventBus.off('fitView', onFitView);
|
||||
props.eventBus.off('selectNodes', onSelectNodes);
|
||||
});
|
||||
|
||||
onPaneReady(async () => {
|
||||
|
|
|
@ -0,0 +1,71 @@
|
|||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`useCanvasOperations > copyNodes > should copy nodes 1`] = `
|
||||
[
|
||||
[
|
||||
"{
|
||||
"nodes": [
|
||||
{
|
||||
"parameters": {},
|
||||
"id": "1",
|
||||
"name": "Node 1",
|
||||
"type": "type",
|
||||
"position": [
|
||||
40,
|
||||
40
|
||||
],
|
||||
"typeVersion": 1
|
||||
},
|
||||
{
|
||||
"parameters": {},
|
||||
"id": "2",
|
||||
"name": "Node 2",
|
||||
"type": "type",
|
||||
"position": [
|
||||
40,
|
||||
40
|
||||
],
|
||||
"typeVersion": 1
|
||||
}
|
||||
],
|
||||
"connections": {},
|
||||
"pinData": {}
|
||||
}",
|
||||
],
|
||||
]
|
||||
`;
|
||||
|
||||
exports[`useCanvasOperations > cutNodes > should copy and delete nodes 1`] = `
|
||||
[
|
||||
[
|
||||
"{
|
||||
"nodes": [
|
||||
{
|
||||
"parameters": {},
|
||||
"id": "1",
|
||||
"name": "Node 1",
|
||||
"type": "type",
|
||||
"position": [
|
||||
40,
|
||||
40
|
||||
],
|
||||
"typeVersion": 1
|
||||
},
|
||||
{
|
||||
"parameters": {},
|
||||
"id": "2",
|
||||
"name": "Node 2",
|
||||
"type": "type",
|
||||
"position": [
|
||||
40,
|
||||
40
|
||||
],
|
||||
"typeVersion": 1
|
||||
}
|
||||
],
|
||||
"connections": {},
|
||||
"pinData": {}
|
||||
}",
|
||||
],
|
||||
]
|
||||
`;
|
|
@ -22,16 +22,22 @@ import { mock } from 'vitest-mock-extended';
|
|||
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
|
||||
import { useCredentialsStore } from '@/stores/credentials.store';
|
||||
import { useWorkflowHelpers } from '@/composables/useWorkflowHelpers';
|
||||
import { telemetry } from '@/plugins/telemetry';
|
||||
import { useClipboard } from '@/composables/useClipboard';
|
||||
|
||||
vi.mock('vue-router', async () => {
|
||||
const actual = await import('vue-router');
|
||||
|
||||
vi.mock('vue-router', async (importOriginal) => {
|
||||
const actual = await importOriginal<{}>();
|
||||
return {
|
||||
...actual,
|
||||
useRouter: () => ({}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@/composables/useClipboard', async () => {
|
||||
const copySpy = vi.fn();
|
||||
return { useClipboard: vi.fn(() => ({ copy: copySpy })) };
|
||||
});
|
||||
|
||||
describe('useCanvasOperations', () => {
|
||||
let workflowsStore: ReturnType<typeof useWorkflowsStore>;
|
||||
let uiStore: ReturnType<typeof useUIStore>;
|
||||
|
@ -71,6 +77,7 @@ describe('useCanvasOperations', () => {
|
|||
await workflowHelpers.initState(workflow);
|
||||
|
||||
canvasOperations = useCanvasOperations({ router, lastClickPosition });
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('addNode', () => {
|
||||
|
@ -904,4 +911,71 @@ describe('useCanvasOperations', () => {
|
|||
expect(addConnectionSpy).toHaveBeenCalledWith({ connection });
|
||||
});
|
||||
});
|
||||
|
||||
describe('duplicateNodes', () => {
|
||||
it('should duplicate nodes', async () => {
|
||||
nodeTypesStore.setNodeTypes([mockNodeTypeDescription({ name: 'type' })]);
|
||||
const telemetrySpy = vi.spyOn(telemetry, 'track');
|
||||
|
||||
const nodes = buildImportNodes();
|
||||
workflowsStore.setNodes(nodes);
|
||||
|
||||
const duplicatedNodeIds = await canvasOperations.duplicateNodes(['1', '2']);
|
||||
expect(duplicatedNodeIds.length).toBe(2);
|
||||
expect(duplicatedNodeIds).not.toContain('1');
|
||||
expect(duplicatedNodeIds).not.toContain('2');
|
||||
expect(workflowsStore.workflow.nodes.length).toEqual(4);
|
||||
expect(telemetrySpy).toHaveBeenCalledWith(
|
||||
'User duplicated nodes',
|
||||
expect.objectContaining({ node_graph_string: expect.any(String), workflow_id: 'test' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('copyNodes', () => {
|
||||
it('should copy nodes', async () => {
|
||||
nodeTypesStore.setNodeTypes([mockNodeTypeDescription({ name: 'type' })]);
|
||||
const telemetrySpy = vi.spyOn(telemetry, 'track');
|
||||
const nodes = buildImportNodes();
|
||||
workflowsStore.setNodes(nodes);
|
||||
|
||||
await canvasOperations.copyNodes(['1', '2']);
|
||||
expect(useClipboard().copy).toHaveBeenCalledTimes(1);
|
||||
expect(vi.mocked(useClipboard().copy).mock.calls).toMatchSnapshot();
|
||||
expect(telemetrySpy).toHaveBeenCalledWith(
|
||||
'User copied nodes',
|
||||
expect.objectContaining({ node_types: ['type', 'type'], workflow_id: 'test' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cutNodes', () => {
|
||||
it('should copy and delete nodes', async () => {
|
||||
nodeTypesStore.setNodeTypes([mockNodeTypeDescription({ name: 'type' })]);
|
||||
const telemetrySpy = vi.spyOn(telemetry, 'track');
|
||||
const nodes = buildImportNodes();
|
||||
workflowsStore.setNodes(nodes);
|
||||
|
||||
await canvasOperations.cutNodes(['1', '2']);
|
||||
expect(useClipboard().copy).toHaveBeenCalledTimes(1);
|
||||
expect(vi.mocked(useClipboard().copy).mock.calls).toMatchSnapshot();
|
||||
expect(telemetrySpy).toHaveBeenCalledWith(
|
||||
'User copied nodes',
|
||||
expect.objectContaining({ node_types: ['type', 'type'], workflow_id: 'test' }),
|
||||
);
|
||||
expect(workflowsStore.getNodes().length).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function buildImportNodes() {
|
||||
return [
|
||||
mockNode({ id: '1', name: 'Node 1', type: 'type' }),
|
||||
mockNode({ id: '2', name: 'Node 2', type: 'type' }),
|
||||
].map((node) => {
|
||||
// Setting position in mockNode will wrap it in a Proxy
|
||||
// This causes deepCopy to remove position -> set position after instead
|
||||
node.position = [40, 40];
|
||||
return node;
|
||||
});
|
||||
}
|
||||
|
|
|
@ -7,6 +7,8 @@ import type {
|
|||
AddedNodesAndConnections,
|
||||
INodeUi,
|
||||
ITag,
|
||||
IUsedCredential,
|
||||
IWorkflowData,
|
||||
IWorkflowDataUpdate,
|
||||
IWorkflowDb,
|
||||
XYPosition,
|
||||
|
@ -20,6 +22,7 @@ import { useTelemetry } from '@/composables/useTelemetry';
|
|||
import { useToast } from '@/composables/useToast';
|
||||
import { useWorkflowHelpers } from '@/composables/useWorkflowHelpers';
|
||||
import {
|
||||
EnterpriseEditionFeature,
|
||||
FORM_TRIGGER_NODE_TYPE,
|
||||
QUICKSTART_NOTE_NAME,
|
||||
STICKY_NODE_TYPE,
|
||||
|
@ -56,7 +59,6 @@ import {
|
|||
} from '@/utils/canvasUtilsV2';
|
||||
import * as NodeViewUtils from '@/utils/nodeViewUtils';
|
||||
import { isValidNodeConnectionType } from '@/utils/typeGuards';
|
||||
import { isPresent } from '@/utils/typesUtils';
|
||||
import type { Connection } from '@vue-flow/core';
|
||||
import type {
|
||||
ConnectionTypes,
|
||||
|
@ -64,20 +66,24 @@ import type {
|
|||
IConnections,
|
||||
INode,
|
||||
INodeConnections,
|
||||
INodeCredentials,
|
||||
INodeInputConfiguration,
|
||||
INodeOutputConfiguration,
|
||||
INodeTypeDescription,
|
||||
INodeTypeNameVersion,
|
||||
IPinData,
|
||||
ITelemetryTrackProperties,
|
||||
IWorkflowBase,
|
||||
NodeParameterValueType,
|
||||
Workflow,
|
||||
} from 'n8n-workflow';
|
||||
import { NodeConnectionType, NodeHelpers, TelemetryHelpers } from 'n8n-workflow';
|
||||
import { deepCopy, NodeConnectionType, NodeHelpers, TelemetryHelpers } from 'n8n-workflow';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import type { Ref } from 'vue';
|
||||
import { computed, nextTick } from 'vue';
|
||||
import type { useRouter } from 'vue-router';
|
||||
import { useClipboard } from '@/composables/useClipboard';
|
||||
import { isPresent } from '../utils/typesUtils';
|
||||
|
||||
type AddNodeData = Partial<INodeUi> & {
|
||||
type: string;
|
||||
|
@ -116,6 +122,7 @@ export function useCanvasOperations({
|
|||
const nodeHelpers = useNodeHelpers();
|
||||
const telemetry = useTelemetry();
|
||||
const externalHooks = useExternalHooks();
|
||||
const clipboard = useClipboard();
|
||||
|
||||
const editableWorkflow = computed(() => workflowsStore.workflow);
|
||||
const editableWorkflowObject = computed(() => workflowsStore.getCurrentWorkflow());
|
||||
|
@ -296,14 +303,14 @@ export function useCanvasOperations({
|
|||
ids: string[],
|
||||
{ trackHistory = true }: { trackHistory?: boolean } = {},
|
||||
) {
|
||||
const nodes = ids.map((id) => workflowsStore.getNodeById(id)).filter(isPresent);
|
||||
const nodes = workflowsStore.getNodesByIds(ids);
|
||||
nodeHelpers.disableNodes(nodes, trackHistory);
|
||||
}
|
||||
|
||||
function toggleNodesPinned(ids: string[], source: PinDataSource) {
|
||||
historyStore.startRecordingUndo();
|
||||
|
||||
const nodes = ids.map((id) => workflowsStore.getNodeById(id)).filter(isPresent);
|
||||
const nodes = workflowsStore.getNodesByIds(ids);
|
||||
const nextStatePinned = nodes.some((node) => !workflowsStore.pinDataByNodeName(node.name));
|
||||
|
||||
for (const node of nodes) {
|
||||
|
@ -1168,7 +1175,7 @@ export function useCanvasOperations({
|
|||
|
||||
// Get only the connections of the nodes that get created
|
||||
const newConnections: IConnections = {};
|
||||
const currentConnections = data.connections!;
|
||||
const currentConnections = data.connections ?? {};
|
||||
const createNodeNames = createNodes.map((node) => node.name);
|
||||
let sourceNode, type, sourceIndex, connectionIndex, connectionData;
|
||||
for (sourceNode of Object.keys(currentConnections)) {
|
||||
|
@ -1271,10 +1278,10 @@ export function useCanvasOperations({
|
|||
workflowData: IWorkflowDataUpdate,
|
||||
source: string,
|
||||
importTags = true,
|
||||
): Promise<void> {
|
||||
): Promise<IWorkflowDataUpdate> {
|
||||
// If it is JSON check if it looks on the first look like data we can use
|
||||
if (!workflowData.hasOwnProperty('nodes') || !workflowData.hasOwnProperty('connections')) {
|
||||
return;
|
||||
return {};
|
||||
}
|
||||
|
||||
try {
|
||||
|
@ -1346,10 +1353,6 @@ export function useCanvasOperations({
|
|||
});
|
||||
}
|
||||
|
||||
// By default we automatically deselect all the currently
|
||||
// selected nodes and select the new ones
|
||||
// this.deselectAllNodes();
|
||||
|
||||
// Fix the node position as it could be totally offscreen
|
||||
// and the pasted nodes would so not be directly visible to
|
||||
// the user
|
||||
|
@ -1360,17 +1363,14 @@ export function useCanvasOperations({
|
|||
|
||||
await addImportedNodesToWorkflow(workflowData);
|
||||
|
||||
// setTimeout(() => {
|
||||
// (data?.nodes ?? []).forEach((node: INodeUi) => {
|
||||
// this.nodeSelectedByName(node.name);
|
||||
// });
|
||||
// });
|
||||
|
||||
if (importTags && settingsStore.areTagsEnabled && Array.isArray(workflowData.tags)) {
|
||||
await importWorkflowTags(workflowData);
|
||||
}
|
||||
|
||||
return workflowData;
|
||||
} catch (error) {
|
||||
toast.showError(error, i18n.baseText('nodeView.showError.importWorkflowData.title'));
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1383,9 +1383,9 @@ export function useCanvasOperations({
|
|||
|
||||
const creatingTagPromises: Array<Promise<ITag>> = [];
|
||||
for (const tag of notFound) {
|
||||
const creationPromise = tagsStore.create(tag.name).then((tag: ITag) => {
|
||||
allTags.push(tag);
|
||||
return tag;
|
||||
const creationPromise = tagsStore.create(tag.name).then((newTag: ITag) => {
|
||||
allTags.push(newTag);
|
||||
return newTag;
|
||||
});
|
||||
|
||||
creatingTagPromises.push(creationPromise);
|
||||
|
@ -1394,7 +1394,7 @@ export function useCanvasOperations({
|
|||
await Promise.all(creatingTagPromises);
|
||||
|
||||
const tagIds = workflowTags.reduce((accu: string[], imported: ITag) => {
|
||||
const tag = allTags.find((tag) => tag.name === imported.name);
|
||||
const tag = allTags.find((t) => t.name === imported.name);
|
||||
if (tag) {
|
||||
accu.push(tag.id);
|
||||
}
|
||||
|
@ -1424,6 +1424,121 @@ export function useCanvasOperations({
|
|||
return workflowData;
|
||||
}
|
||||
|
||||
function getNodesToSave(nodes: INode[]): IWorkflowData {
|
||||
const data = {
|
||||
nodes: [] as INodeUi[],
|
||||
connections: {} as IConnections,
|
||||
pinData: {} as IPinData,
|
||||
} satisfies IWorkflowData;
|
||||
|
||||
const exportedNodeNames = new Set<string>();
|
||||
|
||||
for (const node of nodes) {
|
||||
const nodeSaveData = workflowHelpers.getNodeDataToSave(node);
|
||||
const pinDataForNode = workflowsStore.pinDataByNodeName(node.name);
|
||||
|
||||
if (pinDataForNode) {
|
||||
data.pinData[node.name] = pinDataForNode;
|
||||
}
|
||||
|
||||
if (
|
||||
nodeSaveData.credentials &&
|
||||
settingsStore.isEnterpriseFeatureEnabled(EnterpriseEditionFeature.Sharing)
|
||||
) {
|
||||
nodeSaveData.credentials = filterAllowedCredentials(
|
||||
nodeSaveData.credentials,
|
||||
workflowsStore.usedCredentials,
|
||||
);
|
||||
}
|
||||
|
||||
data.nodes.push(nodeSaveData);
|
||||
exportedNodeNames.add(node.name);
|
||||
}
|
||||
|
||||
data.connections = getConnectionsForNodes(data.nodes, exportedNodeNames);
|
||||
|
||||
workflowHelpers.removeForeignCredentialsFromWorkflow(data, credentialsStore.allCredentials);
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
function filterAllowedCredentials(
|
||||
credentials: INodeCredentials,
|
||||
usedCredentials: Record<string, IUsedCredential>,
|
||||
): INodeCredentials {
|
||||
return Object.fromEntries(
|
||||
Object.entries(credentials).filter(([, credential]) => {
|
||||
return (
|
||||
credential.id &&
|
||||
(!usedCredentials[credential.id] || usedCredentials[credential.id]?.currentUserHasAccess)
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function getConnectionsForNodes(
|
||||
nodes: INodeUi[],
|
||||
includeNodeNames: Set<string>,
|
||||
): Record<string, INodeConnections> {
|
||||
const connections: Record<string, INodeConnections> = {};
|
||||
|
||||
for (const node of nodes) {
|
||||
const outgoingConnections = workflowsStore.outgoingConnectionsByNodeName(node.name);
|
||||
if (!Object.keys(outgoingConnections).length) continue;
|
||||
|
||||
const filteredConnections = filterConnectionsByNodes(outgoingConnections, includeNodeNames);
|
||||
if (Object.keys(filteredConnections).length) {
|
||||
connections[node.name] = filteredConnections;
|
||||
}
|
||||
}
|
||||
|
||||
return connections;
|
||||
}
|
||||
|
||||
function filterConnectionsByNodes(
|
||||
connections: Record<string, IConnection[][]>,
|
||||
includeNodeNames: Set<string>,
|
||||
): INodeConnections {
|
||||
const filteredConnections: INodeConnections = {};
|
||||
|
||||
for (const [type, typeConnections] of Object.entries(connections)) {
|
||||
const validConnections = typeConnections
|
||||
.map((sourceConnections) =>
|
||||
sourceConnections.filter((connection) => includeNodeNames.has(connection.node)),
|
||||
)
|
||||
.filter((sourceConnections) => sourceConnections.length > 0);
|
||||
|
||||
if (validConnections.length) {
|
||||
filteredConnections[type] = validConnections;
|
||||
}
|
||||
}
|
||||
|
||||
return filteredConnections;
|
||||
}
|
||||
|
||||
async function duplicateNodes(ids: string[]) {
|
||||
const workflowData = deepCopy(getNodesToSave(workflowsStore.getNodesByIds(ids)));
|
||||
const result = await importWorkflowData(workflowData, 'duplicate', false);
|
||||
|
||||
return result.nodes?.map((node) => node.id).filter(isPresent) ?? [];
|
||||
}
|
||||
|
||||
async function copyNodes(ids: string[]) {
|
||||
const workflowData = deepCopy(getNodesToSave(workflowsStore.getNodesByIds(ids)));
|
||||
|
||||
await clipboard.copy(JSON.stringify(workflowData, null, 2));
|
||||
|
||||
telemetry.track('User copied nodes', {
|
||||
node_types: workflowData.nodes.map((node) => node.type),
|
||||
workflow_id: workflowsStore.workflowId,
|
||||
});
|
||||
}
|
||||
|
||||
async function cutNodes(ids: string[]) {
|
||||
await copyNodes(ids);
|
||||
deleteNodes(ids);
|
||||
}
|
||||
|
||||
return {
|
||||
editableWorkflow,
|
||||
editableWorkflowObject,
|
||||
|
@ -1441,6 +1556,9 @@ export function useCanvasOperations({
|
|||
revertRenameNode,
|
||||
deleteNode,
|
||||
deleteNodes,
|
||||
copyNodes,
|
||||
cutNodes,
|
||||
duplicateNodes,
|
||||
revertDeleteNode,
|
||||
addConnections,
|
||||
createConnection,
|
||||
|
|
|
@ -403,6 +403,7 @@ export const MODAL_CLOSE = 'close';
|
|||
|
||||
export const VALID_EMAIL_REGEX =
|
||||
/^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
|
||||
export const VALID_WORKFLOW_IMPORT_URL_REGEX = /^http[s]?:\/\/.*\.json$/i;
|
||||
export const LOCAL_STORAGE_ACTIVATION_FLAG = 'N8N_HIDE_ACTIVATION_ALERT';
|
||||
export const LOCAL_STORAGE_PIN_DATA_DISCOVERY_NDV_FLAG = 'N8N_PIN_DATA_DISCOVERY_NDV';
|
||||
export const LOCAL_STORAGE_PIN_DATA_DISCOVERY_CANVAS_FLAG = 'N8N_PIN_DATA_DISCOVERY_CANVAS';
|
||||
|
|
|
@ -64,7 +64,7 @@ import { useUIStore } from '@/stores/ui.store';
|
|||
import { dataPinningEventBus } from '@/event-bus';
|
||||
import { isObject } from '@/utils/objectUtils';
|
||||
import { getPairedItemsMapping } from '@/utils/pairedItemUtils';
|
||||
import { isJsonKeyObject, isEmpty, stringSizeInBytes } from '@/utils/typesUtils';
|
||||
import { isJsonKeyObject, isEmpty, stringSizeInBytes, isPresent } from '@/utils/typesUtils';
|
||||
import { makeRestApiRequest, unflattenExecutionData, ResponseError } from '@/utils/apiUtils';
|
||||
import { useNDVStore } from '@/stores/ndv.store';
|
||||
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
|
||||
|
@ -250,6 +250,10 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
|
|||
return workflow.value.nodes.find((node) => node.id === nodeId);
|
||||
}
|
||||
|
||||
function getNodesByIds(nodeIds: string[]): INodeUi[] {
|
||||
return nodeIds.map(getNodeById).filter(isPresent);
|
||||
}
|
||||
|
||||
function getParametersLastUpdate(nodeName: string): number | undefined {
|
||||
return nodeMetadata.value[nodeName]?.parametersLastUpdatedAt;
|
||||
}
|
||||
|
@ -1584,6 +1588,7 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
|
|||
getWorkflowById,
|
||||
getNodeByName,
|
||||
getNodeById,
|
||||
getNodesByIds,
|
||||
getParametersLastUpdate,
|
||||
isNodePristine,
|
||||
isNodeExecuting,
|
||||
|
|
|
@ -42,12 +42,13 @@ import {
|
|||
PLACEHOLDER_EMPTY_WORKFLOW_ID,
|
||||
START_NODE_TYPE,
|
||||
STICKY_NODE_TYPE,
|
||||
VALID_WORKFLOW_IMPORT_URL_REGEX,
|
||||
VIEWS,
|
||||
} from '@/constants';
|
||||
import { useSourceControlStore } from '@/stores/sourceControl.store';
|
||||
import { useNodeCreatorStore } from '@/stores/nodeCreator.store';
|
||||
import { useExternalHooks } from '@/composables/useExternalHooks';
|
||||
import { TelemetryHelpers, NodeConnectionType } from 'n8n-workflow';
|
||||
import { TelemetryHelpers, NodeConnectionType, jsonParse } from 'n8n-workflow';
|
||||
import type { IDataObject, ExecutionSummary, IConnection, IWorkflowBase } from 'n8n-workflow';
|
||||
import { useToast } from '@/composables/useToast';
|
||||
import { useSettingsStore } from '@/stores/settings.store';
|
||||
|
@ -83,6 +84,7 @@ import { tryToParseNumber } from '@/utils/typesUtils';
|
|||
import { useTemplatesStore } from '@/stores/templates.store';
|
||||
import { createEventBus } from 'n8n-design-system';
|
||||
import type { PinDataSource } from '@/composables/usePinnedData';
|
||||
import { useClipboard } from '@/composables/useClipboard';
|
||||
|
||||
const NodeCreation = defineAsyncComponent(
|
||||
async () => await import('@/components/Node/NodeCreation.vue'),
|
||||
|
@ -141,6 +143,9 @@ const {
|
|||
setNodeParameters,
|
||||
deleteNode,
|
||||
deleteNodes,
|
||||
copyNodes,
|
||||
cutNodes,
|
||||
duplicateNodes,
|
||||
revertDeleteNode,
|
||||
addNodes,
|
||||
createConnection,
|
||||
|
@ -156,6 +161,7 @@ const {
|
|||
editableWorkflowObject,
|
||||
} = useCanvasOperations({ router, lastClickPosition });
|
||||
const { applyExecutionData } = useExecutionDebugging();
|
||||
useClipboard({ onPaste: onClipboardPaste });
|
||||
|
||||
const isLoading = ref(true);
|
||||
const isBlankRedirect = ref(false);
|
||||
|
@ -475,16 +481,76 @@ function onSetNodeSelected(id?: string) {
|
|||
setNodeSelected(id);
|
||||
}
|
||||
|
||||
function onCopyNodes(_ids: string[]) {
|
||||
// @TODO: implement this
|
||||
async function onCopyNodes(ids: string[]) {
|
||||
await copyNodes(ids);
|
||||
|
||||
toast.showMessage({ title: i18n.baseText('generic.copiedToClipboard'), type: 'success' });
|
||||
}
|
||||
|
||||
function onCutNodes(_ids: string[]) {
|
||||
// @TODO: implement this
|
||||
async function onClipboardPaste(plainTextData: string): Promise<void> {
|
||||
if (getNodeViewTab(route) !== MAIN_HEADER_TABS.WORKFLOW) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!checkIfEditingIsAllowed()) {
|
||||
return;
|
||||
}
|
||||
|
||||
let workflowData: IWorkflowDataUpdate | null | undefined = null;
|
||||
|
||||
// Check if it is an URL which could contain workflow data
|
||||
if (plainTextData.match(VALID_WORKFLOW_IMPORT_URL_REGEX)) {
|
||||
const importConfirm = await message.confirm(
|
||||
i18n.baseText('nodeView.confirmMessage.onClipboardPasteEvent.message', {
|
||||
interpolate: { plainTextData },
|
||||
}),
|
||||
i18n.baseText('nodeView.confirmMessage.onClipboardPasteEvent.headline'),
|
||||
{
|
||||
type: 'warning',
|
||||
confirmButtonText: i18n.baseText(
|
||||
'nodeView.confirmMessage.onClipboardPasteEvent.confirmButtonText',
|
||||
),
|
||||
cancelButtonText: i18n.baseText(
|
||||
'nodeView.confirmMessage.onClipboardPasteEvent.cancelButtonText',
|
||||
),
|
||||
dangerouslyUseHTMLString: true,
|
||||
},
|
||||
);
|
||||
|
||||
if (importConfirm !== MODAL_CONFIRM) {
|
||||
return;
|
||||
}
|
||||
|
||||
workflowData = await fetchWorkflowDataFromUrl(plainTextData);
|
||||
} else {
|
||||
// Pasted data is is possible workflow data
|
||||
workflowData = jsonParse<IWorkflowDataUpdate | null>(plainTextData, { fallbackValue: null });
|
||||
}
|
||||
|
||||
if (!workflowData) {
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await importWorkflowData(workflowData, 'paste', false);
|
||||
selectNodes(result.nodes?.map((node) => node.id) ?? []);
|
||||
}
|
||||
|
||||
function onDuplicateNodes(_ids: string[]) {
|
||||
// @TODO: implement this
|
||||
async function onCutNodes(ids: string[]) {
|
||||
if (isCanvasReadOnly.value) {
|
||||
await copyNodes(ids);
|
||||
} else {
|
||||
await cutNodes(ids);
|
||||
}
|
||||
}
|
||||
|
||||
async function onDuplicateNodes(ids: string[]) {
|
||||
if (!checkIfEditingIsAllowed()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newIds = await duplicateNodes(ids);
|
||||
|
||||
selectNodes(newIds);
|
||||
}
|
||||
|
||||
function onPinNodes(ids: string[], source: PinDataSource) {
|
||||
|
@ -666,9 +732,11 @@ async function importWorkflowExact({ workflow: workflowData }: { workflow: IWork
|
|||
}
|
||||
|
||||
async function onImportWorkflowDataEvent(data: IDataObject) {
|
||||
await importWorkflowData(data.data as IWorkflowDataUpdate, 'file');
|
||||
const workflowData = data.data as IWorkflowDataUpdate;
|
||||
await importWorkflowData(workflowData, 'file');
|
||||
|
||||
fitView();
|
||||
selectNodes(workflowData.nodes?.map((node) => node.id) ?? []);
|
||||
}
|
||||
|
||||
async function onImportWorkflowUrlEvent(data: IDataObject) {
|
||||
|
@ -680,6 +748,7 @@ async function onImportWorkflowUrlEvent(data: IDataObject) {
|
|||
await importWorkflowData(workflowData, 'url');
|
||||
|
||||
fitView();
|
||||
selectNodes(workflowData.nodes?.map((node) => node.id) ?? []);
|
||||
}
|
||||
|
||||
function addImportEventBindings() {
|
||||
|
@ -972,12 +1041,16 @@ function removePostMessageEventBindings() {
|
|||
window.removeEventListener('message', onPostMessageReceived);
|
||||
}
|
||||
|
||||
async function onPostMessageReceived(message: MessageEvent) {
|
||||
if (!message || typeof message.data !== 'string' || !message.data?.includes?.('"command"')) {
|
||||
async function onPostMessageReceived(messageEvent: MessageEvent) {
|
||||
if (
|
||||
!messageEvent ||
|
||||
typeof messageEvent.data !== 'string' ||
|
||||
!messageEvent.data?.includes?.('"command"')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const json = JSON.parse(message.data);
|
||||
const json = JSON.parse(messageEvent.data);
|
||||
if (json && json.command === 'openWorkflow') {
|
||||
try {
|
||||
await importWorkflowExact(json);
|
||||
|
@ -1110,6 +1183,10 @@ function fitView() {
|
|||
setTimeout(() => canvasEventBus.emit('fitView'));
|
||||
}
|
||||
|
||||
function selectNodes(ids: string[]) {
|
||||
setTimeout(() => canvasEventBus.emit('selectNodes', ids));
|
||||
}
|
||||
|
||||
/**
|
||||
* Mouse events
|
||||
*/
|
||||
|
@ -1250,9 +1327,6 @@ onMounted(async () => {
|
|||
|
||||
registerCustomActions();
|
||||
|
||||
// @TODO Implement this
|
||||
// this.clipboard.onPaste.value = this.onClipboardPasteEvent;
|
||||
|
||||
// @TODO: This currently breaks since front-end hooks are still not updated to work with pinia store
|
||||
void externalHooks.run('nodeView.mount').catch(() => {});
|
||||
});
|
||||
|
|
|
@ -241,6 +241,7 @@ import {
|
|||
DRAG_EVENT_DATA_KEY,
|
||||
UPDATE_WEBHOOK_ID_NODE_TYPES,
|
||||
CANVAS_AUTO_ADD_MANUAL_TRIGGER_EXPERIMENT,
|
||||
VALID_WORKFLOW_IMPORT_URL_REGEX,
|
||||
} from '@/constants';
|
||||
|
||||
import useGlobalLinkActions from '@/composables/useGlobalLinkActions';
|
||||
|
@ -2024,7 +2025,7 @@ export default defineComponent({
|
|||
return;
|
||||
}
|
||||
// Check if it is an URL which could contain workflow data
|
||||
if (plainTextData.match(/^http[s]?:\/\/.*\.json$/i)) {
|
||||
if (plainTextData.match(VALID_WORKFLOW_IMPORT_URL_REGEX)) {
|
||||
// Pasted data points to a possible workflow JSON file
|
||||
|
||||
if (!this.editAllowedCheck()) {
|
||||
|
|
Loading…
Reference in a new issue