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

Open
wants to merge 8 commits into
base: main
Choose a base branch
from
Open
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
134 changes: 111 additions & 23 deletions nodes/config/ui_base.js
Original file line number Diff line number Diff line change
Expand Up @@ -722,7 +722,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 +889,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 @@ -1138,11 +1138,16 @@ module.exports = function (RED) {
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 +1171,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 +1230,19 @@ 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,
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,13 +1257,61 @@ 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,
url,
Expand All @@ -1236,13 +1320,17 @@ module.exports = function (RED) {
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
57 changes: 48 additions & 9 deletions ui/src/layouts/Flex.vue
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,10 @@
:class="getGroupClass(g)"
:style="{'width': ((rowHeight * 2 * g.width) + 'px')}"
:draggable="editMode"
@dragstart="onDragStart($event, $index)"
@dragover="onDragOver($event, $index, g)"
@dragend="onDragEnd($event, $index, g)"
@dragleave="onDragLeave($event, $index, g)"
@dragstart="onGroupDragStart($event, $index, g)"
@dragover="onGroupDragOver($event, $index, g)"
@dragend="onGroupDragEnd($event, $index, g)"
@dragleave="onGroupDragLeave($event, $index, g)"
@drop.prevent
@dragenter.prevent
>
Expand All @@ -22,7 +22,7 @@
{{ g.name }}
</template>
<template #text>
<widget-group :group="g" :index="$index" :widgets="widgetsByGroup(g.id)" :resizable="editMode" @resize="onGroupResize" />
<widget-group :group="g" :index="$index" :widgets="groupWidgets(g.id)" :resizable="editMode" :group-dragging="groupDragging.active" @resize="onGroupResize" @widget-added="updateEditStateObjects" @widget-removed="updateEditStateObjects" @refresh-state-from-store="updateEditStateObjects" />
</template>
</v-card>
</div>
Expand Down Expand Up @@ -98,6 +98,12 @@ export default {
pageWidgets: function () {
return this.widgetsByPage(this.$route.meta.id)
},
groupWidgets () {
if (this.editMode) { // mixin property
return (groupId) => this.pageGroupWidgets[groupId]
}
return (groupId) => this.widgetsByGroup(groupId)
},
page: function () {
return this.pages[this.$route.meta.id]
},
Expand All @@ -109,8 +115,9 @@ export default {
}
},
mounted () {
console.log('flex layout mounted')
if (this.editMode) { // mixin property
this.pageGroups = this.getPageGroups()
this.updateEditStateObjects()
this.initializeEditTracking() // Mixin method
}
},
Expand All @@ -130,6 +137,21 @@ export default {
})
return groups
},
getGroupWidgets (groupId) {
// get widgets for this group (sorted by layout.order)
const widgets = this.widgetsByGroup(groupId)
// only show the widgets that haven't had their "visible" property set to false
.filter((g) => {
if ('visible' in g) {
return g.visible && g.groupType !== 'dialog'
}
return true
})
.sort((a, b) => {
return a?.layout?.order - b?.layout?.order
})
return widgets
},
getWidgetClass (widget) {
const classes = []
// ensure each widget has a class for its type
Expand All @@ -154,7 +176,7 @@ export default {
classes.push(properties.class)
}
// dragging interaction classes
const dragDropClass = this.getDragDropClass(group) // Mixin method
const dragDropClass = this.getGroupDragDropClass(group) // Mixin method
if (dragDropClass) {
classes.push(dragDropClass)
}
Expand All @@ -179,7 +201,8 @@ export default {
this.deployChanges({
dashboard: this.page.ui,
page: this.page.id,
groups: this.pageGroups
groups: this.pageGroups,
widgets: this.pageGroupWidgets
}).then(() => {
this.acceptChanges() // Mixin method
}).catch((error) => {
Expand All @@ -197,7 +220,7 @@ export default {
},
discardEdits () {
this.revertEdits() // Mixin method
this.pageGroups = this.getPageGroups()
this.updateEditStateObjects()
},
async leaveEditMode () {
let leave = true
Expand All @@ -218,6 +241,22 @@ export default {
this.discardEdits()
}
this.exitEditMode() // Mixin method
},
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
},
updateEditStateObjects () {
console.log('updateEditStateObjects')
this.pageGroups = this.getPageGroups()
const pageGroupWidgets = {}
for (const group of this.pageGroups) {
pageGroupWidgets[group.id] = this.getGroupWidgets(group.id)
}
this.pageGroupWidgets = pageGroupWidgets
}
}
}
Expand Down
Loading
Loading