Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Visual Layout Editor #1369

Merged
merged 49 commits into from
Nov 1, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
49 commits
Select commit Hold shift + click to select a range
8867de8
Layout WYSIWYG - Drag & Drop Ordering for Groups
joepavitt Aug 27, 2024
a833456
Working prototype of end-to-end API for deploy via Dashboard
joepavitt Sep 19, 2024
28b4a1f
Add first iteration of group resizing in WYSIWYG
joepavitt Sep 23, 2024
0ea0cdf
Add "Discard" functionality & disable buttons with no changes
joepavitt Oct 7, 2024
78d7c6a
Componentise the WYSWIYG functinality
joepavitt Oct 8, 2024
a5c7d43
Merge branch 'main' into 30-wysiwyg-editor
Steve-Mcl Oct 8, 2024
daafd72
support enabling edit mode for a page from sidebar
Steve-Mcl Oct 14, 2024
fda6848
on-hover buttons for enabling edit mode on supported pages
Steve-Mcl Oct 14, 2024
88d0de9
consistent target (per base)
Steve-Mcl Oct 14, 2024
35bbd88
update deploy endpoint to v2 and add error handling
Steve-Mcl Oct 14, 2024
6258104
add i18n messages fro edit buttons
Steve-Mcl Oct 14, 2024
ec47675
put edit controls in own component
Steve-Mcl Oct 14, 2024
25797d0
ensure query param edit-key is carried over upon forced refresh
Steve-Mcl Oct 14, 2024
b040f62
Add edit tracking for edit buffer and state
Steve-Mcl Oct 14, 2024
3e967b5
Init edit mode when matching query and config are present
Steve-Mcl Oct 14, 2024
609aad4
component name correction
Steve-Mcl Oct 14, 2024
36633a9
fix security for node-red deploy function
Steve-Mcl Oct 14, 2024
78537a4
add reusable confirm dialog (for internal use)
Steve-Mcl Oct 14, 2024
7ad0938
grab/move/resize pointers
Steve-Mcl Oct 14, 2024
5737de6
typo
Steve-Mcl Oct 14, 2024
c2060eb
fix jump to size 1 when releasing mouse
Steve-Mcl Oct 14, 2024
6c29dca
cast columns to integer
Steve-Mcl Oct 14, 2024
6ad445b
cast to integer with default of 1
Steve-Mcl Oct 14, 2024
ee305db
code reorder
Steve-Mcl Oct 14, 2024
2c161e3
dont attempt resize if not dragging
Steve-Mcl Oct 14, 2024
ba54734
prevent invalid values causing a resize
Steve-Mcl Oct 14, 2024
6f3a45b
add missing reset
Steve-Mcl Oct 14, 2024
1b8140f
dont resize if not active
Steve-Mcl Oct 14, 2024
cc1a332
only apply resize if something changed
Steve-Mcl Oct 14, 2024
08af2d5
move implementation from temp wysiwyg to supported page types
Steve-Mcl Oct 14, 2024
57af067
Merge pull request #1399 from FlowFuse/30-wysiwyg-editor-patch
Steve-Mcl Oct 14, 2024
45e9600
remove dead code
Steve-Mcl Oct 14, 2024
a108625
Resolve conflicts. Merge 'main' into 30-wysiwyg-editor
Steve-Mcl Oct 14, 2024
3f5cdcc
Add pill indicator and draw indicator when in edit mode
Steve-Mcl Oct 14, 2024
97b74d8
lint fixes
Steve-Mcl Oct 14, 2024
ba0cb26
allow widget width to be larger than group width
Steve-Mcl Oct 14, 2024
ce4f5d6
revert url query param clean up code (fix e2e tests)
Steve-Mcl Oct 15, 2024
529be18
fix resize and drag pointers and placement
Steve-Mcl Oct 15, 2024
352a4cc
fix untranslated RED notification
Steve-Mcl Oct 16, 2024
f62ea3b
add handshake to ws so only original edit-key holder can edit.
Steve-Mcl Oct 16, 2024
622d262
remove console.logs
Steve-Mcl Oct 16, 2024
6c52a4d
Revert drop on move, fixes drag cursor, improves sizing accuracy + re…
Steve-Mcl Oct 19, 2024
ebb0191
fixed sizing for consistent toolbar regardless of user theme
Steve-Mcl Oct 20, 2024
e50c72d
Only delete items when that item is de-registering
Steve-Mcl Oct 28, 2024
46d1622
fix node/flows deploy types
Steve-Mcl Oct 28, 2024
68ddf8a
Guard against missing handshake in socket
Steve-Mcl Oct 28, 2024
20ff5b2
Merge branch 'main' into 30-wysiwyg-editor
Steve-Mcl Oct 28, 2024
5db69d2
ensure different editor path is supported
Steve-Mcl Nov 1, 2024
2f77c4f
Remove new line in Page name
joepavitt Nov 1, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions nodes/config/locales/en-US/ui_base.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,9 @@
"page": "Page",
"link": "Link",
"group": "Group",
"addGroup": "Add Group",
"edit": "Edit",
"layoutEditor": "Layout Editor",
"focus": "Focus",
"collapse": "Collapse",
"expand": "Expand",
Expand Down
1 change: 1 addition & 0 deletions nodes/config/locales/en-US/ui_page.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
"icon": "Icon",
"theme": "Theme",
"layout": "Layout",
"wysiwyg": "Editor",
"grid": "Grid",
"fixed": "Fixed",
"tabs": "Tabs",
Expand Down
81 changes: 70 additions & 11 deletions nodes/config/ui_base.html
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,7 @@
// #endregion

(function () {
const supportedEditableLayouts = ['grid', 'flex']
const sidebarContainer = '<div style="position: relative; height: 100%;"></div>'
const sidebarContentTemplate = $('<div id="ff-node-red-dashboard"></div>').appendTo(sidebarContainer)
const sidebar = $(sidebarContentTemplate)
Expand Down Expand Up @@ -460,8 +461,8 @@
editSettingsButton.on('click', function () {
RED.editor.editConfig('', 'ui-base', id)
})

const openDashboardButton = $(`<a id="open-dashboard" href="${fullPath}" target="nr-dashboard" class="editor-button editor-button-small nr-db-sb-list-header-button">` +
const target = `nr-dashboard-${id}` // try to reuse the same window per base
const openDashboardButton = $(`<a id="open-dashboard" href="${fullPath}" target="${target}" class="editor-button editor-button-small nr-db-sb-list-header-button">` +
c_('label.openDashboard') + ' <i style="margin-left: 3px;" class="fa fa-external-link"></i></a>')

label.appendTo(header)
Expand Down Expand Up @@ -832,14 +833,16 @@
const configNodes = ['ui-base', 'ui-page', 'ui-link', 'ui-group', 'ui-theme']
const btnGroup = $('<div>', { class: 'nrdb2-sb-list-header-button-group', id: item.id }).appendTo(parent)
if (!configNodes.includes(item.type)) {
const focusButton = $('<a href="#" class="nr-db-sb-tab-focus-button editor-button editor-button-small nr-db-sb-list-header-button"><i class="fa fa-bullseye"></i> ' + c_('layout.focus') + '</a>').appendTo(btnGroup)
const focusButton = $(`<a href="#" class="nr-db-sb-tab-focus-button editor-button editor-button-small nr-db-sb-list-header-button" title="${c_('layout.focus')}"><i class="fa fa-bullseye"></i></a>`).appendTo(btnGroup)
focusButton.on('click', function (evt) {
RED.view.reveal(item.id)
evt.stopPropagation()
evt.preventDefault()
})
}
const editButton = $('<a href="#" class="nr-db-sb-tab-edit-button editor-button editor-button-small nr-db-sb-list-header-button"><i class="fa fa-pencil"></i> ' + c_('layout.edit') + '</a>').appendTo(btnGroup)

// button to edit node via node-red editor panel
const editButton = $(`<a href="#" class="nr-db-sb-tab-edit-button editor-button editor-button-small nr-db-sb-list-header-button" title=${c_('layout.edit')}><i class="fa fa-cog"></i></a>`).appendTo(btnGroup)
editButton.on('click', function (evt) {
if (configNodes.includes(item.type)) {
RED.editor.editConfig('', item.type, item.id)
Expand All @@ -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 = $(`<a class="nr-db-sb-tab-layout-button editor-button editor-button-small nr-db-sb-list-header-button" title="${c_('layout.layoutEditor')}"><i class="fa fa-pencil-square-o"></i></a>`).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
$('<a href="#" class="editor-button editor-button-small nr-db-sb-list-header-button"><i class="fa fa-plus"></i> ' + c_('layout.group') + '</a>')
.click(function (evt) {
list.editableList('addItem')
evt.preventDefault()
})
.appendTo(btnGroup)
const groupEditButton = $(`<a href="#" class="editor-button editor-button-small nr-db-sb-list-header-button" title="${c_('layout.addGroup')}"><i class="fa fa-plus"></i></a>`).appendTo(btnGroup)
groupEditButton.on('click', function (evt) {
list.editableList('addItem')
evt.preventDefault()
})
}
}

Expand Down
181 changes: 172 additions & 9 deletions nodes/config/ui_base.js
Original file line number Diff line number Diff line change
@@ -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')
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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()
}
Expand All @@ -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)
})
}
1 change: 1 addition & 0 deletions nodes/config/ui_page.html
Original file line number Diff line number Diff line change
Expand Up @@ -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') },
Expand Down
2 changes: 1 addition & 1 deletion nodes/widgets/ui_button.html
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
2 changes: 1 addition & 1 deletion nodes/widgets/ui_button_group.html
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
2 changes: 1 addition & 1 deletion nodes/widgets/ui_chart.html
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
Loading
Loading