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

WYSIWYG: Widget resizing and re-ordering #1469

Merged
merged 14 commits into from
Dec 13, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
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 @@ -722,7 +723,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 @@ -889,9 +890,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 @@ -1130,19 +1131,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 @@ -1166,6 +1191,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 @@ -1199,14 +1250,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 @@ -1221,28 +1278,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
Loading