Skip to content

Commit

Permalink
Merge pull request #1469 from FlowFuse/widget-wysiwyg
Browse files Browse the repository at this point in the history
WYSIWYG: Widget resizing and re-ordering
  • Loading branch information
joepavitt authored Dec 13, 2024
2 parents 6a1f164 + 281e3c2 commit 9d52ea9
Show file tree
Hide file tree
Showing 16 changed files with 1,253 additions and 202 deletions.
158 changes: 134 additions & 24 deletions nodes/config/ui_base.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
const { Agent } = require('https')
const path = require('path')

const axios = require('axios')
Expand Down Expand Up @@ -736,7 +737,7 @@ module.exports = function (RED) {
// any widgets we hard-code into our front end (e.g ui-notification for connection alerts) will start with ui-
// Node-RED built nodes will be a random UUID
if (!wNode && !id.startsWith('ui-')) {
console.log('widget does not exist any more')
console.log('widget does not exist in the runtime', id) // TODO: Handle this better for edit-time added nodes (e.g. ui-spacer)
return // widget does not exist any more (e.g. deleted from NR and deployed BUT the ui page was not refreshed)
}
async function handler () {
Expand Down Expand Up @@ -903,9 +904,9 @@ module.exports = function (RED) {
type: widgetConfig.type,
props: widgetConfig,
layout: {
width: widgetConfig.width || 3,
height: widgetConfig.height || 1,
order: widgetConfig.order || 0
width: widgetConfig.width || 3, // default width of 3: this must match up with defaults in wysiwyg editing
height: widgetConfig.height || 1, // default height of 1: this must match up with defaults in wysiwyg editing
order: widgetConfig.order || 0 // default order of 0: this must match up with defaults in wysiwyg editing
},
state: statestore.getAll(widgetConfig.id),
hooks: widgetEvents,
Expand Down Expand Up @@ -1144,19 +1145,43 @@ module.exports = function (RED) {
const host = RED.settings.uiHost
const port = RED.settings.uiPort
const httpAdminRoot = RED.settings.httpAdminRoot
const url = 'http://' + (`${host}:${port}/${httpAdminRoot}flows`).replace('//', '/')
let scheme = 'http://'
let httpsAgent
if (RED.settings.https) {
let https = RED.settings.https
try {
if (typeof https === 'function') {
// since https() could return a promise / be async, we need to await it
// if however the function is actually sync, JS will auto wrap it in a promise and await it
https = await https()
}
httpsAgent = new Agent({
rejectUnauthorized: false,
...(https || {})
})
scheme = 'https://'
} catch (error) {
return res.status(500).json({ error: 'Error processing https settings' })
}
}
const url = scheme + (`${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 || []
const allWidgets = (changes.widgets || [])
const updatedWidgets = allWidgets.filter(w => !w.__DB2_ADD_WIDGET && !w.__DB2_REMOVE_WIDGET)
const addedWidgets = allWidgets.filter(w => !!w.__DB2_ADD_WIDGET).map(w => { delete w.__DB2_ADD_WIDGET; return w })
const removedWidgets = allWidgets.filter(w => !!w.__DB2_REMOVE_WIDGET).map(w => { delete w.__DB2_REMOVE_WIDGET; return w })

console.log(changes, editKey, dashboardId)
const baseNode = RED.nodes.getNode(dashboardId)

// validity checks
if (groups.length === 0) {
if (groups.length === 0 && allWidgets.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.
Expand All @@ -1180,6 +1205,32 @@ module.exports = function (RED) {
}
}

for (const widget of updatedWidgets) {
const existingWidget = baseNode.ui.widgets.get(widget.id)
if (!existingWidget) {
return res.status(400).json({ error: 'Widget not found' })
}
}

for (const added of addedWidgets) {
// for now, only ui-spacer is supported
if (added.type !== 'ui-spacer') {
return res.status(400).json({ error: 'Cannot add this kind of widget' })
}

// check if the widget is being added to a valid group
const group = baseNode.ui.groups.get(added.group)
if (!group) {
return res.status(400).json({ error: 'Invalid group id' })
}
}
for (const removed of removedWidgets) {
// for now, only ui-spacer is supported
if (removed.type !== 'ui-spacer') {
return res.status(400).json({ error: 'Cannot remove this kind of widget' })
}
}

// Prepare headers for the requests
const getHeaders = {
'Node-RED-API-Version': 'v2',
Expand Down Expand Up @@ -1213,14 +1264,20 @@ module.exports = function (RED) {
}
return false
}
let rev = null
return axios.request({
method: 'GET',
headers: getHeaders,
url
}).then(response => {
const flows = response.data?.flows || []
rev = response.data?.rev
try {
const getResponse = await axios.request({
method: 'GET',
headers: getHeaders,
httpsAgent,
url
})

if (getResponse.status !== 200) {
return res.status(getResponse.status).json({ error: getResponse?.data?.message || 'An error occurred getting flows', code: 'GET_FAILED' })
}

const flows = getResponse.data?.flows || []
const rev = getResponse.data?.rev
const changeResult = []
for (const modified of groups) {
const current = flows.find(n => n.id === modified.id)
Expand All @@ -1235,28 +1292,81 @@ module.exports = function (RED) {
changeResult.push(applyIfDifferent(current, modified, 'width'))
changeResult.push(applyIfDifferent(current, modified, 'order'))
}
// scan through the widgets and apply changes (if any)
for (const modified of updatedWidgets) {
const current = flows.find(n => n.id === modified.id)
if (!current) {
// widget not found in current flows! integrity of data suspect! Has flows changed on the server?
return res.status(400).json({ error: 'Widget not found', code: 'WIDGET_NOT_FOUND' })
}
if (modified.group !== current.group) {
// integrity of data suspect! Has flow changed on the server?
// Currently we dont support moving widgets between groups
return res.status(400).json({ error: 'Invalid group id', code: 'INVALID_GROUP_ID' })
}
changeResult.push(applyIfDifferent(current, modified, 'order'))
changeResult.push(applyIfDifferent(current, modified, 'width'))
changeResult.push(applyIfDifferent(current, modified, 'height'))
}

// scan through the added widgets
for (const added of addedWidgets) {
const current = flows.find(n => n.id === added.id)
if (current) {
// widget already exists in current flows! integrity of data suspect! Has flows changed on the server?
return res.status(400).json({ error: 'Widget already exists', code: 'WIDGET_ALREADY_EXISTS' })
}
// sanitize the added widget (NOTE: only ui-spacer is supported for now & these are the only properties we care about)
const newWidget = {
id: added.id,
type: added.type,
group: added.group,
name: added.name || '',
order: added.order ?? 0,
width: added.width ?? 1,
height: added.height ?? 1,
className: added.className || ''
}
flows.push(newWidget)
changeResult.push(true)
}
for (const removed of removedWidgets) {
const current = flows.find(n => n.id === removed.id)
if (!current) {
// widget not found in current flows! integrity of data suspect! Has flows changed on the server?
return res.status(400).json({ error: 'Widget not found', code: 'WIDGET_NOT_FOUND' })
}
const index = flows.indexOf(current)
if (index > -1) {
flows.splice(index, 1)
changeResult.push(true)
}
}
if (changeResult.length === 0 || !changeResult.includes(true)) {
return res.status(200).json({ message: 'No changes were' })
return res.status(201).json({ message: 'No changes were found', code: 'NO_CHANGES' })
}
return flows
}).then(flows => {
// update the flows with the new group order
return axios.request({

const postResponse = await axios.request({
method: 'POST',
headers: postHeaders,
httpsAgent,
url,
data: {
flows,
rev
}
})
}).then(response => {
return res.status(200).json(response.data)
}).catch(error => {

if (postResponse.status !== 200) {
return res.status(postResponse.status).json({ error: postResponse?.data?.message || 'An error occurred deploying flows', code: 'POST_FAILED' })
}

return res.status(postResponse.status).json(postResponse.data)
} catch (error) {
console.error(error)
const status = error.response?.status || 500
return res.status(status).json({ error: error.message })
})
return res.status(status).json({ error: error.message || 'An error occurred' })
}
})

// PATCH: /dashboard/api/v1/:dashboardId/edit/:pageId - start editing a page
Expand Down
19 changes: 4 additions & 15 deletions ui/src/EditTracking.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,7 @@ const state = reactive({
editPage: '',
editMode: false,
editorPath: '', // the custom httpAdminRoot path for the NR editor
isTrackingEdits: false,
originalGroups: []
isTrackingEdits: false
})

// Methods
Expand All @@ -27,36 +26,26 @@ function initialise (editKey, editPage, editorPath) {
/**
* Start tracking edits
*/
function startEditTracking (groups) {
function startEditTracking () {
state.isTrackingEdits = true
updateEditTracking(groups)
}

/**
* Stop tracking edits, clear editKey/editPage & exit edit mode
*/
function exitEditMode () {
function endEditMode () {
state.editKey = ''
state.editPage = ''
state.editMode = false
state.isTrackingEdits = false
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 editorPath = computed(() => state.editorPath)
const originalGroups = computed(() => state.originalGroups)
const isTrackingEdits = computed(() => state.isTrackingEdits)

export { editKey, editMode, editPage, editorPath, originalGroups, isTrackingEdits, initialise, exitEditMode, startEditTracking, updateEditTracking }
export { editKey, editMode, editPage, editorPath, isTrackingEdits, initialise, startEditTracking, endEditMode }
5 changes: 3 additions & 2 deletions ui/src/api/node-red.js
Original file line number Diff line number Diff line change
Expand Up @@ -50,10 +50,11 @@ export default {
* @param {string} options.page - The page id
* @param {string} options.key - The edit key for verification
* @param {Array<Object>} options.groups - The updated group objects to apply
* @param {Array<Object>} options.widgets - The updated widget objects to apply
* @returns the axios request
*/
deployChanges: async function deployChangesViaHttpAdminEndpoint ({ dashboard, page, groups, key, editorPath }) {
const changes = { groups }
deployChanges: async function deployChangesViaHttpAdminEndpoint ({ dashboard, page, groups, widgets, key, editorPath }) {
const changes = { groups, widgets }
return axios.request({
method: 'PATCH',
url: getDashboardApiUrl(editorPath || '', dashboard, 'flows'),
Expand Down
Loading

0 comments on commit 9d52ea9

Please sign in to comment.