diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 8e010c63e..70d36deb1 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -16,7 +16,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - node-version: [18, 20] + node-version: [18, 20, 22] steps: - uses: actions/checkout@v4 - name: Use Node.js ${{ matrix.node-version }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 78663e0a6..2aea548f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,57 @@ +#### 4.0.0-beta.3: Beta Release + +Editor + + - Improve background-deploy notification handling (#4692) @knolleary + - Hide workspace tab on middle mouse click (#4657) @Steve-Mcl + - multiplayer: Add user presence indicators (#4666) @knolleary + - Enable updating dependency node of package.json in project feature (#4676) @kazuhitoyokoi + - Add French translations for 4.0.0-beta.2 (#4681) @GogoVega + - Add Japanese translations for 4.0.0-beta.2 (#4674) @kazuhitoyokoi + - Fix saving of conf-type properties in module packaged subflows (#4658) @knolleary + - Add npm install timeout notification (#4662) @hardillb + - Fix undo of subflow env property edits (#4667) @knolleary + - Fix three error typos in monaco.js (#4660) @JoshuaCWebDeveloper + - docs: Add closing paragraph tag (#4664) @ZJvandeWeg + - Avoid login loops when autoLogin enabled but login fails (#4684) @knolleary + +Runtime + + - Allow blank strings to be used for env var property substitutions (#4672) @knolleary + - Use rfdc for cloning pure JSON values (#4679) @knolleary + - fix: remove outdated Node 11+ check (#4314) @Rotzbua + - feat(ci): add new nodejs v22 (#4694) @Rotzbua + - fix(node): increase required node >=18.5 (#4690) @Rotzbua + - fix(dns): remove outdated node check (#4689) @Rotzbua + - fix(polyfill): remove import module polyfill (#4688) @Rotzbua + - Fix typo (#4686) @Rotzbua + +Nodes + + - Pass full error object in Function node and copy over cause property (#4685) @knolleary + - Replacing vm.createScript in favour of vm.Script (#4534) @patlux + +#### 4.0.0-beta.2: Beta Release + +Editor + + - Introduce multiplayer feature (#4629) @knolleary + - Separate the "add new config-node" option into a new (+) button (#4627) @GogoVega + - Retain Palette categories collapsed and filter to localStorage (#4634) @knolleary + - Ensure palette filter reapplies and clear up unknown categories (#4637) @knolleary + - Add support for plugin (only) modules to the palette manager (#4620) @knolleary + - Update monaco to latest and node types to 18 LTS (#4615) @Steve-Mcl + +Runtime + + - Fix handling of subflow config-node select type in sf module (#4643) @knolleary + - Comms API updates (#4628) @knolleary + - Add French translations for 4.0.0-beta.1 (#4621) @GogoVega + - Add Japanese translations for 4.0.0-beta.1 (#4612) @kazuhitoyokoi + +Nodes + - Fix change node handling of replacing with boolean (#4639) @knolleary + #### 4.0.0-beta.1: Beta Release Editor @@ -29,6 +83,22 @@ Nodes - Let debug node status msg length be settable via settings (#4402) @dceejay - Feat: Add ability to set headers for WebSocket client (#4436) @marcus-j-davies +#### 3.1.9: Maintenance Release + + - Prevent subflow being added to itself (#4654) @knolleary + - Fix use of spawn on windows with cmd files (#4652) @knolleary + - Guard refresh of unknown subflow (#4640) @knolleary + - Fix subflow module sending messages to debug sidebar (#4642) @knolleary + +#### 3.1.8: Maintenance Release + + - Add validation and error handling on subflow instance properties (#4632) @knolleary + - Hide import/export context menu if disabled in theme (#4633) @knolleary + - Show change indicator on subflow tabs (#4631) @knolleary + - Bump dependencies (#4630) @knolleary + - Reset workspace index when clearing nodes (#4619) @knolleary + - Remove typo in global config (#4613) @kazuhitoyokoi + #### 3.1.7: Maintenance Release - Add Japanese translation for v3.1.6 (#4603) @kazuhitoyokoi diff --git a/Gruntfile.js b/Gruntfile.js index 09b057837..b599d0b0f 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -143,6 +143,7 @@ module.exports = function(grunt) { "packages/node_modules/@node-red/editor-client/src/js/user.js", "packages/node_modules/@node-red/editor-client/src/js/comms.js", "packages/node_modules/@node-red/editor-client/src/js/runtime.js", + "packages/node_modules/@node-red/editor-client/src/js/multiplayer.js", "packages/node_modules/@node-red/editor-client/src/js/text/bidi.js", "packages/node_modules/@node-red/editor-client/src/js/text/format.js", "packages/node_modules/@node-red/editor-client/src/js/ui/state.js", diff --git a/package.json b/package.json index d58680afd..5d7922789 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "node-red", - "version": "4.0.0-beta.1", + "version": "4.0.0-beta.3-1", "description": "Low-code programming for event-driven applications", "homepage": "https://nodered.org", "license": "Apache-2.0", @@ -73,8 +73,9 @@ "passport-http-bearer": "1.0.1", "passport-oauth2-client-password": "0.1.2", "raw-body": "2.5.2", + "rfdc": "^1.3.1", "semver": "7.5.4", - "tar": "6.1.13", + "tar": "6.2.1", "tough-cookie": "4.1.3", "uglify-js": "3.17.4", "uuid": "9.0.0", @@ -122,6 +123,6 @@ "supertest": "6.3.3" }, "engines": { - "node": ">=18" + "node": ">=18.5" } } diff --git a/packages/node_modules/@node-red/editor-api/lib/auth/index.js b/packages/node_modules/@node-red/editor-api/lib/auth/index.js index eefdd85e0..c5e1d93c7 100644 --- a/packages/node_modules/@node-red/editor-api/lib/auth/index.js +++ b/packages/node_modules/@node-red/editor-api/lib/auth/index.js @@ -160,20 +160,30 @@ function completeVerify(profile,done) { function genericStrategy(adminApp,strategy) { - var crypto = require("crypto") - var session = require('express-session') - var MemoryStore = require('memorystore')(session) + const crypto = require("crypto") + const session = require('express-session') + const MemoryStore = require('memorystore')(session) - adminApp.use(session({ - // As the session is only used across the life-span of an auth - // hand-shake, we can use a instance specific random string - secret: crypto.randomBytes(20).toString('hex'), - resave: false, - saveUninitialized: false, - store: new MemoryStore({ - checkPeriod: 86400000 // prune expired entries every 24h - }) - })); + const sessionOptions = { + // As the session is only used across the life-span of an auth + // hand-shake, we can use a instance specific random string + secret: crypto.randomBytes(20).toString('hex'), + resave: false, + saveUninitialized: false, + store: new MemoryStore({ + checkPeriod: 86400000 // prune expired entries every 24h + }) + } + if (settings.httpAdminCookieOptions) { + sessionOptions.cookie = { + path: '/', + httpOnly: true, + secure: false, + maxAge: null, + ...settings.httpAdminCookieOptions + } + } + adminApp.use(session(sessionOptions)); //TODO: all passport references ought to be in ./auth adminApp.use(passport.initialize()); adminApp.use(passport.session()); @@ -205,9 +215,10 @@ function genericStrategy(adminApp,strategy) { passport.use(new strategy.strategy(options, verify)); adminApp.get('/auth/strategy', - passport.authenticate(strategy.name, {session:false, + passport.authenticate(strategy.name, { + session:false, failureMessage: true, - failureRedirect: settings.httpAdminRoot + failureRedirect: settings.httpAdminRoot + '?session_message=Login Failed' }), completeGenerateStrategyAuth, handleStrategyError @@ -221,7 +232,7 @@ function genericStrategy(adminApp,strategy) { passport.authenticate(strategy.name, { session:false, failureMessage: true, - failureRedirect: settings.httpAdminRoot + failureRedirect: settings.httpAdminRoot + '?session_message=Login Failed' }), completeGenerateStrategyAuth, handleStrategyError diff --git a/packages/node_modules/@node-red/editor-api/lib/auth/permissions.js b/packages/node_modules/@node-red/editor-api/lib/auth/permissions.js index ba5005ba8..77545a73c 100644 --- a/packages/node_modules/@node-red/editor-api/lib/auth/permissions.js +++ b/packages/node_modules/@node-red/editor-api/lib/auth/permissions.js @@ -25,7 +25,7 @@ function hasPermission(userScope,permission) { } var i; - if (util.isArray(permission)) { + if (Array.isArray(permission)) { // Multiple permissions requested - check each one for (i=0;iFailed to load node catalogue.

Check the browser console for more information

", "installFailed": "

Failed to install: __module__

__message__

Check the log for more information

", + "installTimeout": "

Install continuing the background.

Nodes will appear in palette when complete. Check the log for more information.

", "removeFailed": "

Failed to remove: __module__

__message__

Check the log for more information

", "updateFailed": "

Failed to update: __module__

__message__

Check the log for more information

", "enableFailed": "

Failed to enable: __module__

__message__

Check the log for more information

", @@ -657,6 +659,9 @@ "body": "

Removing '__module__'

Removing the node will uninstall it from Node-RED. The node may continue to use resources until Node-RED is restarted.

", "title": "Remove nodes" }, + "removePlugin": { + "body": "

Removed plugin __module__. Please reload the editor to clear left-overs.

" + }, "update": { "body": "

Updating '__module__'

Updating the node will require a restart of Node-RED to complete the update. This must be done manually.

", "title": "Update nodes" @@ -668,7 +673,8 @@ "review": "Open node information", "install": "Install", "remove": "Remove", - "update": "Update" + "update": "Update", + "understood": "Understood" } } } diff --git a/packages/node_modules/@node-red/editor-client/locales/fr/editor.json b/packages/node_modules/@node-red/editor-client/locales/fr/editor.json index 1043f6f9d..e0ebdbb2a 100644 --- a/packages/node_modules/@node-red/editor-client/locales/fr/editor.json +++ b/packages/node_modules/@node-red/editor-client/locales/fr/editor.json @@ -614,6 +614,8 @@ }, "nodeCount": "__label__ noeud", "nodeCount_plural": "__label__ noeuds", + "pluginCount": "__count__ plugin", + "pluginCount_plural": "__count__ plugins", "moduleCount": "__count__ module disponible", "moduleCount_plural": "__count__ modules disponibles", "inuse": "En cours d'utilisation", @@ -641,6 +643,7 @@ "errors": { "catalogLoadFailed": "

Échec du chargement du catalogue de noeuds.

Vérifier la console du navigateur pour plus d'informations

", "installFailed": "

Échec lors de l'installation : __module__

__message__

Consulter le journal pour plus d'informations

", + "installTimeout": "

L'installation continue en arrière-plan.

Les noeuds apparaîtront dans la palette une fois l'installation terminée. Consulter le journal pour plus d'informations.

", "removeFailed": "

Échec lors de la suppression : __module__

__message__

Consulter le journal pour plus d'informations

", "updateFailed": "

Échec lors de la mise à jour : __module__

__message__

Consulter le journal pour plus d'informations

", "enableFailed": "

Échec lors de l'activation : __module__

__message__

Consulter le journal pour plus d'informations

", @@ -652,9 +655,12 @@ "title": "Installer les noeuds" }, "remove": { - "body": "

Suppression de '__module__'

La suppression du noeud le désinstallera de Node-RED. Le noeud peut continuer à utiliser des ressources jusqu'au redémarrage de Node-RED.

", + "body": "

Suppression de '__module__'

La suppression du noeud le désinstallera de Node-RED. Le noeud peut continuer à utiliser ses ressources jusqu'au redémarrage de Node-RED.

", "title": "Supprimer les noeuds" }, + "removePlugin": { + "body": "

Suppression du plugin '__module__'. Veuillez recharger l'éditeur afin d'appliquer les changements.

" + }, "update": { "body": "

Mise à jour de '__module__'

La mise à jour du noeud nécessitera un redémarrage de Node-RED pour terminer la mise à jour. Cela doit être fait manuellement.

", "title": "Mettre à jour les noeuds" @@ -666,7 +672,8 @@ "review": "Ouvrir la documentation", "install": "Installer", "remove": "Supprimer", - "update": "Mettre à jour" + "update": "Mettre à jour", + "understood": "Compris" } } } diff --git a/packages/node_modules/@node-red/editor-client/locales/ja/editor.json b/packages/node_modules/@node-red/editor-client/locales/ja/editor.json index ad0ddb4ce..33e56f9ae 100644 --- a/packages/node_modules/@node-red/editor-client/locales/ja/editor.json +++ b/packages/node_modules/@node-red/editor-client/locales/ja/editor.json @@ -372,6 +372,7 @@ "deleted": "削除", "flowDeleted": "削除されたフロー", "flowAdded": "追加されたフロー", + "moved": "移動", "movedTo": "__id__ へ移動", "movedFrom": "__id__ から移動" }, @@ -614,6 +615,8 @@ }, "nodeCount": "__label__ 個のノード", "nodeCount_plural": "__label__ 個のノード", + "pluginCount": "__count__ 個のプラグイン", + "pluginCount_plural": "__count__ 個のプラグイン", "moduleCount": "__count__ 個のモジュール", "moduleCount_plural": "__count__ 個のモジュール", "inuse": "使用中", @@ -641,6 +644,7 @@ "errors": { "catalogLoadFailed": "

ノードのカタログの読み込みに失敗しました。

詳細はブラウザのコンソールを確認してください。

", "installFailed": "

追加処理が失敗しました: __module__

__message__

詳細はログを確認してください。

", + "installTimeout": "

バックグラウンドでインストールが継続されます。

完了した時にノードが表示されます。詳細はログを確認してください。

", "removeFailed": "

削除処理が失敗しました: __module__

__message__

詳細はログを確認してください。

", "updateFailed": "

更新処理が失敗しました: __module__

__message__

詳細はログを確認してください。

", "enableFailed": "

有効化処理が失敗しました: __module__

__message__

詳細はログを確認してください。

", @@ -655,6 +659,9 @@ "body": "

__module__ を削除します。

Node-REDからノードを削除します。ノードはNode-REDが再起動されるまで、リソースを使い続ける可能性があります。

", "title": "ノードを削除" }, + "removePlugin": { + "body": "

プラグイン __module__ を削除しました。ブラウザを再読み込みして残った表示を消してください。

" + }, "update": { "body": "

__module__ を更新します。

更新を完了するには手動でNode-REDを再起動する必要があります。

", "title": "ノードの更新" @@ -666,7 +673,8 @@ "review": "ノードの情報を参照", "install": "追加", "remove": "削除", - "update": "更新" + "update": "更新", + "understood": "了解" } } } diff --git a/packages/node_modules/@node-red/editor-client/package.json b/packages/node_modules/@node-red/editor-client/package.json index c6d49fced..1b9193f97 100644 --- a/packages/node_modules/@node-red/editor-client/package.json +++ b/packages/node_modules/@node-red/editor-client/package.json @@ -1,6 +1,6 @@ { "name": "@node-red/editor-client", - "version": "4.0.0-beta.1", + "version": "4.0.0-beta.3-1", "license": "Apache-2.0", "repository": { "type": "git", 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 c3a966890..2fa4e4427 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 @@ -29,7 +29,14 @@ RED.history = (function() { } return RED.nodes.junction(id); } - + function ensureUnlocked(id, flowsToLock) { + const flow = id && (RED.nodes.workspace(id) || RED.nodes.subflow(id) || null); + const isLocked = flow ? flow.locked : false; + if (flow && isLocked) { + flow.locked = false; + flowsToLock.add(flow) + } + } function undoEvent(ev) { var i; var len; @@ -59,18 +66,46 @@ RED.history = (function() { t: 'replace', config: RED.nodes.createCompleteNodeSet(), changed: {}, - rev: RED.nodes.version() + moved: {}, + complete: true, + rev: RED.nodes.version(), + dirty: RED.nodes.dirty() }; + var selectedTab = RED.workspaces.active(); + inverseEv.config.forEach(n => { + const node = RED.nodes.node(n.id) + if (node) { + inverseEv.changed[n.id] = node.changed + inverseEv.moved[n.id] = node.moved + } + }) RED.nodes.clear(); var imported = RED.nodes.import(ev.config); + // Clear all change flags from the import + RED.nodes.dirty(false); + + const flowsToLock = new Set() + imported.nodes.forEach(function(n) { if (ev.changed[n.id]) { + ensureUnlocked(n.z, flowsToLock) n.changed = true; - inverseEv.changed[n.id] = true; } + if (ev.moved[n.id]) { + ensureUnlocked(n.z, flowsToLock) + n.moved = true; + } + }) + flowsToLock.forEach(flow => { + flow.locked = true }) RED.nodes.version(ev.rev); + RED.view.redraw(true); + RED.palette.refresh(); + RED.workspaces.refresh(); + RED.workspaces.show(selectedTab, true); + RED.sidebar.config.refresh(); } else { var importMap = {}; ev.config.forEach(function(n) { diff --git a/packages/node_modules/@node-red/editor-client/src/js/multiplayer.js b/packages/node_modules/@node-red/editor-client/src/js/multiplayer.js new file mode 100644 index 000000000..ea836eaf4 --- /dev/null +++ b/packages/node_modules/@node-red/editor-client/src/js/multiplayer.js @@ -0,0 +1,490 @@ +RED.multiplayer = (function () { + + // activeSessionId - used to identify sessions across websocket reconnects + let activeSessionId + + let headerWidget + // Map of session id to { session:'', user:{}, location:{}} + let sessions = {} + // Map of username to { user:{}, sessions:[] } + let users = {} + + function addUserSession (session) { + if (sessions[session.session]) { + // This is an existing connection that has been authenticated + const existingSession = sessions[session.session] + if (existingSession.user.username !== session.user.username) { + removeUserHeaderButton(users[existingSession.user.username]) + } + } + sessions[session.session] = session + const user = users[session.user.username] = users[session.user.username] || { + user: session.user, + sessions: [] + } + if (session.user.profileColor === undefined) { + session.user.profileColor = (1 + Math.floor(Math.random() * 5)) + } + session.location = session.location || {} + user.sessions.push(session) + + if (session.session === activeSessionId) { + // This is the current user session - do not add a extra button for them + } else { + if (user.sessions.length === 1) { + if (user.button) { + clearTimeout(user.inactiveTimeout) + clearTimeout(user.removeTimeout) + user.button.removeClass('inactive') + } else { + addUserHeaderButton(user) + } + } + sessions[session.session].location = session.location + updateUserLocation(session.session) + } + } + + function removeUserSession (sessionId, isDisconnected) { + removeUserLocation(sessionId) + const session = sessions[sessionId] + delete sessions[sessionId] + const user = users[session.user.username] + const i = user.sessions.indexOf(session) + user.sessions.splice(i, 1) + if (isDisconnected) { + removeUserHeaderButton(user) + } else { + if (user.sessions.length === 0) { + // Give the user 5s to reconnect before marking inactive + user.inactiveTimeout = setTimeout(() => { + user.button.addClass('inactive') + // Give the user further 20 seconds to reconnect before removing them + // from the user toolbar entirely + user.removeTimeout = setTimeout(() => { + removeUserHeaderButton(user) + }, 20000) + }, 5000) + } + } + } + + function addUserHeaderButton (user) { + user.button = $('
  • ') + .attr('data-username', user.user.username) + .prependTo("#red-ui-multiplayer-user-list"); + var button = user.button.find("button") + RED.popover.tooltip(button, user.user.username) + button.on('click', function () { + const location = user.sessions[0].location + revealUser(location) + }) + + const userProfile = RED.user.generateUserIcon(user.user) + userProfile.appendTo(button) + } + + function removeUserHeaderButton (user) { + user.button.remove() + delete user.button + } + + function getLocation () { + const location = { + workspace: RED.workspaces.active() + } + const editStack = RED.editor.getEditStack() + for (let i = editStack.length - 1; i >= 0; i--) { + if (editStack[i].id) { + location.node = editStack[i].id + break + } + } + return location + } + function publishLocation () { + const location = getLocation() + if (location.workspace !== 0) { + log('send', 'multiplayer/location', location) + RED.comms.send('multiplayer/location', location) + } + } + + function revealUser(location, skipWorkspace) { + if (location.node) { + // Need to check if this is a known node, so we can fall back to revealing + // the workspace instead + const node = RED.nodes.node(location.node) + if (node) { + RED.view.reveal(location.node) + } else if (!skipWorkspace && location.workspace) { + RED.view.reveal(location.workspace) + } + } else if (!skipWorkspace && location.workspace) { + RED.view.reveal(location.workspace) + } + } + + const workspaceTrays = {} + function getWorkspaceTray(workspaceId) { + // console.log('get tray for',workspaceId) + if (!workspaceTrays[workspaceId]) { + const tray = $('
    ') + const users = [] + const userIcons = {} + + const userCountIcon = $(`
    `) + const userCountSpan = userCountIcon.find('span span') + userCountIcon.hide() + userCountSpan.text('') + userCountIcon.appendTo(tray) + const userCountTooltip = RED.popover.tooltip(userCountIcon, function () { + const content = $('
    ') + users.forEach(sessionId => { + $('
    ').append($('').text(sessions[sessionId].user.username).on('click', function (evt) { + evt.preventDefault() + revealUser(sessions[sessionId].location, true) + userCountTooltip.close() + })).appendTo(content) + }) + return content + }, + null, + true + ) + + const updateUserCount = function () { + const maxShown = 2 + const children = tray.children() + children.each(function (index, element) { + const i = users.length - index + if (i > maxShown) { + $(this).hide() + } else if (i >= 0) { + $(this).show() + } + }) + if (users.length < maxShown + 1) { + userCountIcon.hide() + } else { + userCountSpan.text('+'+(users.length - maxShown)) + userCountIcon.show() + } + } + workspaceTrays[workspaceId] = { + attached: false, + tray, + users, + userIcons, + addUser: function (sessionId) { + if (users.indexOf(sessionId) === -1) { + // console.log(`addUser ws:${workspaceId} session:${sessionId}`) + users.push(sessionId) + const userLocationId = `red-ui-multiplayer-user-location-${sessionId}` + const userLocationIcon = $(`
    `) + RED.user.generateUserIcon(sessions[sessionId].user).appendTo(userLocationIcon) + userLocationIcon.prependTo(tray) + RED.popover.tooltip(userLocationIcon, sessions[sessionId].user.username) + userIcons[sessionId] = userLocationIcon + updateUserCount() + } + }, + removeUser: function (sessionId) { + // console.log(`removeUser ws:${workspaceId} session:${sessionId}`) + const userLocationId = `red-ui-multiplayer-user-location-${sessionId}` + const index = users.indexOf(sessionId) + if (index > -1) { + users.splice(index, 1) + userIcons[sessionId].remove() + delete userIcons[sessionId] + } + updateUserCount() + }, + updateUserCount + } + } + const trayDef = workspaceTrays[workspaceId] + if (!trayDef.attached) { + const workspaceTab = $(`#red-ui-tab-${workspaceId}`) + if (workspaceTab.length > 0) { + trayDef.attached = true + trayDef.tray.appendTo(workspaceTab) + trayDef.users.forEach(sessionId => { + trayDef.userIcons[sessionId].on('click', function (evt) { + revealUser(sessions[sessionId].location, true) + }) + }) + } + } + return workspaceTrays[workspaceId] + } + function attachWorkspaceTrays () { + let viewTouched = false + for (let sessionId of Object.keys(sessions)) { + const location = sessions[sessionId].location + if (location) { + if (location.workspace) { + getWorkspaceTray(location.workspace).updateUserCount() + } + if (location.node) { + addUserToNode(sessionId, location.node) + viewTouched = true + } + } + } + if (viewTouched) { + RED.view.redraw() + } + } + + function addUserToNode(sessionId, nodeId) { + const node = RED.nodes.node(nodeId) + if (node) { + if (!node._multiplayer) { + node._multiplayer = { + users: [sessionId] + } + node._multiplayer_refresh = true + } else { + if (node._multiplayer.users.indexOf(sessionId) === -1) { + node._multiplayer.users.push(sessionId) + node._multiplayer_refresh = true + } + } + } + } + function removeUserFromNode(sessionId, nodeId) { + const node = RED.nodes.node(nodeId) + if (node && node._multiplayer) { + const i = node._multiplayer.users.indexOf(sessionId) + if (i > -1) { + node._multiplayer.users.splice(i, 1) + } + if (node._multiplayer.users.length === 0) { + delete node._multiplayer + } else { + node._multiplayer_refresh = true + } + } + + } + + function removeUserLocation (sessionId) { + updateUserLocation(sessionId, {}) + } + function updateUserLocation (sessionId, location) { + let viewTouched = false + const oldLocation = sessions[sessionId].location + if (location) { + if (oldLocation.workspace !== location.workspace) { + // console.log('removing', sessionId, oldLocation.workspace) + workspaceTrays[oldLocation.workspace]?.removeUser(sessionId) + } + if (oldLocation.node !== location.node) { + removeUserFromNode(sessionId, oldLocation.node) + viewTouched = true + } + sessions[sessionId].location = location + } else { + location = sessions[sessionId].location + } + // console.log(`updateUserLocation sessionId:${sessionId} oldWS:${oldLocation?.workspace} newWS:${location.workspace}`) + if (location.workspace) { + getWorkspaceTray(location.workspace).addUser(sessionId) + } + if (location.node) { + addUserToNode(sessionId, location.node) + viewTouched = true + } + if (viewTouched) { + RED.view.redraw() + } + } + + // function refreshUserLocations () { + // for (const session of Object.keys(sessions)) { + // if (session !== activeSessionId) { + // updateUserLocation(session) + // } + // } + // } + + return { + init: function () { + + function createAnnotationUser(user) { + + const group = document.createElementNS("http://www.w3.org/2000/svg","g"); + const badge = document.createElementNS("http://www.w3.org/2000/svg","circle"); + const radius = 20 + badge.setAttribute("cx",radius/2); + badge.setAttribute("cy",radius/2); + badge.setAttribute("r",radius/2); + badge.setAttribute("class", "red-ui-multiplayer-annotation-background") + group.appendChild(badge) + if (user && user.profileColor !== undefined) { + badge.setAttribute("class", "red-ui-multiplayer-annotation-background red-ui-user-profile-color-" + user.profileColor) + } + if (user && user.image) { + const image = document.createElementNS("http://www.w3.org/2000/svg","image"); + image.setAttribute("width", radius) + image.setAttribute("height", radius) + image.setAttribute("href", user.image) + image.setAttribute("clip-path", "circle("+Math.floor(radius/2)+")") + group.appendChild(image) + } else if (user && user.anonymous) { + const anonIconHead = document.createElementNS("http://www.w3.org/2000/svg","circle"); + anonIconHead.setAttribute("cx", radius/2) + anonIconHead.setAttribute("cy", radius/2 - 2) + anonIconHead.setAttribute("r", 2.4) + anonIconHead.setAttribute("class","red-ui-multiplayer-annotation-anon-label"); + group.appendChild(anonIconHead) + const anonIconBody = document.createElementNS("http://www.w3.org/2000/svg","path"); + anonIconBody.setAttribute("class","red-ui-multiplayer-annotation-anon-label"); + // anonIconBody.setAttribute("d",`M ${radius/2 - 4} ${radius/2 + 1} h 8 v4 h -8 z`); + anonIconBody.setAttribute("d",`M ${radius/2} ${radius/2 + 5} h -2.5 c -2 1 -2 -5 0.5 -4.5 c 2 1 2 1 4 0 c 2.5 -0.5 2.5 5.5 0 4.5 z`); + group.appendChild(anonIconBody) + } else { + const labelText = user.username ? user.username.substring(0,2) : user + const label = document.createElementNS("http://www.w3.org/2000/svg","text"); + if (user.username) { + label.setAttribute("class","red-ui-multiplayer-annotation-label"); + label.textContent = user.username.substring(0,2) + } else { + label.setAttribute("class","red-ui-multiplayer-annotation-label red-ui-multiplayer-user-count") + label.textContent = user + } + label.setAttribute("text-anchor", "middle") + label.setAttribute("x",radius/2); + label.setAttribute("y",radius/2 + 3); + group.appendChild(label) + } + const border = document.createElementNS("http://www.w3.org/2000/svg","circle"); + border.setAttribute("cx",radius/2); + border.setAttribute("cy",radius/2); + border.setAttribute("r",radius/2); + border.setAttribute("class", "red-ui-multiplayer-annotation-border") + group.appendChild(border) + + + + return group + } + + RED.view.annotations.register("red-ui-multiplayer",{ + type: 'badge', + align: 'left', + class: "red-ui-multiplayer-annotation", + show: "_multiplayer", + refresh: "_multiplayer_refresh", + element: function(node) { + const containerGroup = document.createElementNS("http://www.w3.org/2000/svg","g"); + containerGroup.setAttribute("transform","translate(0,-4)") + if (node._multiplayer) { + let y = 0 + for (let i = Math.min(1, node._multiplayer.users.length - 1); i >= 0; i--) { + const user = sessions[node._multiplayer.users[i]].user + const group = createAnnotationUser(user) + group.setAttribute("transform","translate("+y+",0)") + y += 15 + containerGroup.appendChild(group) + } + if (node._multiplayer.users.length > 2) { + const group = createAnnotationUser('+'+(node._multiplayer.users.length - 2)) + group.setAttribute("transform","translate("+y+",0)") + y += 12 + containerGroup.appendChild(group) + } + + } + return containerGroup; + }, + tooltip: node => { return node._multiplayer.users.map(u => sessions[u].user.username).join('\n') } + }); + + + // activeSessionId = RED.settings.getLocal('multiplayer:sessionId') + // if (!activeSessionId) { + activeSessionId = RED.nodes.id() + // RED.settings.setLocal('multiplayer:sessionId', activeSessionId) + // log('Session ID (new)', activeSessionId) + // } else { + log('Session ID', activeSessionId) + // } + + headerWidget = $('
    • ').prependTo('.red-ui-header-toolbar') + + RED.comms.on('connect', () => { + const location = getLocation() + const connectInfo = { + session: activeSessionId + } + if (location.workspace !== 0) { + connectInfo.location = location + } + RED.comms.send('multiplayer/connect', connectInfo) + }) + RED.comms.subscribe('multiplayer/#', (topic, msg) => { + log('recv', topic, msg) + if (topic === 'multiplayer/init') { + // We have just reconnected, runtime has sent state to + // initialise the world + sessions = {} + users = {} + $('#red-ui-multiplayer-user-list').empty() + + msg.sessions.forEach(session => { + addUserSession(session) + }) + } else if (topic === 'multiplayer/connection-added') { + addUserSession(msg) + } else if (topic === 'multiplayer/connection-removed') { + removeUserSession(msg.session, msg.disconnected) + } else if (topic === 'multiplayer/location') { + const session = msg.session + delete msg.session + updateUserLocation(session, msg) + } + }) + + RED.events.on('workspace:change', (event) => { + getWorkspaceTray(event.workspace) + publishLocation() + }) + RED.events.on('editor:open', () => { + publishLocation() + }) + RED.events.on('editor:close', () => { + publishLocation() + }) + RED.events.on('editor:change', () => { + publishLocation() + }) + RED.events.on('login', () => { + publishLocation() + }) + RED.events.on('flows:loaded', () => { + attachWorkspaceTrays() + }) + RED.events.on('workspace:close', (event) => { + // A subflow tab has been closed. Need to mark its tray as detached + if (workspaceTrays[event.workspace]) { + workspaceTrays[event.workspace].attached = false + } + }) + RED.events.on('logout', () => { + const disconnectInfo = { + session: activeSessionId + } + RED.comms.send('multiplayer/disconnect', disconnectInfo) + RED.settings.removeLocal('multiplayer:sessionId') + }) + } + } + + function log() { + if (RED.multiplayer.DEBUG) { + console.log('[multiplayer]', ...arguments) + } + } +})(); diff --git a/packages/node_modules/@node-red/editor-client/src/js/red.js b/packages/node_modules/@node-red/editor-client/src/js/red.js index e33672342..f8130a686 100644 --- a/packages/node_modules/@node-red/editor-client/src/js/red.js +++ b/packages/node_modules/@node-red/editor-client/src/js/red.js @@ -298,6 +298,7 @@ var RED = (function() { RED.workspaces.show(workspaces[0]); } } + RED.events.emit('flows:loaded') } catch(err) { console.warn(err); RED.notify( @@ -839,6 +840,10 @@ var RED = (function() { RED.nodes.init(); RED.runtime.init() + + if (RED.settings.theme("multiplayer.enabled",false)) { + RED.multiplayer.init() + } RED.comms.connect(); $("#red-ui-main-container").show(); diff --git a/packages/node_modules/@node-red/editor-client/src/js/ui/common/popover.js b/packages/node_modules/@node-red/editor-client/src/js/ui/common/popover.js index 9ddd3d866..381bb9d3a 100644 --- a/packages/node_modules/@node-red/editor-client/src/js/ui/common/popover.js +++ b/packages/node_modules/@node-red/editor-client/src/js/ui/common/popover.js @@ -211,7 +211,7 @@ RED.popover = (function() { closePopup(true); }); } - if (trigger === 'hover' && options.interactive) { + if (/*trigger === 'hover' && */options.interactive) { div.on('mouseenter', function(e) { clearTimeout(timer); active = true; @@ -445,9 +445,12 @@ RED.popover = (function() { return { create: createPopover, - tooltip: function(target,content, action) { + tooltip: function(target,content, action, interactive) { var label = function() { var label = content; + if (typeof content === 'function') { + label = content() + } if (action) { var shortcut = RED.keyboard.getShortcut(action); if (shortcut && shortcut.key) { @@ -463,6 +466,7 @@ RED.popover = (function() { size: "small", direction: "bottom", content: label, + interactive, delay: { show: 750, hide: 50 } }); popover.setContent = function(newContent) { diff --git a/packages/node_modules/@node-red/editor-client/src/js/ui/common/tabs.js b/packages/node_modules/@node-red/editor-client/src/js/ui/common/tabs.js index abb76e622..d9dc4b289 100644 --- a/packages/node_modules/@node-red/editor-client/src/js/ui/common/tabs.js +++ b/packages/node_modules/@node-red/editor-client/src/js/ui/common/tabs.js @@ -365,7 +365,10 @@ RED.tabs = (function() { var thisTabA = thisTab.find("a"); if (options.onclick) { - options.onclick(tabs[thisTabA.attr('href').slice(1)]); + options.onclick(tabs[thisTabA.attr('href').slice(1)], evt); + if (evt.isDefaultPrevented() && evt.isPropagationStopped()) { + return false + } } activateTab(thisTabA); if (fireSelectionChanged) { @@ -548,6 +551,8 @@ RED.tabs = (function() { ul.find("li.red-ui-tab a") .on("mousedown", function(evt) { mousedownTab = evt.currentTarget }) .on("mouseup",onTabClick) + // prevent browser-default middle-click behaviour + .on("auxclick", function(evt) { evt.preventDefault() }) .on("click", function(evt) {evt.preventDefault(); }) .on("dblclick", function(evt) {evt.stopPropagation(); evt.preventDefault(); }) @@ -816,6 +821,8 @@ RED.tabs = (function() { } link.on("mousedown", function(evt) { mousedownTab = evt.currentTarget }) link.on("mouseup",onTabClick); + // prevent browser-default middle-click behaviour + link.on("auxclick", function(evt) { evt.preventDefault() }) link.on("click", function(evt) { evt.preventDefault(); }) link.on("dblclick", function(evt) { evt.stopPropagation(); evt.preventDefault(); }) diff --git a/packages/node_modules/@node-red/editor-client/src/js/ui/deploy.js b/packages/node_modules/@node-red/editor-client/src/js/ui/deploy.js index a09fdeb01..90857e865 100644 --- a/packages/node_modules/@node-red/editor-client/src/js/ui/deploy.js +++ b/packages/node_modules/@node-red/editor-client/src/js/ui/deploy.js @@ -34,6 +34,8 @@ RED.deploy = (function() { var currentDiff = null; + var activeBackgroundDeployNotification; + function changeDeploymentType(type) { deploymentType = type; $("#red-ui-header-button-deploy-icon").attr("src",deploymentTypes[type].img); @@ -61,7 +63,7 @@ RED.deploy = (function() { ''+ ''+ '
      '+ - ''+ + ''+ '').prependTo(".red-ui-header-toolbar"); const mainMenuItems = [ {id:"deploymenu-item-full",toggle:"deploy-type",icon:"red/images/deploy-full.svg",label:RED._("deploy.full"),sublabel:RED._("deploy.fullDesc"),selected: true, onselect:function(s) { if(s){changeDeploymentType("full")}}}, @@ -112,53 +114,80 @@ RED.deploy = (function() { RED.actions.add("core:set-deploy-type-to-modified-nodes",function() { RED.menu.setSelected("deploymenu-item-node",true); }); } - + window.addEventListener('beforeunload', function (event) { + if (RED.nodes.dirty()) { + event.preventDefault(); + event.stopImmediatePropagation() + event.returnValue = RED._("deploy.confirm.undeployedChanges"); + return + } + }) RED.events.on('workspace:dirty',function(state) { + if (RED.settings.user?.permissions === 'read') { + return + } if (state.dirty) { - window.onbeforeunload = function() { - return RED._("deploy.confirm.undeployedChanges"); - } + // window.onbeforeunload = function() { + // return + // } $("#red-ui-header-button-deploy").removeClass("disabled"); } else { - window.onbeforeunload = null; + // window.onbeforeunload = null; $("#red-ui-header-button-deploy").addClass("disabled"); } }); - var activeNotifyMessage; RED.comms.subscribe("notification/runtime-deploy",function(topic,msg) { - if (!activeNotifyMessage) { - var currentRev = RED.nodes.version(); - if (currentRev === null || deployInflight || currentRev === msg.revision) { - return; - } - var message = $('

      ').text(RED._('deploy.confirm.backgroundUpdate')); - activeNotifyMessage = RED.notify(message,{ - modal: true, - fixed: true, - buttons: [ - { - text: RED._('deploy.confirm.button.ignore'), - click: function() { - activeNotifyMessage.close(); - activeNotifyMessage = null; - } - }, - { - text: RED._('deploy.confirm.button.review'), - class: "primary", - click: function() { - activeNotifyMessage.close(); - var nns = RED.nodes.createCompleteNodeSet(); - resolveConflict(nns,false); - activeNotifyMessage = null; - } + var currentRev = RED.nodes.version(); + if (currentRev === null || deployInflight || currentRev === msg.revision) { + return; + } + if (activeBackgroundDeployNotification?.hidden && !activeBackgroundDeployNotification?.closed) { + activeBackgroundDeployNotification.showNotification() + return + } + const message = $('

      ').text(RED._('deploy.confirm.backgroundUpdate')); + const options = { + id: 'background-update', + type: 'compact', + modal: false, + fixed: true, + timeout: 10000, + buttons: [ + { + text: RED._('deploy.confirm.button.review'), + class: "primary", + click: function() { + activeBackgroundDeployNotification.hideNotification(); + var nns = RED.nodes.createCompleteNodeSet(); + resolveConflict(nns,false); } - ] - }); + } + ] + } + if (!activeBackgroundDeployNotification || activeBackgroundDeployNotification.closed) { + activeBackgroundDeployNotification = RED.notify(message, options) + } else { + activeBackgroundDeployNotification.update(message, options) } }); + + + updateLockedState() + RED.events.on('login', updateLockedState) + } + + function updateLockedState() { + if (RED.settings.user?.permissions === 'read') { + $(".red-ui-deploy-button-group").addClass("readOnly"); + $("#red-ui-header-button-deploy").addClass("disabled"); + } else { + $(".red-ui-deploy-button-group").removeClass("readOnly"); + if (RED.nodes.dirty()) { + $("#red-ui-header-button-deploy").removeClass("disabled"); + } + } } function getNodeInfo(node) { @@ -213,7 +242,11 @@ RED.deploy = (function() { class: "primary disabled", click: function() { if (!$("#red-ui-deploy-dialog-confirm-deploy-review").hasClass('disabled')) { - RED.diff.showRemoteDiff(); + RED.diff.showRemoteDiff(null, { + onmerge: function () { + activeBackgroundDeployNotification.close() + } + }); conflictNotification.close(); } } @@ -226,6 +259,7 @@ RED.deploy = (function() { if (!$("#red-ui-deploy-dialog-confirm-deploy-merge").hasClass('disabled')) { RED.diff.mergeDiff(currentDiff); conflictNotification.close(); + activeBackgroundDeployNotification.close() } } } @@ -238,6 +272,7 @@ RED.deploy = (function() { click: function() { save(true,activeDeploy); conflictNotification.close(); + activeBackgroundDeployNotification.close() } }) } @@ -248,21 +283,17 @@ RED.deploy = (function() { buttons: buttons }); - var now = Date.now(); RED.diff.getRemoteDiff(function(diff) { - var ellapsed = Math.max(1000 - (Date.now()-now), 0); currentDiff = diff; - setTimeout(function() { - conflictCheck.hide(); - var d = Object.keys(diff.conflicts); - if (d.length === 0) { - conflictAutoMerge.show(); - $("#red-ui-deploy-dialog-confirm-deploy-merge").removeClass('disabled') - } else { - conflictManualMerge.show(); - } - $("#red-ui-deploy-dialog-confirm-deploy-review").removeClass('disabled') - },ellapsed); + conflictCheck.hide(); + var d = Object.keys(diff.conflicts); + if (d.length === 0) { + conflictAutoMerge.show(); + $("#red-ui-deploy-dialog-confirm-deploy-merge").removeClass('disabled') + } else { + conflictManualMerge.show(); + } + $("#red-ui-deploy-dialog-confirm-deploy-review").removeClass('disabled') }) } function cropList(list) { @@ -612,7 +643,10 @@ RED.deploy = (function() { } }); RED.nodes.eachSubflow(function (subflow) { - subflow.changed = false; + if (subflow.changed) { + subflow.changed = false; + RED.events.emit("subflows:change", subflow); + } }); RED.nodes.eachWorkspace(function (ws) { if (ws.changed || ws.added) { diff --git a/packages/node_modules/@node-red/editor-client/src/js/ui/diff.js b/packages/node_modules/@node-red/editor-client/src/js/ui/diff.js index 3f73e29aa..ebdf683e3 100644 --- a/packages/node_modules/@node-red/editor-client/src/js/ui/diff.js +++ b/packages/node_modules/@node-red/editor-client/src/js/ui/diff.js @@ -1,5 +1,4 @@ RED.diff = (function() { - var currentDiff = {}; var diffVisible = false; var diffList; @@ -62,12 +61,14 @@ RED.diff = (function() { addedCount:0, deletedCount:0, changedCount:0, + movedCount:0, unchangedCount: 0 }, remote: { addedCount:0, deletedCount:0, changedCount:0, + movedCount:0, unchangedCount: 0 }, conflicts: 0 @@ -138,7 +139,7 @@ RED.diff = (function() { $(this).parent().toggleClass('collapsed'); }); - createNodePropertiesTable(def,tab,localTabNode,remoteTabNode,conflicts).appendTo(div); + createNodePropertiesTable(def,tab,localTabNode,remoteTabNode).appendTo(div); selectState = ""; if (conflicts[tab.id]) { flowStats.conflicts++; @@ -208,19 +209,26 @@ RED.diff = (function() { var localStats = $('',{class:"red-ui-diff-list-flow-stats"}).appendTo(localCell); $('').text(RED._('diff.nodeCount',{count:localNodeCount})).appendTo(localStats); - if (flowStats.conflicts + flowStats.local.addedCount + flowStats.local.changedCount + flowStats.local.deletedCount > 0) { + if (flowStats.conflicts + flowStats.local.addedCount + flowStats.local.changedCount + flowStats.local.movedCount + flowStats.local.deletedCount > 0) { $(' [ ').appendTo(localStats); if (flowStats.conflicts > 0) { $(' '+flowStats.conflicts+'').appendTo(localStats); } if (flowStats.local.addedCount > 0) { - $(' '+flowStats.local.addedCount+'').appendTo(localStats); + const cell = $(' '+flowStats.local.addedCount+'').appendTo(localStats); + RED.popover.tooltip(cell, RED._('diff.type.added')) } if (flowStats.local.changedCount > 0) { - $(' '+flowStats.local.changedCount+'').appendTo(localStats); + const cell = $(' '+flowStats.local.changedCount+'').appendTo(localStats); + RED.popover.tooltip(cell, RED._('diff.type.changed')) + } + if (flowStats.local.movedCount > 0) { + const cell = $(' '+flowStats.local.movedCount+'').appendTo(localStats); + RED.popover.tooltip(cell, RED._('diff.type.moved')) } if (flowStats.local.deletedCount > 0) { - $(' '+flowStats.local.deletedCount+'').appendTo(localStats); + const cell = $(' '+flowStats.local.deletedCount+'').appendTo(localStats); + RED.popover.tooltip(cell, RED._('diff.type.deleted')) } $(' ] ').appendTo(localStats); } @@ -246,19 +254,26 @@ RED.diff = (function() { } var remoteStats = $('',{class:"red-ui-diff-list-flow-stats"}).appendTo(remoteCell); $('').text(RED._('diff.nodeCount',{count:remoteNodeCount})).appendTo(remoteStats); - if (flowStats.conflicts + flowStats.remote.addedCount + flowStats.remote.changedCount + flowStats.remote.deletedCount > 0) { + if (flowStats.conflicts + flowStats.remote.addedCount + flowStats.remote.changedCount + flowStats.remote.movedCount + flowStats.remote.deletedCount > 0) { $(' [ ').appendTo(remoteStats); if (flowStats.conflicts > 0) { $(' '+flowStats.conflicts+'').appendTo(remoteStats); } if (flowStats.remote.addedCount > 0) { - $(' '+flowStats.remote.addedCount+'').appendTo(remoteStats); + const cell = $(' '+flowStats.remote.addedCount+'').appendTo(remoteStats); + RED.popover.tooltip(cell, RED._('diff.type.added')) } if (flowStats.remote.changedCount > 0) { - $(' '+flowStats.remote.changedCount+'').appendTo(remoteStats); + const cell = $(' '+flowStats.remote.changedCount+'').appendTo(remoteStats); + RED.popover.tooltip(cell, RED._('diff.type.changed')) + } + if (flowStats.remote.movedCount > 0) { + const cell = $(' '+flowStats.remote.movedCount+'').appendTo(remoteStats); + RED.popover.tooltip(cell, RED._('diff.type.moved')) } if (flowStats.remote.deletedCount > 0) { - $(' '+flowStats.remote.deletedCount+'').appendTo(remoteStats); + const cell = $(' '+flowStats.remote.deletedCount+'').appendTo(remoteStats); + RED.popover.tooltip(cell, RED._('diff.type.deleted')) } $(' ] ').appendTo(remoteStats); } @@ -293,7 +308,7 @@ RED.diff = (function() { if (options.mode === "merge") { diffPanel.addClass("red-ui-diff-panel-merge"); } - var diffList = createDiffTable(diffPanel, diff); + var diffList = createDiffTable(diffPanel, diff, options); var localDiff = diff.localDiff; var remoteDiff = diff.remoteDiff; @@ -516,7 +531,6 @@ RED.diff = (function() { var hasChanges = false; // exists in original and local/remote but with changes var unChanged = true; // existing in original,local,remote unchanged - var localChanged = false; if (localDiff.added[node.id]) { stats.local.addedCount++; @@ -535,12 +549,20 @@ RED.diff = (function() { unChanged = false; } if (localDiff.changed[node.id]) { - stats.local.changedCount++; + if (localDiff.positionChanged[node.id]) { + stats.local.movedCount++ + } else { + stats.local.changedCount++; + } hasChanges = true; unChanged = false; } if (remoteDiff && remoteDiff.changed[node.id]) { - stats.remote.changedCount++; + if (remoteDiff.positionChanged[node.id]) { + stats.remote.movedCount++ + } else { + stats.remote.changedCount++; + } hasChanges = true; unChanged = false; } @@ -605,27 +627,32 @@ RED.diff = (function() { localNodeDiv.addClass("red-ui-diff-status-moved"); var localMovedMessage = ""; if (node.z === localN.z) { - localMovedMessage = RED._("diff.type.movedFrom",{id:(localDiff.currentConfig.all[node.id].z||'global')}); + const movedFromNodeTab = localDiff.currentConfig.all[localDiff.currentConfig.all[node.id].z] + const movedFromLabel = `'${movedFromNodeTab ? (movedFromNodeTab.label || movedFromNodeTab.id) : 'global'}'` + localMovedMessage = RED._("diff.type.movedFrom",{id: movedFromLabel}); } else { - localMovedMessage = RED._("diff.type.movedTo",{id:(localN.z||'global')}); + const movedToNodeTab = localDiff.newConfig.all[localN.z] + const movedToLabel = `'${movedToNodeTab ? (movedToNodeTab.label || movedToNodeTab.id) : 'global'}'` + localMovedMessage = RED._("diff.type.movedTo",{id:movedToLabel}); } $(' '+localMovedMessage+'').appendTo(localNodeDiv); } - localChanged = true; } else if (localDiff.deleted[node.z]) { localNodeDiv.addClass("red-ui-diff-empty"); - localChanged = true; } else if (localDiff.deleted[node.id]) { localNodeDiv.addClass("red-ui-diff-status-deleted"); $(' ').appendTo(localNodeDiv); - localChanged = true; } else if (localDiff.changed[node.id]) { if (localDiff.newConfig.all[node.id].z !== node.z) { localNodeDiv.addClass("red-ui-diff-empty"); } else { - localNodeDiv.addClass("red-ui-diff-status-changed"); - $(' ').appendTo(localNodeDiv); - localChanged = true; + if (localDiff.positionChanged[node.id]) { + localNodeDiv.addClass("red-ui-diff-status-moved"); + $(' ').appendTo(localNodeDiv); + } else { + localNodeDiv.addClass("red-ui-diff-status-changed"); + $(' ').appendTo(localNodeDiv); + } } } else { if (localDiff.newConfig.all[node.id].z !== node.z) { @@ -646,9 +673,13 @@ RED.diff = (function() { remoteNodeDiv.addClass("red-ui-diff-status-moved"); var remoteMovedMessage = ""; if (node.z === remoteN.z) { - remoteMovedMessage = RED._("diff.type.movedFrom",{id:(remoteDiff.currentConfig.all[node.id].z||'global')}); + const movedFromNodeTab = remoteDiff.currentConfig.all[remoteDiff.currentConfig.all[node.id].z] + const movedFromLabel = `'${movedFromNodeTab ? (movedFromNodeTab.label || movedFromNodeTab.id) : 'global'}'` + remoteMovedMessage = RED._("diff.type.movedFrom",{id:movedFromLabel}); } else { - remoteMovedMessage = RED._("diff.type.movedTo",{id:(remoteN.z||'global')}); + const movedToNodeTab = remoteDiff.newConfig.all[remoteN.z] + const movedToLabel = `'${movedToNodeTab ? (movedToNodeTab.label || movedToNodeTab.id) : 'global'}'` + remoteMovedMessage = RED._("diff.type.movedTo",{id:movedToLabel}); } $(' '+remoteMovedMessage+'').appendTo(remoteNodeDiv); } @@ -661,8 +692,13 @@ RED.diff = (function() { if (remoteDiff.newConfig.all[node.id].z !== node.z) { remoteNodeDiv.addClass("red-ui-diff-empty"); } else { - remoteNodeDiv.addClass("red-ui-diff-status-changed"); - $(' ').appendTo(remoteNodeDiv); + if (remoteDiff.positionChanged[node.id]) { + remoteNodeDiv.addClass("red-ui-diff-status-moved"); + $(' ').appendTo(remoteNodeDiv); + } else { + remoteNodeDiv.addClass("red-ui-diff-status-changed"); + $(' ').appendTo(remoteNodeDiv); + } } } else { if (remoteDiff.newConfig.all[node.id].z !== node.z) { @@ -788,7 +824,7 @@ RED.diff = (function() { $("",{class:"red-ui-diff-list-cell-label"}).text("position").appendTo(row); localCell = $("",{class:"red-ui-diff-list-cell red-ui-diff-list-node-local"}).appendTo(row); if (localNode) { - localCell.addClass("red-ui-diff-status-"+(localChanged?"changed":"unchanged")); + localCell.addClass("red-ui-diff-status-"+(localChanged?"moved":"unchanged")); $(''+(localChanged?'':'')+'').appendTo(localCell); element = $('').appendTo(localCell); var localPosition = {x:localNode.x,y:localNode.y}; @@ -813,7 +849,7 @@ RED.diff = (function() { if (remoteNode !== undefined) { remoteCell = $("",{class:"red-ui-diff-list-cell red-ui-diff-list-node-remote"}).appendTo(row); - remoteCell.addClass("red-ui-diff-status-"+(remoteChanged?"changed":"unchanged")); + remoteCell.addClass("red-ui-diff-status-"+(remoteChanged?"moved":"unchanged")); if (remoteNode) { $(''+(remoteChanged?'':'')+'').appendTo(remoteCell); element = $('').appendTo(remoteCell); @@ -1099,11 +1135,11 @@ RED.diff = (function() { // var diff = generateDiff(originalFlow,nns); // showDiff(diff); // } - function showRemoteDiff(diff) { - if (diff === undefined) { - getRemoteDiff(showRemoteDiff); + function showRemoteDiff(diff, options = {}) { + if (!diff) { + getRemoteDiff((remoteDiff) => showRemoteDiff(remoteDiff, options)); } else { - showDiff(diff,{mode:'merge'}); + showDiff(diff,{...options, mode:'merge'}); } } function parseNodes(nodeList) { @@ -1144,23 +1180,53 @@ RED.diff = (function() { } } function generateDiff(currentNodes,newNodes) { - var currentConfig = parseNodes(currentNodes); - var newConfig = parseNodes(newNodes); - var added = {}; - var deleted = {}; - var changed = {}; - var moved = {}; + const currentConfig = parseNodes(currentNodes); + const newConfig = parseNodes(newNodes); + const added = {}; + const deleted = {}; + const changed = {}; + const positionChanged = {}; + const moved = {}; Object.keys(currentConfig.all).forEach(function(id) { - var node = RED.nodes.workspace(id)||RED.nodes.subflow(id)||RED.nodes.node(id); + const node = RED.nodes.workspace(id)||RED.nodes.subflow(id)||RED.nodes.node(id); if (!newConfig.all.hasOwnProperty(id)) { deleted[id] = true; - } else if (JSON.stringify(currentConfig.all[id]) !== JSON.stringify(newConfig.all[id])) { + return + } + const currentConfigJSON = JSON.stringify(currentConfig.all[id]) + const newConfigJSON = JSON.stringify(newConfig.all[id]) + + if (currentConfigJSON !== newConfigJSON) { changed[id] = true; - if (currentConfig.all[id].z !== newConfig.all[id].z) { moved[id] = true; + } else if ( + currentConfig.all[id].x !== newConfig.all[id].x || + currentConfig.all[id].y !== newConfig.all[id].y || + currentConfig.all[id].w !== newConfig.all[id].w || + currentConfig.all[id].h !== newConfig.all[id].h + ) { + // This node's position on its parent has changed. We want to + // check if this is the *only* change for this given node + const currentNodeClone = JSON.parse(currentConfigJSON) + const newNodeClone = JSON.parse(newConfigJSON) + + delete currentNodeClone.x + delete currentNodeClone.y + delete currentNodeClone.w + delete currentNodeClone.h + delete newNodeClone.x + delete newNodeClone.y + delete newNodeClone.w + delete newNodeClone.h + + if (JSON.stringify(currentNodeClone) === JSON.stringify(newNodeClone)) { + // Only the position has changed - everything else is the same + positionChanged[id] = true + } } + } }); Object.keys(newConfig.all).forEach(function(id) { @@ -1169,13 +1235,14 @@ RED.diff = (function() { } }); - var diff = { - currentConfig: currentConfig, - newConfig: newConfig, - added: added, - deleted: deleted, - changed: changed, - moved: moved + const diff = { + currentConfig, + newConfig, + added, + deleted, + changed, + positionChanged, + moved }; return diff; } @@ -1240,12 +1307,14 @@ RED.diff = (function() { return diff; } - function showDiff(diff,options) { + function showDiff(diff, options) { if (diffVisible) { return; } options = options || {}; var mode = options.mode || 'merge'; + + options.hidePositionChanges = true var localDiff = diff.localDiff; var remoteDiff = diff.remoteDiff; @@ -1315,6 +1384,9 @@ RED.diff = (function() { if (!$("#red-ui-diff-view-diff-merge").hasClass('disabled')) { refreshConflictHeader(diff); mergeDiff(diff); + if (options.onmerge) { + options.onmerge() + } RED.tray.close(); } } @@ -1345,6 +1417,7 @@ RED.diff = (function() { var newConfig = []; var node; var nodeChangedStates = {}; + var nodeMovedStates = {}; var localChangedStates = {}; for (id in localDiff.newConfig.all) { if (localDiff.newConfig.all.hasOwnProperty(id)) { @@ -1352,12 +1425,14 @@ RED.diff = (function() { if (resolutions[id] === 'local') { if (node) { nodeChangedStates[id] = node.changed; + nodeMovedStates[id] = node.moved; } newConfig.push(localDiff.newConfig.all[id]); } else if (resolutions[id] === 'remote') { if (!remoteDiff.deleted[id] && remoteDiff.newConfig.all.hasOwnProperty(id)) { if (node) { nodeChangedStates[id] = node.changed; + nodeMovedStates[id] = node.moved; } localChangedStates[id] = 1; newConfig.push(remoteDiff.newConfig.all[id]); @@ -1381,8 +1456,9 @@ RED.diff = (function() { } return { config: newConfig, - nodeChangedStates: nodeChangedStates, - localChangedStates: localChangedStates + nodeChangedStates, + nodeMovedStates, + localChangedStates } } @@ -1393,6 +1469,7 @@ RED.diff = (function() { var newConfig = appliedDiff.config; var nodeChangedStates = appliedDiff.nodeChangedStates; + var nodeMovedStates = appliedDiff.nodeMovedStates; var localChangedStates = appliedDiff.localChangedStates; var isDirty = RED.nodes.dirty(); @@ -1401,33 +1478,56 @@ RED.diff = (function() { t:"replace", config: RED.nodes.createCompleteNodeSet(), changed: nodeChangedStates, + moved: nodeMovedStates, + complete: true, dirty: isDirty, rev: RED.nodes.version() } RED.history.push(historyEvent); - var originalFlow = RED.nodes.originalFlow(); - // originalFlow is what the editor things it loaded - // - add any newly added nodes from remote diff as they are now part of the record - for (var id in diff.remoteDiff.added) { - if (diff.remoteDiff.added.hasOwnProperty(id)) { - if (diff.remoteDiff.newConfig.all.hasOwnProperty(id)) { - originalFlow.push(JSON.parse(JSON.stringify(diff.remoteDiff.newConfig.all[id]))); - } - } - } + // var originalFlow = RED.nodes.originalFlow(); + // // originalFlow is what the editor thinks it loaded + // // - add any newly added nodes from remote diff as they are now part of the record + // for (var id in diff.remoteDiff.added) { + // if (diff.remoteDiff.added.hasOwnProperty(id)) { + // if (diff.remoteDiff.newConfig.all.hasOwnProperty(id)) { + // originalFlow.push(JSON.parse(JSON.stringify(diff.remoteDiff.newConfig.all[id]))); + // } + // } + // } RED.nodes.clear(); var imported = RED.nodes.import(newConfig); - // Restore the original flow so subsequent merge resolutions can properly - // identify new-vs-old - RED.nodes.originalFlow(originalFlow); + // // Restore the original flow so subsequent merge resolutions can properly + // // identify new-vs-old + // RED.nodes.originalFlow(originalFlow); + + // Clear all change flags from the import + RED.nodes.dirty(false); + + const flowsToLock = new Set() + function ensureUnlocked(id) { + const flow = id && (RED.nodes.workspace(id) || RED.nodes.subflow(id) || null); + const isLocked = flow ? flow.locked : false; + if (flow && isLocked) { + flow.locked = false; + flowsToLock.add(flow) + } + } imported.nodes.forEach(function(n) { - if (nodeChangedStates[n.id] || localChangedStates[n.id]) { + if (nodeChangedStates[n.id]) { + ensureUnlocked(n.z) n.changed = true; } + if (nodeMovedStates[n.id]) { + ensureUnlocked(n.z) + n.moved = true; + } + }) + flowsToLock.forEach(flow => { + flow.locked = true }) RED.nodes.version(diff.remoteDiff.rev); 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 1b73430dc..894ff3e0f 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 @@ -341,8 +341,9 @@ RED.editor = (function() { nodeValue = node[property] } - const buttonId = `${prefix}-lookup-${property}` - const selectId = prefix + '-' + property + const addBtnId = `${prefix}-btn-${property}-add`; + const editBtnId = `${prefix}-btn-${property}-edit`; + const selectId = prefix + '-' + property; const input = $(`#${selectId}`); if (input.length === 0) { return; @@ -365,40 +366,68 @@ RED.editor = (function() { select.css({ 'flex-grow': 1 }); + updateConfigNodeSelect(property, type, nodeValue, prefix, filter); - const disableButton = function(disabled) { - btn.prop( "disabled", !!disabled) - btn.toggleClass("disabled", !!disabled) - } + // create the edit button - const btn = $('') + const editButton = $('') .css({ "margin-left": "10px" }) .appendTo(outerWrap); + RED.popover.tooltip(editButton, RED._('editor.editConfig', { type })); + + // create the add button + const addButton = $('') + .css({ "margin-left": "10px" }) + .appendTo(outerWrap); + RED.popover.tooltip(addButton, RED._('editor.addNewConfig', { type })); + + const disableButton = function(button, disabled) { + $(button).prop("disabled", !!disabled) + $(button).toggleClass("disabled", !!disabled) + }; + // add the click handler - btn.on("click", function (e) { + addButton.on("click", function (e) { + if (addButton.prop("disabled")) { return } + showEditConfigNodeDialog(property, type, "_ADD_", prefix, node); + e.preventDefault(); + }); + editButton.on("click", function (e) { const selectedOpt = select.find(":selected") if (selectedOpt.data('env')) { return } // don't show the dialog for env vars items (MVP. Future enhancement: lookup the env, if present, show the associated edit dialog) - if (btn.prop("disabled")) { return } + if (editButton.prop("disabled")) { return } showEditConfigNodeDialog(property, type, selectedOpt.val(), prefix, node); e.preventDefault(); }); // dont permit the user to click the button if the selected option is an env var select.on("change", function () { - const selectedOpt = select.find(":selected") + const selectedOpt = select.find(":selected"); + const optionsLength = select.find("option").length; if (selectedOpt?.data('env')) { - disableButton(true) + disableButton(addButton, true); + disableButton(editButton, true); + // disable the edit button if no options available + } else if (optionsLength === 1 && selectedOpt.val() === "_ADD_") { + disableButton(addButton, false); + disableButton(editButton, true); + } else if (selectedOpt.val() === "") { + disableButton(addButton, false); + disableButton(editButton, true); } else { - disableButton(false) + disableButton(addButton, false); + disableButton(editButton, false); } }); + var label = ""; var configNode = RED.nodes.node(nodeValue); if (configNode) { label = RED.utils.getNodeLabel(configNode, configNode.id); } + input.val(label); } @@ -892,7 +921,12 @@ RED.editor = (function() { } } - select.append(''); + if (!configNodes.length) { + select.append(''); + } else { + select.append(''); + } + window.setTimeout(function() { select.trigger("change");},50); } } @@ -1687,8 +1721,8 @@ RED.editor = (function() { } if (!isSameObj(old_env, new_env)) { - editing_node.env = new_env; editState.changes.env = editing_node.env; + editing_node.env = new_env; editState.changed = true; } diff --git a/packages/node_modules/@node-red/editor-client/src/js/ui/editors/code-editors/monaco.js b/packages/node_modules/@node-red/editor-client/src/js/ui/editors/code-editors/monaco.js index d034af3d7..cbeecd512 100644 --- a/packages/node_modules/@node-red/editor-client/src/js/ui/editors/code-editors/monaco.js +++ b/packages/node_modules/@node-red/editor-client/src/js/ui/editors/code-editors/monaco.js @@ -514,7 +514,7 @@ RED.editor.codeEditor.monaco = (function() { _monaco.languages.json.jsonDefaults.setDiagnosticsOptions(diagnosticOptions); if(modeConfiguration) { _monaco.languages.json.jsonDefaults.setModeConfiguration(modeConfiguration); } } catch (error) { - console.warn("monaco - Error setting up json options", err) + console.warn("monaco - Error setting up json options", error) } } @@ -526,7 +526,7 @@ RED.editor.codeEditor.monaco = (function() { if(htmlDefaults) { _monaco.languages.html.htmlDefaults.setOptions(htmlDefaults); } if(handlebarDefaults) { _monaco.languages.html.handlebarDefaults.setOptions(handlebarDefaults); } } catch (error) { - console.warn("monaco - Error setting up html options", err) + console.warn("monaco - Error setting up html options", error) } } @@ -546,7 +546,7 @@ RED.editor.codeEditor.monaco = (function() { if(lessDefaults_modeConfiguration) { _monaco.languages.css.cssDefaults.setDiagnosticsOptions(lessDefaults_modeConfiguration); } if(scssDefaults_modeConfiguration) { _monaco.languages.css.cssDefaults.setDiagnosticsOptions(scssDefaults_modeConfiguration); } } catch (error) { - console.warn("monaco - Error setting up CSS/SCSS/LESS options", err) + console.warn("monaco - Error setting up CSS/SCSS/LESS options", error) } } diff --git a/packages/node_modules/@node-red/editor-client/src/js/ui/notifications.js b/packages/node_modules/@node-red/editor-client/src/js/ui/notifications.js index 30dcc4bd5..d68d03d3f 100644 --- a/packages/node_modules/@node-red/editor-client/src/js/ui/notifications.js +++ b/packages/node_modules/@node-red/editor-client/src/js/ui/notifications.js @@ -221,12 +221,12 @@ RED.notifications = (function() { if (newType) { n.className = "red-ui-notification red-ui-notification-"+newType; } - + newTimeout = newOptions.hasOwnProperty('timeout')?newOptions.timeout:timeout if (!fixed || newOptions.fixed === false) { - newTimeout = (newOptions.hasOwnProperty('timeout')?newOptions.timeout:timeout)||5000; + newTimeout = newTimeout || 5000 } if (newOptions.buttons) { - var buttonSet = $('

      ').appendTo(nn) + var buttonSet = $('
      ').appendTo(nn) newOptions.buttons.forEach(function(buttonDef) { var b = $('