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 2fa4e4427..2b95b35d3 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 @@ -453,10 +453,61 @@ RED.history = (function() { RED.events.emit("nodes:change",newConfigNode); } }); + } else if (i === "env" && ev.node.type.indexOf("subflow:") === 0) { + // Subflow can have config node in node.env + let nodeList = ev.node.env || []; + nodeList = nodeList.reduce((list, prop) => { + if (prop.type === "conf-type" && prop.value) { + list.push(prop.value); + } + return list; + }, []); + + nodeList.forEach(function(id) { + const configNode = RED.nodes.node(id); + if (configNode) { + if (configNode.users.indexOf(ev.node) !== -1) { + configNode.users.splice(configNode.users.indexOf(ev.node), 1); + RED.events.emit("nodes:change", configNode); + } + } + }); + + nodeList = ev.changes.env || []; + nodeList = nodeList.reduce((list, prop) => { + if (prop.type === "conf-type" && prop.value) { + list.push(prop.value); + } + return list; + }, []); + + nodeList.forEach(function(id) { + const configNode = RED.nodes.node(id); + if (configNode) { + if (configNode.users.indexOf(ev.node) === -1) { + configNode.users.push(ev.node); + RED.events.emit("nodes:change", configNode); + } + } + }); + } + if (i === "credentials" && ev.changes[i]) { + // Reset - Only want to keep the changes + inverseEv.changes[i] = {}; + for (const [key, value] of Object.entries(ev.changes[i])) { + // Edge case: node.credentials is cleared after a deploy, so we can't + // capture values for the inverse event when undoing past a deploy + if (ev.node.credentials) { + inverseEv.changes[i][key] = ev.node.credentials[key]; + } + ev.node.credentials[key] = value; + } + } else { + ev.node[i] = ev.changes[i]; } - ev.node[i] = ev.changes[i]; } } + ev.node.dirty = true; ev.node.changed = ev.changed; @@ -536,6 +587,24 @@ RED.history = (function() { RED.editor.updateNodeProperties(ev.node,outputMap); RED.editor.validateNode(ev.node); } + // If it's a Config Node, validate user nodes too. + // NOTE: The Config Node must be validated before validating users. + if (ev.node.users) { + const validatedNodes = new Set(); + const userStack = ev.node.users.slice(); + + validatedNodes.add(ev.node.id); + while (userStack.length) { + const node = userStack.pop(); + if (!validatedNodes.has(node.id)) { + validatedNodes.add(node.id); + if (node.users) { + userStack.push(...node.users); + } + RED.editor.validateNode(node); + } + } + } if (ev.links) { inverseEv.createdLinks = []; for (i=0;i n.outputs)) { n.outputs = n.wires.length; } n.dirty = true; - updateConfigNodeUsers(n); + updateConfigNodeUsers(newNode, { action: "add" }); if (n._def.category == "subflows" && typeof n.i === "undefined") { var nextId = 0; RED.nodes.eachNode(function(node) { @@ -785,6 +785,7 @@ RED.nodes = (function() { delete nodeLinks[id]; removedLinks = links.filter(function(l) { return (l.source === node) || (l.target === node); }); removedLinks.forEach(removeLink); + updateConfigNodeUsers(node, { action: "remove" }); var updatedConfigNode = false; for (var d in node._def.defaults) { if (node._def.defaults.hasOwnProperty(d)) { @@ -798,10 +799,6 @@ RED.nodes = (function() { if (configNode._def.exclusive) { removeNode(node[d]); removedNodes.push(configNode); - } else { - var users = configNode.users; - users.splice(users.indexOf(node),1); - RED.events.emit('nodes:change',configNode) } } } @@ -1798,9 +1795,20 @@ RED.nodes = (function() { // Replace config nodes // configNodeIds.forEach(function(id) { - removedNodes = removedNodes.concat(convertNode(getNode(id))); + const configNode = getNode(id); + const currentUserCount = configNode.users; + + // Add a snapshot of the Config Node + removedNodes = removedNodes.concat(convertNode(configNode)); + + // Remove the Config Node instance removeNode(id); - importNodes([newConfigNodes[id]]) + + // Import the new one + importNodes([newConfigNodes[id]]); + + // Re-attributes the user count + getNode(id).users = currentUserCount; }); return { @@ -2349,29 +2357,31 @@ RED.nodes = (function() { node.type = "unknown"; } if (node._def.category != "config") { - if (n.hasOwnProperty('inputs')) { - node.inputs = n.inputs; + if (n.hasOwnProperty('inputs') && def.defaults.hasOwnProperty("inputs")) { + node.inputs = parseInt(n.inputs, 10); node._config.inputs = JSON.stringify(n.inputs); } else { node.inputs = node._def.inputs; } - if (n.hasOwnProperty('outputs')) { - node.outputs = n.outputs; + if (n.hasOwnProperty('outputs') && def.defaults.hasOwnProperty("outputs")) { + node.outputs = parseInt(n.outputs, 10); node._config.outputs = JSON.stringify(n.outputs); } else { node.outputs = node._def.outputs; } - if (node.hasOwnProperty('wires') && node.wires.length > node.outputs) { - if (!node._def.defaults.hasOwnProperty("outputs") || !isNaN(parseInt(n.outputs))) { - // If 'wires' is longer than outputs, clip wires - console.log("Warning: node.wires longer than node.outputs - trimming wires:",node.id," wires:",node.wires.length," outputs:",node.outputs); - node.wires = node.wires.slice(0,node.outputs); - } else { - // The node declares outputs in its defaults, but has not got a valid value - // Defer to the length of the wires array + + // The node declares outputs in its defaults, but has not got a valid value + // Defer to the length of the wires array + if (node.hasOwnProperty('wires')) { + if (isNaN(node.outputs)) { node.outputs = node.wires.length; + } else if (node.wires.length > node.outputs) { + // If 'wires' is longer than outputs, clip wires + console.log("Warning: node.wires longer than node.outputs - trimming wires:", node.id, " wires:", node.wires.length, " outputs:", node.outputs); + node.wires = node.wires.slice(0, node.outputs); } } + for (d in node._def.defaults) { if (node._def.defaults.hasOwnProperty(d) && d !== 'inputs' && d !== 'outputs') { node[d] = n[d]; @@ -2468,11 +2478,6 @@ RED.nodes = (function() { nodeList = nodeList.map(function(id) { var node = node_map[id]; if (node) { - if (node._def.category === 'config') { - if (node.users.indexOf(n) === -1) { - node.users.push(n); - } - } return node.id; } return id; @@ -2699,25 +2704,78 @@ RED.nodes = (function() { return result; } - // Update any config nodes referenced by the provided node to ensure their 'users' list is correct - function updateConfigNodeUsers(n) { - for (var d in n._def.defaults) { - if (n._def.defaults.hasOwnProperty(d)) { - var property = n._def.defaults[d]; + /** + * Update any config nodes referenced by the provided node to ensure + * their 'users' list is correct. + * + * @param {object} node The node in which to check if it contains references + * @param {object} options Options to apply. + * @param {"add" | "remove"} [options.action] Add or remove the node from + * the Config Node users list. Default `add`. + * @param {boolean} [options.emitEvent] Emit the `nodes:changes` event. + * Default true. + */ + function updateConfigNodeUsers(node, options) { + const defaultOptions = { action: "add", emitEvent: true }; + options = Object.assign({}, defaultOptions, options); + + 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[n[d]]; + var configNode = configNodes[node[d]]; if (configNode) { - if (configNode.users.indexOf(n) === -1) { - configNode.users.push(n); - RED.events.emit('nodes:change',configNode) + if (options.action === "add") { + if (configNode.users.indexOf(node) === -1) { + configNode.users.push(node); + if (options.emitEvent) { + RED.events.emit('nodes:change', configNode); + } + } + } else if (options.action === "remove") { + if (configNode.users.indexOf(node) !== -1) { + const users = configNode.users; + users.splice(users.indexOf(node), 1); + if (options.emitEvent) { + RED.events.emit('nodes:change', configNode); + } + } } } } } } } + + // Subflows can have config node env + if (node.type.indexOf("subflow:") === 0) { + node.env?.forEach((prop) => { + if (prop.type === "conf-type" && prop.value) { + // Add the node to the config node users + const configNode = getNode(prop.value); + if (configNode) { + if (options.action === "add") { + if (configNode.users.indexOf(node) === -1) { + configNode.users.push(node); + if (options.emitEvent) { + RED.events.emit('nodes:change', configNode); + } + } + } else if (options.action === "remove") { + if (configNode.users.indexOf(node) !== -1) { + const users = configNode.users; + users.splice(users.indexOf(node), 1); + if (options.emitEvent) { + RED.events.emit('nodes:change', configNode); + } + } + } + } + } + }); + } } function flowVersion(version) { diff --git a/packages/node_modules/@node-red/editor-client/src/js/ui/editor.js b/packages/node_modules/@node-red/editor-client/src/js/ui/editor.js index 8d515e7c8..ca74b3eff 100644 --- a/packages/node_modules/@node-red/editor-client/src/js/ui/editor.js +++ b/packages/node_modules/@node-red/editor-client/src/js/ui/editor.js @@ -808,6 +808,20 @@ RED.editor = (function() { } } + const oldCreds = {}; + if (editing_node._def.credentials) { + for (const prop in editing_node._def.credentials) { + if (Object.prototype.hasOwnProperty.call(editing_node._def.credentials, prop)) { + if (editing_node._def.credentials[prop].type === 'password') { + oldCreds['has_' + prop] = editing_node.credentials['has_' + prop]; + } + if (prop in editing_node.credentials) { + oldCreds[prop] = editing_node.credentials[prop]; + } + } + } + } + try { const rc = editing_node._def.oneditsave.call(editing_node); if (rc === true) { @@ -839,6 +853,25 @@ RED.editor = (function() { } } } + + if (editing_node._def.credentials) { + for (const prop in editing_node._def.credentials) { + if (Object.prototype.hasOwnProperty.call(editing_node._def.credentials, prop)) { + if (oldCreds[prop] !== editing_node.credentials[prop]) { + if (editing_node.credentials[prop] === '__PWRD__') { + // The password may not exist in oldCreds + // The value '__PWRD__' means the password exists, + // so ignore this change + continue; + } + editState.changes.credentials = editState.changes.credentials || {}; + editState.changes.credentials['has_' + prop] = oldCreds['has_' + prop]; + editState.changes.credentials[prop] = oldCreds[prop]; + editState.changed = true; + } + } + } + } } } @@ -1481,134 +1514,181 @@ RED.editor = (function() { }, { id: "node-config-dialog-ok", - text: adding?RED._("editor.configAdd"):RED._("editor.configUpdate"), + text: adding ? RED._("editor.configAdd") : RED._("editor.configUpdate"), class: "primary", click: function() { - var editState = { + // TODO: Already defined + const configProperty = name; + const configType = type; + const configTypeDef = RED.nodes.getType(configType); + + const wasChanged = editing_config_node.changed; + const editState = { changes: {}, changed: false, outputMap: null }; - var configProperty = name; - var configId = editing_config_node.id; - var configType = type; - var configAdding = adding; - var configTypeDef = RED.nodes.getType(configType); - var d; - var input; + + // Call `oneditsave` and search for changes + handleEditSave(editing_config_node, editState); - if (configTypeDef.oneditsave) { - try { - configTypeDef.oneditsave.call(editing_config_node); - } catch(err) { - console.warn("oneditsave",editing_config_node.id,editing_config_node.type,err.toString()); - } - } - - for (d in configTypeDef.defaults) { - if (configTypeDef.defaults.hasOwnProperty(d)) { - var newValue; - input = $("#node-config-input-"+d); - if (input.attr('type') === "checkbox") { - newValue = input.prop('checked'); - } else if ("format" in configTypeDef.defaults[d] && configTypeDef.defaults[d].format !== "" && input[0].nodeName === "DIV") { - newValue = input.text(); - } else { - newValue = input.val(); - } - if (newValue != null && newValue !== editing_config_node[d]) { - if (editing_config_node._def.defaults[d].type) { - if (newValue == "_ADD_") { - newValue = ""; - } - // Change to a related config node - var configNode = RED.nodes.node(editing_config_node[d]); - if (configNode) { - var users = configNode.users; - users.splice(users.indexOf(editing_config_node),1); - RED.events.emit("nodes:change",configNode); - } - configNode = RED.nodes.node(newValue); - if (configNode) { - configNode.users.push(editing_config_node); - RED.events.emit("nodes:change",configNode); - } - } - editing_config_node[d] = newValue; - } - } - } - - activeEditPanes.forEach(function(pane) { + // Search for changes in the edit box (panes) + activeEditPanes.forEach(function (pane) { if (pane.apply) { pane.apply.call(pane, editState); } - }) + }); - editing_config_node.label = configTypeDef.label; - - var scope = $("#red-ui-editor-config-scope").val(); - editing_config_node.z = scope; + // TODO: Why? + editing_config_node.label = configTypeDef.label + // Check if disabled has changed if ($("#node-config-input-node-disabled").prop('checked')) { if (editing_config_node.d !== true) { + editState.changes.d = editing_config_node.d; + editState.changed = true; editing_config_node.d = true; } } else { if (editing_config_node.d === true) { + editState.changes.d = editing_config_node.d; + editState.changed = true; delete editing_config_node.d; } } + // NOTE: must be undefined if no scope used + const scope = $("#red-ui-editor-config-scope").val() || undefined; + + // Check if the scope has changed + if (editing_config_node.z !== scope) { + editState.changes.z = editing_config_node.z; + editState.changed = true; + editing_config_node.z = scope; + } + + // Search for nodes that use this config node that are no longer + // in scope, so must be removed + const historyEvents = []; if (scope) { - // Search for nodes that use this one that are no longer - // in scope, so must be removed - editing_config_node.users = editing_config_node.users.filter(function(n) { - var keep = true; - for (var d in n._def.defaults) { - if (n._def.defaults.hasOwnProperty(d)) { - if (n._def.defaults[d].type === editing_config_node.type && - n[d] === editing_config_node.id && - n.z !== scope) { - keep = false; - // Remove the reference to this node - // and revalidate - n[d] = null; - n.dirty = true; - n.changed = true; - validateNode(n); + const newUsers = editing_config_node.users.filter(function (node) { + let keepNode = false; + let nodeModified = null; + + for (const d in node._def.defaults) { + if (node._def.defaults.hasOwnProperty(d)) { + if (node._def.defaults[d].type === editing_config_node.type) { + if (node[d] === editing_config_node.id) { + if (node.z === editing_config_node.z) { + // The node is kept only if at least one property uses + // this config node in the correct scope. + keepNode = true; + } else { + if (!nodeModified) { + nodeModified = { + t: "edit", + node: node, + changes: { [d]: node[d] }, + changed: node.changed, + dirty: node.dirty + }; + } else { + nodeModified.changes[d] = node[d]; + } + + // Remove the reference to the config node + node[d] = ""; + } + } } } } - return keep; - }); - } - if (configAdding) { - RED.nodes.add(editing_config_node); - } - - validateNode(editing_config_node); - var validatedNodes = {}; - validatedNodes[editing_config_node.id] = true; - - var userStack = editing_config_node.users.slice(); - while(userStack.length > 0) { - var user = userStack.pop(); - if (!validatedNodes[user.id]) { - validatedNodes[user.id] = true; - if (user.users) { - userStack = userStack.concat(user.users); + // Add the node modified to the history + if (nodeModified) { + historyEvents.push(nodeModified); } - validateNode(user); + + // Mark as changed and revalidate this node + if (!keepNode) { + node.changed = true; + node.dirty = true; + validateNode(node); + RED.events.emit("nodes:change", node); + } + + return keepNode; + }); + + // Check if users are changed + if (editing_config_node.users.length !== newUsers.length) { + editState.changes.users = editing_config_node.users; + editState.changed = true; + editing_config_node.users = newUsers; } } - RED.nodes.dirty(true); - RED.view.redraw(true); - if (!configAdding) { - RED.events.emit("editor:save",editing_config_node); - RED.events.emit("nodes:change",editing_config_node); + + if (editState.changed) { + // Set the congig node as changed + editing_config_node.changed = true; } + + // Now, validate the config node + validateNode(editing_config_node); + + // And validate nodes using this config node too + const validatedNodes = new Set(); + const userStack = editing_config_node.users.slice(); + + validatedNodes.add(editing_config_node.id); + while (userStack.length) { + const node = userStack.pop(); + if (!validatedNodes.has(node.id)) { + validatedNodes.add(node.id); + if (node.users) { + userStack.push(...node.users); + } + validateNode(node); + } + } + + let historyEvent = { + t: "edit", + node: editing_config_node, + changes: editState.changes, + changed: wasChanged, + dirty: RED.nodes.dirty() + }; + + if (historyEvents.length) { + // Need a multi events + historyEvent = { + t: "multi", + events: [historyEvent].concat(historyEvents), + dirty: historyEvent.dirty + }; + } + + if (!adding) { + // This event is triggered when the edit box is saved, + // regardless of whether there are any modifications. + RED.events.emit("editor:save", editing_config_node); + } + + if (editState.changed) { + if (adding) { + RED.history.push({ t: "add", nodes: [editing_config_node.id], dirty: RED.nodes.dirty() }); + // Add the new config node and trigger the `nodes:add` event + RED.nodes.add(editing_config_node); + } else { + RED.history.push(historyEvent); + RED.events.emit("nodes:change", editing_config_node); + } + + RED.nodes.dirty(true); + RED.view.redraw(true); + } + RED.tray.close(function() { var filter = null; // when editing a config via subflow edit panel, the `configProperty` will not diff --git a/packages/node_modules/@node-red/editor-client/src/js/ui/editors/panes/envVarProperties.js b/packages/node_modules/@node-red/editor-client/src/js/ui/editors/panes/envVarProperties.js index b004662be..0c7694126 100644 --- a/packages/node_modules/@node-red/editor-client/src/js/ui/editors/panes/envVarProperties.js +++ b/packages/node_modules/@node-red/editor-client/src/js/ui/editors/panes/envVarProperties.js @@ -20,10 +20,31 @@ apply: function(editState) { var old_env = node.env; var new_env = []; + if (/^subflow:/.test(node.type)) { + // Get the list of environment variables from the node properties new_env = RED.subflow.exportSubflowInstanceEnv(node); } + if (old_env && old_env.length) { + old_env.forEach(function (prop) { + if (prop.type === "conf-type" && prop.value) { + const stillInUse = new_env?.some((p) => p.type === "conf-type" && p.name === prop.name && p.value === prop.value); + if (!stillInUse) { + // Remove the node from the config node users + // Only for empty value or modified + const configNode = RED.nodes.node(prop.value); + if (configNode) { + if (configNode.users.indexOf(node) !== -1) { + configNode.users.splice(configNode.users.indexOf(node), 1); + RED.events.emit('nodes:change', configNode) + } + } + } + } + }); + } + // Get the values from the Properties table tab var items = this.list.editableList('items'); items.each(function (i,el) { @@ -41,7 +62,6 @@ } }); - if (new_env && new_env.length > 0) { new_env.forEach(function(prop) { if (prop.type === "cred") { @@ -52,6 +72,15 @@ editState.changed = true; } delete prop.value; + } else if (prop.type === "conf-type" && prop.value) { + const configNode = RED.nodes.node(prop.value); + if (configNode) { + if (configNode.users.indexOf(node) === -1) { + // Add the node to the config node users + configNode.users.push(node); + RED.events.emit('nodes:change', configNode); + } + } } }); } diff --git a/packages/node_modules/@node-red/editor-client/src/js/ui/editors/panes/properties.js b/packages/node_modules/@node-red/editor-client/src/js/ui/editors/panes/properties.js index cfa72be10..88472f97e 100644 --- a/packages/node_modules/@node-red/editor-client/src/js/ui/editors/panes/properties.js +++ b/packages/node_modules/@node-red/editor-client/src/js/ui/editors/panes/properties.js @@ -44,6 +44,7 @@ apply: function(editState) { var newValue; var d; + // If the node is a subflow, the node's properties (exepts name) are saved by `envProperties` if (node._def.defaults) { for (d in node._def.defaults) { if (node._def.defaults.hasOwnProperty(d)) { @@ -131,9 +132,16 @@ } } if (node._def.credentials) { - var credDefinition = node._def.credentials; - var credsChanged = updateNodeCredentials(node,credDefinition,this.inputClass); - editState.changed = editState.changed || credsChanged; + const credDefinition = node._def.credentials; + const credChanges = updateNodeCredentials(node, credDefinition, this.inputClass); + + if (Object.keys(credChanges).length) { + editState.changed = true; + editState.changes.credentials = { + ...(editState.changes.credentials || {}), + ...credChanges + }; + } } } } @@ -161,10 +169,11 @@ * @param node - the node containing the credentials * @param credDefinition - definition of the credentials * @param prefix - prefix of the input fields - * @return {boolean} whether anything has changed + * @return {object} an object containing the modified properties */ function updateNodeCredentials(node, credDefinition, prefix) { - var changed = false; + const changes = {}; + if (!node.credentials) { node.credentials = {_:{}}; } else if (!node.credentials._) { @@ -177,22 +186,33 @@ if (input.length > 0) { var value = input.val(); if (credDefinition[cred].type == 'password') { - node.credentials['has_' + cred] = (value !== ""); - if (value == '__PWRD__') { - continue; + if (value === '__PWRD__') { + // A cred value exists - no changes + } else if (value === '' && node.credentials['has_' + cred] === false) { + // Empty cred value exists - no changes + } else if (value === node.credentials[cred]) { + // A cred value exists locally in the editor - no changes + // Like the user sets a value, saves the config, + // reopens the config and save the config again + } else { + changes['has_' + cred] = node.credentials['has_' + cred]; + changes[cred] = node.credentials[cred]; + node.credentials[cred] = value; } - changed = true; - } - node.credentials[cred] = value; - if (value != node.credentials._[cred]) { - changed = true; + node.credentials['has_' + cred] = (value !== ''); + } else { + // Since these creds are loaded by the editor, + // values can be directly compared + if (value !== node.credentials[cred]) { + changes[cred] = node.credentials[cred]; + node.credentials[cred] = value; + } } } } } - return changed; + + return changes; } - - })(); diff --git a/packages/node_modules/@node-red/editor-client/src/js/ui/subflow.js b/packages/node_modules/@node-red/editor-client/src/js/ui/subflow.js index 751bf9cfb..3e1b9a410 100644 --- a/packages/node_modules/@node-red/editor-client/src/js/ui/subflow.js +++ b/packages/node_modules/@node-red/editor-client/src/js/ui/subflow.js @@ -1362,7 +1362,7 @@ RED.subflow = (function() { item.value = ""+input.prop("checked"); break; case "conf-types": - item.value = input.val() + item.value = input.val() === "_ADD_" ? "" : input.val(); item.type = "conf-type" } if (ui.type === "cred" || item.type !== data.parent.type || item.value !== data.parent.value) {