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,180 lines (1,047 loc) • 146 kB
JavaScript
// @ts-nocheck
/**
* @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
* @version 1.0.0
* @license Apache-2.0
* @author Julian Knight (Totally Information)
* @copyright (c) 2022-2025 Julian Knight (Totally Information)
*/
//#region --- Type Defs --- //
/**
* A string containing HTML markup
* @typedef {string} html
*/
//#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'
import io from 'socket.io-client' // eslint-disable-line import/no-named-as-default
import UibVar from '../components/uib-var'
import UibMeta from '../components/uib-meta'
import ApplyTemplate from '../components/apply-template'
// 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.2.0-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
}
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
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: ['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
* @param {object} json A JSON/JavaScript Object
* @returns {html} Object reformatted as highlighted HTML
*/
function syntaxHighlight(json) {
if (json === undefined) {
json = '<span class="undefined">undefined</span>'
} else {
try {
json = JSON.stringify(json, undefined, 4)
// json = json.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>') // 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
// 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
// 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', '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 = {}
// List of uib specific attributes that will be watched and processed dynamically
uibAttribs = ['uib-topic', 'data-uib-topic']
#uibAttrSel = `[${this.uibAttribs.join('], [')}]`
//#endregion
//#region public class vars
// TODO Move to proper getters
//#region ---- Externally read-only (via .get method) ---- //
// version - moved to _meta
/** 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
//#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 }
/** Function to set uibuilder properties to a new value - works on any property except _* or #*
* Also triggers any event listeners.
* Example: this.set('msg', {topic:'uibuilder', payload: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 for an old value
const oldVal = this[prop] ?? undefined
// We must add the var to the uibuilder object
this[prop] = val
// Keep track of all managed variables
this.#managedVars[prop] = prop
// If requested, save to store
if (store === true) this.setStore(prop, 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)
// this.emit(prop, val)
// trigger an event on the prop name, pass both the name and value to the event details
this._dispatchCustomEvent('uibuilder:propertyChanged', { 'prop': prop, 'value': val, 'oldValue': oldVal, 'store': store, 'autoload': autoload, })
this._dispatchCustomEvent(`uibuilder:propertyChanged:${prop}`, { 'prop': prop, 'value': val, 'oldValue': oldVal, 'store': store, 'autoload': autoload, })
return val
}
/** Function to get the value of a uibuilder property
* Example: uibuilder.get('msg')
* @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) {
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
if (this[prop] === undefined) {
log('warn', '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 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
*/
_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
* 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)
*
* @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)
return nextCbRef
}
cancelTopic(topic, cbRef) {
document.removeEventListener('uibuilder:stdMsgReceived', this.#msgRecvdByTopicCallbacks[topic][cbRef])
delete 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
*/
_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
*/
_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 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 (decimalPlaces) {
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
}
/** 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(/\?.*$/, '')
}
log() {
log(...arguments)()
}
/** 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
}
// ! 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.onChange('ping', function(data) {
* console.log('pinger', data)
* })
* @param {number} ms Repeat interval in ms
*/
setPing(ms = 0) {
const oReq = new XMLHttpRequest()
oReq.addEventListener('load', () => {
const headers = (oReq.getAllResponseHeaders()).split('\r\n')
const elapsedTime = Number(new Date()) - Number((oReq.responseURL.split('='))[1])
this.set('ping', {
success: !!((oReq.status === 201) || (oReq.status === 204)), // true if one of the listed codes else false
status: oReq.status,
headers: headers,
url: oReq.responseURL,
elapsedTime: elapsedTime,
})
})
if (this.#pingInterval) {
clearInterval(this.#pingInterval)
this.#pingInterval = undefined
}
oReq.open('GET', `${this.httpNodeRoot}/uibuilder/ping?t=${Number(new Date())}`)
oReq.send()
if (ms > 0) {
this.#pingInterval = setInterval(() => {
oReq.open('GET', `${this.httpNodeRoot}/uibuilder/ping?t=${Number(new Date())}`)
oReq.send()
}, ms)
}
} // ---- End of ping ---- //
/** Convert JSON to Syntax Highlighted HTML
* @param {object} json A JSON/JavaScript Object
* @returns {html} Object reformatted as highlighted HTML
*/
syntaxHighlight(json) {
return syntaxHighlight(json)
} // --- End of syntaxHighlight --- //
/** Returns true/false or a default value for truthy/falsy and other values
* @param {string|number|boolean|*} val The value to test
* @param {any} deflt Default value to use if the value is not truthy/falsy
* @returns {boolean|any} The truth! Or the default
*/
truthy(val, deflt) {
let ret
if (['on', 'On', 'ON', 'true', 'True', 'TRUE', '1', true, 1].includes(val)) ret = true
else if (['off', 'Off', 'OFF', 'false', 'False', 'FALSE', '0', false, 0].includes(val)) ret = false
else ret = deflt
return ret
}
/** Joins all arguments as a URL string
* see http://stackoverflow.com/a/28592528/3016654
* since v1.0.10, fixed potential double // issue
* arguments {string} URL fragments
* @returns {string} _
*/
urlJoin() {
const paths = Array.prototype.slice.call(arguments)
const url = '/' + paths.map(function (e) {
return e.replace(/^\/|\/$/g, '')
})
.filter(function (e) {
return e
})
.join('/')
return url.replace('//', '/')
} // ---- End of urlJoin ---- //
/** Turn on/off/toggle sending URL hash changes back to Node-RED
* @param {string|number|boolean|undefined} [toggle] Optional on/off/etc
* @returns {boolean} True if we will send a msg to Node-RED on a hash change
*/
watchUrlHash(toggle) {
this.#sendUrlHash = this.truthy(toggle, this.#sendUrlHash !== true)
return this.#sendUrlHash
}
/** DEPRECATED FOR NOW - wasn't working properly.
* Is the chosen CSS Selector currently visible to the user? NB: Only finds the FIRST element of the selection.
* Requires IntersectionObserver (available to all mainstream browsers from early 2019)
* Automatically sends a msg back to Node-RED.
* Requires the element to already exist.
* @returns {false} False if not visible
*/
elementIsVisible() {
const info = 'elementIsVisible has been temporarily DEPRECATED as it was not working correctly and a fix is complex'
log('error', 'uib:elementIsVisible', info)()
this.send({ payload: 'elementIsVisible has been temporarily DEPRECATED as it was not working correctly and a fix is complex', })
return false
} // --- End of elementIsVisible --- //
//#endregion -------- -------- -------- //
//#region ------- UI handlers --------- //
//#region -- Direct to _ui --
// ! NOTE: Direct assignments change the target `this` to here. Use with caution
// However, also note that the window/jsdom and the window.document
// references are now static in _ui so not impacted by this.
/** Simplistic jQuery-like document CSS query selector, returns an HTML Element
* NOTE that this fn returns the element itself. Use $$ to get the properties of 1 or more elements.
* If the selected element is a <template>, returns the first child element.
* type {HTMLElement}
* @param {string} cssSelector A CSS Selector that identifies the element to return
* @returns {HTMLElement|null} Selected HTML element or null
*/
$ = _ui.$
/** CSS query selector that returns ALL found selections. Matches the Chromium DevTools feature of the same name.
* NOTE that this fn returns an array showing the PROPERTIES of the elements whereas $ returns the element itself
* @param {string} cssSelector A CSS Selector that identifies the elements to return
* @returns {HTMLElement[]} Array of DOM elements/nodes. Array is empty if selector is not found.
*/
$$ = _ui.$$
/** Reference to the full ui library */
$ui = _ui
/** Add 1 or several class names to an element
* @param {string|string[]} classNames Single or array of classnames
* @param {HTMLElement} el HTML Element to add class(es) to
*/
addClass = _ui.addClass
/** Apply a source template tag to a target html element
* NOTES:
* - styles in ALL templates are accessible to all templates.
* - scripts in templates are run AT TIME OF APPLICATION (so may run multiple times).
* - scripts in templates are applied in order of application, so variables may not yet exist if defined in subsequent templates
* @param {HTMLElement} source The source element
* @param {HTMLElement} target The target element
* @param {boolean} onceOnly If true, the source will be adopted (the source is moved)
*/
applyTemplate = _ui.applyTemplate
/** Column metadata object definition
* @typedef columnDefinition
* @property {number} index The column index number
* @property {boolean} hasName Whether the column has a defined name or not
* @property {string} title The title of the column. Shown in the table header row
* @property {string=} name Optional. A defined column name that will be added as the `data-col-name` to all cells in the column if defined
* @property {string|number=} key Optional. A key value (currently unused)
* @property {"string"|"date"|"number"|"html"=} dataType FOR FUTURE USE. Optional. What type of data will this column contain?
* @property {boolean=} editable FOR FUTURE USE. Optional. Can cells in this column be edited?
*/
/** Builds an HTML table from an array (or object) of objects
* 1st row is used for columns.
* If an object of objects, inner keys are used to populate th/td `data-col-name` attribs.
* @param {Array<object>|object} data Input data array or object
* @param {object} opts Table options
* @param {Array<columnDefinition>=} opts.cols Column metadata. If not provided will be derived from 1st row of data
* @returns {HTMLTableElement|HTMLParagraphElement} Output HTML Element
*/
buildHtmlTable(data, opts={}) {
return _ui.buildHtmlTable(data, opts)
}
/** Directly add a table to a parent element.
* @param {Array<object>|Array<Array>|object} data Input data array or object. Object of objects gives named rows. Array of objects named cols. Array of arrays no naming.
* @param {object} [opts] Build options
* @param {Array<columnDefinition>=} opts.cols Column metadata. If not provided will be derived from 1st row of data
* @param {HTMLElement|string} opts.parent Default=body. The table will be added as a child instead of returned. May be an actual HTML element or a CSS Selector
* @param {boolean=} opts.allowHTML Optional, default=false. If true, allows HTML cell content, otherwise only allows text. Always sanitise HTML inputs
*/
createTable(data=[], opts={parent: 'body',}) {
_ui.createTable(data, opts)
}
/** Converts markdown text input to HTML if the Markdown-IT library is loaded
* Otherwise simply returns the text
* @param {string} mdText The input markdown string
* @returns {string} HTML (if Markdown-IT library loaded and parse successful) or original text
*/
convertMarkdown(mdText) {
return _ui.convertMarkdown(mdText)
}
/** ASYNC: Include HTML fragment, img, video, text, json, form data, pdf or anything else from an external file or API
* Wraps the included object in a div tag.
* PDF's, text or unknown MIME types are also wrapped in an iFrame.
* @param {string} url The URL of the source file to include
* @param {object} uiOptions Object containing properties recognised by the _uiReplace function. Must at least contain an id
* param {string} uiOptions.id The HTML ID given to the wrapping DIV tag
* param {string} uiOptions.parentSelector The CSS selector for a parent element to insert the new HTML under (defaults to 'body')
*/
async include(url, uiOptions) {
await _ui.include(url, uiOption