UNPKG

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.

863 lines (746 loc) 32.7 kB
/** Defines all no-code elements * * Copyright (c) 2024-2025 Julian Knight (Totally Information) * https://it.knightnet.org.uk, https://github.com/TotallyInformation/node-red-contrib-uibuilder * * Licensed under the Apache License, Version 2.0 (the 'License'); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an 'AS IS' BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ 'use strict' /** --- Type Defs --- * @typedef {import('../../typedefs.js').uibConfig} uibConfig * @typedef {import('../../typedefs').runtimeRED} runtimeRED * @typedef {import('../../typedefs').runtimeNodeConfig} runtimeNodeConfig * @typedef {import('../../typedefs').runtimeNode} runtimeNode * @typedef {import('../../typedefs').uibElNode} uibElNode <= uib-element node */ // const elements = require('./elements') module.exports = { /** ENTRYPOINT: Create/update the _ui object and retain for replay * @param {*} msg incoming msg * @param {runtimeNode & uibElNode} node reference to node instance */ buildUi: async function buildUi(msg, node) { // Allow combination of msg._ui and this node allowing chaining of the nodes if ( msg._ui ) { if (!Array.isArray(msg._ui)) msg._ui = [msg._ui] node._ui = msg._ui } else node._ui = [] // If no mode specified, we assume the desire is to update (since a removal attempt with nothing to remove is safe) // ! TODO This should be replace not update if ( !msg.mode ) msg.mode = 'update' // If mode is remove, then simply do that and return if ( msg.mode === 'delete' || msg.mode === 'remove' ) { if (!node.elementId) { node.warn('[uib-element:buildUi] Cannot remove element as no HTML ID provided') return } node._ui.push({ 'method': 'removeAll', 'components': [ `#${node.elementId}`, ] }) return } // If no HMTL ID is specified & not deleting & type isn't "title", then always ADD if (node.elementtype !== 'title' && !node.elementId) { msg.mode = 'add' } // Otherwise ... // Add the outer component which is always a div node._ui.push({ 'method': msg.mode === 'add' ? 'add' : 'replace', 'components': [], } ) let parent = node._ui[node._ui.length - 1] // Keep track of the next set of components to add to the hierarchy // let nextComponents = node._ui[node._ui.length - 1].components // TODO if heading, unshift new component object into nextComponents // Get the component array for the actual element we are adding // nextComponents = nextComponents[nextComponents.length - 1].components // ! What type to process? let err = '' switch (node.elementtype) { case 'article': { parent = this.addDiv(parent, node) parent = this.addHeading(parent, node) err = this.buildArticle(node, msg, parent) break } case 'list': case 'ol': case 'ul': { parent = this.addDiv(parent, node) parent = this.addHeading(parent, node) err = this.buildUlOlList(node, msg, parent) break } case 'dl': { parent = this.addDiv(parent, node) parent = this.addHeading(parent, node) err = this.buildDlList(node, msg, parent) break } case 'table': { parent = this.addDiv(parent, node) parent = this.addHeading(parent, node) err = this.buildTable(node, msg, parent) break } case 'sform': { parent = this.addDiv(parent, node) parent = this.addHeading(parent, node) err = this.buildSForm(node, msg, parent) break } case 'html': { // parent = this.addDiv(parent, node) // parent = this.addHeading(parent, node) err = this.buildHTML(node, msg, parent, false) break } case 'markdown': { // parent = this.addDiv(parent, node) // parent = this.addHeading(parent, node) err = this.buildHTML(node, msg, parent, true) break } case 'title': { // In this case, we do not want a wrapping div err = this.buildTitle(node, msg, parent) break } case 'tr': { err = this.buildTableRow(node, msg, parent) break } case 'li': { err = this.buildUlOlRow(node, msg, parent) break } case 'tag': { parent = this.addDiv(parent, node) parent = this.addHeading(parent, node) err = this.buildTag(node, msg, parent) break } // Unknown type. Issue warning and exit default: { err = `Type "${node.elementtype}" is unknown. Cannot process.` break } } if (err.length > 0) { node.error(err, node) } }, // ---------------------- /** Adds a wrapping DIV tag * @param {object} parent The parent JSON node that we will add components to * @param {runtimeNode & uibElNode} node reference to node instance * @returns {object} Reference to new compontents array for next element to be added into */ addDiv: function addDiv(parent, node) { if (!parent.components) parent.components = [] parent.components.push( { 'type': 'div', 'id': node.elementId, 'parent': node.parent, 'position': node.position, 'attributes': {}, 'components': [], }, ) return node._ui[node._ui.length - 1].components[0] }, /** Add the element heading if defined * @param {object} parent The parent JSON node that we will add components to * @param {runtimeNode & uibElNode} node reference to node instance * @returns {object} Reference to new compontents array for next element to be added into */ addHeading: function addHeading(parent, node) { if (!node.heading) return parent const hdId = `${node.elementId}-heading` // Add accessibility label if (!parent.attributes) parent.attributes = {} parent.attributes['aria-labelledby'] = hdId if (!parent.components) parent.components = [] parent.components.push( { 'type': node.headingLevel, 'id': hdId, 'slot': node.heading, 'components': [], }, ) return parent }, /** Build the UI config instructions for the ARTICLE element * @param {runtimeNode & uibElNode} node reference to node instance * @param {*} msg The msg data in the custom event * @param {object} parent The parent JSON node that we will add components to * @returns {string} Error description or empty error string */ buildArticle: function buildArticle(node, msg, parent) { const err = '' parent.attributes.class = 'box' // Add the ol/ul tag parent.components.push({ 'type': node.elementtype, 'slot': node.data, }) return err }, /** Build the UI config instructions for DL LIST elements * NB: Row ids all removed since rows might change position * @param {runtimeNode & uibElNode} node reference to node instance * @param {*} msg The msg data in the custom event * @param {object} parent The parent JSON node that we will add components to * @returns {string} Error description or empty error string */ buildDlList: function buildDlList(node, msg, parent) { // Make sure node.data is an object or an array - if not, force to array if (!(Array.isArray(node.data) || node.data.constructor.name === 'Object')) node.data = [node.data] const err = '' // Add the dl tag parent.components.push({ 'type': node.elementtype, 'components': [], }) // Convenient references const listRows = parent.components[parent.components.length - 1].components const tbl = node.data // Walk through the inbound msg payload (works as both object or array) Object.keys(tbl).forEach( (row, i) => { // Each DL entry needs two elements - treated as a single row // Track the data row offset // const rowNum = i + 1 // Check if we have an object(or array)? If not, make content an array - will output only DT's if (!(tbl[row] instanceof Object)) { tbl[row] = [tbl[row]] } // Check if the inner data is an object - if so, convert to key/value array if (!Array.isArray(tbl[row])) tbl[row] = Object.entries(tbl[row])[0] const listIndex = listRows.push( { 'type': 'div', // 'id': `${node.elementId}-data-R${rowNum}`, 'attributes': { // NB: Making all indexes 1-based for consistency // 'data-row-index': rowNum, // 'class': ((rowNum) % 2 === 0) ? 'even' : 'odd' }, 'components': [], } ) // Add a row name attrib from the object key if the input is an object if ( node.data !== null && node.data.constructor.name === 'Object' ) { listRows[listIndex - 1].attributes['data-row-name'] = row } // If multiple elements, treat the 1st as the term and the rest as definitions. // Object.keys(tbl[row]).slice(0,2).forEach( (el,indx) => { tbl[row].forEach( (el, indx) => { let lType if (indx === 0) { lType = 'dt' } else { lType = 'dd' } // If a 3rd-level object, stringify it (an array will stringify itself) if ( tbl[row][indx] !== null && tbl[row][indx].constructor.name === 'Object' ) { try { tbl[row][indx] = JSON.stringify(tbl[row][indx]) } catch (e) { } } listRows[listIndex - 1].components.push( { 'type': lType, 'slot': tbl[row][indx], } ) }) } ) return err }, /** Build the UI config instructions for the HTML element * @param {runtimeNode & uibElNode} node reference to node instance * @param {*} msg The msg data in the custom event * @param {object} parent The parent JSON node that we will add components to * @param {boolean} [md] If true, input is Markdown rather than HTML (MD requires the front-end to have loaded the Markdown-IT library) * @returns {string} Error description or empty error string */ buildHTML: function buildHTML(node, msg, parent, md = false) { // Must be a string so convert arrays/objects let data = node.data if (!node.data) data = '' else if (Array.isArray(node.data)) data = node.data.join('/n') else if ( node.data.constructor.name === 'Object' ) { try { data = JSON.stringify(node.data) } catch (e) { data = 'ERROR: Could not parse input data' } } const err = '' // No wrapping div at this end - done in the client, so deal with parent/position here parent.components.push( { 'id': node.elementId, 'type': md === true ? 'div' : node.elementtype, 'parent': node.parent, 'position': node.position, 'slot': md !== true ? data : undefined, 'slotMarkdown': md === true ? data : undefined, } ) return err }, /** Build the UI config instructions for the SIMPLE FORM element * @param {runtimeNode & uibElNode} node reference to node instance * @param {*} msg The msg data in the custom event * @param {object} parent The parent JSON node that we will add components to * @returns {string} Error description or empty error string */ buildSForm: function buildSForm(node, msg, parent) { // Make sure node.data is an object or an array - if not, force to array if (!(Array.isArray(node.data) || node.data.constructor.name === 'Object')) node.data = [node.data] const err = '' // Add the form tag parent.components.push({ 'type': 'form', 'id': `form-${node.elementId}`, 'components': [], }) // Convenient references const frmBody = parent.components[parent.components.length - 1] const frm = node.data let btnCount = 0 // Walk through the inbound msg payload (works as both object or array) Object.keys(frm).forEach( (rowRef, i) => { // Data for this row/element of the form: id, type const frmRow = frm[rowRef] // TODO Check that required properties are present // Handle non-input inputs let tag = 'input' if (frmRow.type === 'textarea') tag = 'textarea' else if (frmRow.type === 'select') tag = 'select' // Add event handlers if (frmRow.type === 'button') { // Automatically submit form data on click frmRow.onclick = 'uibuilder.eventSend(event)' } else if (frmRow.type === 'checkbox') { // ! Stupid HTML checkbox input does not set the value attribute! frmRow.onchange = 'this.dataset.oldValue=this.value;this.value=this.checked.toString();this.dataset.newValue=this.value' if ('value' in frmRow) frmRow.checked = frmRow.value.toString() else if ('checked' in frmRow) frmRow.value = frmRow.checked.toString() else frmRow.value = 'false' } else { try { // Tracks old/new values on data attributes for input fields frmRow.onchange = 'this.dataset.newValue = this.value' frmRow.onfocus = 'this.dataset.oldValue = this.value' } catch (e) { // Catch edge-case where the form row input is a string (incorrect use of template node to send input data) node.error(`[uib-element:elementBuilder:buildSForm] Failed to add onchange/onfocus. Form incomplete. Check input data. ${e.message}`, e) return } } if (!frmRow.name) frmRow.name = frmRow.id // TODO Maybe wrap all buttons in a single row at the end of the form? // Create the form row (label and input wrapped in a div) const rLen = frmBody.components.push( { 'type': 'div', 'components': [], // label and input/form/select/button - 2 for input, 1 for button 'attributes': {}, } ) const row = frmBody.components[rLen - 1] if (frmRow.title) row.attributes.title = frmRow.title else row.attributes.title = frmRow.title = `Type: ${frmRow.type}` if (frmRow.required === true) { row.attributes.class = 'required' row.attributes.title = frmRow.title = `Required. ${row.attributes.title}` } if (frmRow.disabled === true) { row.attributes.title = frmRow.title = `Disabled. ${row.attributes.title}` } // Add the row elements if (frmRow.type === 'button') { btnCount++ // keep track of the number of buttons const len = row.components.push({ 'type': 'button', 'attributes': frmRow, 'slot': frmRow.label, }) row.components[len - 1].attributes.type = 'button' } else { row.components.push({ 'type': 'label', 'attributes': { 'for': frmRow.id, }, 'slot': frmRow.label ? frmRow.label : `${frmRow.type}:`, }) const n = row.components.push({ 'type': tag, 'id': frmRow.id, 'attributes': frmRow, }) // Dropdown select - add selection options if ( frmRow.type === 'select' && 'options' in frmRow ) { row.components[n - 1].components = [] frmRow.options.forEach( opt => { row.components[n - 1].components.push({ type: 'option', attributes: { value: opt.value, selected: opt.value === frmRow.value ? 'selected' : undefined, }, slot: opt.label, }) }) } } } ) // If user didn't specify any buttons, add them now. if (btnCount < 1) { // frmBody.components[frmBody.components.length - 1].components.push({ frmBody.components.push({ 'type': 'div', 'attributes': {}, 'components': [ { 'type': 'button', 'id': `${node.elementId}-btn-send`, 'attributes': { type: 'button', title: 'Send the form data back to Node-RED', onclick: "uibuilder.eventSend(event)" }, // Doing it this way, the click event is not visible in the HTML - not so good for saving the result // 'events': { // click: 'uibuilder.eventSend' // }, 'slot': 'Send', }, { 'type': 'button', 'id': `${node.elementId}-btn-reset`, 'attributes': { type: 'reset', title: 'Reset the form', }, // Not really needed and not visible in the HTML // 'events': { // click: 'event.srcElement.form.reset' // }, 'slot': 'Reset', } ] }) } return err }, /** Build the UI config instructions for the TABLE element * NB: Row ids all removed since rows might change position * @param {runtimeNode & uibElNode} node reference to node instance * @param {*} msg The msg data in the custom event * @param {object} parent The parent JSON node that we will add components to * @returns {string} Error description or empty error string */ buildTable: function buildTable(node, msg, parent) { // Make sure node.data is an object or an array - if not, force to array if (!(Array.isArray(node.data) || node.data.constructor.name === 'Object')) node.data = [node.data] let cols = [] const err = '' // Add the table and thead/tbody tags parent.components.push({ 'type': node.elementtype, 'id': `${node.elementId}-table`, 'components': [ { 'type': 'thead', 'components': [] }, { 'type': 'tbody', 'components': [] } ], }) // Convenient references const thead = parent.components[parent.components.length - 1].components[0] const tbody = parent.components[parent.components.length - 1].components[1] const tbl = node.data // Walk through the inbound msg payload (works as both object or array) Object.keys(tbl).forEach( (row, i) => { // TODO Allow for msg.cols to be used as override // Build the columns from the first set of entries & add the thead if (i === 0) { // const hdrRowNum = i + 1 // TODO inp[el] has to be an object // TODO check that there are >0 columns // Create the header row thead.components = [ { 'type': 'tr', // 'id': `${node.elementId}-head-r${hdrRowNum}`, // 'attributes': { // 'data-hdr-row-index': hdrRowNum, // }, 'components': [] } ] // TODO Allow override from msg.cols // Save the col names cols = Object.keys(tbl[row]) // Build the headings cols.forEach( (colName, k) => { const colNum = k + 1 thead.components[0].components.push({ 'type': 'th', // 'id': `${node.elementId}-head-r${hdrRowNum}-c${colNum}`, 'attributes': { // 'data-hdr-row-index': hdrRowNum, 'data-col-index': colNum, 'data-col-name': colName, }, 'slot': colName }) } ) } // Track the data row offset const rowNum = i + 1 // Create the data row const rLen = tbody.components.push( { 'type': 'tr', // 'id': `${node.elementId}-data-R${rowNum}`, 'components': [], 'attributes': {}, } ) // // Add the row index attrib and even/odd class // tbody.components[rLen - 1].attributes = { // // NB: Making all indexes 1-based for consistency // 'data-row-index': rLen, // // 'class': (rLen % 2 === 0) ? 'even' : 'odd' // } // Add row id class tbody.components[rLen - 1].attributes.class = `r${rowNum}` // Add a row name attrib from the object key if the input is an object if ( node.data !== null && node.data.constructor.name === 'Object' ) { tbody.components[rLen - 1].attributes['data-row-name'] = row } // TODO If tbl is an object - get the row names and apply to data-rowname attrib // TODO Allow for class overrides in node // Build the columns cols.forEach( (colName, j) => { const colNum = j + 1 tbody.components[rLen - 1].components.push({ 'type': 'td', // 'id': `${node.elementId}-data-R${rowNum}-C${colNum}`, 'attributes': { // 'class': ((rowNum) % 2 === 0) ? 'even' : 'odd', // 'data-row-index': rowNum, // NB: Making all indexes 1-based for consistency 'data-col-index': colNum, 'data-col-name': colName, 'class': `r${rowNum} c${colNum}`, }, 'slot': tbl[row][colName], }) } ) } ) return err }, /** Build the UI config instructions for adding a table row to an existing table * NB: Row ids all removed since rows might change position * @param {runtimeNode & uibElNode} node reference to node instance * @param {*} msg The msg data in the custom event * @param {object} parent The parent JSON node that we will add components to * @returns {string} Error description or empty error string */ buildTableRow: function buildTableRow(node, msg, parent) { // Payload must be an object (col/value pairs) // TODO Allow payload to be an array const err = '' parent.method = 'add' const rLen = parent.components.push({ 'type': 'tr', 'parent': `${node.parent} > table > tbody`, 'position': node.position, 'components': [], }) // const rowNum = -10 Object.keys(node.data).forEach( (colName, j) => { const colNum = j + 1 parent.components[rLen - 1].components.push({ 'type': 'td', 'attributes': { 'data-col-index': colNum, 'data-col-name': colName, }, 'slot': node.data[colName], }) } ) return err }, /** Build the UI config instructions for any HTML/custom tag element * @param {runtimeNode & uibElNode} node reference to node instance * @param {*} msg The msg data in the custom event * @param {object} parent The parent JSON node that we will add components to * @returns {string} Error description or empty error string */ buildTag: function buildTag(node, msg, parent) { // Must be a string or array/object of strings // If array/object, then add LAST entry entry as <div role="doc-subtitle"> if (!Array.isArray(node.data)) node.data = [node.data] let err = '' if (node.tag === '') { err = 'tag name must be provided' return err } parent.components.push({ 'type': node.tag, 'slot': node.data[0] }) // parent.components.push({ // 'type': 'h1', // 'selector': 'h1', // 'parent': node.parent ? node.parent : 'body', // 'position': 'first', // 'attributes': { // 'class': 'with-subtitle', // }, // 'slot': node.data.shift(), // }) // if (node.data.length > 0) { // node.data.forEach( (element, i) => { // parent.components.push({ // 'type': 'div', // 'selector': 'div[role="doc-subtitle"] ', // 'parent': node.parent ? node.parent : 'body', // 'position': 'last', // 'attributes': { // 'role': 'doc-subtitle', // }, // 'slot': element, // }) // } ) // } return err }, /** Build the UI config instructions for the Title and leading H1 * @param {runtimeNode & uibElNode} node reference to node instance * @param {*} msg The msg data in the custom event * @param {object} parent The parent JSON node that we will add components to * @returns {string} Error description or empty error string */ buildTitle: function buildTitle(node, msg, parent) { // Must be a string or array/object of strings // If array/object, then add LAST entry entry as <div role="doc-subtitle"> if (!Array.isArray(node.data)) node.data = [node.data] const err = '' parent.components.push({ 'type': 'title', // title tags can appear in SVGs as well so limit this 'selector': 'head title', 'slot': node.data[0] }) parent.components.push({ 'type': 'h1', 'selector': 'h1', 'parent': node.parent ? node.parent : 'body', 'position': 'first', 'attributes': { 'class': 'with-subtitle', }, 'slot': node.data.shift(), }) if (node.data.length > 0) { node.data.forEach( (element, i) => { parent.components.push({ 'type': 'div', 'selector': 'div[role="doc-subtitle"] ', 'parent': node.parent ? node.parent : 'body', 'position': 'last', 'attributes': { 'role': 'doc-subtitle', }, 'slot': element, }) } ) } return err }, /** Build the UI config instructions for the UL or OL LIST elements * NB: Row ids all removed since rows might change position * @param {runtimeNode & uibElNode} node reference to node instance * @param {*} msg The msg data in the custom event * @param {object} parent The parent JSON node that we will add components to * @returns {string} Error description or empty error string */ buildUlOlList: function buildUlOlList(node, msg, parent) { // Make sure node.data is an object or an array - if not, force to array if (!(Array.isArray(node.data) || node.data.constructor.name === 'Object')) node.data = [node.data] const err = '' // Add the ol/ul tag parent.components.push({ 'type': node.elementtype, 'components': [], }) // Convenient references const listRows = parent.components[parent.components.length - 1].components const tbl = node.data // Walk through the inbound msg payload (works as both object or array) Object.keys(tbl).forEach( (row, i) => { // Track the data row offset // const rowNum = i + 1 // Create next list row listRows.push( { 'type': 'li', // 'id': `${node.elementId}-data-R${rowNum}`, 'attributes': { // NB: Making all indexes 1-based for consistency // 'data-row-index': rowNum, // 'class': ((rowNum) % 2 === 0) ? 'even' : 'odd' }, 'slot': tbl[row] } ) // Add a row name attrib from the object key if the input is an object if ( node.data !== null && node.data.constructor.name === 'Object' ) { listRows[i].attributes['data-row-name'] = row } } ) return err }, /** Build the UI config instructions for adding a table row to an existing table * NB: Row ids all removed since rows might change position * @param {runtimeNode & uibElNode} node reference to node instance * @param {*} msg The msg data in the custom event * @param {object} parent The parent JSON node that we will add components to * @returns {string} Error description or empty error string */ buildUlOlRow: function buildUlOlRow(node, msg, parent) { // Payload must be a a string if ( !Array.isArray(node.data) ) node.data = [node.data] const err = '' parent.method = 'add' // const rowNum = -10 Object.keys(node.data).forEach( (rowName, j) => { parent.components.push({ 'type': 'li', 'parent': `${node.parent} > ul`, 'position': node.position, // 'attributes': { // 'data-row-name': rowName, // }, 'slot': node.data[j], }) } ) return err }, }