From a48f0827aecd4f46ed1d29d9d2dac90311f86b23 Mon Sep 17 00:00:00 2001 From: Nick O'Leary Date: Wed, 16 Sep 2020 11:42:22 +0100 Subject: [PATCH] Detect importing duplicate nodes and help user resolve --- .../editor-client/locales/en-US/editor.json | 10 +- .../@node-red/editor-client/src/js/history.js | 46 +- .../@node-red/editor-client/src/js/nodes.js | 405 +++++++++++++----- .../editor-client/src/js/ui/clipboard.js | 361 +++++++++++++++- .../src/js/ui/common/checkboxSet.js | 36 +- .../src/js/ui/common/editableList.js | 3 + .../editor-client/src/js/ui/subflow.js | 4 +- .../src/js/ui/tab-info-outliner.js | 2 +- .../@node-red/editor-client/src/js/ui/view.js | 80 +++- .../editor-client/src/sass/library.scss | 58 +++ .../editor-client/src/sass/tab-info.scss | 16 +- .../src/sass/ui/common/checkboxSet.scss | 2 +- 12 files changed, 844 insertions(+), 179 deletions(-) diff --git a/packages/node_modules/@node-red/editor-client/locales/en-US/editor.json b/packages/node_modules/@node-red/editor-client/locales/en-US/editor.json index 83ca0c151..26f0df2af 100755 --- a/packages/node_modules/@node-red/editor-client/locales/en-US/editor.json +++ b/packages/node_modules/@node-red/editor-client/locales/en-US/editor.json @@ -198,6 +198,8 @@ "flow_plural": "__count__ flows", "subflow": "__count__ subflow", "subflow_plural": "__count__ subflows", + "replacedNodes": "__count__ node replaced", + "replacedNodes_plural": "__count__ nodes replaced", "pasteNodes": "Paste flow json or", "selectFile": "select a file to import", "importNodes": "Import nodes", @@ -230,13 +232,19 @@ }, "import": { "import": "Import to", + "importSelected": "Import selected", + "importCopy": "Import copy", + "viewNodes": "View nodes...", "newFlow": "new flow", + "replace": "replace", "errors": { "notArray": "Input not a JSON Array", "itemNotObject": "Input not a valid flow - item __index__ not a node object", "missingId": "Input not a valid flow - item __index__ missing 'id' property", "missingType": "Input not a valid flow - item __index__ missing 'type' property" - } + }, + "conflictNotification1": "Some of the nodes you are importing already exist in your workspace.", + "conflictNotification2": "Select which nodes to import and whether to replace the existing nodes, or to import a copy of them." }, "copyMessagePath": "Path copied", "copyMessageValue": "Value copied", diff --git a/packages/node_modules/@node-red/editor-client/src/js/history.js b/packages/node_modules/@node-red/editor-client/src/js/history.js index f7c43b5c9..a3392fd26 100644 --- a/packages/node_modules/@node-red/editor-client/src/js/history.js +++ b/packages/node_modules/@node-red/editor-client/src/js/history.js @@ -37,22 +37,38 @@ RED.history = (function() { inverseEv.events.push(r); } } else if (ev.t == 'replace') { - inverseEv = { - t: 'replace', - config: RED.nodes.createCompleteNodeSet(), - changed: {}, - rev: RED.nodes.version() - }; - RED.nodes.clear(); - var imported = RED.nodes.import(ev.config); - imported[0].forEach(function(n) { - if (ev.changed[n.id]) { - n.changed = true; - inverseEv.changed[n.id] = true; - } - }) + if (ev.complete) { + // This is a replace of everything. We can short-cut + // the logic by clearing everyting first, then importing + // the ev.config. + // Used by RED.diff.mergeDiff + inverseEv = { + t: 'replace', + config: RED.nodes.createCompleteNodeSet(), + changed: {}, + rev: RED.nodes.version() + }; + RED.nodes.clear(); + var imported = RED.nodes.import(ev.config); + imported.nodes.forEach(function(n) { + if (ev.changed[n.id]) { + n.changed = true; + inverseEv.changed[n.id] = true; + } + }) - RED.nodes.version(ev.rev); + RED.nodes.version(ev.rev); + } else { + var importMap = {}; + ev.config.forEach(function(n) { + importMap[n.id] = "replace"; + }) + var importedResult = RED.nodes.import(ev.config,{importMap: importMap}) + inverseEv = { + t: 'replace', + config: importedResult.removedNodes + } + } } else if (ev.t == 'add') { inverseEv = { t: "delete", diff --git a/packages/node_modules/@node-red/editor-client/src/js/nodes.js b/packages/node_modules/@node-red/editor-client/src/js/nodes.js index 9e1279342..30bfb4906 100644 --- a/packages/node_modules/@node-red/editor-client/src/js/nodes.js +++ b/packages/node_modules/@node-red/editor-client/src/js/nodes.js @@ -16,7 +16,7 @@ RED.nodes = (function() { var node_defs = {}; - var nodes = []; + var nodes = {}; var nodeTabMap = {}; var configNodes = {}; @@ -189,6 +189,7 @@ RED.nodes = (function() { })(); function getID() { + // return Math.floor(Math.random()*15728640 + 1048576).toString(16) return (1+Math.random()*4294967295).toString(16); } @@ -216,7 +217,7 @@ RED.nodes = (function() { }); n.i = nextId+1; } - nodes.push(n); + nodes[n.id] = n; if (nodeTabMap[n.z]) { nodeTabMap[n.z][n.id] = n; } else { @@ -233,12 +234,8 @@ RED.nodes = (function() { function getNode(id) { if (id in configNodes) { return configNodes[id]; - } else { - for (var n in nodes) { - if (nodes[n].id == id) { - return nodes[n]; - } - } + } else if (id in nodes) { + return nodes[id]; } return null; } @@ -252,58 +249,56 @@ RED.nodes = (function() { delete configNodes[id]; RED.events.emit('nodes:remove',node); RED.workspaces.refresh(); - } else { - node = getNode(id); - if (node) { - nodes.splice(nodes.indexOf(node),1); - if (nodeTabMap[node.z]) { - delete nodeTabMap[node.z][node.id]; - } - removedLinks = links.filter(function(l) { return (l.source === node) || (l.target === node); }); - removedLinks.forEach(removeLink); - var updatedConfigNode = false; - for (var d in node._def.defaults) { - if (node._def.defaults.hasOwnProperty(d)) { - var property = node._def.defaults[d]; - if (property.type) { - var type = registry.getNodeType(property.type); - if (type && type.category == "config") { - var configNode = configNodes[node[d]]; - if (configNode) { - updatedConfigNode = true; - if (configNode._def.exclusive) { - removeNode(node[d]); - removedNodes.push(configNode); - } else { - var users = configNode.users; - users.splice(users.indexOf(node),1); - } + } else if (id in nodes) { + node = nodes[id]; + delete nodes[id] + if (nodeTabMap[node.z]) { + delete nodeTabMap[node.z][node.id]; + } + removedLinks = links.filter(function(l) { return (l.source === node) || (l.target === node); }); + removedLinks.forEach(removeLink); + var updatedConfigNode = false; + for (var d in node._def.defaults) { + if (node._def.defaults.hasOwnProperty(d)) { + var property = node._def.defaults[d]; + if (property.type) { + var type = registry.getNodeType(property.type); + if (type && type.category == "config") { + var configNode = configNodes[node[d]]; + if (configNode) { + updatedConfigNode = true; + if (configNode._def.exclusive) { + removeNode(node[d]); + removedNodes.push(configNode); + } else { + var users = configNode.users; + users.splice(users.indexOf(node),1); } } } } } - - if (node.type.indexOf("subflow:") === 0) { - var subflowId = node.type.substring(8); - var sf = RED.nodes.subflow(subflowId); - if (sf) { - sf.instances.splice(sf.instances.indexOf(node),1); - } - } - - if (updatedConfigNode) { - RED.workspaces.refresh(); - } - try { - if (node._def.oneditdelete) { - node._def.oneditdelete.call(node); - } - } catch(err) { - console.log("oneditdelete",node.id,node.type,err.toString()); - } - RED.events.emit('nodes:remove',node); } + + if (node.type.indexOf("subflow:") === 0) { + var subflowId = node.type.substring(8); + var sf = RED.nodes.subflow(subflowId); + if (sf) { + sf.instances.splice(sf.instances.indexOf(node),1); + } + } + + if (updatedConfigNode) { + RED.workspaces.refresh(); + } + try { + if (node._def.oneditdelete) { + node._def.oneditdelete.call(node); + } + } catch(err) { + console.log("oneditdelete",node.id,node.type,err.toString()); + } + RED.events.emit('nodes:remove',node); } @@ -377,10 +372,13 @@ RED.nodes = (function() { workspacesOrder.splice(workspacesOrder.indexOf(id),1); var i; var node; - for (i=0;i 0) { + var existingNodesError = new Error(); + existingNodesError.code = "import_conflict"; + existingNodesError.importConfig = identifyImportConflicts(newNodes); + throw existingNodesError; + } + var removedNodes; + if (nodesToReplace.length > 0) { + var replaceResult = replaceNodes(nodesToReplace); + removedNodes = replaceResult.removedNodes; + } + + var isInitialLoad = false; if (!initialLoad) { isInitialLoad = true; @@ -954,6 +1131,7 @@ RED.nodes = (function() { var unknownTypes = []; for (i=0;i
') .appendTo("#red-ui-editor") @@ -42,14 +44,14 @@ RED.clipboard = (function() { "ui-widget-overlay": "red-ui-editor-dialog" }, buttons: [ - { + { // red-ui-clipboard-dialog-cancel id: "red-ui-clipboard-dialog-cancel", text: RED._("common.label.cancel"), click: function() { $( this ).dialog( "close" ); } }, - { + { // red-ui-clipboard-dialog-download id: "red-ui-clipboard-dialog-download", class: "primary", text: RED._("clipboard.download"), @@ -64,7 +66,7 @@ RED.clipboard = (function() { $( this ).dialog( "close" ); } }, - { + { // red-ui-clipboard-dialog-export id: "red-ui-clipboard-dialog-export", class: "primary", text: RED._("clipboard.export.copy"), @@ -134,7 +136,7 @@ RED.clipboard = (function() { } } }, - { + { // red-ui-clipboard-dialog-ok id: "red-ui-clipboard-dialog-ok", class: "primary", text: RED._("common.label.import"), @@ -157,6 +159,38 @@ RED.clipboard = (function() { } $( this ).dialog( "close" ); } + }, + { // red-ui-clipboard-dialog-import-conflict + id: "red-ui-clipboard-dialog-import-conflict", + class: "primary", + text: RED._("clipboard.import.importSelected"), + click: function() { + var importMap = {}; + $('#red-ui-clipboard-dialog-import-conflicts-list input[type="checkbox"]').each(function() { + importMap[$(this).attr("data-node-id")] = this.checked?"import":"skip"; + }) + + $('.red-ui-clipboard-dialog-import-conflicts-controls input[type="checkbox"]').each(function() { + if (!$(this).attr("disabled")) { + importMap[$(this).attr("data-node-id")] = this.checked?"replace":"copy" + } + }) + // skip - don't import + // import - import as-is + // copy - import with new id + // replace - import over the top of existing + pendingImportConfig.importOptions.importMap = importMap; + + var newNodes = pendingImportConfig.importNodes.filter(function(n) { + if (!importMap[n.id] || importMap[n.z]) { + importMap[n.id] = importMap[n.z]; + } + return importMap[n.id] !== "skip" + }) + // console.table(pendingImportConfig.importNodes.map(function(n) { return {id:n.id,type:n.type,result:importMap[n.id]}})) + RED.view.importNodes(newNodes, pendingImportConfig.importOptions); + $( this ).dialog( "close" ); + } } ], open: function( event, ui ) { @@ -236,6 +270,14 @@ RED.clipboard = (function() { ''+ ''; + importConflictsDialog = + '
'+ + '

'+ + '
'+ + '
'+ + '
'+ + '
'; + } var validateExportFilenameTimeout @@ -445,6 +487,8 @@ RED.clipboard = (function() { $("#red-ui-clipboard-dialog-cancel").show(); $("#red-ui-clipboard-dialog-export").hide(); $("#red-ui-clipboard-dialog-download").hide(); + $("#red-ui-clipboard-dialog-import-conflict").hide(); + $("#red-ui-clipboard-dialog-ok").button("disable"); $("#red-ui-clipboard-dialog-import-text").on("keyup", validateImport); $("#red-ui-clipboard-dialog-import-text").on('paste',function() { setTimeout(validateImport,10)}); @@ -485,7 +529,9 @@ RED.clipboard = (function() { } $(".red-ui-clipboard-dialog-box").height(dialogHeight); - dialog.dialog("option","title",RED._("clipboard.importNodes")).dialog("open"); + dialog.dialog("option","title",RED._("clipboard.importNodes")) + .dialog("option","width",700) + .dialog("open"); popover = RED.popover.create({ target: $("#red-ui-clipboard-dialog-import-text"), trigger: "manual", @@ -631,6 +677,8 @@ RED.clipboard = (function() { $("#red-ui-clipboard-dialog-ok").hide(); $("#red-ui-clipboard-dialog-cancel").hide(); $("#red-ui-clipboard-dialog-export").hide(); + $("#red-ui-clipboard-dialog-import-conflict").hide(); + var selection = RED.workspaces.selection(); if (selection.length > 0) { $("#red-ui-clipboard-dialog-export-rng-selected").trigger("click"); @@ -657,12 +705,15 @@ RED.clipboard = (function() { } $(".red-ui-clipboard-dialog-box").height(dialogHeight); - dialog.dialog("option","title",RED._("clipboard.exportNodes")).dialog( "open" ); + dialog.dialog("option","title",RED._("clipboard.exportNodes")) + .dialog("option","width",700) + .dialog("open"); $("#red-ui-clipboard-dialog-export-text").trigger("focus"); $("#red-ui-clipboard-dialog-cancel").show(); $("#red-ui-clipboard-dialog-export").show(); $("#red-ui-clipboard-dialog-download").show(); + $("#red-ui-clipboard-dialog-import-conflict").hide(); } @@ -752,21 +803,297 @@ RED.clipboard = (function() { function importNodes(nodesStr,addFlow) { - var newNodes; - try { - nodesStr = nodesStr.trim(); - if (nodesStr.length === 0) { - return; + var newNodes = nodesStr; + if (typeof nodesStr === 'string') { + try { + nodesStr = nodesStr.trim(); + if (nodesStr.length === 0) { + return; + } + newNodes = JSON.parse(nodesStr); + } catch(err) { + var e = new Error(RED._("clipboard.invalidFlow",{message:err.message})); + e.code = "NODE_RED"; + throw e; } - newNodes = JSON.parse(nodesStr); - } catch(err) { - var e = new Error(RED._("clipboard.invalidFlow",{message:err.message})); - e.code = "NODE_RED"; - throw e; + } + var importOptions = {generateIds: false, addFlow: addFlow}; + try { + RED.view.importNodes(newNodes, importOptions); + } catch(error) { + // Thrown for import_conflict + confirmImport(error.importConfig, newNodes, importOptions); + } + } + + function confirmImport(importConfig,importNodes,importOptions) { + var notification = RED.notify("

"+RED._("clipboard.import.conflictNotification1")+"

",{ + type: "info", + fixed: true, + buttons: [ + {text: RED._("common.label.cancel"), click: function() { notification.close(); }}, + {text: RED._("clipboard.import.viewNodes"), click: function() { + notification.close(); + showImportConflicts(importConfig,importNodes,importOptions); + }}, + {text: RED._("clipboard.import.importCopy"), click: function() { + notification.close(); + // generateIds=true to avoid conflicts + // and default to the 'old' behaviour around matching + // config nodes and subflows + importOptions.generateIds = true; + RED.view.importNodes(importNodes, importOptions); + }} + ] + }) + } + + function showImportConflicts(importConfig,importNodes,importOptions) { + + pendingImportConfig = { + importConfig: importConfig, + importNodes: importNodes, + importOptions: importOptions } + var id,node; + var treeData = []; + var container; + var addedHeader = false; + for (id in importConfig.subflows) { + if (importConfig.subflows.hasOwnProperty(id)) { + if (!addedHeader) { + treeData.push({gutter:$(''), label: '', class:"red-ui-clipboard-dialog-import-conflicts-item-header"}) + addedHeader = true; + } + node = importConfig.subflows[id]; + var isConflicted = importConfig.conflicted[node.id]; + var isSelected = !isConflicted; + var elements = getNodeElement(node, isConflicted, isSelected ); + container = { + id: node.id, + gutter: elements.gutter.element, + element: elements.element, + class: isSelected?"":"disabled", + deferBuild: true, + children: [] + } + treeData.push(container); + if (importConfig.zMap[id]) { + importConfig.zMap[id].forEach(function(node) { + var childElements = getNodeElement(node, importConfig.conflicted[node.id], isSelected, elements.gutter.cb); + container.children.push({ + id: node.id, + gutter: childElements.gutter.element, + element: childElements.element, + class: isSelected?"":"disabled" + }) + }); + } + } + } + addedHeader = false; + for (id in importConfig.tabs) { + if (importConfig.tabs.hasOwnProperty(id)) { + if (!addedHeader) { + treeData.push({gutter:$(''), label: '', class:"red-ui-clipboard-dialog-import-conflicts-item-header"}) + addedHeader = true; + } + node = importConfig.tabs[id]; + var isConflicted = importConfig.conflicted[node.id]; + var isSelected = true; + var elements = getNodeElement(node, isConflicted, isSelected); + container = { + id: node.id, + gutter: elements.gutter.element, + element: elements.element, + icon: "red-ui-icons red-ui-icons-flow", + deferBuild: true, + class: isSelected?"":"disabled", + children: [] + } + treeData.push(container); + if (importConfig.zMap[id]) { + importConfig.zMap[id].forEach(function(node) { + var childElements = getNodeElement(node, importConfig.conflicted[node.id], isSelected, elements.gutter.cb); + container.children.push({ + id: node.id, + gutter: childElements.gutter.element, + element: childElements.element, + class: isSelected?"":"disabled" + }) + // console.log(" ["+(importConfig.conflicted[node.id]?"*":" ")+"] "+node.type+" "+node.id); + }); + } + } + } + addedHeader = false; + var extraNodes = []; + importConfig.all.forEach(function(node) { + if (node.type !== "tab" && node.type !== "subflow" && !importConfig.tabs[node.z] && !importConfig.subflows[node.z]) { + var isConflicted = importConfig.conflicted[node.id]; + var isSelected = !isConflicted || !importConfig.configs[node.id]; + var elements = getNodeElement(node, isConflicted, isSelected); + var item = { + id: node.id, + gutter: elements.gutter.element, + element: elements.element, + class: isSelected?"":"disabled" + } + if (importConfig.configs[node.id]) { + extraNodes.push(item); + } else { + if (!addedHeader) { + treeData.push({gutter:$(''), label: '', class:"red-ui-clipboard-dialog-import-conflicts-item-header"}) + addedHeader = true; + } + treeData.push(item); + } + // console.log("["+(importConfig.conflicted[node.id]?"*":" ")+"] "+node.type+" "+node.id); + } + }) + if (extraNodes.length > 0) { + treeData.push({gutter:$(''), label: '', class:"red-ui-clipboard-dialog-import-conflicts-item-header"}) + addedHeader = true; + treeData = treeData.concat(extraNodes); - RED.view.importNodes(newNodes,{addFlow: addFlow}); + } + dialogContainer.empty(); + dialogContainer.append($(importConflictsDialog)); + + + var nodeList = $("#red-ui-clipboard-dialog-import-conflicts-list").css({position:"absolute",top:0,right:0,bottom:0,left:0}).treeList({ + data: treeData + }) + + dialogContainer.i18n(); + var dialogHeight = 400; + var winHeight = $(window).height(); + if (winHeight < 600) { + dialogHeight = 400 - (600 - winHeight); + } + $(".red-ui-clipboard-dialog-box").height(dialogHeight); + + $("#red-ui-clipboard-dialog-ok").hide(); + $("#red-ui-clipboard-dialog-cancel").show(); + $("#red-ui-clipboard-dialog-export").hide(); + $("#red-ui-clipboard-dialog-download").hide(); + $("#red-ui-clipboard-dialog-import-conflict").show(); + + + dialog.dialog("option","title",RED._("clipboard.importNodes")) + .dialog("option","width",500) + .dialog( "open" ); + + } + + function getNodeElement(n, isConflicted, isSelected, parent) { + var element; + if (n.type === "tab") { + element = getFlowLabel(n, isSelected); + } else { + element = getNodeLabel(n, isConflicted, isSelected); + } + var controls = $('
',{class:"red-ui-clipboard-dialog-import-conflicts-controls"}).appendTo(element); + controls.on("click", function(evt) { evt.stopPropagation(); }); + if (isConflicted && !parent) { + var cb = $('').appendTo(controls); + if (n.type === "tab" || (n.type !== "subflow" && n.hasOwnProperty("x") && n.hasOwnProperty("y"))) { + cb.hide(); + } + } + return { + element: element, + gutter: getGutter(n, isSelected, parent) + } + } + + function getGutter(n, isSelected, parent) { + var span = $("