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.

1,192 lines (1,065 loc) 192 kB
/* eslint-disable no-undef */ /* eslint-disable jsdoc/valid-types */ /* eslint-disable jsdoc/check-param-names */ /* eslint-disable @stylistic/no-multi-spaces */ // @ts-nocheck /** This is the source main file for the uibuilder client library * @kind module * @module uibuilder * @description The client-side Front-End JavaScript for uibuilder in HTML Module form * It provides a number of global objects that can be used in your own javascript. * see the docs folder `./docs/uibuilder.module.md` for details of how to use this fully. * * Please use the default index.js file for your own code and leave this as-is. * See Uib._meta for client version string * @license Apache-2.0 * @author Julian Knight (Totally Information) * @copyright (c) 2022-2026 Julian Knight (Totally Information) */ const perf = new Map() perf.set('loading uibuilder client library', performance.now()) // Fastest way to get the http headers - called in constructor const __uibHeadersPromise = fetch(location.href, { method: 'HEAD', cache: 'no-store', }) .then((r) => { // console.log('>> >> HEADERS >> >> ', r.headers) const h = {} r.headers.forEach((v, k) => h[k] = v) window.__uibHeaders = h return h // return r.headers }) // #region --- Type Defs --- // /** * A string containing HTML markup * @typedef {string} html */ /** * @type {import('./libs/uib-worker.mjs').UibHeaderWorker} Reference to header worker */ // #endregion --- Type Defs --- // // We need the Socket.IO & ui libraries & the uib-var component --- // // @ts-ignore - Note: Only works when using esbuild to bundle import Ui from './ui.mjs' import io from 'socket.io-client' // eslint-disable-line import-x/no-named-as-default import UibVar from '../components/uib-var.mjs' import UibMeta from '../components/uib-meta.mjs' import ApplyTemplate from '../components/apply-template.mjs' import UibControl from '../components/uib-control.mjs' import { reactive as createReactive, Reactive } from './reactive.mjs' import { formatDate } from './libs/format-date-time.mjs' // import uibHeaderWorker from './libs/uib-worker.mjs' // import { dom } from './tinyDom' // Incorporate the logger module - NB: This sets a global `log` object for use if it can. // import logger from './logger' const version = '7.7.4-src' // #region --- Module-level utility functions --- // // Detect whether the loaded library is minified or not const isMinified = !(/param/).test(function (param) { }) // TODO - switch to logger module // #region --- print/console - debugging output functions --- // /** Custom logging. e.g. log(2, 'here:there', 'jiminy', {fred:'jim'})() * @returns {Function} Log function @example log(2, 'here:there', 'jiminy', {fred:'jim'})() */ function log() { // Get the args const args = Array.from(arguments) // 1st arg is the log level/type let level = args.shift() let strLevel switch (level) { case 'trace': case 5: { if (log.level < 5) break level = 5 // make sure level is numeric strLevel = 'trace' break } case 'debug': case 4: { if (log.level < 4) break level = 4 strLevel = 'debug' break } case 'log': case 3: { if (log.level < 3) break level = 3 strLevel = 'log' break } case 'info': case '': case 2: { if (log.level < 2) break level = 2 strLevel = 'info' break } case 'warn': case 1: { if (log.level < 1) break level = 1 strLevel = 'warn' break } case 'error': case 'err': case 0: { if (log.level < 0) break level = 0 strLevel = 'error' break } case 'print': { if (log.level < 0) break level = 0 strLevel = 'print' break } default: { level = -1 break } } // If set to something unknown, no log output if (strLevel === undefined) return function () { } // 2nd arg is a heading that will be colour highlighted const head = args.shift() // Bind back to console.log (could use console[strLevel] but some levels ignore some formatting, use console.xxx directly or dedicated fn) return Function.prototype.bind.call( console[log.LOG_STYLES[strLevel].console], console, `%c${log.LOG_STYLES[strLevel].pre}${strLevel}%c [${head}]`, `${log.LOG_STYLES.level} ${log.LOG_STYLES[strLevel].css}`, `${log.LOG_STYLES.head} ${log.LOG_STYLES[strLevel].txtCss}`, ...args ) } // Nice console styling log.LOG_STYLES = { // 0 print: { css: 'background: grey; color: yellow;', txtCss: 'color: grey;', pre: '➡️', console: 'log', }, error: { css: 'background: red; color: black;', txtCss: 'color: red; ', pre: '⛔ ', console: 'error', // or trace }, // 1 warn: { css: 'background: darkorange; color: black;', txtCss: 'color: darkorange; ', pre: '⚠ ', console: 'warn', }, // 2 info: { css: 'background: aqua; color: black;', txtCss: 'color: aqua;', pre: '❗ ', console: 'info', }, // 3 log: { css: 'background: grey; color: yellow;', txtCss: 'color: grey;', pre: '', console: 'log', }, // 4 debug: { css: 'background: chartreuse; color: black;', txtCss: 'color: chartreuse;', pre: '', console: 'debug', }, // 5 trace: { css: 'background: indigo; color: yellow;', txtCss: 'color: hotpink;', pre: '', console: 'log', }, names: ['print', 'error', 'warn', 'info', 'log', 'debug', 'trace'], reset: 'color: inherit;', head: 'font-weight:bold; font-style:italic;', level: 'font-weight:bold; border-radius: 3px; padding: 2px 5px; display:inline-block;', } /** Default log level - Warn (@since v7.1.0) */ log.default = 1 let ll // Check if the script element was found and get the data-log-level attribute (only numeric levels allowed here) let scriptElement try { scriptElement = document.currentScript ll = scriptElement.getAttribute('logLevel') } catch (e) {} // Otherwise check if the import url (for ESM only) has a logLevel query param if (ll === undefined) { try { const url = new URL(import.meta.url).searchParams ll = url.get('logLevel') } catch (e) {} } // If either found, check numeric and set default level if so if (ll !== undefined) { ll = Number(ll) if (isNaN(ll)) { console.warn( `[Uib:constructor] Cannot set logLevel to "${scriptElement?.getAttribute('logLevel')}". Defaults to 0 (error).`) log.default = 0 } else log.default = ll } // Set current level to default log.level = log.default // log.default = isMinified ? 0 : 1 // When using minified lib, assume production and only log errors otherwise also log warn //#endregion /** A hack to dynamically load a remote module and wait until it is loaded * @param {string} url The URL of the module to load * @returns {object|null} Either the result object or null (if the load fails) */ // function loadModule(url) { // eslint-disable-line no-unused-vars // let done // import(url) // .then(res => { // log('debug', '>> then >>', res)() // done = res // }) // .catch(err => { // console.error(`[uibuilder:loadModule] Could not load module ${url}`, err) // done = null // }) // // eslint-disable-next-line no-empty // while (!done) { } // eslint-disable-line no-unmodified-loop-condition // return done // } /** Convert JSON to Syntax Highlighted HTML * If the `json-viewer` custom element is registered, delegates to its static * `renderToHTML` method. Otherwise falls back to the built-in regex renderer. * @param {object} json A JSON/JavaScript Object * @returns {html} Object reformatted as highlighted HTML */ function syntaxHighlight(json) { if (json === undefined) { return '<span class="undefined">undefined</span>' } // Use the json-viewer component's pure renderer if it is loaded try { if (JsonViewer) return JsonViewer.renderToHTML(json, { includeStyles: false, }) } catch (e) { /* Fall through to built-in renderer on failure */ } // Built-in regex-based fallback renderer try { json = JSON.stringify(json, undefined, 4) // json = json.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;') // eslint-disable-line newline-per-chained-call json = json.replace(/("(\\u[a-zA-Z0-9]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(true|false|null)\b|-?\d+(?:\.\d*)?(?:[eE][+-]?\d+)?)/g, function (match) { let cls = 'number' if ((/^"/).test(match)) { if ((/:$/).test(match)) { cls = 'key' } else { cls = 'string' } } else if ((/true|false/).test(match)) { cls = 'boolean' } else if ((/null/).test(match)) { cls = 'null' } return `<span class="${cls}">${match}</span>` }) } catch (e) { json = `Syntax Highlight ERROR: ${e.message}` } return json } /** msg._ui handling functions */ const _ui = new Ui(window, log, syntaxHighlight) // #endregion --- Module-level utility functions --- // /** Define and export the Uib class - note that an instance of the class is also exported in the wrap-up * @typicalname uibuilder */ export const Uib = class Uib { // #region --- Static variables --- static _meta = { version: version, type: 'module', displayName: 'uibuilder', } // #endregion ---- ---- ---- ---- // #region private class vars // How many times has the loaded instance connected to Socket.IO (detect if not a new load?) connectedNum = 0 // event listener callbacks by property name // #events = {} // Socket.IO channel names _ioChannels = { control: 'uiBuilderControl', client: 'uiBuilderClient', server: 'uiBuilder', } /** setInterval holder for pings @type {function|undefined} */ #pingInterval // onChange event callbacks #propChangeCallbacks = {} // onTopic event callbacks #msgRecvdByTopicCallbacks = {} // Is Vue available? isVue = false // What version? Set in startup if Vue is loaded. Won't always work vueVersion = undefined /** setInterval id holder for Socket.IO checkConnect * @type {number|null} */ #timerid = null /** True when disconnect() was called intentionally - prevents _onDisconnect from triggering auto-reconnect * @type {boolean} */ #manualDisconnect = false // Holds the reference ID for the internal msg change event handler so that it can be cancelled #MsgHandler // Placeholder for io.socket - can't make a # var until # fns allowed in all browsers _socket // Placeholder for an observer that watches the whole DOM for changes - can't make a # var until # fns allowed in all browsers _htmlObserver // Has showMsg been turned on? #isShowMsg = false // Has showStatus been turned on? #isShowStatus = false // If true, URL hash changes send msg back to node-red. Controlled by watchUrlHash() #sendUrlHash = false // Used to help create unique element ID's if one hasn't been provided, increment on use #uniqueElID = 0 // Reference to our mini web worker // #headerWorker = uibHeaderWorker // Externally accessible command functions (NB: Case must match) - remember to update _uibCommand for new commands #extCommands = [ 'elementExists', 'get', 'getManagedVarList', 'getWatchedVars', 'htmlSend', 'include', 'navigate', 'scrollTo', 'set', 'showMsg', 'showStatus', 'showOverlay', 'uiGet', 'uiWatch', 'watchUrlHash', ] /** @type {Object<string, string>} Managed uibuilder variables */ #managedVars = {} // What status variables to show via showStatus() #showStatus = { online: { var: 'online', label: 'Online?', description: 'Is the browser online?', }, ioConnected: { var: 'ioConnected', label: 'Socket.IO connected?', description: 'Is Socket.IO connected?', }, connectedNum: { var: 'connectedNum', label: '# reconnections', description: 'How many times has Socket.IO had to reconnect since last page load?', }, clientId: { var: 'clientId', label: 'Client ID', description: 'Static client unique id set in Node-RED. Only changes when browser is restarted.', }, tabId: { var: 'tabId', label: 'Browser tab ID', description: 'Static unique id for the browser\'s current tab', }, cookies: { var: 'cookies', label: 'Cookies', description: 'Cookies set in Node-RED', }, httpNodeRoot: { var: 'httpNodeRoot', label: 'httpNodeRoot', description: 'From Node-RED\' settings.js, affects URL\'s. May be wrong for pages in sub-folders', }, pageName: { var: 'pageName', label: 'Page name', description: 'Actual name of this page', }, ioNamespace: { var: 'ioNamespace', label: 'SIO namespace', description: 'Socket.IO namespace - unique to each uibuilder node instance', }, // ioPath: { 'var': 'ioPath', 'label': 'SIO path', 'description': '', }, // no longer needed in the modern client socketError: { var: 'socketError', label: 'Socket error', description: 'If the Socket.IO connection has failed, says why', }, msgsSent: { var: 'msgsSent', label: '# msgs sent', description: 'How many standard messages have been sent to Node-RED?', }, msgsReceived: { var: 'msgsReceived', label: '# msgs received', description: 'How many standard messages have been received from Node-RED?', }, msgsSentCtrl: { var: 'msgsSentCtrl', label: '# control msgs sent', description: 'How many control messages have been sent to Node-RED?', }, msgsCtrlReceived: { var: 'msgsCtrlReceived', label: '# control msgs received', description: 'How many control messages have been received from Node-RED?', }, originator: { var: 'originator', label: 'Node Originator', description: 'If the last msg from Node-RED was from a `uib-sender` node, this will be its node id so that messasges can be returned to it', }, topic: { var: 'topic', label: 'Default topic', description: 'Optional default topic to be included in outgoing standard messages', }, started: { var: 'started', label: 'Has uibuilder client started?', description: 'Whether `uibuilder.start()` ran successfully. This should self-run and should not need to be run manually', }, version: { var: 'version', label: 'uibuilder client version', description: 'The version of the loaded uibuilder client library', }, serverTimeOffset: { var: 'serverTimeOffset', label: 'Server time offset (Hrs)', description: 'The number of hours difference between the Node-red server and the client', }, } // Track ui observers (see uiWatch) #uiObservers = {} // CSS selector for querySelectorAll to find elements with known uib attributes. // NOTE: CSS cannot match attribute name prefixes, so this covers known names. // _uibAttrScanOne handles any uib-prefixed attributes found on matched elements. #uibAttrSel = '[uib-topic], [data-uib-topic], [uib-var], [data-uib-var], [uib-if], [data-uib-if]' //#endregion // #region public class vars // TODO Move to proper getters // #region ---- Externally read-only (via .get method) ---- // // version - moved to _meta // Has the initial Socket.IO connection been made? Used to detect if a page reload or just a disconnect/reconnect initialConnect = null // How many times has the client reconnected to Socket.IO since the page was loaded? Note that this will be 0 on the initial connection, so can be used to detect a page reload vs a disconnect/reconnect reconnected = 0 /** Client ID set by uibuilder on connect */ clientId = '' /** The collection of cookies provided by uibuilder */ cookies = {} /** Copy of last control msg object received from sever */ ctrlMsg = {} /** Is Socket.IO client connected to the server? */ ioConnected = false // Is the library running from a minified version? isMinified = isMinified // Is the browser tab containing this page visible or not? isVisible = false // Remember the last page (re)load/navigation type: navigate, reload, back_forward, prerender lastNavType = '' // Max msg size that can be sent over Socket.IO - updated by "client connect" msg receipt maxHttpBufferSize = 1048576 /** Last std msg received from Node-RED */ msg = {} /** number of messages sent to server since page load */ msgsSent = 0 /** number of messages received from server since page load */ msgsReceived = 0 /** number of control messages sent to server since page load */ msgsSentCtrl = 0 /** number of control messages received from server since page load */ msgsCtrlReceived = 0 /** Is the client online or offline? */ online = navigator.onLine /** last control msg object sent via uibuilder.send() @since v2.0.0-dev3 */ sentCtrlMsg = {} /** last std msg object sent via uibuilder.send() */ sentMsg = {} /** placeholder to track time offset from server, see fn socket.on(ioChannels.server ...) */ serverTimeOffset = null /** placeholder for a socket error message */ socketError = null // tab identifier from session storage tabId = '' // Actual name of current page (set in constructor) pageName = null // Is the DOMPurify library loaded? Updated in start() purify = false // Is the Markdown-IT library loaded? Updated in start() markdown = false // Current URL hash. Initial set is done from start->watchHashChanges via a set to make it watched urlHash = location.hash // HTTP Headers on initial load - contain useful uibuilder info httpHeaders = {} // #endregion ---- ---- ---- ---- // // TODO Move to proper getters/setters // #region ---- Externally Writable (via .set method, read via .get method) ---- // /** Default originator node id - empty string by default * @type {string} */ originator = '' /** Default topic - used by send if set and no topic provided * @type {(string|undefined)} */ topic = undefined /** Either undefined or a reference to a uib router instance * Set by uibrouter, do not set manually. */ uibrouterinstance /** Set by uibrouter, do not set manually */ uibrouter_CurrentRoute // #endregion ---- ---- ---- ---- // // #region ---- These are unlikely to be needed externally: ---- autoSendReady = true httpNodeRoot = '' // Node-RED setting (via cookie) ioNamespace = '' ioPath = '' retryFactor = 1.5 // starting delay factor for subsequent reconnect attempts retryMs = 2000 // starting retry ms period for manual socket reconnections workaround storePrefix = 'uib_' // Prefix for all uib-related localStorage started = false // NOTE: These can only change when a client (re)connects socketOptions = { path: this.ioPath, // https://developer.mozilla.org/en-US/docs/Web/API/WebSockets_API // https://developer.mozilla.org/en-US/docs/Web/API/WebTransport_API // https://socket.io/get-started/webtransport // NOTE: webtransport requires HTTP/3 and TLS. HTTP/2 & 3 not yet available in Node.js // transports: ['polling', 'websocket', 'webtransport'], transports: ['polling', 'websocket'], // Using callback so that they are updated automatically on (re)connect // Only put things in here that will be valid for a websocket connected session auth: (cb) => { cb({ clientVersion: version, clientId: this.clientId, pathName: window.location.pathname, urlParams: Object.fromEntries(new URLSearchParams(location.search)), pageName: this.pageName, tabId: this.tabId, lastNavType: this.lastNavType, connectedNum: ++this.connectedNum, // Used to calculate the diff between the server and client connection timestamps - reported if >1 minute browserConnectTimestamp: (new Date()).toISOString(), }) }, transportOptions: { // Can only set headers when polling polling: { extraHeaders: { 'x-clientid': `${Uib._meta.displayName}; ${Uib._meta.type}; ${Uib._meta.version}; ${this.clientId}`, }, }, }, } // #endregion -- not external -- // #endregion --- End of variables --- // #region ------- Getters and Setters ------- // // Change logging level dynamically (affects both console. and print.) set logLevel(level) { log.level = level console.log( '%c❗ info%c [logLevel]', `${log.LOG_STYLES.level} ${log.LOG_STYLES.info.css}`, `${log.LOG_STYLES.head} ${log.LOG_STYLES.info.txtCss}`, `Set to ${level} (${log.LOG_STYLES.names[level]})` ) /* changeLogLevel(level)*/ } get logLevel() { return log.level } get meta() { return Uib._meta } /** Extract the root property from a nested property path (e.g., 'myvar.aprop' or 'myvar["bprop"]'). * @private * @param {string} prop The property name or nested property path * @returns {Object<boolean, string, string|null>} Whether the prop is a nested path, * the root property name (e.g., 'myvar' from 'myvar.aprop' or 'myvar["bprop"]'), * and the nested path or null if not a nested path */ _nestedPath(prop) { const isNestedPath = prop.includes('.') || prop.includes('[') let nestedPath = null let rootProp if (isNestedPath) { const dotIdx = prop.indexOf('.') const bracketIdx = prop.indexOf('[') let sepIdx = -1 if (dotIdx >= 0 && bracketIdx >= 0) sepIdx = Math.min(dotIdx, bracketIdx) else if (dotIdx >= 0) sepIdx = dotIdx else if (bracketIdx >= 0) sepIdx = bracketIdx if (sepIdx > 0) { // Extract the root property and nested path rootProp = prop.substring(0, sepIdx) // For bracket notation, keep the '[' so _setNestedPath can properly strip quotes nestedPath = prop.substring(prop[sepIdx] === '[' ? sepIdx : sepIdx + 1) } } return { isNestedPath, rootProp, nestedPath, } } /** Function to set uibuilder properties to a new value - works on any property except _* or #* * Also triggers any event listeners. * Supports nested property paths (e.g., 'myvar.aprop' or 'myvar["bprop"]'). * Example: this.set('msg', {topic:'uibuilder', payload:42}); * Example: this.set('myvar.nested.prop', 42); * @param {string} prop Any uibuilder property who's name does not start with a _ or # * @param {*} val The set value of the property or a string declaring that a protected property cannot be changed * @param {boolean} [store] If true, the variable is also saved to the browser localStorage if possible * @param {boolean} [autoload] If true & store is true, on load, uib will try to restore the value from the store automatically * @returns {*} Input value */ set(prop, val, store = false, autoload = false) { // Check for excluded properties - we don't want people to set these // if (this.#excludedSet.indexOf(prop) !== -1) { if (prop.startsWith('_') || prop.startsWith('#')) { log('warn', 'Uib:set', `Cannot use set() on protected property "${prop}"`)() return `Cannot use set() on protected property "${prop}"` } // Check if this is a nested property path (contains . or [) const { isNestedPath, rootProp, nestedPath, } = this._nestedPath(prop) // console.log('🪲 is nested?', { prop, isNestedPath, rootProp, nestedPath, }) // Check for an old value const oldVal = isNestedPath ? this._resolveNestedPath(this, prop) : (this[prop] ?? undefined) if (isNestedPath && nestedPath) { // Ensure root property exists and is an object if (this[rootProp] == null || typeof this[rootProp] !== 'object') { this[rootProp] = {} } // Set the nested property this._setNestedPath(this[rootProp], nestedPath, val) // Keep track of all managed variables (track root property for nested paths) this.#managedVars[rootProp] = rootProp } else { // We must add the var to the uibuilder object this[prop] = val // Keep track of all managed variables (track root property for nested paths) this.#managedVars[prop] = prop } // If requested, save to store (save the entire root object for nested paths) if (store === true) this.setStore(rootProp, isNestedPath ? this[rootProp] : val, autoload) log('trace', 'Uib:set', `prop set - prop: ${prop}, val: `, val, ` store: ${store}, autoload: ${autoload}`)() // Trigger this prop's event callbacks (listeners which are set by this.onChange) // Fire event for the root property (since that's what actually changed) const eventProp = isNestedPath ? rootProp : prop const eventVal = this[eventProp] // get the current value of the root property (which will include the nested changes if it's a nested path) // trigger an event on the prop name, pass both the name and value to the event details this._dispatchCustomEvent('uibuilder:propertyChanged', { prop: eventProp, value: eventVal, oldValue: oldVal, store: store, autoload: autoload, }) this._dispatchCustomEvent(`uibuilder:propertyChanged:${eventProp}`, { prop: eventProp, value: eventVal, oldValue: oldVal, store: store, autoload: autoload, }) // And return the (root) value for good measure return eventVal } /** Function to get the value of a uibuilder property * Supports nested property paths (e.g., 'myvar.aprop' or 'myvar["bprop"]'). * Example: uibuilder.get('msg') * Example: uibuilder.get('myvar.nested.prop') * @param {string} prop The name of the property to get as long as it does not start with a _ or # * @returns {*|undefined} The current value of the property */ get(prop) { // Handle special properties first - these are either protected or virtual properties that don't exist directly on the uibuilder object if (prop.startsWith('_') || prop.startsWith('#')) { log('warn', 'Uib:get', `Cannot use get() on protected property "${prop}"`)() return } if (prop === 'version') return Uib._meta.version if (prop === 'msgsCtrl') return this.msgsCtrlReceived if (prop === 'reconnections') return this.connectedNum // Extract root property for nested paths const { isNestedPath, rootProp, } = this._nestedPath(prop) // Handle nested paths if (isNestedPath) { const value = this._resolveNestedPath(this, prop) if (value === undefined) { log('info', 'Uib:get', `get() - property "${prop}" is undefined`)() } return value } if (this[prop] === undefined) { log('info', 'Uib:get', `get() - property "${prop}" is undefined`)() } return this[prop] } /** Write to localStorage if possible. console error output if can't write * Also uses this.storePrefix * @example * uibuilder.setStore('fred', 42) * console.log(uibuilder.getStore('fred')) * @param {string} id localStorage var name to be used (prefixed with 'uib_') * @param {*} value value to write to localstore * @param {boolean} [autoload] If true, on load, uib will try to restore the value from the store * @returns {boolean} True if succeeded else false */ setStore(id, value, autoload = false) { let autoVars = {} if (autoload === true) { try { autoVars = this.getStore('_uibAutoloadVars') || {} } catch (e) {} } if (typeof value === 'object') { try { value = JSON.stringify(value) } catch (e) { log('error', 'Uib:setStore', 'Cannot stringify object, not storing. ', e)() return false } } try { localStorage.setItem(this.storePrefix + id, value) if (autoload) { autoVars[id] = id try { localStorage.setItem(this.storePrefix + '_uibAutoloadVars', JSON.stringify(autoVars)) } catch (e) { log('error', 'Uib:setStore', 'Cannot save autoload list. ', e)() } } this._dispatchCustomEvent('uibuilder:propertyStored', { prop: id, value: value, autoload: autoload, }) return true } catch (e) { log('error', 'Uib:setStore', 'Cannot write to localStorage. ', e)() return false } } // --- end of setStore --- // /** Attempt to get and re-hydrate a key value from localStorage * Note that all uib storage is automatically prefixed using this.storePrefix * @param {*} id The key of the value to attempt to retrieve * @returns {*|null|undefined} The re-hydrated value of the key or null if key not found, undefined on error */ getStore(id) { try { // @ts-ignore return JSON.parse(localStorage.getItem(this.storePrefix + id)) } catch (e) { } try { return localStorage.getItem(this.storePrefix + id) } catch (e) { return undefined } } /** Remove a given id from the uib keys in localStorage * @param {*} id The key to remove */ removeStore(id) { try { localStorage.removeItem(this.storePrefix + id) } catch (e) { } } /** Returns a list of the externally accessible command functions that are available to be called from Node-RED * @returns {string[]} List of externally accessible command functions */ getCommandList() { return this.#extCommands } /** Returns a list of uibuilder properties (variables) that can be watched with onChange * @returns {Object<string,string>} List of uibuilder managed variables */ getManagedVarList() { return this.#managedVars } getWatchedVars() { return Object.keys(this.#propChangeCallbacks) } // #endregion ------- -------- ------- // // #region ------- Our own event handling system ---------- // /** Standard fn to create a custom event with details & dispatch it * @param {string} title The event name * @param {*} details Any details to pass to event output * @private */ _dispatchCustomEvent(title, details) { const event = new CustomEvent(title, { detail: details, }) document.dispatchEvent(event) } // See the this.#propChangeCallbacks & msgRecvdByTopicCallbacks private vars /** Register on-change event listeners for uibuilder tracked properties * Make it possible to register a function that will be run when the property changes. * Note that you can create listeners for non-existant properties * @example uibuilder.onChange('msg', (msg) => { console.log('uibuilder.msg changed! It is now: ', msg) }) * * @param {string} prop The property of uibuilder that we want to monitor * @param {Function} callback The function that will run when the property changes, parameter is the new value of the property after change * @returns {number} A reference to the callback to cancel, save and pass to uibuilder.cancelChange if you need to remove a listener */ onChange(prop, callback) { // Note: Property does not have to exist yet // console.debug(`[Uib:onchange] pushing new callback (event listener) for property: ${prop}`) // Create a new array or add to the array of callback functions for the property in the events object // if (this.#events[prop]) { // this.#events[prop].push(callback) // } else { // this.#events[prop] = [callback] // } // Make sure we have an object to receive the saved callback, update the latest reference number if (!this.#propChangeCallbacks[prop]) this.#propChangeCallbacks[prop] = { _nextRef: 1, } else this.#propChangeCallbacks[prop]._nextRef++ const nextCbRef = this.#propChangeCallbacks[prop]._nextRef // Register the callback function. It is saved so that we can remove the event listener if we need to const propChangeCallback = this.#propChangeCallbacks[prop][nextCbRef] = function propChangeCallback(e) { // If the prop name matches the 1st arg in the onChange fn: if (prop === e.detail.prop) { const value = e.detail.value // console.warn('[Uib:onChange:evt] uibuilder:propertyChanged. ', e.detail) // Set the callback fn's `this` and its single argument to the msg callback.call(value, value) } } document.addEventListener('uibuilder:propertyChanged', propChangeCallback) return nextCbRef } // ---- End of onChange() ---- // cancelChange(prop, cbRef) { document.removeEventListener('uibuilder:propertyChanged', this.#propChangeCallbacks[prop][cbRef]) delete this.#propChangeCallbacks[prop][cbRef] // this.#propChangeCallbacks[topic]._nextRef-- // Don't bother, let the ref# increase } /** Register a change callback for a specific msg.topic (works for std and ctrl msgs) * Similar to onChange but more convenient if needing to differentiate by msg.topic. * @example let otRef = uibuilder.onTopic('mytopic', function(){ console.log('Received a msg with msg.topic=`mytopic`. msg: ', this) }) * To cancel a change listener: uibuilder.cancelTopic('mytopic', otRef) * Since v7.6, added ctrl msgs so that <uib-var> and uib-topic in HTML can process control as well as standard msgs * * @param {string} topic The msg.topic we want to listen for * @param {Function} callback The function that will run when an appropriate msg is received. `this` inside the callback as well as the cb's single argument is the received msg. * @returns {number} A reference to the callback to cancel, save and pass to uibuilder.cancelTopic if you need to remove a listener */ onTopic(topic, callback) { // Make sure we have an object to receive the saved callback, update the latest reference number if (!this.#msgRecvdByTopicCallbacks[topic]) this.#msgRecvdByTopicCallbacks[topic] = { _nextRef: 1, } else this.#msgRecvdByTopicCallbacks[topic]._nextRef++ const nextCbRef = this.#msgRecvdByTopicCallbacks[topic]._nextRef // Register the callback function. It is saved so that we can remove the event listener if we need to const msgRecvdEvtCallback = this.#msgRecvdByTopicCallbacks[topic][nextCbRef] = function msgRecvdEvtCallback(e) { const msg = e.detail // console.log('[Uib:onTopic:evt] uibuilder:stdMsgReceived where topic matches. ', e.detail) if (msg.topic === topic) { // Set the callback fn's `this` and its single argument to the msg callback.call(msg, msg) } } document.addEventListener('uibuilder:stdMsgReceived', msgRecvdEvtCallback) document.addEventListener('uibuilder:ctrlMsgReceived', msgRecvdEvtCallback) return nextCbRef } cancelTopic(topic, cbRef) { document.removeEventListener('uibuilder:stdMsgReceived', this.#msgRecvdByTopicCallbacks[topic][cbRef]) document.removeEventListener('uibuilder:ctrlMsgReceived', this.#msgRecvdByTopicCallbacks[topic][cbRef]) // this.#msgRecvdCallbacks[topic]._nextRef-- // Don't bother, let the ref# increase } /** Trigger event listener for a given property * Called when uibuilder.set is used * * @param {*} prop The property for which to run the callback functions * arguments: Additional arguments contain the value to pass to the event callback (e.g. newValue) */ // emit(prop) { // var evt = this.#events[prop] // if (!evt) { // return // } // var args = Array.prototype.slice.call(arguments, 1) // for (var i = 0; i < evt.length; i++) { // evt[i].apply(this, args) // } // log('trace', 'Uib:emit', `${evt.length} listeners run for prop ${prop} `)() // } /** Forcibly removes all event listeners from the events array * Use if you need to re-initialise the environment */ // clearEventListeners() { // this.#events = [] // } // ---- End of clearEventListeners() ---- // /** Clear a single property event listeners * @param {string} prop The property of uibuilder for which we want to clear the event listener */ // clearListener(prop) { // if (this.#events[prop]) delete this.#events[prop] // } // #endregion ---------- End of event handling system ---------- // // #region ------- General Utility Functions -------- // /** Check supplied msg from server for a timestamp - if received, work out & store difference to browser time * @param {object} receivedMsg A message object recieved from Node-RED * @returns {void} Updates self.serverTimeOffset if different to previous value * @private */ _checkTimestamp(receivedMsg) { if (Object.prototype.hasOwnProperty.call(receivedMsg, 'serverTimestamp')) { const serverTimestamp = new Date(receivedMsg.serverTimestamp) // @ts-ignore const offset = Math.round(((new Date()) - serverTimestamp) / 3600000) // in ms /3.6m to get hours if (offset !== this.serverTimeOffset) { log('trace', `Uib:checkTimestamp:${this._ioChannels.server} (server)`, `Offset changed to: ${offset} from: ${this.serverTimeOffset}`)() this.set('serverTimeOffset', offset) } } } /** Set up an event listener to watch for hash changes * and set the watchable urlHash variable * @private */ _watchHashChanges() { this.set('urlHash', location.hash) window.addEventListener('hashchange', (event) => { this.set('urlHash', location.hash) if (this.#sendUrlHash === true) { this.send({ topic: 'hashChange', payload: location.hash, newHash: this.keepHashFromUrl(event.newURL), oldHash: this.keepHashFromUrl(event.oldURL), }) } }) } /** Returns a new array containing the intersection of the 2 input arrays * @param {Array} a1 Array to check * @param {Array} a2 Array to intersect * @returns {Array} The intersection of the 2 arrays (may be an empty array) */ arrayIntersect(a1, a2) { return a1.filter(uName => a2.includes(uName)) } /** Copies a uibuilder variable to the browser clipboard * @param {string} varToCopy The name of the uibuilder variable to copy to the clipboard */ copyToClipboard(varToCopy) { let data = '' try { data = JSON.stringify(this.get(varToCopy)) } catch (e) { log('error', 'copyToClipboard', `Could not copy "${varToCopy}" to clipboard.`, e.message)() } navigator.clipboard.writeText(data) } // --- End of copyToClipboard --- // /** Does the chosen CSS Selector currently exist? * Automatically sends a msg back to Node-RED unless turned off. * @param {string} cssSelector Required. CSS Selector to examine for visibility * @param {boolean} [msg] Optional, default=true. If true also sends a message back to Node-RED * @returns {boolean} True if the element exists */ elementExists(cssSelector, msg = true) { const el = document.querySelector(cssSelector) let exists = false if (el !== null) exists = true if (msg === true) { this.send({ payload: exists, info: `Element "${cssSelector}" ${exists ? 'exists' : 'does not exist'}`, }) } return exists } // --- End of elementExists --- // /** Format a Date using Intl with optional pattern support. * @param {Date|string|number} date Input JS Date, date string, or timestamp * @param {string} [pattern] Optional pattern string * @param {string} [locale] Locale code. Defaults to browser locale. * @returns {string} Formatted date string */ // formatDate = formatDate formatDate = formatDate /** Format a number using the INTL standard library - compatible with uib-var filter function * @param {number} value Number to format * @param {number} decimalPlaces Number of decimal places to include. Default=no default * @param {string} intl standard locale spec, e.g. "ja-JP" or "en-GB". Default=navigator.language * @param {object} opts INTL library options object. Optional * @returns {string} formatted number * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Number/toLocaleString */ formatNumber(value, decimalPlaces, intl, opts) { if (isNaN(value)) { log('error', 'formatNumber', `Value must be a number. Value type: "${typeof value}"`)() return 'NaN' } if (!opts) opts = {} if (!intl) intl = navigator.language ? navigator.language : 'en-GB' if (typeof decimalPlaces === 'number') { opts.minimumFractionDigits = decimalPlaces opts.maximumFractionDigits = decimalPlaces } let out try { out = Number(value).toLocaleString(intl, opts) } catch (e) { log('error', 'formatNumber', `${e.message}. value=${value}, dp=${decimalPlaces}, intl="${intl}", opts=${JSON.stringify(opts)}`)() return 'NaN' } return out } /** Attempt to get rough size of an object * @param {*} obj Any serialisable object * @returns {number|undefined} Rough size of object in bytes or undefined */ getObjectSize(obj) { let size try { const jsonString = JSON.stringify(obj) // Encode the string to a Uint8Array and measure its length const encoder = new TextEncoder() const uint8Array = encoder.encode(jsonString) size = uint8Array.length } catch (e) { log('error', 'uibuilder:getObjectSize', 'Could not stringify, cannot determine size', obj, e)() } return size } /** Returns true if a uibrouter instance is loaded, otherwise returns false * @returns {boolean} true if uibrouter instance loaded else false */ hasUibRouter() { return !!this.uibrouterinstance } /** Check if an attribute name is a uibuilder-specific attribute. * Matches names starting with 'uib-', 'data-uib-', or ':'. * @param {string} name The attribute name to check * @returns {boolean} True if the attribute name is a uib attribute */ isUibAttribute(name) { return name.startsWith('uib-') || name.startsWith('data-uib-') || name.startsWith(':') } /** Only keep the URL Hash & ignoring query params * @param {string} url URL to extract the hash from * @returns {string} Just the route id */ keepHashFromUrl(url) { if (!url) return '' return '#' + url.replace(/^.*#(.*)/, '$1').replace(/\?.*$/, '') } /** Standardised logging function that uses the log level system and can be extended in the future to do more than just log to the console * @param {...*} arguments The arguments to log, * first argument can optionally be a log level string or number (e.g., 'info', 'warn', 'error', 'debug', 'trace') * 2nd argument can optionally be a source string to identify the source of the log (e.g., 'uibuilder:myFunction') * Remaining arguments are the data to log, can be multiple and of any type. */ log() { log(...arguments)() } /** Output the current call stack to the console */ logStack() { const stack = this.stack() stack.shift() // Drop 1st entry as it's always the logStack function itself console.log('%cCall stack:', log.LOG_STYLES.info.css, stack) // log('info', 'Uib:logStack', 'Call stack:', this.stack())() } /** Makes a null or non-object into an object. If thing is already an object. * If not null, moves "thing" to {payload:thing} * @param {*} thing Thing to check * @param {string} [property] property that "thing" is moved to if not null and not an object. Default='payload' * @returns {!object} _ */ makeMeAnObject(thing, property) { if (!property) property = 'payload' if (typeof property !== 'string') { log('warn', 'uibuilder:makeMeAnObject', `WARNING: property parameter must be a string and not: ${typeof property}`)() property = 'payload' } let out = {} if ( thing !== null && thing.constructor.name === 'Object' ) { out = thing } else if (thing !== null) { out[property] = thing } return out } // --- End of make me an object --- // /** Navigate to a new page or a new route (hash) * @param {string} url URL to navigate to. Can be absolute or relative (to current page) or just a hash for a route change * @returns {Location} The new window.location string */ navigate(url) { if (url) window.location.href = url return window.location } /** Create and return a randomised UUID * Uses the browsers crypto library if available (newer browsers) * or something based on the timestamp and a random number * @returns {string} The UUID */ randomUUID() { return (typeof crypto !== 'undefined' && crypto.randomUUID) ? crypto.randomUUID() : `${Date.now()}-${Math.random().toString(36) .substring(2, 11)}` } /** Wrap a provided variable in a proxy object so that it can be used reactively * @param {*} srcvar The source variable to wrap * @returns {Proxy} A proxy object that can be used reactively */ reactive(srcvar) { return createReactive(srcvar) } /** Get the Reactive class for advanced usage * @returns {Function} The Reactive class constructor * @example * const ReactiveClass = uib.getReactiveClass() * const reactiveInstance = new ReactiveClass(data, customEventDispatcher) * const proxy = reactiveInstance.create() */ getReactiveClass() { return Reactive } // ! TODO change ui uib-* attributes to use this /** Convert a string attribute into an variable/constant reference * Used to resolve data sources in attributes * @param {string} path The string path to resolve, must be relative to the `window` global scope * @returns {*} The resolved data source or null */ resolveDataSource(path) { try { const parts = path.split(/[\.\[\]\'\"]/).filter(Boolean) let data = window for (const part of parts) { data = data?.[part] } return data } catch (error) { // console.error('Error resolving data source:', error) log('error', 'uibuilder:resolveDataSource', `Error resolving data source "${path}", returned 'null'. ${error.message}`)() return null } } /** Fast but accurate number rounding (https://stackoverflow.com/a/48764436/1309986 solution 2) * Half away from zero method (AKA "commercial" rounding), most common type * @param {number} num The number to be rounded * @param {number} decimalPlaces Number of DP's to round to * @returns {number} Rounded number */ round(num, decimalPlaces) { const p = Math.pow(10, decimalPlaces || 0) const n = (num * p) * (1 + Number.EPSILON) return Math.round(n) / p } /** Set the default originator. Set to '' to ignore. Used with uib-sender. * @param {string} [originator] A Node-RED node ID to return the message to */ setOriginator(originator = '') { this.set('originator', originator) } // ---- End of setOriginator ---- // /** HTTP Ping/Keep-alive - makes a call back to uibuilder's ExpressJS server and receives a 204 response * Can be used to keep sessions alive. * @example * uibuilder.setPing(2000) // repeat every 2 sec. Re-issue with ping(0) to turn off repeat. * uibuilder.onChang