From 8867de8397a567e4ce6c6df9e59a4743ec5b1401 Mon Sep 17 00:00:00 2001 From: Joe Pavitt Date: Tue, 27 Aug 2024 16:33:16 +0100 Subject: [PATCH 01/45] Layout WYSIWYG - Drag & Drop Ordering for Groups --- nodes/config/locales/en-US/ui_page.json | 1 + nodes/config/ui_page.html | 1 + ui/src/api/node-red.js | 18 ++ ui/src/layouts/WYSIWYG.vue | 228 ++++++++++++++++++++++++ ui/src/layouts/index.mjs | 4 +- 5 files changed, 251 insertions(+), 1 deletion(-) create mode 100644 ui/src/api/node-red.js create mode 100644 ui/src/layouts/WYSIWYG.vue diff --git a/nodes/config/locales/en-US/ui_page.json b/nodes/config/locales/en-US/ui_page.json index 3214ea881..aacd528c1 100644 --- a/nodes/config/locales/en-US/ui_page.json +++ b/nodes/config/locales/en-US/ui_page.json @@ -7,6 +7,7 @@ "icon": "Icon", "theme": "Theme", "layout": "Layout", + "wysiwyg": "Editor", "grid": "Grid", "fixed": "Fixed", "tabs": "Tabs", diff --git a/nodes/config/ui_page.html b/nodes/config/ui_page.html index 371eb9274..1f061f41d 100644 --- a/nodes/config/ui_page.html +++ b/nodes/config/ui_page.html @@ -51,6 +51,7 @@ types: [{ value: 'layout', options: [ + { value: 'wysiwyg', label: RED._('@flowfuse/node-red-dashboard/ui-page:ui-page.label.wysiwyg') }, { value: 'grid', label: RED._('@flowfuse/node-red-dashboard/ui-page:ui-page.label.grid') }, { value: 'flex', label: RED._('@flowfuse/node-red-dashboard/ui-page:ui-page.label.fixed') }, { value: 'tabs', label: RED._('@flowfuse/node-red-dashboard/ui-page:ui-page.label.tabs') }, diff --git a/ui/src/api/node-red.js b/ui/src/api/node-red.js new file mode 100644 index 000000000..6103e7bb3 --- /dev/null +++ b/ui/src/api/node-red.js @@ -0,0 +1,18 @@ +export default { + deploy: function () { + console.log('deploy changes') + // return cy.request({ + // method: 'POST', + // url: 'http://localhost:1881/flows', + // headers: { + // 'Content-type': 'application/json', + // 'Node-RED-API-Version': 'v2', + // 'Node-RED-Deployment-Type': 'full' + // }, + // body: { + // rev, + // flows + // } + // }) + } +} diff --git a/ui/src/layouts/WYSIWYG.vue b/ui/src/layouts/WYSIWYG.vue new file mode 100644 index 000000000..45ffa271d --- /dev/null +++ b/ui/src/layouts/WYSIWYG.vue @@ -0,0 +1,228 @@ + + + + + diff --git a/ui/src/layouts/index.mjs b/ui/src/layouts/index.mjs index 74052a14e..19cc76c4a 100644 --- a/ui/src/layouts/index.mjs +++ b/ui/src/layouts/index.mjs @@ -2,10 +2,12 @@ import Flex from './Flex.vue' import Grid from './Grid.vue' import Notebook from './Notebook.vue' import Tabs from './Tabs.vue' +import WYSIWYG from './WYSIWYG.vue' export default { flex: Flex, grid: Grid, tabs: Tabs, - notebook: Notebook + notebook: Notebook, + wysiwyg: WYSIWYG } From a83345628be2b4898f1ccc0ea9581189b68df72a Mon Sep 17 00:00:00 2001 From: Joe Pavitt Date: Thu, 19 Sep 2024 21:59:57 +0100 Subject: [PATCH 02/45] Working prototype of end-to-end API for deploy via Dashboard --- nodes/config/ui_base.js | 40 ++++++++++++++++++++++++++++++++++++++ package-lock.json | 18 ++++++----------- package.json | 1 + ui/src/api/node-red.js | 27 +++++++++++++------------ ui/src/layouts/WYSIWYG.vue | 16 +++++++++++---- ui/src/store/ui.mjs | 6 ++++++ 6 files changed, 78 insertions(+), 30 deletions(-) diff --git a/nodes/config/ui_base.js b/nodes/config/ui_base.js index b12a048a7..e18911cc6 100644 --- a/nodes/config/ui_base.js +++ b/nodes/config/ui_base.js @@ -1,5 +1,7 @@ const path = require('path') +const axios = require('axios') + const v = require('../../package.json').version const datastore = require('../store/data.js') const statestore = require('../store/state.js') @@ -1069,4 +1071,42 @@ module.exports = function (RED) { } RED.nodes.registerType('ui-base', UIBaseNode) + + RED.httpAdmin.patch('/dashboard/:dashboardId', RED.auth.needsPermission('flows.write'), async function (req, res) { + const host = RED.settings.uiHost + const port = RED.settings.uiPort + const httpAdminRoot = RED.settings.httpAdminRoot + const url = 'http://' + (`${host}:${port}/${httpAdminRoot}flows`).replace('//', '/') + console.log('url', url) + // get request body + const changes = req.body + console.log(changes) + // get the active Node-RED flows json via the GET /flows API + return axios.request({ + method: 'GET', + url + }).then(response => { + const flows = response.data + // console.log('API Flows', flows) + // check groups for updates + if (changes.groups) { + for (const group of changes.groups) { + const existingGroup = flows.find(n => n.id === group.id) + if (existingGroup) { + existingGroup.order = group.order + } + } + } + return flows + }).then(flows => { + // update the flows with the new group order + return axios.request({ + method: 'POST', + url, + data: flows + }) + }).then(response => { + return res.status(200).json(response.data) + }) + }) } diff --git a/package-lock.json b/package-lock.json index 2f308cb7a..e97d7965a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "license": "Apache-2.0", "dependencies": { "acorn": "^8.11.2", + "axios": "^1.7.7", "chartjs-adapter-luxon": "^1.3.1", "core-js": "^3.32.0", "d3": "^7.8.5", @@ -4696,8 +4697,7 @@ "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "dev": true + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" }, "node_modules/at-least-node": { "version": "1.0.0", @@ -4739,10 +4739,9 @@ "dev": true }, "node_modules/axios": { - "version": "1.7.4", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.4.tgz", - "integrity": "sha512-DukmaFRnY6AzAALSH4J2M3k6PkaC+MfaAGdEERRWcC9q3/TWQwLpHR8ZRLKTdQ3aBDL64EdluRDjJqKw+BPZEw==", - "dev": true, + "version": "1.7.7", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.7.tgz", + "integrity": "sha512-S4kL7XrjgBmvdGut0sN3yJxqYzrDOnivkBiN0OFs6hLiUam3UPvswUo0kqGyhqUZGEOytHyumEdXsAkgCOUf3Q==", "dependencies": { "follow-redirects": "^1.15.6", "form-data": "^4.0.0", @@ -4753,7 +4752,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", - "dev": true, "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", @@ -4766,8 +4764,7 @@ "node_modules/axios/node_modules/proxy-from-env": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", - "dev": true + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" }, "node_modules/b4a": { "version": "1.6.6", @@ -5782,7 +5779,6 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "dev": true, "dependencies": { "delayed-stream": "~1.0.0" }, @@ -6878,7 +6874,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "dev": true, "engines": { "node": ">=0.4.0" } @@ -8379,7 +8374,6 @@ "version": "1.15.6", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.6.tgz", "integrity": "sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA==", - "dev": true, "funding": [ { "type": "individual", diff --git a/package.json b/package.json index bf7b80447..709cc38ad 100644 --- a/package.json +++ b/package.json @@ -54,6 +54,7 @@ }, "dependencies": { "acorn": "^8.11.2", + "axios": "^1.7.7", "chartjs-adapter-luxon": "^1.3.1", "core-js": "^3.32.0", "d3": "^7.8.5", diff --git a/ui/src/api/node-red.js b/ui/src/api/node-red.js index 6103e7bb3..a61378628 100644 --- a/ui/src/api/node-red.js +++ b/ui/src/api/node-red.js @@ -1,18 +1,17 @@ +import axios from 'axios' + export default { - deploy: function () { + deployChanges: function (dashboardId, changes) { console.log('deploy changes') - // return cy.request({ - // method: 'POST', - // url: 'http://localhost:1881/flows', - // headers: { - // 'Content-type': 'application/json', - // 'Node-RED-API-Version': 'v2', - // 'Node-RED-Deployment-Type': 'full' - // }, - // body: { - // rev, - // flows - // } - // }) + return axios.request({ + method: 'PATCH', + url: '/editor/dashboard/' + dashboardId, + headers: { + 'Content-type': 'application/json', + 'Node-RED-API-Version': 'v2', + 'Node-RED-Deployment-Type': 'full' + }, + data: changes + }) } } diff --git a/ui/src/layouts/WYSIWYG.vue b/ui/src/layouts/WYSIWYG.vue index 45ffa271d..90492cc34 100644 --- a/ui/src/layouts/WYSIWYG.vue +++ b/ui/src/layouts/WYSIWYG.vue @@ -67,7 +67,7 @@ export default { computed: { ...mapState('ui', ['pages']), ...mapState('data', ['properties']), - ...mapGetters('ui', ['groupsByPage', 'widgetsByGroup', 'widgetsByPage']), + ...mapGetters('ui', ['id', 'groupsByPage', 'widgetsByGroup', 'widgetsByPage']), orderedGroups: function () { // get groups on this page const groups = this.groupsByPage(this.$route.meta.id) @@ -111,7 +111,9 @@ export default { }, save () { // API call to NR to trigger a deploy - NodeREDAPI.deploy() + NodeREDAPI.deployChanges(this.id, { + groups: this.groups + }) }, discard () { // reload groups from store @@ -177,7 +179,7 @@ export default { } - diff --git a/ui/src/layouts/WYSIWYG.vue b/ui/src/layouts/WYSIWYG.vue index 90492cc34..aa55ca17c 100644 --- a/ui/src/layouts/WYSIWYG.vue +++ b/ui/src/layouts/WYSIWYG.vue @@ -1,6 +1,15 @@ @@ -71,6 +86,9 @@ export default { groups: [], dragging: { index: -1 + }, + init: { + groups: [] } } }, @@ -98,11 +116,17 @@ export default { }, page: function () { return this.pages[this.$route.meta.id] + }, + hasChanges () { + return JSON.stringify(this.groups) !== JSON.stringify(this.init.groups) } }, mounted () { // get groups for this page this.groups = this.loadGroupsFromStore() + + // clone to track changes + this.init.groups = JSON.parse(JSON.stringify(this.groups)) }, methods: { loadGroupsFromStore () { @@ -123,11 +147,19 @@ export default { // API call to NR to trigger a deploy NodeREDAPI.deployChanges(this.id, { groups: this.groups + }).then(() => { + // update saved state + this.init.groups = JSON.parse(JSON.stringify(this.groups)) + }).catch((error) => { + console.error('Error saving changes', error) }) }, discard () { // reload groups from store - this.groups = this.loadGroupsFromStore() + this.groups = JSON.parse(JSON.stringify(this.init.groups)) + }, + cancel () { + console.log('cancel editing placeholder') }, onDragStart (event, index) { this.dragging.index = index @@ -212,7 +244,7 @@ export default { .nrdb-ui-editor-tray { background-color: white; border: 1px solid #ccc; - box-shadow: 0px 0px 5px black; + box-shadow: 0px 0px 5px #00000021; padding: 12px; border-radius: 4px; display: flex; From 78d7c6a72b15129180636fa22b427d8dd2f7df20 Mon Sep 17 00:00:00 2001 From: Joe Pavitt Date: Tue, 8 Oct 2024 12:00:03 +0100 Subject: [PATCH 05/45] Componentise the WYSWIYG functinality --- ui/src/layouts/WYSIWYG.vue | 39 ++------------------------ ui/src/layouts/wysiwyg/index.js | 44 ++++++++++++++++++++++++++++++ ui/src/layouts/wysiwyg/sortable.js | 39 ++++++++++++++++++++++++++ 3 files changed, 86 insertions(+), 36 deletions(-) create mode 100644 ui/src/layouts/wysiwyg/index.js create mode 100644 ui/src/layouts/wysiwyg/sortable.js diff --git a/ui/src/layouts/WYSIWYG.vue b/ui/src/layouts/WYSIWYG.vue index 6ba82c082..34aae96af 100644 --- a/ui/src/layouts/WYSIWYG.vue +++ b/ui/src/layouts/WYSIWYG.vue @@ -61,8 +61,6 @@ - {{ groups }} - {{ init.groups }} @@ -72,6 +70,8 @@ import NodeREDAPI from '../api/node-red' import BaselineLayout from './Baseline.vue' import WidgetGroup from './Group.vue' +import WYSIWYG from './wysiwyg' + // eslint-disable-next-line import/order, sort-imports import { mapState, mapGetters } from 'vuex' @@ -81,6 +81,7 @@ export default { BaselineLayout, WidgetGroup }, + mixins: [WYSIWYG], data () { return { groups: [], @@ -161,36 +162,6 @@ export default { cancel () { console.log('cancel editing placeholder') }, - onDragStart (event, index) { - this.dragging.index = index - event.dataTransfer.effectAllowed = 'move' - }, - onDragOver (event, index) { - if (this.dragging.index >= 0) { - event.preventDefault() - event.dataTransfer.dropEffect = 'move' - this.moveGroup(this.dragging.index, index) - } - }, - onDrop (event, index) { - event.preventDefault() - if (this.dragging.index >= 0) { - this.moveGroup(this.dragging.index, index) - this.dragging.index = -1 - } - }, - onDragEnd (event, index) { - this.dragging.index = -1 - }, - moveGroup (fromIndex, toIndex) { - const movedItem = this.groups.splice(fromIndex, 1)[0] - this.groups.splice(toIndex, 0, movedItem) - // update .order property of all groups - this.groups.forEach((group, index) => { - group.order = index + 1 - }) - this.dragging.index = toIndex - }, getWidgetClass (widget) { const classes = [] // ensure each widget has a class for its type @@ -221,10 +192,6 @@ export default { classes.push('dragging') } return classes.join(' ') - }, - onGroupResize (opts) { - this.groups[opts.index].width = opts.width - // this.groups[opts.index].height = opts.height } } } diff --git a/ui/src/layouts/wysiwyg/index.js b/ui/src/layouts/wysiwyg/index.js new file mode 100644 index 000000000..1a66f4e45 --- /dev/null +++ b/ui/src/layouts/wysiwyg/index.js @@ -0,0 +1,44 @@ +export default { + data () { + return { + dragging: { + index: -1 + } + } + }, + methods: { + onDragStart (event, index) { + this.dragging.index = index + event.dataTransfer.effectAllowed = 'move' + }, + onDragOver (event, index) { + if (this.dragging.index >= 0) { + event.preventDefault() + event.dataTransfer.dropEffect = 'move' + this.moveGroup(this.dragging.index, index) + } + }, + onDrop (event, index) { + event.preventDefault() + if (this.dragging.index >= 0) { + this.moveGroup(this.dragging.index, index) + this.dragging.index = -1 + } + }, + onDragEnd (event, index) { + this.dragging.index = -1 + }, + moveGroup (fromIndex, toIndex) { + const movedItem = this.groups.splice(fromIndex, 1)[0] + this.groups.splice(toIndex, 0, movedItem) + // update .order property of all groups + this.groups.forEach((group, index) => { + group.order = index + 1 + }) + this.dragging.index = toIndex + }, + onGroupResize (opts) { + this.groups[opts.index].width = opts.width + } + } +} diff --git a/ui/src/layouts/wysiwyg/sortable.js b/ui/src/layouts/wysiwyg/sortable.js new file mode 100644 index 000000000..96053b454 --- /dev/null +++ b/ui/src/layouts/wysiwyg/sortable.js @@ -0,0 +1,39 @@ +const sortable = { + data () { + + }, + created: (el, binding) => { + console.log(binding.instance) + function onDragStart (event, index) { + this.dragging.index = index + event.dataTransfer.effectAllowed = 'move' + } + + function onDragOver (event, index) { + if (this.dragging.index >= 0) { + event.preventDefault() + event.dataTransfer.dropEffect = 'move' + this.moveGroup(this.dragging.index, index) + } + } + + function onDrop (event, index) { + event.preventDefault() + if (this.dragging.index >= 0) { + this.moveGroup(this.dragging.index, index) + this.dragging.index = -1 + } + } + + function onDragEnd (event, index) { + this.dragging.index = -1 + } + + el.addEventListener('dragstart', onDragStart) + el.addEventListener('dragover', onDragOver) + el.addEventListener('drop', onDrop) + el.addEventListener('dragend', onDragEnd) + } +} + +export default sortable From daafd72fe676311f542813017d2b630fc17fce2a Mon Sep 17 00:00:00 2001 From: Steve-Mcl Date: Mon, 14 Oct 2024 10:26:24 +0100 Subject: [PATCH 06/45] support enabling edit mode for a page from sidebar --- nodes/config/ui_base.js | 44 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 43 insertions(+), 1 deletion(-) diff --git a/nodes/config/ui_base.js b/nodes/config/ui_base.js index f9cb5996e..846cb7ca2 100644 --- a/nodes/config/ui_base.js +++ b/nodes/config/ui_base.js @@ -418,6 +418,7 @@ module.exports = function (RED) { // pass the connected UI the UI config socket.emit('ui-config', node.id, { + meta: node.ui.meta, dashboards: Object.fromEntries(node.ui.dashboards), heads: Object.fromEntries(node.ui.heads), pages: Object.fromEntries(node.ui.pages), @@ -758,6 +759,15 @@ module.exports = function (RED) { */ // store ui config to be sent to UI node.ui = { + meta: { + wysiwyg: { + enabled: false, + timestamp: null, + dashboard: null, + page: null, + editKey: null + } + }, heads: new Map(), dashboards: new Map(), pages: new Map(), @@ -1089,7 +1099,8 @@ module.exports = function (RED) { RED.nodes.registerType('ui-base', UIBaseNode) - RED.httpAdmin.patch('/dashboard/:dashboardId', RED.auth.needsPermission('flows.write'), async function (req, res) { + // PATCH: /dashboard/api/v1/:dashboardId/flows - deploy curated/controlled updates to the flows + RED.httpAdmin.patch('/dashboard/api/v1/:dashboardId/flows', RED.auth.needsPermission('flows.write'), async function (req, res) { const host = RED.settings.uiHost const port = RED.settings.uiPort const httpAdminRoot = RED.settings.httpAdminRoot @@ -1126,5 +1137,36 @@ module.exports = function (RED) { }).then(response => { return res.status(200).json(response.data) }) + console.error(error) + const status = error.response?.status || 500 + return res.status(status).json({ error: error.message }) + }) + }) + + // PATCH: /dashboard/api/v1/:dashboardId/edit/:pageId - start editing a page + RED.httpAdmin.patch('/dashboard/api/v1/:dashboardId/edit/:pageId', RED.auth.needsPermission('flows.write'), async function (req, res) { + /** @type {UIBaseNode} */ + const baseNode = RED.nodes.getNode(req.params.dashboardId) + if (!baseNode) { + return res.status(404).json({ error: 'Dashboard not found' }) + } + const pageNode = baseNode.ui.pages.get(req.params.pageId) + if (!pageNode) { + return res.status(404).json({ error: 'Page not found' }) + } + const editConfig = { + timestamp: Date.now(), + path: pageNode.path || '', + dashboard: baseNode.id, + page: pageNode.id, + editKey: Math.random().toString(36).substring(2) + } + baseNode.ui.meta.wysiwyg.enabled = true + baseNode.ui.meta.wysiwyg.timestamp = editConfig.timestamp + baseNode.ui.meta.wysiwyg.editKey = editConfig.editKey + baseNode.ui.meta.wysiwyg.dashboard = baseNode.id + baseNode.ui.meta.wysiwyg.page = pageNode.id + + return res.status(200).json(editConfig) }) } From fda6848473f9f9fbf5484bbabad24dd679458c9a Mon Sep 17 00:00:00 2001 From: Steve-Mcl Date: Mon, 14 Oct 2024 10:28:53 +0100 Subject: [PATCH 07/45] on-hover buttons for enabling edit mode on supported pages --- nodes/config/ui_base.html | 79 ++++++++++++++++++++++++++++++++++----- 1 file changed, 70 insertions(+), 9 deletions(-) diff --git a/nodes/config/ui_base.html b/nodes/config/ui_base.html index aa3953925..d239578b5 100644 --- a/nodes/config/ui_base.html +++ b/nodes/config/ui_base.html @@ -288,6 +288,7 @@ // #endregion (function () { + const supportedEditableLayouts = ['grid', 'flex'] const sidebarContainer = '
' const sidebarContentTemplate = $('
').appendTo(sidebarContainer) const sidebar = $(sidebarContentTemplate) @@ -824,14 +825,16 @@ const configNodes = ['ui-base', 'ui-page', 'ui-link', 'ui-group', 'ui-theme'] const btnGroup = $('
', { class: 'nrdb2-sb-list-header-button-group', id: item.id }).appendTo(parent) if (!configNodes.includes(item.type)) { - const focusButton = $(' ' + c_('layout.focus') + '').appendTo(btnGroup) + const focusButton = $(``).appendTo(btnGroup) focusButton.on('click', function (evt) { RED.view.reveal(item.id) evt.stopPropagation() evt.preventDefault() }) } - const editButton = $(' ' + c_('layout.edit') + '').appendTo(btnGroup) + + // button to edit node via node-red editor panel + const editButton = $(``).appendTo(btnGroup) editButton.on('click', function (evt) { if (configNodes.includes(item.type)) { RED.editor.editConfig('', item.type, item.id) @@ -841,14 +844,72 @@ evt.stopPropagation() evt.preventDefault() }) - if (item.type === 'ui-page') { + + if (item.type === 'ui-page' && supportedEditableLayouts.includes(item.node.layout)) { + // button to edit group in wysiwyg layout editor + const layoutButton = $(``).appendTo(btnGroup) + layoutButton.on('click', async function (evt) { + evt.preventDefault() + evt.stopPropagation() + + // call to httpadmin endpoint requesting layout editor mode for this group + const windowUrl = new URL(window.location.href) + const pageId = item.id + const baseId = item.node.ui + const base = RED.nodes.node(item.node.ui) + let basePath = base.path || '/dashboard' + if (basePath.endsWith('/')) { + basePath = basePath.slice(0, -1) + } + if (!basePath.startsWith('/')) { + basePath = `/${basePath}` + } + const authTokens = RED.settings.get('auth-tokens') || {} + const headers = {} + if (authTokens.access_token) { + headers.Authorization = `${authTokens.token_type || 'Bearer'} ${authTokens.access_token}` + } + + // promisify the ajax call so we can await it & return false after opening the popup + const ajax = () => { + return new Promise((resolve, reject) => { + $.ajax({ + url: `${base.path}/api/v1/${baseId}/edit/${pageId}`, // e.g. /dashboard/api/v1/123/edit/456 + type: 'PATCH', + headers, + data: { + mode: 'edit', + dashboard: baseId, + page: pageId + }, + success: function (data) { + // open the dashboard on that page in a _blank window + const _url = new URL(`${basePath}/${data.path}`.replace(/\/\//g, '/'), windowUrl.origin) + _url.searchParams.set('edit-key', data.editKey) + resolve(_url) + }, + error: function (err) { + reject(err) + } + }) + }) + } + try { + const url = await ajax() + const target = `ff-dashboard-${baseId}` // try to reuse the same window per base + window.open(url, target) + } catch (err) { + console.error('layout mode error', err) + RED.notify(base._('common.notification.error', { message: 'Unable to begin layout editor' }), 'error') + } + return false // return false to click event to prevent default + }) // add the "+ group" button - $(' ' + c_('layout.group') + '') - .click(function (evt) { - list.editableList('addItem') - evt.preventDefault() - }) - .appendTo(btnGroup) + const groupEditButton = $(``).appendTo(btnGroup) + groupEditButton.on('click', function (evt) { + list.editableList('addItem') + evt.preventDefault() + }) } } From 88d0de9e2db2b69699b1badf746997b889efe6e4 Mon Sep 17 00:00:00 2001 From: Steve-Mcl Date: Mon, 14 Oct 2024 10:29:19 +0100 Subject: [PATCH 08/45] consistent target (per base) --- nodes/config/ui_base.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/nodes/config/ui_base.html b/nodes/config/ui_base.html index d239578b5..a306a7086 100644 --- a/nodes/config/ui_base.html +++ b/nodes/config/ui_base.html @@ -453,8 +453,8 @@ editSettingsButton.on('click', function () { RED.editor.editConfig('', 'ui-base', id) }) - - const openDashboardButton = $(`` + + const target = `nr-dashboard-${id}` // try to reuse the same window per base + const openDashboardButton = $(`` + c_('label.openDashboard') + ' ') label.appendTo(header) From 35bbd8874ba4cfa4c3a4de97b0ca826dfad79626 Mon Sep 17 00:00:00 2001 From: Steve-Mcl Date: Mon, 14 Oct 2024 10:30:06 +0100 Subject: [PATCH 09/45] update deploy endpoint to v2 and add error handling --- nodes/config/ui_base.js | 106 ++++++++++++++++++++++++++++++++++------ 1 file changed, 91 insertions(+), 15 deletions(-) diff --git a/nodes/config/ui_base.js b/nodes/config/ui_base.js index 846cb7ca2..21933fa97 100644 --- a/nodes/config/ui_base.js +++ b/nodes/config/ui_base.js @@ -1107,36 +1107,112 @@ module.exports = function (RED) { const url = 'http://' + (`${host}:${port}/${httpAdminRoot}flows`).replace('//', '/') console.log('url', url) // get request body - const changes = req.body - console.log(changes) - // get the active Node-RED flows json via the GET /flows API + const dashboardId = req.params.dashboardId + const pageId = req.body.page + const changes = req.body.changes || {} + const editKey = req.body.key + const groups = changes.groups || [] + console.log(changes, editKey, dashboardId) + const baseNode = RED.nodes.getNode(dashboardId) + + // validity checks + if (groups.length === 0) { + // this could be a 200 but since the group data might be missing due to + // a bug or regression, we'll return a 400 and let the user know + // there were no changes provided. + return res.status(400).json({ error: 'No changes to deploy' }) + } + if (!baseNode) { + return res.status(404).json({ error: 'Dashboard not found' }) + } + if (!baseNode.ui.meta.wysiwyg.enabled) { + return res.status(403).json({ error: 'Unauthorized' }) + } + if (editKey !== baseNode.ui.meta.wysiwyg.editKey) { + return res.status(403).json({ error: 'Unauthorized' }) + } + if (pageId !== baseNode.ui.meta.wysiwyg.page) { + return res.status(403).json({ error: 'Unauthorized' }) + } + for (const modified of groups) { + if (modified.page !== baseNode.ui.meta.wysiwyg.page) { + return res.status(400).json({ error: 'Invalid page id' }) + } + } + + // Prepare headers for the requests + const getHeaders = { + 'Node-RED-API-Version': 'v2', + Accept: 'application/json' + } + const postHeaders = { + 'Node-RED-Deployment-Type': 'nodes', // only update the nodes (don't restart ALL nodes! Only those that have changed) + 'Node-RED-API-Version': 'v2', + 'Content-Type': 'application/json' + } + // apply headers from the incoming request + if (req.headers.cookie) { + getHeaders.cookie = req.headers.cookie + postHeaders.cookie = req.headers.cookie + } + if (req.headers.authorization) { + getHeaders.authorization = req.headers.authorization + postHeaders.authorization = req.headers.authorization + } + if (req.headers.referer) { + getHeaders.referer = req.headers.referer + postHeaders.referer = req.headers.referer + } + + const applyIfDifferent = (node, nodeNew, propName) => { + const origValue = node[propName] + const newValue = nodeNew[propName] + if (origValue !== newValue) { + node[propName] = newValue + return true + } + return false + } + let rev = null return axios.request({ method: 'GET', + headers: getHeaders, url }).then(response => { - const flows = response.data - // console.log('API Flows', flows) - // check groups for updates - if (changes.groups) { - for (const group of changes.groups) { - const existingGroup = flows.find(n => n.id === group.id) - if (existingGroup) { - existingGroup.width = group.width - existingGroup.order = group.order - } + const flows = response.data?.flows || [] + rev = response.data?.rev + const changeResult = [] + for (const modified of groups) { + const current = flows.find(n => n.id === modified.id) + if (!current) { + // group not found in current flows! integrity of data suspect! Has flows changed on the server? + return res.status(400).json({ error: 'Group not found', code: 'GROUP_NOT_FOUND' }) + } + if (modified.page !== current.page) { + // integrity of data suspect! Has flow changed on the server? + return res.status(400).json({ error: 'Invalid page id', code: 'INVALID_PAGE_ID' }) } + changeResult.push(applyIfDifferent(current, modified, 'width')) + changeResult.push(applyIfDifferent(current, modified, 'order')) + } + if (changeResult.length === 0 || !changeResult.includes(true)) { + return res.status(200).json({ message: 'No changes were' }) } return flows }).then(flows => { // update the flows with the new group order return axios.request({ method: 'POST', + headers: postHeaders, url, - data: flows + data: { + flows, + rev + } }) }).then(response => { return res.status(200).json(response.data) - }) + }).catch(error => { console.error(error) const status = error.response?.status || 500 return res.status(status).json({ error: error.message }) From 6258104bdd1403e5d20284451755fbe12634b16d Mon Sep 17 00:00:00 2001 From: Steve-Mcl Date: Mon, 14 Oct 2024 10:30:37 +0100 Subject: [PATCH 10/45] add i18n messages fro edit buttons --- nodes/config/locales/en-US/ui_base.json | 2 ++ 1 file changed, 2 insertions(+) diff --git a/nodes/config/locales/en-US/ui_base.json b/nodes/config/locales/en-US/ui_base.json index c9870ea12..614b615a4 100644 --- a/nodes/config/locales/en-US/ui_base.json +++ b/nodes/config/locales/en-US/ui_base.json @@ -37,7 +37,9 @@ "page": "Page", "link": "Link", "group": "Group", + "addGroup": "Add Group", "edit": "Edit", + "layoutEditor": "Layout Editor", "focus": "Focus", "collapse": "Collapse", "expand": "Expand", From ec476750216552694f3533b1f3ac094caf4b2779 Mon Sep 17 00:00:00 2001 From: Steve-Mcl Date: Mon, 14 Oct 2024 10:33:10 +0100 Subject: [PATCH 11/45] put edit controls in own component --- ui/src/layouts/wysiwyg/EditControls.vue | 63 +++++++++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 ui/src/layouts/wysiwyg/EditControls.vue diff --git a/ui/src/layouts/wysiwyg/EditControls.vue b/ui/src/layouts/wysiwyg/EditControls.vue new file mode 100644 index 000000000..47062cc6d --- /dev/null +++ b/ui/src/layouts/wysiwyg/EditControls.vue @@ -0,0 +1,63 @@ + + + + + From 25797d051fd2ce15083970ceef6dd3623985ce79 Mon Sep 17 00:00:00 2001 From: Steve-Mcl Date: Mon, 14 Oct 2024 10:34:07 +0100 Subject: [PATCH 12/45] ensure query param edit-key is carried over upon forced refresh --- ui/src/main.mjs | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/ui/src/main.mjs b/ui/src/main.mjs index 5ea62b7fa..6fc0468cf 100644 --- a/ui/src/main.mjs +++ b/ui/src/main.mjs @@ -74,14 +74,19 @@ function forcePageReload (err) { console.log('redirecting to:', window.location.origin + '/dashboard') // Reloading dashboard without using cache by appending a cache-busting string to fully reload page to allow redirecting to auth - window.location.replace(window.location.origin + '/dashboard' + '?' + 'reloadTime=' + Date.now().toString() + Math.random()) // Seems to work on Edge and Chrome on Windows, Chromium and Firefox on Linux, and also on Chrome Android (and also as PWA App) + const url = new URL(window.location.origin + '/dashboard') + url.searchParams.set('reloadTime', Date.now().toString() + Math.random()) + if (host.searchParams.has('edit-key')) { + url.searchParams.set('edit-key', host.searchParams.get('edit-key')) + } + window.location.replace(url) } /* * Configure SocketIO Client to Interact with Node-RED */ -// if our scoket disconnects, we should inform the user when it reconnects +// if our socket disconnects, we should inform the user when it reconnects // GET our SocketIO Config from Node-RED & any other bits plugins have added to the _setup endpoint fetch('_setup') @@ -93,10 +98,15 @@ fetch('_setup') case !response.ok: console.error('Failed to fetch setup data:', response) return - case host.origin !== new URL(response.url).origin: + case host.origin !== new URL(response.url).origin: { console.log('Following redirect:', response.url) - window.location.replace(response.url) + const url = new URL(response.url) + if (host.searchParams.has('edit-key')) { + url.searchParams.set('edit-key', host.searchParams.get('edit-key')) + } + window.location.replace(url) return + } default: break } From b040f625d9138fefadfcb7f92c4c1f127ef2abfb Mon Sep 17 00:00:00 2001 From: Steve-Mcl Date: Mon, 14 Oct 2024 10:38:14 +0100 Subject: [PATCH 13/45] Add edit tracking for edit buffer and state --- ui/src/EditTracking.js | 57 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 ui/src/EditTracking.js diff --git a/ui/src/EditTracking.js b/ui/src/EditTracking.js new file mode 100644 index 000000000..59485eb83 --- /dev/null +++ b/ui/src/EditTracking.js @@ -0,0 +1,57 @@ +import { computed, reactive } from 'vue' + +// A simple non-vuex state store for edit tracking +const state = reactive({ + editKey: '', + editPage: '', + editMode: false, + isTrackingEdits: false, + originalGroups: [] +}) + +// Methods + +/** + * Initialise the edit tracking state + * @param {String} editKey - The edit key provided by the server + * @param {String} editPage - The page id to edit (provided by the server) + */ +function initialise (editKey, editPage) { + state.editKey = editKey + state.editPage = editPage + state.editMode = !!editKey && !!editPage +} + +/** + * Start tracking edits + */ +function startEditTracking (groups) { + state.isTrackingEdits = true + updateEditTracking(groups) +} + +/** + * Stop tracking edits, clear editKey/editPage & exit edit mode + */ +function exitEditMode () { + state.editKey = '' + state.editPage = '' + state.initialised = false + state.originalGroups = [] +} + +/** + * Update the original groups with the current groups + */ +function updateEditTracking (groups) { + state.originalGroups = JSON.parse(JSON.stringify(groups)) +} + +// RO computed props +const editKey = computed(() => state.editKey) +const editPage = computed(() => state.editPage) +const editMode = computed(() => !!state.editKey && !!state.editPage) +const originalGroups = computed(() => state.originalGroups) +const isTrackingEdits = computed(() => state.isTrackingEdits) + +export { editMode, editKey, editPage, originalGroups, isTrackingEdits, initialise, exitEditMode, startEditTracking, updateEditTracking } From 3e967b5f2d25bcde862caf87110018b9dcb0e759 Mon Sep 17 00:00:00 2001 From: Steve-Mcl Date: Mon, 14 Oct 2024 10:39:22 +0100 Subject: [PATCH 14/45] Init edit mode when matching query and config are present --- ui/src/App.vue | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/ui/src/App.vue b/ui/src/App.vue index b8afbb0d9..041a5418e 100644 --- a/ui/src/App.vue +++ b/ui/src/App.vue @@ -20,6 +20,7 @@ + + From 7ad09387b8ddf31112dc4f46889613e90a5dc028 Mon Sep 17 00:00:00 2001 From: Steve-Mcl Date: Mon, 14 Oct 2024 10:43:36 +0100 Subject: [PATCH 18/45] grab/move/resize pointers --- ui/src/layouts/Group.vue | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/ui/src/layouts/Group.vue b/ui/src/layouts/Group.vue index f24050142..2792a4f2d 100644 --- a/ui/src/layouts/Group.vue +++ b/ui/src/layouts/Group.vue @@ -155,7 +155,9 @@ export default { top: 0; left: 0; --handler-size: 12px; + cursor: grab; &.active { + cursor: grabbing !important; background-color: #ff00001f; border: 1px dashed red; } @@ -167,7 +169,6 @@ export default { background-color: white; border: 1px solid black; border-radius: 6px; - cursor: pointer; } .nrdb-resizable--top-right { top: calc(-1 * var(--handler-size) / 2); @@ -186,6 +187,9 @@ export default { cursor: ew-resize; background-color: #eee; } + &:active { + cursor: ew-resize !important; + } } .nrdb-resizable--right:after { From 5737de60fa221d45b9c6fc3329a182b46cdb2cd5 Mon Sep 17 00:00:00 2001 From: Steve-Mcl Date: Mon, 14 Oct 2024 10:44:49 +0100 Subject: [PATCH 19/45] typo --- ui/src/layouts/Group.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/src/layouts/Group.vue b/ui/src/layouts/Group.vue index 2792a4f2d..5e4df710d 100644 --- a/ui/src/layouts/Group.vue +++ b/ui/src/layouts/Group.vue @@ -60,7 +60,7 @@ export default { columns: 0, rows: 0, width: 0, - heigh: 0 + height: 0 }, current: { columns: 0, From c2060eb1dd1a03f0b5fc50a826d7d664e42b57ba Mon Sep 17 00:00:00 2001 From: Steve-Mcl Date: Mon, 14 Oct 2024 10:45:13 +0100 Subject: [PATCH 20/45] fix jump to size 1 when releasing mouse --- ui/src/layouts/Group.vue | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ui/src/layouts/Group.vue b/ui/src/layouts/Group.vue index 5e4df710d..ea2c89def 100644 --- a/ui/src/layouts/Group.vue +++ b/ui/src/layouts/Group.vue @@ -63,8 +63,8 @@ export default { height: 0 }, current: { - columns: 0, - rows: 0, + columns: null, + rows: null, width: null } } From 6c29dca788ed8c9495c717a9af4307cbb7fc7709 Mon Sep 17 00:00:00 2001 From: Steve-Mcl Date: Mon, 14 Oct 2024 10:45:39 +0100 Subject: [PATCH 21/45] cast columns to integer --- ui/src/layouts/Group.vue | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/ui/src/layouts/Group.vue b/ui/src/layouts/Group.vue index ea2c89def..e9452f6ea 100644 --- a/ui/src/layouts/Group.vue +++ b/ui/src/layouts/Group.vue @@ -72,8 +72,7 @@ export default { }, computed: { columns () { - const cols = this.dragging.current.columns > 0 ? this.dragging.current.columns : this.group.width - return cols + return this.dragging.current.columns > 0 ? this.dragging.current.columns : +this.group.width } }, methods: { From 6ad445b0f3214b12447db51cac98b9c4748f1284 Mon Sep 17 00:00:00 2001 From: Steve-Mcl Date: Mon, 14 Oct 2024 10:46:36 +0100 Subject: [PATCH 22/45] cast to integer with default of 1 --- ui/src/layouts/Group.vue | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/ui/src/layouts/Group.vue b/ui/src/layouts/Group.vue index e9452f6ea..3b73956e4 100644 --- a/ui/src/layouts/Group.vue +++ b/ui/src/layouts/Group.vue @@ -90,15 +90,8 @@ export default { }, onHandleDragStart (event, vertical, horizontal) { this.dragging.active = true - this.dragging.init.columns = parseFloat(this.group.width) - this.dragging.init.rows = parseFloat(this.group.height) - // event.preventDefault() - event.stopPropagation() - // don't show image preview - const EMPTY_IMAGE = this.$refs['blank-img'] - event.dataTransfer.setDragImage(EMPTY_IMAGE, 0, 0) - - this.dragging.init.x = event.x + this.dragging.init.columns = +this.group.width || 1 + this.dragging.init.rows = +this.group.height || 1 this.dragging.init.width = this.$refs['resize-view'].clientWidth return false }, From ee305dbbba2fab0e7199d4ca3cf236db30fcbf27 Mon Sep 17 00:00:00 2001 From: Steve-Mcl Date: Mon, 14 Oct 2024 10:47:28 +0100 Subject: [PATCH 23/45] code reorder --- ui/src/layouts/Group.vue | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/ui/src/layouts/Group.vue b/ui/src/layouts/Group.vue index 3b73956e4..c2776b4a3 100644 --- a/ui/src/layouts/Group.vue +++ b/ui/src/layouts/Group.vue @@ -93,6 +93,11 @@ export default { this.dragging.init.columns = +this.group.width || 1 this.dragging.init.rows = +this.group.height || 1 this.dragging.init.width = this.$refs['resize-view'].clientWidth + this.dragging.init.x = event.x + const EMPTY_IMAGE = this.$refs['blank-img'] // don't show image preview + event.dataTransfer.setDragImage(EMPTY_IMAGE, 0, 0) + // prevent default drag behavior + event.stopPropagation() return false }, onHandleDrag (event, vertical, horizontal) { From 2c161e38ebefbc8b4f6ea3eb13adc70d43447f09 Mon Sep 17 00:00:00 2001 From: Steve-Mcl Date: Mon, 14 Oct 2024 10:48:10 +0100 Subject: [PATCH 24/45] dont attempt resize if not dragging --- ui/src/layouts/Group.vue | 1 + 1 file changed, 1 insertion(+) diff --git a/ui/src/layouts/Group.vue b/ui/src/layouts/Group.vue index c2776b4a3..028dcdc7d 100644 --- a/ui/src/layouts/Group.vue +++ b/ui/src/layouts/Group.vue @@ -101,6 +101,7 @@ export default { return false }, onHandleDrag (event, vertical, horizontal) { + if (this.dragging.active === false) { return } if (event.x > 0 && event.y > 0) { // odd event fired on mouse up with x/y = 0 const stepX = this.$el.clientWidth / this.group.width From ba54734944ea0c44c0a6cdf9441efff088d856f6 Mon Sep 17 00:00:00 2001 From: Steve-Mcl Date: Mon, 14 Oct 2024 10:49:12 +0100 Subject: [PATCH 25/45] prevent invalid values causing a resize --- ui/src/layouts/Group.vue | 26 ++++++-------------------- 1 file changed, 6 insertions(+), 20 deletions(-) diff --git a/ui/src/layouts/Group.vue b/ui/src/layouts/Group.vue index 028dcdc7d..feab5a20a 100644 --- a/ui/src/layouts/Group.vue +++ b/ui/src/layouts/Group.vue @@ -103,29 +103,15 @@ export default { onHandleDrag (event, vertical, horizontal) { if (this.dragging.active === false) { return } if (event.x > 0 && event.y > 0) { - // odd event fired on mouse up with x/y = 0 - const stepX = this.$el.clientWidth / this.group.width - // const stepY = 50 - + const stepX = this.$el.clientWidth / +this.group.width const dx = event.x - this.dragging.init.x - // what change does this reflect in the grid? const dw = dx < 0 ? Math.ceil(dx / stepX) : Math.floor(dx / stepX) - - // const dh = Math.floor(event.offsetY / stepY) - this.dragging.current.width = this.dragging.init.width + dx - - if (dw !== 0) { - const width = this.dragging.init.columns + dw - // const height = this.dragging.init.rows + dh - - if (width !== this.group.width) { - this.dragging.current.columns = width - this.$emit('resize', { - index: this.index, - width - }) - } + const width = Math.max(this.dragging.init.columns + dw, 1) + if (width !== +this.group.width) { + // console.log('drag handle drag: width', width, 'emitting resize') + this.dragging.current.columns = width + this.$emit('resize', { index: this.index, width }) } } }, From 6f3a45b5cf636908ff710dc40055d8d9f47295a8 Mon Sep 17 00:00:00 2001 From: Steve-Mcl Date: Mon, 14 Oct 2024 10:49:37 +0100 Subject: [PATCH 26/45] add missing reset --- ui/src/layouts/Group.vue | 1 + 1 file changed, 1 insertion(+) diff --git a/ui/src/layouts/Group.vue b/ui/src/layouts/Group.vue index feab5a20a..6e1eec2d0 100644 --- a/ui/src/layouts/Group.vue +++ b/ui/src/layouts/Group.vue @@ -125,6 +125,7 @@ export default { resetDragState () { this.dragging.current.width = null this.dragging.current.columns = null + this.dragging.current.rows = null } } From 1b8140fbd83fd4a1939375f10cfd4d45321b12d3 Mon Sep 17 00:00:00 2001 From: Steve-Mcl Date: Mon, 14 Oct 2024 10:50:06 +0100 Subject: [PATCH 27/45] dont resize if not active --- ui/src/layouts/Group.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/src/layouts/Group.vue b/ui/src/layouts/Group.vue index 6e1eec2d0..859ebe7de 100644 --- a/ui/src/layouts/Group.vue +++ b/ui/src/layouts/Group.vue @@ -115,7 +115,7 @@ export default { } } }, - onHandleDragEnd () { + if (this.dragging.active === false) { return } this.dragging.active = false const width = Math.max(this.dragging.current.columns, 1) const height = Math.max(this.dragging.current.rows, 1) From cc1a33298224e400834b5a5772f79e66c0957c77 Mon Sep 17 00:00:00 2001 From: Steve-Mcl Date: Mon, 14 Oct 2024 10:50:39 +0100 Subject: [PATCH 28/45] only apply resize if something changed --- ui/src/layouts/Group.vue | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/ui/src/layouts/Group.vue b/ui/src/layouts/Group.vue index 859ebe7de..12fae7483 100644 --- a/ui/src/layouts/Group.vue +++ b/ui/src/layouts/Group.vue @@ -115,12 +115,21 @@ export default { } } }, + onHandleDragEnd (event) { if (this.dragging.active === false) { return } this.dragging.active = false - const width = Math.max(this.dragging.current.columns, 1) - const height = Math.max(this.dragging.current.rows, 1) + if (this.dragging.current.columns === null && this.dragging.current.rows === null) { + // console.log('drag handle end reset due to null columns or rows') + this.resetDragState() + return + } + const columns = Math.max(this.dragging.current.columns, 1) + const rows = Math.max(this.dragging.current.rows, 1) + const changed = this.dragging.init.columns !== columns || this.dragging.init.rows !== rows this.resetDragState() - this.$emit('resize', { index: this.index, width, height }) + if (changed) { + this.$emit('resize', { index: this.index, width: columns, height: rows }) + } }, resetDragState () { this.dragging.current.width = null From 08af2d51c8b526ab54e73b46ce299365a923b61b Mon Sep 17 00:00:00 2001 From: Steve-Mcl Date: Mon, 14 Oct 2024 10:52:12 +0100 Subject: [PATCH 29/45] move implementation from temp wysiwyg to supported page types --- ui/src/layouts/Flex.vue | 110 ++++++++++++-- ui/src/layouts/Grid.vue | 116 +++++++++++++-- ui/src/layouts/WYSIWYG.vue | 254 -------------------------------- ui/src/layouts/index.mjs | 4 +- ui/src/layouts/wysiwyg/index.js | 98 +++++++++++- ui/src/stylesheets/common.css | 5 + 6 files changed, 297 insertions(+), 290 deletions(-) delete mode 100644 ui/src/layouts/WYSIWYG.vue diff --git a/ui/src/layouts/Flex.vue b/ui/src/layouts/Flex.vue index 6ef255057..3edeacf18 100644 --- a/ui/src/layouts/Flex.vue +++ b/ui/src/layouts/Flex.vue @@ -1,24 +1,30 @@ - - diff --git a/ui/src/layouts/index.mjs b/ui/src/layouts/index.mjs index 19cc76c4a..74052a14e 100644 --- a/ui/src/layouts/index.mjs +++ b/ui/src/layouts/index.mjs @@ -2,12 +2,10 @@ import Flex from './Flex.vue' import Grid from './Grid.vue' import Notebook from './Notebook.vue' import Tabs from './Tabs.vue' -import WYSIWYG from './WYSIWYG.vue' export default { flex: Flex, grid: Grid, tabs: Tabs, - notebook: Notebook, - wysiwyg: WYSIWYG + notebook: Notebook } diff --git a/ui/src/layouts/wysiwyg/index.js b/ui/src/layouts/wysiwyg/index.js index 1a66f4e45..3b627fbc7 100644 --- a/ui/src/layouts/wysiwyg/index.js +++ b/ui/src/layouts/wysiwyg/index.js @@ -1,20 +1,90 @@ +import { editKey, editMode, editPage, exitEditMode, isTrackingEdits, originalGroups, startEditTracking, updateEditTracking } from '../../EditTracking.js' +import NodeRedApi from '../../api/node-red' + export default { data () { return { + pageGroups: [], dragging: { index: -1 } } }, + computed: { + dirty () { + if (!this.editMode || !isTrackingEdits.value) { + return false + } + return JSON.stringify(this.pageGroups) !== JSON.stringify(originalGroups.value) + }, + editMode: function () { + return editMode.value && editPage.value === this.$route.meta.id + } + }, methods: { + initializeEditTracking () { + if (this.editMode && !isTrackingEdits.value) { + startEditTracking(this.pageGroups) + } + }, + acceptChanges () { + updateEditTracking(this.pageGroups) + }, + exitEditMode () { + const url = new URL(window.location.href) + url.searchParams.delete('edit-key') + const query = { ...this.$route.query } + delete query['edit-key'] + this.$router.replace({ query }) + window.history.replaceState({}, document.title, url) + exitEditMode() // EditTracking method + }, + revertEdits () { + this.pageGroups = JSON.parse(JSON.stringify(originalGroups.value)) + }, + deployChanges ({ dashboard, page, groups }) { + return NodeRedApi.deployChanges({ dashboard, page, groups, key: editKey.value }) + }, + /** + * Event handler for dragstart event + * @param {DragEvent} event - The drag event + * @param {Number} index - The index of the group + */ onDragStart (event, index) { this.dragging.index = index event.dataTransfer.effectAllowed = 'move' }, + /** + * Event handler for dragover event + * @param {DragEvent} event - The drag event + * @param {Number} index - The index of the group + */ onDragOver (event, index) { + // ensure the mouse is over a different group + if (this.dragging.index === index || this.dragging.index < 0) { + return + } + // ensure the mouse is within the bounds of the source group size + // to avoid flip-flop when the target group is larger than the source group + const sourceGroup = this.pageGroups[this.dragging.index] + const sourceId = `nrdb-ui-group-${sourceGroup.id}` + const sourceEl = document.getElementById(sourceId) + const sourceBounds = sourceEl.getBoundingClientRect() + const targetGroup = this.pageGroups[index] + const targetId = `nrdb-ui-group-${targetGroup.id}` + const targetEl = document.getElementById(targetId) + const targetBounds = targetEl.getBoundingClientRect() + const hitBoundX1 = targetBounds.left + const hitBoundX2 = targetBounds.left + sourceBounds.width + const hitBoundY1 = targetBounds.top + const hitBoundY2 = targetBounds.top + sourceBounds.height + if (event.clientX < hitBoundX1 || event.clientX > hitBoundX2 || event.clientY < hitBoundY1 || event.clientY > hitBoundY2) { + return + } + if (this.dragging.index >= 0) { event.preventDefault() - event.dataTransfer.dropEffect = 'move' + event.dataTransfer.dropEffect = 'drop' this.moveGroup(this.dragging.index, index) } }, @@ -29,16 +99,34 @@ export default { this.dragging.index = -1 }, moveGroup (fromIndex, toIndex) { - const movedItem = this.groups.splice(fromIndex, 1)[0] - this.groups.splice(toIndex, 0, movedItem) + const movedItem = this.pageGroups.splice(fromIndex, 1)[0] + this.pageGroups.splice(toIndex, 0, movedItem) // update .order property of all groups - this.groups.forEach((group, index) => { + this.pageGroups.forEach((group, index) => { group.order = index + 1 }) this.dragging.index = toIndex }, onGroupResize (opts) { - this.groups[opts.index].width = opts.width + // ensure opts.width is a number and is greater than 0 + if (typeof opts.width !== 'number' || opts.width < 1) { + return + } + this.pageGroups[opts.index].width = opts.width + }, + isDragging (group) { + const dragging = this.pageGroups[this.dragging.index] + if (dragging?.id === group?.id) { + return true + } + return false + }, + getElementBounds (id) { + const sourceGroup = this.pageGroups[this.dragging.index] + const sourceId = `nrdb-ui-group-${sourceGroup.id}` + const sourceEl = document.getElementById(sourceId) + const sourceBounds = sourceEl.getBoundingClientRect() + return sourceBounds } } } diff --git a/ui/src/stylesheets/common.css b/ui/src/stylesheets/common.css index e7b5f0439..23ec9a864 100644 --- a/ui/src/stylesheets/common.css +++ b/ui/src/stylesheets/common.css @@ -484,4 +484,9 @@ NB! supresses the gridarea for messages, but those are not in use*/ .v-btn.v-btn--density-comfortable { height: auto; min-height: var(--widget-row-height); +} + +/* WYSIWYG styling */ +.nrdb-ui-group.dragging { + border-style: dashed; } \ No newline at end of file From 45e96005b17dba762b18633c511931ba3908adbb Mon Sep 17 00:00:00 2001 From: Steve-Mcl Date: Mon, 14 Oct 2024 14:21:19 +0100 Subject: [PATCH 30/45] remove dead code --- ui/src/layouts/wysiwyg/sortable.js | 39 ------------------------------ 1 file changed, 39 deletions(-) delete mode 100644 ui/src/layouts/wysiwyg/sortable.js diff --git a/ui/src/layouts/wysiwyg/sortable.js b/ui/src/layouts/wysiwyg/sortable.js deleted file mode 100644 index 96053b454..000000000 --- a/ui/src/layouts/wysiwyg/sortable.js +++ /dev/null @@ -1,39 +0,0 @@ -const sortable = { - data () { - - }, - created: (el, binding) => { - console.log(binding.instance) - function onDragStart (event, index) { - this.dragging.index = index - event.dataTransfer.effectAllowed = 'move' - } - - function onDragOver (event, index) { - if (this.dragging.index >= 0) { - event.preventDefault() - event.dataTransfer.dropEffect = 'move' - this.moveGroup(this.dragging.index, index) - } - } - - function onDrop (event, index) { - event.preventDefault() - if (this.dragging.index >= 0) { - this.moveGroup(this.dragging.index, index) - this.dragging.index = -1 - } - } - - function onDragEnd (event, index) { - this.dragging.index = -1 - } - - el.addEventListener('dragstart', onDragStart) - el.addEventListener('dragover', onDragOver) - el.addEventListener('drop', onDrop) - el.addEventListener('dragend', onDragEnd) - } -} - -export default sortable From 3f5cdcc7cb5a5d57bfa3e83eaeaca974e069cc4e Mon Sep 17 00:00:00 2001 From: Steve-Mcl Date: Mon, 14 Oct 2024 18:24:18 +0100 Subject: [PATCH 31/45] Add pill indicator and draw indicator when in edit mode --- ui/src/layouts/Baseline.vue | 39 +++++++++++++++++++++++++++++++++++-- 1 file changed, 37 insertions(+), 2 deletions(-) diff --git a/ui/src/layouts/Baseline.vue b/ui/src/layouts/Baseline.vue index 354016f50..2fc124336 100644 --- a/ui/src/layouts/Baseline.vue +++ b/ui/src/layouts/Baseline.vue @@ -7,6 +7,9 @@
@@ -37,7 +40,11 @@ :to="page.type === 'ui-page' ? { name: page.route.name } : null" :data-nav="page.id" @click="closeNavigationDrawer()" - /> + > + + @@ -65,6 +72,8 @@ + + From 97b74d8c822dc6ae21d36f4469bc374d5d8d9bc8 Mon Sep 17 00:00:00 2001 From: Steve-Mcl Date: Mon, 14 Oct 2024 18:49:34 +0100 Subject: [PATCH 32/45] lint fixes --- nodes/widgets/ui_dropdown.html | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/nodes/widgets/ui_dropdown.html b/nodes/widgets/ui_dropdown.html index f40cc2ed2..75c662d47 100644 --- a/nodes/widgets/ui_dropdown.html +++ b/nodes/widgets/ui_dropdown.html @@ -54,13 +54,13 @@ } if (this.chips === undefined) { $('#node-input-chips').prop('checked', false) - } + } if (this.clearable === undefined) { $('#node-input-clearable').prop('checked', false) - } + } if (this.typeIsComboBox === undefined) { $('#node-input-typeIsComboBox').prop('checked', true) - } + } // if this groups parent is a subflow template, set the node-config-input-width and node-config-input-height up // as typedInputs and hide the elementSizer (as it doesn't make sense for subflow templates) if (RED.nodes.subflow(this.z)) { From ba0cb2635bbe86bb5c6dbbc2740aaf3a9baa3dcd Mon Sep 17 00:00:00 2001 From: Steve-Mcl Date: Mon, 14 Oct 2024 19:21:03 +0100 Subject: [PATCH 33/45] allow widget width to be larger than group width --- nodes/widgets/ui_button.html | 2 +- nodes/widgets/ui_button_group.html | 2 +- nodes/widgets/ui_chart.html | 2 +- nodes/widgets/ui_dropdown.html | 2 +- nodes/widgets/ui_file_input.html | 2 +- nodes/widgets/ui_form.html | 2 +- nodes/widgets/ui_gauge.html | 2 +- nodes/widgets/ui_number_input.html | 2 +- nodes/widgets/ui_radio_group.html | 2 +- nodes/widgets/ui_slider.html | 2 +- nodes/widgets/ui_switch.html | 2 +- nodes/widgets/ui_table.html | 2 +- nodes/widgets/ui_template.html | 6 +++--- nodes/widgets/ui_text.html | 2 +- nodes/widgets/ui_text_input.html | 2 +- 15 files changed, 17 insertions(+), 17 deletions(-) diff --git a/nodes/widgets/ui_button.html b/nodes/widgets/ui_button.html index e965f70af..925e3d4c9 100644 --- a/nodes/widgets/ui_button.html +++ b/nodes/widgets/ui_button.html @@ -17,7 +17,7 @@ const width = v || 0 const currentGroup = $('#node-input-group').val() || this.group const groupNode = RED.nodes.node(currentGroup) - const valid = !groupNode || +width <= +groupNode.width + const valid = !groupNode || +width >= 0 $('#node-input-size').toggleClass('input-error', !valid) return valid } diff --git a/nodes/widgets/ui_button_group.html b/nodes/widgets/ui_button_group.html index 1254a1284..94ca11fae 100644 --- a/nodes/widgets/ui_button_group.html +++ b/nodes/widgets/ui_button_group.html @@ -15,7 +15,7 @@ const width = v || 0 const currentGroup = $('#node-input-group').val() || this.group const groupNode = RED.nodes.node(currentGroup) - const valid = !groupNode || +width <= +groupNode.width + const valid = !groupNode || +width >= 0 $('#node-input-size').toggleClass('input-error', !valid) return valid } diff --git a/nodes/widgets/ui_chart.html b/nodes/widgets/ui_chart.html index 8512ded08..d5b745a3a 100644 --- a/nodes/widgets/ui_chart.html +++ b/nodes/widgets/ui_chart.html @@ -134,7 +134,7 @@ const width = v || 0 const currentGroup = $('#node-input-group').val() || this.group const groupNode = RED.nodes.node(currentGroup) - const valid = !groupNode || +width <= +groupNode.width + const valid = !groupNode || +width >= 0 $('#node-input-size').toggleClass('input-error', !valid) return valid } diff --git a/nodes/widgets/ui_dropdown.html b/nodes/widgets/ui_dropdown.html index 75c662d47..5c06df3fd 100644 --- a/nodes/widgets/ui_dropdown.html +++ b/nodes/widgets/ui_dropdown.html @@ -18,7 +18,7 @@ const width = v || 0 const currentGroup = $('#node-input-group').val() || this.group const groupNode = RED.nodes.node(currentGroup) - const valid = !groupNode || +width <= +groupNode.width + const valid = !groupNode || +width >= 0 $('#node-input-size').toggleClass('input-error', !valid) return valid } diff --git a/nodes/widgets/ui_file_input.html b/nodes/widgets/ui_file_input.html index 8fe5763be..4130b98ae 100644 --- a/nodes/widgets/ui_file_input.html +++ b/nodes/widgets/ui_file_input.html @@ -16,7 +16,7 @@ const width = v || 0 const currentGroup = $('#node-input-group').val() || this.group const groupNode = RED.nodes.node(currentGroup) - const valid = !groupNode || +width <= +groupNode.width + const valid = !groupNode || +width >= 0 $('#node-input-size').toggleClass('input-error', !valid) return valid } diff --git a/nodes/widgets/ui_form.html b/nodes/widgets/ui_form.html index 441211da9..b2bfa318a 100644 --- a/nodes/widgets/ui_form.html +++ b/nodes/widgets/ui_form.html @@ -21,7 +21,7 @@ const width = v || 0 const currentGroup = $('#node-input-group').val() || this.group const groupNode = RED.nodes.node(currentGroup) - const valid = !groupNode || +width <= +groupNode.width + const valid = !groupNode || +width >= 0 $('#node-input-size').toggleClass('input-error', !valid) return valid } diff --git a/nodes/widgets/ui_gauge.html b/nodes/widgets/ui_gauge.html index b1d680672..85c49a635 100644 --- a/nodes/widgets/ui_gauge.html +++ b/nodes/widgets/ui_gauge.html @@ -129,7 +129,7 @@ const width = v || 0 const currentGroup = $('#node-input-group').val() || this.group const groupNode = RED.nodes.node(currentGroup) - const valid = !groupNode || +width <= +groupNode.width + const valid = !groupNode || +width >= 0 $('#node-input-size').toggleClass('input-error', !valid) return valid } diff --git a/nodes/widgets/ui_number_input.html b/nodes/widgets/ui_number_input.html index b7a4bc8b5..7ff748ab8 100644 --- a/nodes/widgets/ui_number_input.html +++ b/nodes/widgets/ui_number_input.html @@ -17,7 +17,7 @@ const width = v || 0 const currentGroup = $('#node-input-group').val() || this.group const groupNode = RED.nodes.node(currentGroup) - const valid = !groupNode || +width <= +groupNode.width + const valid = !groupNode || +width >= 0 $('#node-input-size').toggleClass('input-error', !valid) return valid } diff --git a/nodes/widgets/ui_radio_group.html b/nodes/widgets/ui_radio_group.html index 97a40ab36..fab4fa481 100644 --- a/nodes/widgets/ui_radio_group.html +++ b/nodes/widgets/ui_radio_group.html @@ -17,7 +17,7 @@ const width = v || 0 const currentGroup = $('#node-input-group').val() || this.group const groupNode = RED.nodes.node(currentGroup) - const valid = !groupNode || +width <= +groupNode.width + const valid = !groupNode || +width >= 0 $('#node-input-size').toggleClass('input-error', !valid) return valid } diff --git a/nodes/widgets/ui_slider.html b/nodes/widgets/ui_slider.html index 6658e9a25..db5440b17 100644 --- a/nodes/widgets/ui_slider.html +++ b/nodes/widgets/ui_slider.html @@ -18,7 +18,7 @@ const width = v || 0 const currentGroup = $('#node-input-group').val() || this.group const groupNode = RED.nodes.node(currentGroup) - const valid = !groupNode || +width <= +groupNode.width + const valid = !groupNode || +width >= 0 $('#node-input-size').toggleClass('input-error', !valid) return valid } diff --git a/nodes/widgets/ui_switch.html b/nodes/widgets/ui_switch.html index ecd9c120f..e447e8839 100644 --- a/nodes/widgets/ui_switch.html +++ b/nodes/widgets/ui_switch.html @@ -18,7 +18,7 @@ const width = v || 0 const currentGroup = $('#node-input-group').val() || this.group const groupNode = RED.nodes.node(currentGroup) - const valid = !groupNode || +width <= +groupNode.width + const valid = !groupNode || +width >= 0 $('#node-input-size').toggleClass('input-error', !valid) return valid } diff --git a/nodes/widgets/ui_table.html b/nodes/widgets/ui_table.html index 2bc40eab0..90a2858ab 100644 --- a/nodes/widgets/ui_table.html +++ b/nodes/widgets/ui_table.html @@ -72,7 +72,7 @@ const width = v || 0 const currentGroup = $('#node-input-group').val() || this.group const groupNode = RED.nodes.node(currentGroup) - const valid = !groupNode || +width <= +groupNode.width + const valid = !groupNode || +width >= 0 $('#node-input-size').toggleClass('input-error', !valid) return valid } diff --git a/nodes/widgets/ui_template.html b/nodes/widgets/ui_template.html index 058e15659..7953e274d 100644 --- a/nodes/widgets/ui_template.html +++ b/nodes/widgets/ui_template.html @@ -135,15 +135,15 @@ width: { value: 0, validate: function (v) { - let valid = true if (this.templateScope !== 'global') { const width = v || 0 const currentGroup = $('#node-input-group').val() || this.group const groupNode = RED.nodes.node(currentGroup) - valid = !groupNode || +width <= +groupNode.width + const valid = !groupNode || +width >= 0 $('#node-input-size').toggleClass('input-error', !valid) + return valid } - return valid + return true } }, height: { value: 0 }, diff --git a/nodes/widgets/ui_text.html b/nodes/widgets/ui_text.html index 55a8f2fbb..8e05630ce 100644 --- a/nodes/widgets/ui_text.html +++ b/nodes/widgets/ui_text.html @@ -83,7 +83,7 @@ const width = v || 0 const currentGroup = $('#node-input-group').val() || this.group const groupNode = RED.nodes.node(currentGroup) - const valid = !groupNode || +width <= +groupNode.width + const valid = !groupNode || +width >= 0 $('#node-input-size').toggleClass('input-error', !valid) return valid } diff --git a/nodes/widgets/ui_text_input.html b/nodes/widgets/ui_text_input.html index c010be80b..5029f5044 100644 --- a/nodes/widgets/ui_text_input.html +++ b/nodes/widgets/ui_text_input.html @@ -17,7 +17,7 @@ const width = v || 0 const currentGroup = $('#node-input-group').val() || this.group const groupNode = RED.nodes.node(currentGroup) - const valid = !groupNode || +width <= +groupNode.width + const valid = !groupNode || +width >= 0 $('#node-input-size').toggleClass('input-error', !valid) return valid } From ce4f5d64c7adfa7e9f50abf0cef4178442177b93 Mon Sep 17 00:00:00 2001 From: Steve-Mcl Date: Tue, 15 Oct 2024 10:52:05 +0100 Subject: [PATCH 34/45] revert url query param clean up code (fix e2e tests) --- ui/src/App.vue | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/ui/src/App.vue b/ui/src/App.vue index 041a5418e..6b3aee749 100644 --- a/ui/src/App.vue +++ b/ui/src/App.vue @@ -191,10 +191,7 @@ export default { // if this is the first time we load the Dashboard, the router hasn't registered the current route properly, // so best we just navigate to the existing URL to let router catch up - // ensure we remove the edit key from the URL - const url = new URL(window.location.href) - url.searchParams.delete('edit-key') - this.$router.push(url) + this.$router.push(this.$route.fullPath) // loop over the widgets defined in Node-RED, // map their respective Vue component for rendering on a page From 529be18957e9ffcfcd340c03f9a1b7ff4fe4906c Mon Sep 17 00:00:00 2001 From: Steve-Mcl Date: Tue, 15 Oct 2024 17:25:24 +0100 Subject: [PATCH 35/45] fix resize and drag pointers and placement --- ui/src/layouts/Flex.vue | 13 ++++-- ui/src/layouts/Grid.vue | 13 ++++-- ui/src/layouts/Group.vue | 83 +++++++++++++++++++++------------ ui/src/layouts/wysiwyg/index.js | 77 +++++++++++++++++------------- ui/src/stylesheets/common.css | 16 ++++++- 5 files changed, 128 insertions(+), 74 deletions(-) diff --git a/ui/src/layouts/Flex.vue b/ui/src/layouts/Flex.vue index 47dffab79..770e1c49a 100644 --- a/ui/src/layouts/Flex.vue +++ b/ui/src/layouts/Flex.vue @@ -11,9 +11,11 @@ :style="{'width': ((rowHeight * 2 * g.width) + 'px')}" :draggable="editMode" @dragstart="onDragStart($event, $index)" - @dragend="onDragEnd($event, $index)" - @dragover="onDragOver($event, $index)" - @drop="onDrop($event, $index)" + @dragend="onDragEnd($event, $index, g)" + @dragover="onDragOver($event, $index, g)" + @drop="onDrop($event, $index, g)" + @dragleave="onDragLeave($event, $index, g)" + @dragenter.prevent >