Skip to content

Commit

Permalink
Merge pull request #1369 from FlowFuse/30-wysiwyg-editor
Browse files Browse the repository at this point in the history
Visual Layout Editor
  • Loading branch information
Steve-Mcl authored Nov 1, 2024
2 parents ab26bb1 + 2f77c4f commit 7df37fe
Show file tree
Hide file tree
Showing 39 changed files with 1,214 additions and 100 deletions.
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 @@ -1075,14 +1076,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 @@ -1092,14 +1095,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

0 comments on commit 7df37fe

Please sign in to comment.