UNPKG

node-red-contrib-uibuilder

Version:

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

329 lines (287 loc) 13.6 kB
/** A zero dependency web component that will display a managed uibuilder variable. * * Version: 1.0.1 2023-12-27 */ /* Copyright (c) 2023-2024 Julian Knight (Totally Information) 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. */ // const template = document.createElement('template') // template.innerHTML = /** @type {HTMLTemplateElement} */ /*html*/`<span></span>` // template.innerHTML = /** @type {HTMLTemplateElement} */ /*html*/`<link type="text/css" rel="stylesheet" href=""../uibuilder/uib-brand.min.css"" media="all"><span></span>` // template.innerHTML = /** @type {HTMLTemplateElement} */ /*html*/` // {/* <style>@import url("../uibuilder/uib-brand.min.css");</style><span></span> */} // ` export default class UibVar extends HTMLElement { //#region --- Class Properties --- /** @type {string} Name of the uibuilder mangaged variable to use */ variable /** Current value of the watched variable */ value /** Whether to output if the variable is undefined */ undef = false /** Whether to send update value to Node-RED on change */ report = false /** What is the value type */ type = 'plain' /** what are the available types? */ types = ['plain', 'html', 'markdown', 'object', 'json', 'table', 'list', 'array'] /** Holds uibuilder.onTopic listeners */ topicMonitors = {} /** Is UIBUILDER loaded? */ uib = !!window['uibuilder'] /** Mini jQuery-like shadow dom selector (see constructor) */ $ /** Holds a count of how many instances of this component are on the page */ static _iCount = 0 /** @type {Array<string>} List of all of the html attribs (props) listened to */ static props = ['filter', 'id', 'name', 'report', 'topic', 'type', 'undefined', 'variable',] //#endregion --- Class Properties --- constructor() { super() this.shadow = this.attachShadow({ mode: 'open', delegatesFocus: true }) // .append(template.content.cloneNode(true)) this.$ = this.shadowRoot.querySelector.bind(this.shadowRoot) // Apply external styles to the shadow dom - assumes you use index.css in same url location as main url this.css = document.createElement('link') this.css.setAttribute('type', 'text/css') this.css.setAttribute('rel', 'stylesheet') this.css.setAttribute('href', './index.css') this.dispatchEvent(new Event('uib-var:construction', { bubbles: true, composed: true })) } // Makes HTML attribute change watched static get observedAttributes() { return UibVar.props } /** Handle watched attributes * NOTE: On initial startup, this is called for each watched attrib set in HTML - BEFORE connectedCallback is called. * Attribute values can only ever be strings * @param {string} attrib The name of the attribute that is changing * @param {string} newVal The new value of the attribute * @param {string} oldVal The old value of the attribute */ attributeChangedCallback(attrib, oldVal, newVal) { if ( oldVal === newVal ) return switch (attrib) { case 'variable': { // NB Doesn't check if var exists because it might not be set yet if (newVal === '') throw new Error('[uib-var] Attribute "variable" MUST be set to a UIBUILDER managed variable name') this.variable = newVal this.doWatch() break } case 'undefined': { if (newVal === '' || ['on', 'true', 'report'].includes(newVal.toLowerCase())) this.undef = true else this.undef = false break } case 'report': { if (newVal === '' || ['on', 'true', 'report'].includes(newVal.toLowerCase())) this.report = true else this.report = false break } case 'type': { if (newVal === '' || !this.types.includes(newVal.toLowerCase())) this.type = 'plain' else this.type = newVal break } case 'topic': { // console.log(1, attrib, newVal) // Handle empty topic if (!newVal) break // Ignore if not using uibuilder if (!this.uib) break // If variable attrib set, give warning and ignore if (this.variable) { console.warn('⚠️ [uib-var] Cannot process both variable and topic attributes, use only 1. Using variable') break } this.topic = newVal // Cancel old listener just in case if (this.topicMonitors[newVal]) uibuilder.cancelTopic(newVal, this.topicMonitors[newVal]) // Set up a uibuilder listener for this topic - ASSUMES msg.payload contains the VALUE to show this.topicMonitors[newVal] = uibuilder.onTopic(newVal, (msg) => { // console.log('🔦 topicMonitor ⟫', newVal, msg) this.value = msg.payload this.varDom() if (this.report === true) window['uibuilder'].send({ topic: this.variable, payload: this.value || undefined }) }) this.varDom() break } case 'filter': { this.filter = undefined this.filterArgs = [] // Handle empty filter if (!newVal) break this.filter = newVal // Filter the input - at least limit the length of the attr newVal = newVal.slice(0, 127) // Remove spaces and then separate fn name from potential extra arguments const f = newVal.replace(/\s/g, '').match(/([a-zA-Z_$][a-zA-Z_$0-9.-]+)(\((.*)\))?/) if (!f) { console.warn(`⚠️ [uib-var] Filter function "${newVal}" invalid. Cannot process.`) break } // Fn name this.filter = f[1] // Fn arguments if (f[3]) { // undefined if no args found // TODO Do we really want to try this? try { this.filterArgs = JSON.parse(f[3]) } catch (e) {} this.filterArgs = f[3].split(',').map((x) => { // No objects/arrays allowed - if they have a , they are split x = x.trim() // @ts-ignore NB: Do NOT use Number.isNaN here, it is too narrow minded if (isNaN(x)) { // String inside string ends up double quoted so remove those let y = x.replace(/^["'`]/, '').replace(/["'`]$/, '') // Attempt very limited parse in case it is valid object/array try { y = new Function(`return ${y}`)() // eslint-disable-line no-new-func } catch (e) {} return y } return Number(x) }) } // Apply the filter directly if neither variable nor topic attribs set if (!this.variable && !this.topic) this.varDom(false) break } default: { this[attrib] = newVal break } } } // --- end of attributeChangedCallback --- // // Runs when an instance is added to the DOM connectedCallback() { // Make sure instance has an ID. Create an id from name or calculation if needed if (!this.id) { if (!this.name) this.name = this.getAttribute('name') if (this.name) this.id = this.name.toLowerCase().replace(/\s/g, '_') else this.id = `uib-var-${++UibVar._iCount}` } } // ---- end of connectedCallback ---- // // Runs when an instance is removed from the DOM disconnectedCallback() { // Ignore if not using uibuilder if (this.uib) { Object.keys(this.topicMonitors).forEach( topic => { uibuilder.cancelTopic(topic, this.topicMonitors[topic]) }) } } // ---- end of disconnectedCallback ---- // /** Process changes to the required uibuilder variable */ doWatch() { if (!this.variable) throw new Error('No variable name provided') // if (!this.uib) throw new Error('UIBUILDER client library not loaded - this component must be loaded AFTER the UIBUILDER library') this.value = window['uibuilder'].get(this.variable) this.varDom() // Watch for changes in the variable window['uibuilder'].onChange(this.variable, (val) => { this.value = val this.varDom() if (this.report === true) window['uibuilder'].send({ topic: this.variable, payload: this.value || undefined }) }) } /** Convert this.value to DOM output (applies output filter if needed) * @param {boolean} chkVal If true (default), check for undefined value. False used to run filter even with no value set. */ varDom(chkVal = true) { // If user doesn't want to show undefined vars, allow the component slot to show instead if (chkVal === true && !this.value && this.undef !== true) { this.shadow.innerHTML = '<slot></slot>' return } // Apply the filter to the value before display let val = chkVal ? this.doFilter(this.value) : this.doFilter() // console.log('🔦 varDOM ⟫ ', val, typeof val, this.type) let out = val switch (this.type) { case 'markdown': { if (this.uib) out = window['uibuilder'].convertMarkdown(val) break } case 'json': case 'object': { // console.log(window['uibuilder'].syntaxHighlight(val)) out = `<pre class="syntax-highlight">${this.uib ? window['uibuilder'].syntaxHighlight(val) : val}</pre>` break } case 'table': { // console.log('🔦 val ⟫', val) // if (!Array.isArray(val)) { // out = '<code>Contents of msg.payload is not an array which is required for table output.</code>' // break // } out = window['uibuilder'].sanitiseHTML(window['uibuilder'].buildHtmlTable(val).outerHTML) break } case 'array': case 'list': { if (!Array.isArray(val)) val = [val] // console.log('🔦 val ⟫', val) out = '<ul>' val.forEach( li => { out += `<li>${window['uibuilder'].sanitiseHTML(li)}</li>` }) out += '</ul>' break } case 'plain': case 'html': default: { const t = typeof val if (Array.isArray(val) || t === '[object Object]' || t === 'object') { try { out = JSON.stringify(val) } catch (e) {} } break } } if (this.uib) this.shadow.innerHTML = window['uibuilder'].sanitiseHTML(out) else this.shadow.innerHTML = out this.shadow.appendChild(this.css) } /** Apply value filter if specified * @param {*} value The value to change * @returns {*} The amended value that will be displayed */ doFilter(value) { if (this.filter) { // Cater for dotted notation functions (e.g. uibuilder.get) const splitFilter = this.filter.split('.') let globalFn = globalThis[splitFilter[0]] if (globalFn && splitFilter.length > 1) { const parts = [splitFilter.pop()] parts.forEach( part => { globalFn = globalFn[part] } ) } if (!globalFn && this.uib === true) globalFn = globalThis['uibuilder'][splitFilter[0]] if (globalFn && typeof globalFn !== 'function' ) globalFn = undefined if (globalFn) { const argList = value === undefined ? [...this.filterArgs] : [value, ...this.filterArgs] value = Reflect.apply(globalFn, value ?? globalFn, argList) } else { console.warn(`⚠️ [uib-var] Filter function "${this.filter}" ${typeof globalFn === 'object' ? 'is an object not a function' : 'not found'}`) } } return value } } // Add the class as a new Custom Element to the window object // customElements.define('uib-var', UibVar)