UNPKG

node-red-contrib-uibuilder

Version:

Easily create data-driven web UI's for Node-RED using any (or no) front-end library.

536 lines (487 loc) 23.2 kB
/* eslint-disable strict, sonarjs/no-duplicate-string, sonarjs/no-duplicated-branches */ // Isolate this code ;(function () { 'use strict' // NOTE: window.uibuilder is added - see `resources` folder // RED._debug({topic: 'RED.settings', payload:RED.settings}) const uibuilder = window['uibuilder'] // const log = uibuilder.log /** Module name must match this nodes html file @constant {string} moduleName */ const moduleName = 'uib-element' /** Element Types definitions */ const elTypes = { table: { value: 'table', label: 'Simple Table', description: ` <p> A simple but accessible table. <b><a href="./uibuilder/docs/#/elements/tables" target="_blank">Docs</a></b>. </p><p> Set the input data to an <i>Array of Objects<i>. Each array entry will be a new row. Each property of the first array entry will be used for the column names. </p><p> An Object of Objects can also be used. In that case, the outer object's keys will be used as row names by adding a <code>data-row-name</code> attribute to each row. </p><p> Each column in the table has <code>data-col-index</code> and <code>data-col-name</code> attributes. </p> `, allowsParent: true, allowsHead: true, allowsPos: true, }, sform: { value: 'sform', label: 'Simple Form', description: ` <p> A simple but accessible Form with inputs and buttons. <b><a href="./uibuilder/docs/#/elements/forms" target="_blank">Docs</a></b>. </p><p> Set the input data to an <i>Array of Objects<i>. Each array entry will be a new form input or button. An Object of Objects can also be used where the outer object is key'd by the ID of the entry. </p><p> Currently supported properties in the inner objects are: <code>type=</code>One of the input types listed below, <code>id=</code>Unique HTML identifier, <code>label=</code>Label text of the input field or button, <code>required=</code>true/false whether a value is required, <code>value=</code>Optional starting value. </p><p> Other properties can be provided. These only work with the appropriate input types and are otherwise ignored </p><p> Available input types are: button, checkbox, color, date, detetime-local, email, hidden, month, number, password, radio, range, tel, text, time, url, week. See <a href="https://developer.mozilla.org/en-US/docs/Web/HTML/Element/Input" target="_blank">this explanation of the types and properties</a>. </p><p> Some additional types will be made available in the future: select, combo, file, image, textarea. Auto-complete will also be added eventually. </p><p> If a button is included, pressing the button will automatically send a message from the client back to Node-RED All of the form data will be included in that message in <code>msg._ui.form</code>. </p> `, allowsParent: true, allowsHead: true, allowsPos: true, }, ul: { value: 'ul', label: 'Unordered List (ul)', description: ` <p> Outputs a simple, accessible, bullet list. <b><a href="./uibuilder/docs/#/elements/lists" target="_blank">Docs</a></b>. </p><p> Input data should be an array of strings. An object of key/values can also be used. </p> `, allowsParent: true, allowsHead: true, allowsPos: true, }, ol: { value: 'ol', label: 'Ordered List (ol)', description: ` <p> Outputs a simple, accessible, numbered list. <b><a href="./uibuilder/docs/#/elements/lists" target="_blank">Docs</a></b>. </p><p> Input data should be an array of strings. An object of key/values can also be used. </p> `, allowsParent: true, allowsHead: true, allowsPos: true, }, dl: { value: 'dl', label: 'Description List (dl)', description: ` <p> Outputs a simple, accessible, description list. <b><a href="./uibuilder/docs/#/elements/lists" target="_blank">Docs</a></b>. </p> <p> Set the input data to be an Array of Array's. The outer array representing each row and the inner array containing at least 2 string entries representing the term/description pair. Additional entries in the inner array are added as secondary descriptions (<code>dd</code> tags). </p> <p> You can also use an Array of Objects where each object is a simple key/value pair. Or even an Object of Objects. Inner structures are catenated into a string separated by commas. </p> <p> Each entry has a wrapping <code>&lt;div></code> tag containing a term (<code>dt</code>) and one or more descriptions (<code>dd</code>). </p> `, allowsParent: true, allowsHead: true, allowsPos: true, }, article: { value: 'article', label: 'Text box', description: ` <p> A simple box containing text with an optional heading. <b><a href="./uibuilder/docs/#/elements/other" target="_blank">Docs</a></b>. </p> `, allowsParent: true, allowsHead: true, allowsPos: true, }, html: { value: 'html', label: 'HTML', description: /*html*/` <p> Pass-through HTML from the input data. When sent to the uibuilder node, will be reproduced in your page(s). Output is wrapped in a <code>div</code> but the optional heading is ignored. <b><a href="./uibuilder/docs/#/elements/html" target="_blank">Docs</a></b>. </p> <p> May be used with the Node-RED core <code>template</code> node. </p> <p> <b>NOTE</b>: Use with caution, no validity checking is currently done unless the optional <a href="./uibuilder/docs/#/client-docs/readme?id=_1-dompurify-sanitises-html-to-ensure-safety-and-security" target="_blank">DOMPurify library</a> is loaded. </p> `, allowsParent: true, allowsHead: false, allowsPos: true, }, markdown: { value: 'markdown', label: 'Markdown', description: /*html*/` <p> Pass-through Markdown from the input data. When sent to the uibuilder node, will be reproduced as HTML in your page(s). Output is wrapped in a <code>div</code> but the optional heading is ignored. <b><a href="./uibuilder/docs/#/elements/markdown" target="_blank">Docs</a></b>. </p> <p> Markdown will not be rendered as HTML unless the optional <a href="./uibuilder/docs/#/client-docs/readme?id=_2-markdown-it-converts-markdown-markup-into-html" target="_blank">Markdown-IT library</a> is loaded. </p> <p> May be used with the Node-RED core <code>template</code> node. </p> <p> <b>NOTE</b>: Use with caution, no validity checking is currently done unless the optional <a href="./uibuilder/docs/#/client-docs/readme?id=_1-dompurify-sanitises-html-to-ensure-safety-and-security" target="_blank">DOMPurify library</a> is loaded. </p> `, allowsParent: true, allowsHead: false, allowsPos: true, }, title: { value: 'title', label: 'Page Title', description: ` <p> Updates the HTML page title and meta description. <b><a href="./uibuilder/docs/#/elements/lists" target="_blank">Docs</a></b>. </p> <p> Amends the first <code>&lt;h1></code> tag on the page if it exists else adds one at the top of the page. There should only ever be one H1 tag on a page. </p> <p> The <b>Parent</b> and <b>HTML ID</b> are ignored in this case </p> <p> The input data must be a simple string. </p> `, allowsParent: false, allowsHead: false, allowsPos: false, }, li: { value: 'li', label: 'Add row to existing ordered or unordered list', description: ` <p> Always add a new row to an existing list. (no replace). <b><a href="./uibuilder/docs/#/elements/lists" target="_blank">Docs</a></b>. </p><p> Set the <b>Parent</b> to the id of the existing table. </p><p> Set the input data to a string. </p><p> Set the <b>Position</b> to "first", "last" or a number. </p> `, allowsParent: true, allowsHead: false, allowsPos: true, }, tr: { value: 'tr', label: 'Add row to existing table', description: ` <p> Always add a new row to an existing table (no replace). <b><a href="./uibuilder/docs/#/elements/tables" target="_blank">Docs</a></b>. </p><p> Set the <b>Parent</b> to the id of the existing table. </p><p> Set the input data to an <i>Object<i>. The properties of the object must match the column definitions of the existing table. </p><p> Set the <b>Position</b> to "first", "last" or a number. </p> `, allowsParent: true, allowsHead: false, allowsPos: true, }, } // Standard typed input types for string fields const stdStrTypes = [ 'msg', 'flow', 'global', 'str', 'env', 'jsonata', 're', ] // Standard width for typed input fields const tiWidth = '68.5%' /** Prep for edit * @param {*} node A node instance as seen from the Node-RED Editor */ function onEditPrepare(node) { // Initial config data if (!node.confData) node.confData = {} if (node.parent === '' || !node.parent) $('#node-input-parent').val('body') if (node.position === '' || !node.position ) { $('#node-input-position').val('last') node.position = 'last' } // initial checkbox states if (!node.passthrough) node.passthrough = false // Define element types for drop-down $('#node-input-elementtype').typedInput({ types: [ { value: 'elementType', options: Object.values(elTypes), } ] // On-change of element type, update the info panel }).on('change', function() { // @ts-ignore if (elTypes[this.value].description === undefined) elTypes[this.value].description = 'No description available.' // @ts-ignore $('#type-info').html(elTypes[this.value].description) // @ts-ignore if ( elTypes[this.value].allowsParent === false ) $('#node-input-parent').prop('disabled', true) else $('#node-input-parent').prop('disabled', false) // @ts-ignore if ( elTypes[this.value].allowsHead === false ) $('#node-input-heading').typedInput('disable') else $('#node-input-heading').typedInput('enable') // @ts-ignore if ( elTypes[this.value].allowsPos === false ) $('#node-input-position').prop('disabled', true) else $('#node-input-position').prop('disabled', false) // TODO If type is heading - change parent to disabled (perhaps hide the input) }) // parent selector typed input - https://nodered.org/docs/api/ui/typedInput/ $('#node-input-parent').typedInput({ types: stdStrTypes, default: 'str', typeField: $('#node-input-parentSourceType'), }).typedInput('width', tiWidth) // element id typed input $('#node-input-elementid') .typedInput({ types: stdStrTypes, default: 'str', typeField: $('#node-input-elementIdSourceType'), }).typedInput('width', tiWidth) // Set up optional heading input $('#node-input-heading').typedInput({ types: stdStrTypes, default: 'str', typeField: $('#node-input-headingSourceType'), }).typedInput('width', '40%') // @ts-ignore core data typed input $('#node-input-data').typedInput({ // types: stdStrTypes, default: 'msg', typeField: $('#node-input-dataSourceType'), }).on('change', function(event, type, value) { // console.log('change') if ( type === 'msg' && !this.value ) { $('#node-input-data').typedInput('value', 'payload') // console.log('change 2') } } ).typedInput('width', tiWidth) // position typed input $('#node-input-position').typedInput({ types: stdStrTypes, default: 'str', typeField: $('#node-input-positionSourceType'), }).typedInput('width', tiWidth) // Ensure data value never blank // $('#node-input-data').on('change', function(event, type, value) { // if ( !value ) { // if ( type === 'msg' ) $('#node-input-data').typedInput('value', 'payload') // } // } ) // Make position of aria-labels dynamic to cursor // $('#ti-edit-panel *[aria-label]').on('mousemove', function(event) { // document.documentElement.style.setProperty('--x', event.pageX ) // document.documentElement.style.setProperty('--y', event.pageY ) // document.documentElement.style.setProperty('--moveX', event.originalEvent.movementX ) // document.documentElement.style.setProperty('--moveY', event.originalEvent.movementY ) // console.log(event) // // document.documentElement.style.setProperty('--x', event.clientX) // // document.documentElement.style.setProperty('--y', event.clientY) // // $(this).prop('style', `top:${event.pageY}px;left:${event.pageX}`) // }) // TODO reset unused conf props on type change? // Delegated event handler for conf data - just marks things as changed $('#el-tab-conf').on('change', '[data-uib-el-prop]', function() { $(this).attr('data-changed', 'true') }) const tabs = RED.tabs.create({ id: 'el-tabs', // scrollable: true, // collapsible: true, onchange: function (tab) { let templ, docFrag $('#el-tabs-content').children().hide() // Populate the element config tab based on type if ( tab.id === 'el-tab-conf') { const type = $('#node-input-elementtype').val() switch (type) { // eslint-disable-line sonarjs/no-small-switch // case 'text': { // templ = document.querySelector('#text-template') // break // } // case 'table': { // templ = document.querySelector('#table-template') // break // } // case 'list': // case 'ol': // case 'ul': // case 'dl': { // templ = document.querySelector('#list-template') // break // } default: { // templ = document.createElement('template') templ = document.querySelector('#default-template') break } } // @ts-ignore - Clone the template and apply to the UI docFrag = templ.content.cloneNode(true) $('#el-tab-conf').append(docFrag) // Get any required functions for this type from the template (append runs the script tags immediately) // const confFns = window['uibElementConfigFns'] // console.log('confFns', confFns.type, confFns) // Re-constitute node.conf properties and values to the conf tab // TODO Deal with select tags Object.keys(node.confData).forEach( conf => { $(`#conf-${type}-${conf}`).val(node.confData[conf]) }) } else { $('#el-tab-conf').empty() } $('#' + tab.id).show() } }) tabs.addTab({ id: 'el-tab-main', label: 'Main' }) tabs.addTab({ id: 'el-tab-conf', label: 'Element Config' }) uibuilder.doTooltips('#ti-edit-panel') // Do this at the end } // ----- end of onEditPrepare() ----- // /** Prep for save * @param {*} node A node instance as seen from the Node-RED Editor */ function onEditSave(node) { $('[data-changed="true"]').each(function(i) { node.confData[this.dataset.prop] = $(this).val() node.changed = true RED.nodes.dirty(true) }) } // ----- end of onEditPrepare() ----- // //#region ---- Validation functions ---- // /** Validate a typed input as a string * Must not be JSON. Can be a number only if allowNum=true. Can be an empty string only if allowBlank=true * Sets typedInput border to red if not valid since custom validation not available on std typedInput types * @param {string} value Input value * @param {string} inpName Input field name * @param {boolean} allowBlank true=allow blank string. Default=true * @param {boolean} allowNum true=allow numeric input. Default=false * @returns {boolean} True if valid */ function tiValidateOptString(value, inpName, allowBlank = true, allowNum = false) { let isValid = true let f try { f = value.slice(0, 1) } catch (e) {} if (allowBlank === false && value === '') { isValid = false // console.log({ name: inpName, why: 'Blank failed', value: value, allowBlank: allowBlank }) } if ( allowNum === false && (value !== '' && !isNaN(Number(value))) ) { isValid = false // console.log({ name: inpName, why: 'Num failed', value: value, allowNum: allowNum }) } if ( f === '{' || f === '[' ) isValid = false $(`#node-input-${inpName} + .red-ui-typedInput-container`).css('border-color', isValid ? 'var(--red-ui-form-input-border-color)' : 'red') return isValid } //#endregion ---- Validation functions ---- // RED.nodes.registerType(moduleName, { defaults: { name: { value: '' }, topic: { value: '' }, elementtype: { value: 'table', required: true }, parent: { value: 'body', validate: (v) => tiValidateOptString(v, 'parent', false, false) }, parentSource: { value: '' }, // ! only here to allow for migration to parentSource field - remove after go-live of v6.1 parentSourceType: { value: 'str' }, elementid: { value: '', validate: (v) => tiValidateOptString(v, 'elementid', true, false) }, elementId: { value: '' }, // ! TODO remove after go-live 6.1 elementIdSourceType: { value: 'str' }, heading: { value: '', validate: (v) => tiValidateOptString(v, 'heading', true, false) }, headingSourceType: { value: 'str' }, headingLevel: { value: 'h2', required: true }, data: { value: 'payload' }, dataSourceType: { value: 'msg' }, position: { value: 'last', validate: (v) => tiValidateOptString(v, 'position', false, true) }, positionSourceType: { value: 'str' }, passthrough: { value: false }, // Configuration data specific to the chosen type confData: { value: {} }, }, align: 'left', inputs: 1, inputLabels: '', outputs: 1, outputLabels: ['uibuilder dynamic UI configuration'], icon: 'pencilBoxMultipleWhite.svg', label: function () { return this.name || `[${this.elementtype}] ${this.parent ? `${this.parent}.` : ''}${this.elementid || moduleName}` }, paletteLabel: moduleName, category: uibuilder.paletteCategory, color: 'var(--uib-node-colour)', // '#E6E0F8' /** Prepares the Editor panel */ oneditprepare: function () { onEditPrepare(this) }, /** Runs before save (Actually when Done button pressed) - oneditsave */ oneditsave: function () { onEditSave(this) }, /** Runs before cancel - oneditcancel */ /** Handle window resizing for the editor - oneditresize */ /** Show notification warning before allowing delete - oneditdelete */ }) // ---- End of registerType() ---- // }())