node-red-contrib-uibuilder
Version:
Easily create data-driven web UI's for Node-RED. Single- & Multi-page. Multiple UI's. Work with existing web development workflows or mix and match with no-code/low-code features.
322 lines (294 loc) • 12.3 kB
JavaScript
// @ts-nocheck
// Now loading as a module so no need to further Isolate this code
// NOTE: window.uibuilder is added by editor-common.js - see `resources` folder
const uibuilder = window['uibuilder']
const log = uibuilder.log
/** Module name must match this nodes html file @constant {string} moduleName */
const moduleName = 'uib-sidebar'
// Create a new set to hold all saved node instances oneditsave
if (!window['uibSidebarNodes']) {
window['uibSidebarNodes'] = new Set()
}
const purifyOpts = {
USE_PROFILES: { html: true, svg: true, svgFilters: true },
}
/** Send a message via the node's runtime (API call)
* @param {*} node -
* @param {*} msg -
*/
function sendToNode(node, msg) {
msg = JSON.stringify(msg) // needs try/catch
const postUrl = '/uibuilder/sidebarui/' + node.id
// console.log('📊 [uib-sidebar:sidebar] Sending to node runtime:', postUrl, msg, node.id, node)
$.ajax({
url: './uibuilder/sidebarui/' + node.id,
type: 'POST',
data: msg,
contentType: 'application/json; charset=utf-8',
success: function (resp) {
RED.notify(
'📊 Sidebar UI send success',
{ type: 'success', id: moduleName, timeout: 2000 }
)
},
error: function (jqXHR, textStatus, errorThrown) {
console.error('📊 ❌ [uib-sidebar:sidebar] POST failed. ', postUrl, errorThrown, textStatus)
RED.notify(
'📊 Failed to send from sidebar UI',
{ type: 'error', id: moduleName }
)
}
})
}
const sbMasterEl = document.createElement('section')
sbMasterEl.id = 'uib-sidebar-ui'
sbMasterEl.className = moduleName
let sbEl
// Keep track of the number of uib-sidebar nodes - used for sending msgs from the sidebar
RED.events.on('nodes:add', function(node) {
if (node.type === moduleName) {
// When the first uib-sidebar node is added ...
if (window['uibSidebarNodes'].size === 0) {
log('📊 [uib-sidebar] FIRST uib-sidebar added - ADDING SIDEBAR')
// Set the default HTML for the sidebar UI
window['uibSidebarHTML'] = node.html ?? '<p>Sidebar UI</p>'
// Add the current node's html to the sbMasterEl
sbMasterEl.innerHTML = window['uibSidebarHTML']
// Add the sidebar tab
RED.sidebar.addTab({
id: 'uibuilder-sidebar-ui',
label: 'uib UI',
name: 'UIBUILDER Sidebar UI',
content: sbMasterEl,
// toolbar: uiComponents.footer,
enableOnEdit: true,
iconClass: 'fa fa-globe uib-blue',
})
// Get a reference to the sidebar UI element because node-red doesn't add a proper id (as of nr v4.0.8)
sbEl = document.getElementById('uib-sidebar-ui')
}
window['uibSidebarNodes'].add(node)
sbEl.addEventListener('change', function(evt) {
// console.log('📊 [uib-sidebar] Input change event:', evt)
const target = evt.target
// Deal with stupid checkboxes and radios
let value = target.value
if (target.localName === 'input' && (target.type === 'checkbox' || target.type === 'radio')) {
value = target.checked
}
// TODO if target is in a form, get all the form data - consider requiring a submit button
const msg = {
payload: value,
topic: `${moduleName}/${target.localName}${target.id ? `/${target.id}` : target.name ? `/${target.name}` : ''}`,
from: moduleName,
id: target.id,
name: target.name,
attributes: {}, //target.attributes,
data: target.dataset,
willValidate: target.willValidate,
type: target.type,
value: value,
checked: target.checked,
localName: target.localName,
modifierKeys: {
altKey: evt.altKey,
ctrlKey: evt.ctrlKey,
metaKey: evt.metaKey,
shiftKey: evt.shiftKey,
},
}
for (const attr of target.attributes) {
msg.attributes[attr.name] = attr.value;
}
// TODO specials for checkboxes and radios
if (!isNaN(target.valueAsNumber)) {
msg.valueAsNumber = target.valueAsNumber
}
if (target.localName === 'select') {
msg.multiple = target.multiple
}
if (target.localName === 'textarea') {
msg.selectionStart = target.selectionStart
msg.selectionEnd = target.selectionEnd
// msg.rows = target.rows
}
log('📊 [uib-sidebar] Input changed:', target, msg)
sendToNode(node, msg)
})
}
})
RED.events.on('nodes:remove', function(node) {
if (node.type === moduleName) {
// Remove the node from the set
window['uibSidebarNodes'].delete(node)
// If there are no more uib-sidebar nodes, remove the sidebar tab
if (window['uibSidebarNodes'].size === 0) {
log('📊 [uib-sidebar] LAST uib-sidebar removed - REMOVING SIDEBAR UI')
RED.sidebar.removeTab('uibuilder-sidebar-ui')
}
}
})
/** Update the sidebar UI tab with new HTML content
* @param {string} html - The new HTML to display in the sidebar UI
*/
function updateTab(html) {
// Empty the current sidebar UI master element
sbEl.innerHTML = ''
// TODO: Needs better config
// Replace with the new HTML - but sanitise it first
// sbEl.innerHTML = DOMPurify.sanitize(html, purifyOpts) // eslint-disable-line no-undef
sbEl.innerHTML = html
}
// Subscribe to notifications from the runtime
RED.comms.subscribe('notification/uibuilder/uib-sidebar/#', function(topic, payload) {
log('📊 [uib-sidebar] Message Received from Sidebar: ', { topic, payload })
const msg = payload
if ('reset' in msg) {
log('📊 [uib-sidebar] Resetting sidebar UI')
// Reset the sidebar UI
updateTab(window['uibSidebarHTML'])
}
if (msg.sidebar) {
// for each entry in msg.sidebar, update the sidebar UI
for (const key in msg.sidebar) {
// log('📊 [uib-sidebar] key:', key, sbEl)
// get a reference to the element with the id of key
/** @type {HTMLElement} */
const el = sbEl.querySelector(`#${key}`)
// TODO Note that this is rather dangerous as it allows arbitrary HTML to be injected into the sidebar
if (el) {
// for each property in msg.sidebar[key], update the element
for (const prop in msg.sidebar[key]) {
switch (prop) {
// Don't allow outerHTML to be updated
case 'outerHTML': {
break
}
// Only update if sanitised. DOMPurify is loaded by Node-RED core.
case 'innerText':
case 'innerHTML': {
// let clean
try {
// TODO: Needs better config
// clean = DOMPurify.sanitize(msg.sidebar[key][prop], purifyOpts) // eslint-disable-line no-undef
// el[prop] = clean
el[prop] = msg.sidebar[key][prop]
} catch (e) {
// log('📊 [uib-sidebar] DOMPurify error:', e)
log(`📊 [uib-sidebar] InnerHTML assignment error for "${key}":`, e)
}
break
}
default: {
try {
el.setAttribute(prop, msg.sidebar[key][prop])
} catch (e) {
log(`📊 [uib-sidebar] Attribute assignment error for "${key}":`, e)
}
// el[prop] = msg.sidebar[key][prop]
break
}
}
}
}
// if (Object.hasOwnProperty.call(msg.sidebar, key)) {
// const html = msg.sidebar[key]
// updateTab(html)
// }
}
}
// TODO Unpack the payload and apply to the sidebar UI
})
//#region --------- module functions for the panel --------- //
/** Prep for edit
* @param {*} node -
*/
function onEditPrepare(node) {
// log('📊 [uib-sidebar] Edit prepare: node', node)
// In case the html was changed by another uib-sidebar node
if (node.html !== window['uibSidebarHTML']) node.html = window['uibSidebarHTML']
const stateId = RED.editor.generateViewStateId('node', node, '')
node.editor = RED.editor.createEditor({
id: 'node-input-editor',
mode: 'ace/mode/html',
stateId: stateId,
value: node.html
})
// const mod = 'ace/mode/html'
// node.editor.getSession().setMode({
// path: mod,
// v: Date.now()
// })
uibuilder.doTooltips('.ti-edit-panel') // Do this at the end
}
// TODO html from editor has to be GLOBAL, not local to the node
/** Handles the save event when editing a node in the Node-RED editor.
* @param {object} node - The node being edited.
* @description
* This function performs the following tasks:
* 1. Copies the value of the editor to the hidden `html` input field.
* 2. Destroys & deletes the editor.
*/
function onEditSave(node) {
// console.log('uibuilder: uib-sidebar: Edit save: node', node)
// Update both the node's html property and the global window['uibSidebarHTML'] (for other uib-sidebar nodes)
const html = window['uibSidebarHTML'] = node.editor.getValue()
$('#node-input-html').val(html)
updateTab(html)
node.editor.destroy()
delete node.editor
}
/** Handles the cancel event when editing a node in the Node-RED editor.
* @param {object} node - The node being edited.
*/
function onEditCancel(node) {
node.editor.destroy()
delete node.editor
}
/** Handles the resize event when editing a node in the Node-RED editor.
* @param {object} size - The size of the editor.
* @param {object} node - The node being edited.
*/
function onEditResize(size, node) {
const rows = $('#dialog-form>div:not(.node-text-editor-row)')
let height = $('#dialog-form').height()
for (let i = 0; i < rows.length; i++) {
height -= $(rows[i]).outerHeight(true)
}
const editorRow = $('#dialog-form>div.node-text-editor-row')
height -= (parseInt(editorRow.css('marginTop')) + parseInt(editorRow.css('marginBottom')))
$('#dialog-form .node-text-editor').css('height', height + 'px')
node.editor.resize()
}
//#endregion ------------------------------------------------- //
// Register the node type, defaults and set up the edit fns
RED.nodes.registerType(moduleName, {
//#region --- options --- //
defaults: {
name: { value: '' },
html: { value: '' },
// topic: { value: '' },
},
inputs: 1,
inputLabels: 'Msg to send to sidebar UI',
outputs: 1,
outputLabels: ['Data from sidebar UI'],
// icon: 'node-white.svg',
icon: 'node-blue-inverted.svg',
// icon: 'semanticWebWhite.svg',
label: function () {
return this.name ? this.name : moduleName
},
paletteLabel: moduleName,
category: uibuilder.paletteCategory,
color: 'var(--uib-node-colour)',
//#endregion --- options --- //
/** Prepares the Editor panel */
oneditprepare: function() { onEditPrepare(this) },
/** Runs BEFORE save (Actually when Done button pressed)
* @this {RED}
*/
oneditsave: function() { onEditSave(this) },
oneditcancel: function() { onEditCancel(this) },
oneditresize: function(size) { onEditResize(size, this) },
})