diff --git a/nodes/config/locales/en-US/ui_base.json b/nodes/config/locales/en-US/ui_base.json index 688ebbc87..0613f1699 100644 --- a/nodes/config/locales/en-US/ui_base.json +++ b/nodes/config/locales/en-US/ui_base.json @@ -38,7 +38,9 @@ "page": "Page", "link": "Link", "group": "Group", + "addGroup": "Add Group", "edit": "Edit", + "layoutEditor": "Layout Editor", "focus": "Focus", "collapse": "Collapse", "expand": "Expand", diff --git a/nodes/config/locales/en-US/ui_page.json b/nodes/config/locales/en-US/ui_page.json index fed5b28f8..0465ae0bb 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_base.html b/nodes/config/ui_base.html index 3896ecf87..3b16e4ab9 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) @@ -460,8 +461,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) @@ -832,14 +833,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) @@ -849,14 +852,70 @@ 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) + const dashboardBasePath = (base.path || 'dashboard').replace(/\/$/, '').replace(/^\/+/, '') + 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: `${dashboardBasePath}/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) { + // construct the url to open the layout editor + const _url = new URL(`${dashboardBasePath}/${data.path}`.replace(/\/\//g, '/'), windowUrl.origin) + _url.searchParams.set('edit-key', data.editKey) + const editorPath = windowUrl.pathname?.replace(/\/$/, '').replace(/^\/+/, '') || '' + if (editorPath) { + _url.searchParams.set('editor-path', editorPath) + } + 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('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() + }) } } diff --git a/nodes/config/ui_base.js b/nodes/config/ui_base.js index 2f2c3bbfd..9d3bffe4a 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') @@ -435,9 +437,17 @@ module.exports = function (RED) { node.ui.groups.set(id, { ...group, ...state }) } } - + // if the socket has an editKey & it matches the editKey in the meta, then we will + // send the wysiwyg meta to the client. Otherwise, we will remove it from the meta + // before sending it to the client + const handshakeEditKey = socket.handshake?.query?.editKey + const meta = { ...node.ui.meta } + if (!handshakeEditKey || !meta?.wysiwyg?.editKey || handshakeEditKey !== meta.wysiwyg.editKey) { + delete meta.wysiwyg + } // pass the connected UI the UI config socket.emit('ui-config', node.id, { + meta, dashboards: Object.fromEntries(node.ui.dashboards), heads: Object.fromEntries(node.ui.heads), pages: Object.fromEntries(node.ui.pages), @@ -778,6 +788,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(), @@ -1083,19 +1102,16 @@ module.exports = function (RED) { } node.ui.widgets.delete(widgetNode.id) changes = true - } - - // if there are no more widgets on this group, remove the group from our UI config - if (group && [...node.ui.widgets].filter(w => w.props?.group === group.id).length === 0) { + } else if (group) { + // remove group from our UI config node.ui.groups.delete(group.id) changes = true - } - - // if there are no more groups on this page, remove the page from our UI config - if (page && [...node.ui.groups].filter(g => g.page === page.id).length === 0) { + } else if (page) { + // remove page from our UI config node.ui.pages.delete(page.id) changes = true } + if (changes) { node.requestEmitConfig() } @@ -1108,4 +1124,151 @@ module.exports = function (RED) { } RED.nodes.registerType('ui-base', UIBaseNode) + + // 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 + const url = 'http://' + (`${host}:${port}/${httpAdminRoot}flows`).replace('//', '/') + console.log('url', url) + // get request body + 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?.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, + 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 }) + }) + }) + + // 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) + }) } diff --git a/nodes/config/ui_page.html b/nodes/config/ui_page.html index 8d8fbbfb7..b4bdff3a9 100644 --- a/nodes/config/ui_page.html +++ b/nodes/config/ui_page.html @@ -97,6 +97,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/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 1cdeea74c..226889167 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 f40cc2ed2..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 } @@ -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)) { 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 } diff --git a/package-lock.json b/package-lock.json index cabbaa9c2..21d616a09 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", @@ -4697,8 +4698,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", @@ -4740,10 +4740,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", @@ -4754,7 +4753,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", @@ -4767,8 +4765,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", @@ -5783,7 +5780,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" }, @@ -6897,7 +6893,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" } @@ -8404,7 +8399,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 b82f89a6e..b442cb36a 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/App.vue b/ui/src/App.vue index b8afbb0d9..bc45c2252 100644 --- a/ui/src/App.vue +++ b/ui/src/App.vue @@ -20,6 +20,7 @@ + + diff --git a/ui/src/layouts/Baseline.vue b/ui/src/layouts/Baseline.vue index 354016f50..220089737 100644 --- a/ui/src/layouts/Baseline.vue +++ b/ui/src/layouts/Baseline.vue @@ -6,7 +6,9 @@
@@ -37,7 +39,11 @@ :to="page.type === 'ui-page' ? { name: page.route.name } : null" :data-nav="page.id" @click="closeNavigationDrawer()" - /> + > + + @@ -65,6 +71,8 @@ + + diff --git a/ui/src/layouts/Flex.vue b/ui/src/layouts/Flex.vue index cd97869e5..0b299f3bd 100644 --- a/ui/src/layouts/Flex.vue +++ b/ui/src/layouts/Flex.vue @@ -2,23 +2,31 @@
+
@@ -47,33 +55,67 @@
+ + + diff --git a/ui/src/layouts/Notebook.vue b/ui/src/layouts/Notebook.vue index e42ff0db7..09ac8c04c 100644 --- a/ui/src/layouts/Notebook.vue +++ b/ui/src/layouts/Notebook.vue @@ -58,7 +58,7 @@ import DialogGroup from './DialogGroup.vue' import WidgetGroup from './Group.vue' export default { - name: 'LayoutFlex', + name: 'LayoutNotebook', components: { BaselineLayout, DialogGroup, diff --git a/ui/src/layouts/wysiwyg/EditControls.vue b/ui/src/layouts/wysiwyg/EditControls.vue new file mode 100644 index 000000000..aa71f1eb3 --- /dev/null +++ b/ui/src/layouts/wysiwyg/EditControls.vue @@ -0,0 +1,68 @@ + + + + + diff --git a/ui/src/layouts/wysiwyg/draggable.js b/ui/src/layouts/wysiwyg/draggable.js new file mode 100644 index 000000000..136542528 --- /dev/null +++ b/ui/src/layouts/wysiwyg/draggable.js @@ -0,0 +1,102 @@ +export default { + data () { + return { + pageGroups: [], + dragging: { + active: false, + index: -1, + dropIndex: -1 + } + } + }, + methods: { + // Drag and Drop placement methods + /** + * Event handler for dragstart event + * @param {DragEvent} event - The drag event + * @param {Number} index - The index of the group + */ + onDragStart (event, index) { + this.dragging.active = true + this.dragging.index = index + this.dragging.dropIndex = index + event.dataTransfer.effectAllowed = 'move' + event.dataTransfer.dropEffect = 'move' + event.stopPropagation() + return false + }, + /** + * Event handler for dragover event + * @param {DragEvent} event - The drag event + * @param {Number} index - The index of the group + */ + onDragOver (event, index, group) { + event.preventDefault() + if (this.dragging.active === false) { return } + event.dataTransfer.dropEffect = 'move' + // update drop index + if (index !== this.dragging.index) { + this.dragging.dropIndex = 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) { + this.moveGroup(this.dragging.index, index) + } + }, + onDragLeave (event, index, group) { + this.dragging.dropIndex = -1 + }, + onDragEnd (event, index, group) { + this.dragging.active = false + this.dragging.index = -1 + this.dragging.dropIndex = -1 + }, + moveGroup (fromIndex, toIndex) { + const movedItem = this.pageGroups.splice(fromIndex, 1)[0] + this.pageGroups.splice(toIndex, 0, movedItem) + // update .order property of all groups + this.pageGroups.forEach((group, index) => { + group.order = index + 1 + }) + this.dragging.index = toIndex + }, + getDragDropClass (group) { + if (this.isDragging(group)) { + return 'drag-start' + } + return '' + }, + isDragging (group) { + if (!this.dragging.active) { + return false + } + const dragging = this.pageGroups[this.dragging.index] + if (dragging?.id === group?.id) { + return true + } + return false + } + } +} diff --git a/ui/src/layouts/wysiwyg/index.js b/ui/src/layouts/wysiwyg/index.js new file mode 100644 index 000000000..0485aabf3 --- /dev/null +++ b/ui/src/layouts/wysiwyg/index.js @@ -0,0 +1,50 @@ +import { editKey, editMode, editPage, editorPath, exitEditMode, isTrackingEdits, originalGroups, startEditTracking, updateEditTracking } from '../../EditTracking.js' +import NodeRedApi from '../../api/node-red' + +import DraggableMixin from './draggable.js' +import ResizableMixin from './resizable.js' + +export default { + mixins: [DraggableMixin, ResizableMixin], + data () { + return { + pageGroups: [] + } + }, + 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, editorPath: editorPath.value }) + } + } +} diff --git a/ui/src/layouts/wysiwyg/resizable.js b/ui/src/layouts/wysiwyg/resizable.js new file mode 100644 index 000000000..896414897 --- /dev/null +++ b/ui/src/layouts/wysiwyg/resizable.js @@ -0,0 +1,100 @@ +export default { + data () { + return { + pageGroups: [], + resizing: { + active: false, + init: { + x: 0, + y: 0, + columns: 0, + rows: 0, + width: 0, + height: 0 + }, + current: { + columns: null, + rows: null, + width: null + } + } + } + }, + watch: { + 'resizing.active' (active) { + // add class 'resize-active' to parent group v-card + // that class is solely to raise the z-index of the group while resizing + // the associated resize-active CSS is in `common.css` + this.$el.parentElement.closest('.nrdb-ui-group > .v-card')?.classList.toggle('resize-active', active) + } + }, + methods: { + onHandleDragStart (/** @type {DragEvent} */ event, vertical, horizontal) { + this.resizing.parent = this.$el.parentElement.closest('.nrdb-ui-group > .v-card') + this.resizing.init.columns = +this.group.width || 1 + this.resizing.init.rows = +this.group.height || 1 + this.resizing.init.width = this.$refs['resize-view'].clientWidth + this.resizing.init.x = event.x + this.resizing.active = true + const EMPTY_IMAGE = this.$refs['blank-img'] // don't show image preview + event.dataTransfer.setDragImage(EMPTY_IMAGE, 0, 0) + event.dataTransfer.effectAllowed = 'move' + event.dataTransfer.dropEffect = 'move' + event.stopPropagation() + return false + }, + onHandleOver (/** @type {DragEvent} */ event, vertical, horizontal) { + event.preventDefault() // required to allow dropEffect + if (this.resizing.active === false) { return } + event.dataTransfer.dropEffect = 'move' + }, + onHandleDrag (/** @type {DragEvent} */ event, vertical, horizontal) { + event.preventDefault() // required to allow drop and show dropEffect + if (this.resizing.active === false) { return } + event.dataTransfer.dropEffect = 'move' + if (event.x > 0 && event.y > 0) { + const dx = event.x - this.resizing.init.x + this.resizing.current.width = this.resizing.init.width + dx + const stepX = this.resizing.parent.clientWidth / +this.group.width + const dw = Math.round(this.resizing.current.width / stepX) - this.resizing.init.columns + const newColumns = Math.max(this.resizing.init.columns + dw, 1) + + let allowIncrease = false + let allowDecrease = false + const stepSnapAt = stepX * 0.85 + if (this.resizing.current.width > this.resizing.parent.clientWidth) { + const diff = this.resizing.current.width - this.resizing.parent.clientWidth + allowIncrease = diff > stepSnapAt + } else if (this.resizing.current.width < this.resizing.parent.clientWidth) { + const diff = this.resizing.parent.clientWidth - this.resizing.current.width + allowDecrease = diff > stepSnapAt + } + if (allowIncrease && newColumns > +this.group.width) { + this.resizing.current.columns = newColumns + this.$emit('resize', { index: this.index, width: newColumns }) + } else if (allowDecrease && newColumns < +this.group.width) { + this.resizing.current.columns = newColumns + this.$emit('resize', { index: this.index, width: newColumns }) + } + } + }, + onHandleDragEnd (/** @type {DragEvent} */ event) { + if (this.resizing.active === false) { return } + this.resetDragState() + }, + resetDragState () { + this.resizing.active = false + this.resizing.parent = null + this.resizing.current.width = null + this.resizing.current.columns = null + this.resizing.current.rows = null + }, + onGroupResize (opts) { + // 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 + } + } +} diff --git a/ui/src/layouts/wysiwyg/resizable.scss b/ui/src/layouts/wysiwyg/resizable.scss new file mode 100644 index 000000000..a5c365956 --- /dev/null +++ b/ui/src/layouts/wysiwyg/resizable.scss @@ -0,0 +1,64 @@ +.nrdb-resizable { + position: absolute; + width: 100%; + height: 100%; + top: 0; + left: 0; + --handler-size: 12px; + cursor: grab; + &.resizing { + background-color: #ff00001f; + border: 1px dashed red; + } +} +.nrdb-resizable--handle { + position: absolute; + width: var(--handler-size); + height: var(--handler-size); + background-color: white; + border: 1px solid black; + border-radius: 6px; + cursor: ew-resize; + &:active { + cursor: ew-resize !important; + } +} +.nrdb-resizable--top-right { + top: calc(-1 * var(--handler-size) / 2); + right: calc(-1 * var(--handler-size) / 2); +} + +.nrdb-resizable--right { + height: calc(2 * var(--handler-size)); + top: 50%; + margin-top: calc(-1 * var(--handler-size)); + right: calc(-1 * var(--handler-size) / 2); + display: flex; + justify-content: center; + align-items: center; + &:hover { + cursor: ew-resize; + background-color: #eee; + } + &:active { + cursor: ew-resize !important; + } +} + +.nrdb-resizable--right:after { + content: ''; + height: calc(0.75 * var(--handler-size)); + width: 1px; + display: inline-block; + background-color: red; +} + +.nrdb-resizable--bottom-left { + bottom: calc(-1 * var(--handler-size) / 2); + left: calc(-1 * var(--handler-size) / 2); +} + +.nrdb-resizable--top-left { + top: calc(-1 * var(--handler-size) / 2); + left: calc(-1 * var(--handler-size) / 2); +} diff --git a/ui/src/main.mjs b/ui/src/main.mjs index 5ea62b7fa..7b0dc0f35 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 } @@ -122,10 +132,11 @@ fetch('_setup') let reconnectTO = null const MAX_RETRIES = 22 // 4 at 2.5 seconds, 10 at 5 secs then 8 at 30 seconds - + const editKey = host.searchParams.get('edit-key') const socket = io({ ...setup.socketio, - reconnection: false + reconnection: false, + query: editKey ? { editKey } : undefined // include handshake data so that only original edit-key holder can edit }) // handle final disconnection diff --git a/ui/src/store/ui.mjs b/ui/src/store/ui.mjs index 4da67d41e..0a08b0c11 100644 --- a/ui/src/store/ui.mjs +++ b/ui/src/store/ui.mjs @@ -9,6 +9,12 @@ const state = () => ({ // getters const getters = { + id: (state) => { + // should only be one dashboard for now + console.log(state.dashboards) + const id = state.dashboards ? Object.keys(state.dashboards)[0] : null + return id + }, dashboards (state) { return state.dashboards }, diff --git a/ui/src/stylesheets/common.css b/ui/src/stylesheets/common.css index 19ea89801..29e569ecb 100644 --- a/ui/src/stylesheets/common.css +++ b/ui/src/stylesheets/common.css @@ -133,7 +133,8 @@ main { } .nrdb-ui-group > .v-card { - border-radius: var(--group-border-radius) + border-radius: var(--group-border-radius); + overflow: visible; } /** @@ -483,4 +484,23 @@ 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); -} \ No newline at end of file +} + +/* WYSIWYG styling */ +.nrdb-ui-group.dragging { + border-style: dashed; +} + +.nrdb-ui-group.drag-start { + opacity: 0.8; + outline: 2px dashed black; +} + +.nrdb-ui-page > .nrdb-ui-group[draggable="true"] > .v-card.resize-active { + /* + ensure card is on top while being actively resized + this is important to ensure the resize handles are visible + above sibling cards + */ + z-index: 1000 !important; +}