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,181 lines (1,032 loc) • 86 kB
JavaScript
// @ts-nocheck
/* Creates HTML UI's based on a standardised data input.
Works stand-alone, with uibuilder or with Node.js/jsdom.
See: https://totallyinformation.github.io/node-red-contrib-uibuilder/#/client-docs/config-driven-ui
Author: Julian Knight (Totally Information), March 2023
License: Apache 2.0
Copyright (c) 2022-2025 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.
*/
// Namespaces - See https://stackoverflow.com/a/52572048/1309986
// const NAMESPACES = {
// svg: 'http://www.w3.org/2000/svg',
// html: 'http://www.w3.org/1999/xhtml',
// xml: 'http://www.w3.org/XML/1998/namespace',
// xlink: 'http://www.w3.org/1999/xlink',
// xmlns: 'http://www.w3.org/2000/xmlns/' // sic for the final slash...
// }
const Ui = class Ui {
//#region --- Class variables ---
version = '7.2.0-src'
// List of tags and attributes not in sanitise defaults but allowed in uibuilder.
sanitiseExtraTags = ['uib-var']
sanitiseExtraAttribs = ['variable', 'report', 'undefined']
/** Reference to DOM window - must be passed in the constructor
* Allows for use of this library/class with `jsdom` in Node.JS as well as the browser.
* @type {Window}
*/
static win
/** Reference to the DOM top-level window.document for convenience - set in constructor @type {Document} */
static doc
/** Log function - passed in constructor or will be a dummy function
* @type {Function}
*/
static log
/** Options for Markdown-IT if available (set in constructor) */
static mdOpts
/** Reference to pre-loaded Markdown-IT library */
static md
/** Optional Markdown-IT Plugins */
ui_md_plugins
//#endregion --- class variables ---
/** Called when `new Ui(...)` is called
* @param {globalThis} win Either the browser global window or jsdom dom.window
* @param {Function} [extLog] A function that returns a function for logging
* @param {Function} [jsonHighlight] A function that returns a highlighted HTML of JSON input
*/
constructor(win, extLog, jsonHighlight) {
// window must be passed in as an arg to the constructor
// Should either be the global window for a browser or `dom.window` for jsdom in Node.js
// @ts-ignore
if (win) Ui.win = win
else {
// Ui.log(0, 'Ui:constructor', 'Current environment does not include `window`, UI functions cannot be used.')()
// return
throw new Error('Ui:constructor. Current environment does not include `window`, UI functions cannot be used.')
}
// For convenience
Ui.doc = Ui.win.document
// If a suitable function not passed in, create a dummy one
if (extLog) Ui.log = extLog
else Ui.log = function() { return function() {} }
// If a JSON HTML highlighting function passed then use it, else a dummy fn
if (jsonHighlight) this.syntaxHighlight = jsonHighlight
else this.syntaxHighlight = function() {}
// If Markdown-IT pre-loaded, then configure it now
if (Ui.win['markdownit']) {
Ui.mdOpts = {
html: true,
xhtmlOut: false,
linkify: true,
_highlight: true,
_strict: false,
_view: 'html',
langPrefix: 'language-',
// NB: the highlightjs (hljs) library must be loaded before markdown-it for this to work
highlight: function(str, lang) {
// https://highlightjs.org
if (lang && window['hljs'] && window['hljs'].getLanguage(lang)) {
try {
return `<pre class="">
<code class="hljs border">${window['hljs'].highlight(str, { language: lang, ignoreIllegals: true, }).value}</code></pre>`
} finally { } // eslint-disable-line no-empty
}
return `<pre class="hljs border"><code>${Ui.md.utils.escapeHtml(str).trim()}</code></pre>`
},
}
Ui.md = Ui.win['markdownit'](Ui.mdOpts)
}
}
//#region ---- Internal Methods ----
_markDownIt() {
// If Markdown-IT pre-loaded, then configure it now
if (!Ui.win['markdownit']) return
// If plugins not yet defined, check if uibuilder has set them
if (!this.ui_md_plugins && Ui.win['uibuilder'] && Ui.win['uibuilder'].ui_md_plugins) this.ui_md_plugins = Ui.win['uibuilder'].ui_md_plugins
Ui.mdOpts = {
html: true,
xhtmlOut: false,
linkify: true,
_highlight: true,
_strict: false,
_view: 'html',
langPrefix: 'language-',
// NB: the highlightjs (hljs) library must be loaded before markdown-it for this to work
highlight: function(str, lang) {
if (window['hljs']) {
if (lang && window['hljs'].getLanguage(lang)) {
try {
return `<pre><code class="hljs border language-${lang}" data-language="${lang}" title="Source language: '${lang}'">${window['hljs'].highlight(str, { language: lang, ignoreIllegals: true, }).value}</code></pre>`
} finally { } // eslint-disable-line no-empty
} else {
try {
const high = window['hljs'].highlightAuto(str)
return `<pre><code class="hljs border language-${high.language}" data-language="${high.language}" title="Source language estimated by HighlightJS: '${high.language}'">${high.value}</code></pre>`
} finally { } // eslint-disable-line no-empty
}
}
return `<pre><code class="border">${Ui.md.utils.escapeHtml(str).trim()}</code></pre>`
},
}
Ui.md = Ui.win['markdownit'](Ui.mdOpts)
// Ui.md.use(Ui.win.markdownitTaskLists, {enabled: true})
if (this.ui_md_plugins) {
if (!Array.isArray(this.ui_md_plugins)) {
Ui.log('error', 'Ui:_markDownIt:plugins', 'Could not load plugins, ui_md_plugins is not an array')()
return
}
this.ui_md_plugins.forEach( plugin => {
if (typeof plugin === 'string') {
Ui.md.use(Ui.win[plugin])
} else {
const name = Object.keys(plugin)[0]
Ui.md.use(Ui.win[name], plugin[name])
}
})
}
}
/** Show a browser notification if the browser and the user allows it
* @param {object} config Notification config data
* @returns {Promise} Resolves on close or click event, returns the event.
*/
_showNotification(config) {
if ( config.topic && !config.title ) config.title = config.topic
if ( !config.title ) config.title = 'uibuilder notification'
if ( config.payload && !config.body ) config.body = config.payload
if ( !config.body ) config.body = ' No message given.'
// Wrap in try/catch since Chrome Android may throw an error
try {
const notify = new Notification(config.title, config)
return new Promise( (resolve, reject) => {
// Doesn't ever seem to fire (at least in Chromium)
notify.addEventListener('close', ev => {
// @ts-ignore
ev.currentTarget.userAction = 'close'
resolve(ev)
})
notify.addEventListener('click', ev => {
// @ts-ignore
ev.currentTarget.userAction = 'click'
resolve(ev)
})
notify.addEventListener('error', ev => {
// @ts-ignore
ev.currentTarget.userAction = 'error'
reject(ev)
})
})
} catch (e) {
return Promise.reject(new Error('Browser refused to create a Notification'))
}
}
// Vue dynamic inserts Don't really work ...
// _uiAddVue(ui, isRecurse) {
// // must be Vue
// // must have only 1 root element
// const compToAdd = ui.components[0]
// const newEl = Ui.doc.createElement(compToAdd.type)
// if (!compToAdd.slot && ui.payload) compToAdd.slot = ui.payload
// this._uiComposeComponent(newEl, compToAdd)
// // If nested components, go again - but don't pass payload to sub-components
// if (compToAdd.components) {
// this._uiExtendEl(newEl, compToAdd.components)
// }
// console.log('MAGIC: ', this.magick, newEl, newEl.outerHTML)()
// this.set('magick', newEl.outerHTML)
// // if (compToAdd.id) newEl.setAttribute('ref', compToAdd.id)
// // if (elParent.id) newEl.setAttribute('data-parent', elParent.id)
// }
// TODO Add check if ID already exists
// TODO Allow single add without using components array
/** Handle incoming msg._ui add requests
* @param {*} ui Standardised msg._ui property object. Note that payload and topic are appended to this object
* @param {boolean} isRecurse Is this a recursive call?
*/
_uiAdd(ui, isRecurse) {
Ui.log('trace', 'Ui:_uiManager:add', 'Starting _uiAdd')()
// Vue dynamic inserts Don't really work ...
// if (this.#isVue && !isRecurse) {
// this._uiAddVue(ui, false)
// return
// }
ui.components.forEach((compToAdd, i) => {
Ui.log('trace', `Ui:_uiAdd:components-forEach:${i}`, 'Component to add: ', compToAdd)()
/** @type {*} Create the new component - some kind of HTML element */
let newEl
switch (compToAdd.type) {
// If trying to insert raw html, wrap in a div
case 'html': {
compToAdd.ns = 'html'
newEl = Ui.doc.createElement('div')
break
}
// If trying to insert raw svg, need to create in namespace
case 'svg': {
compToAdd.ns = 'svg'
newEl = Ui.doc.createElementNS('http://www.w3.org/2000/svg', 'svg')
break
}
default: {
compToAdd.ns = 'dom'
newEl = Ui.doc.createElement(compToAdd.type)
break
}
}
if (!compToAdd.slot && ui.payload) compToAdd.slot = ui.payload
// const parser = new DOMParser()
// const newDoc = parser.parseFromString(compToAdd.slot, 'text/html')
// console.log(compToAdd, newDoc.body)()
this._uiComposeComponent(newEl, compToAdd)
/** @type {HTMLElement} Where to add the new element? */
let elParent
if (compToAdd.parentEl) {
elParent = compToAdd.parentEl
} else if (ui.parentEl) {
elParent = ui.parentEl
} else if (compToAdd.parent) {
elParent = Ui.doc.querySelector(compToAdd.parent)
} else if (ui.parent) {
elParent = Ui.doc.querySelector(ui.parent)
}
if (!elParent) {
Ui.log('info', 'Ui:_uiAdd', 'No parent found, adding to body')()
elParent = Ui.doc.querySelector('body')
}
if (compToAdd.position && compToAdd.position === 'first') {
// Insert new el before the first child of the parent. Ref: https://developer.mozilla.org/en-US/docs/Web/API/Node/insertBefore#example_3
elParent.insertBefore(newEl, elParent.firstChild)
} else if (compToAdd.position && Number.isInteger(Number(compToAdd.position))) {
elParent.insertBefore(newEl, elParent.children[compToAdd.position])
} else {
// Append to the required parent
elParent.appendChild(newEl)
}
// If nested components, go again - but don't pass payload to sub-components
if (compToAdd.components) {
// this._uiAdd({
// method: ui.method,
// parentEl: newEl,
// components: compToAdd.components,
// }, true)
this._uiExtendEl(newEl, compToAdd.components, compToAdd.ns)
}
})
} // --- end of _uiAdd ---
/** Enhance an HTML element that is being composed with ui data
* such as ID, attribs, event handlers, custom props, etc.
* @param {*} el HTML Element to enhance
* @param {*} comp Individual uibuilder ui component spec
*/
_uiComposeComponent(el, comp) {
// Add attributes
if (comp.attributes) {
Object.keys(comp.attributes).forEach((attrib) => {
if (attrib === 'class' && Array.isArray(comp.attributes[attrib])) comp.attributes[attrib].join(' ')
Ui.log('trace', '_uiComposeComponent:attributes-forEach', `Attribute: '${attrib}', value: '${comp.attributes[attrib]}'`)()
// For values, set the actual value as well since the attrib only changes the DEFAULT value
if (attrib === 'value') el.value = comp.attributes[attrib]
if (attrib.startsWith('xlink:')) el.setAttributeNS('http://www.w3.org/1999/xlink', attrib, comp.attributes[attrib])
else el.setAttribute(attrib, comp.attributes[attrib])
})
}
// ID if set
if (comp.id) el.setAttribute('id', comp.id)
// If an SVG tag, ensure we have the appropriate namespaces added
if (comp.type === 'svg') {
el.setAttributeNS('http://www.w3.org/2000/xmlns/', 'xmlns', 'http://www.w3.org/2000/svg')
el.setAttributeNS('http://www.w3.org/2000/xmlns/', 'xmlns:xlink', 'http://www.w3.org/1999/xlink')
}
// Add event handlers
if (comp.events) {
Object.keys(comp.events).forEach((type) => {
// @ts-ignore I'm forever getting this wrong!
if (type.toLowerCase === 'onclick') type = 'click'
// Add the event listener
try {
el.addEventListener(type, (evt) => {
// Use new Function to ensure that esbuild works: https://esbuild.github.io/content-types/#direct-eval
(new Function('evt', `${comp.events[type]}(evt)`))(evt)
})
// newEl.setAttribute( 'onClick', `${comp.events[type]}()` )
} catch (err) {
Ui.log('error', 'Ui:_uiComposeComponent', `Add event '${type}' for element '${comp.type}': Cannot add event handler. ${err.message}`)()
}
})
}
// Add custom properties to the dataset
if (comp.properties) {
Object.keys(comp.properties).forEach((prop) => {
// TODO break a.b into sub properties
el[prop] = comp.properties[prop]
// Auto-dispatch events if changing value or changed since DOM does not do this automatically
if (['value', 'checked'].includes(prop)) {
el.dispatchEvent(new Event('input'))
el.dispatchEvent(new Event('change'))
}
})
}
//#region Add Slot content to innerHTML
if (comp.slot) {
this.replaceSlot(el, comp.slot)
}
//#endregion
// TODO Add multi-slot capability (default slot must always be processed first as innerHTML is replaced)
//#region Add Slot Markdown content to innerHTML IF marked library is available
if (comp.slotMarkdown) {
this.replaceSlotMarkdown(el, comp)
}
//#endregion
}
/** Extend an HTML Element with appended elements using ui components
* NOTE: This fn follows a strict hierarchy of added components.
* @param {HTMLElement} parentEl The parent HTML Element we want to append to
* @param {*} components The ui component(s) we want to add
* @param {string} [ns] Optional. The namespace to use.
*/
_uiExtendEl(parentEl, components, ns = '') {
components.forEach((compToAdd, i) => {
Ui.log('trace', `Ui:_uiExtendEl:components-forEach:${i}`, compToAdd)()
/** @type {HTMLElement} Create the new component */
let newEl
compToAdd.ns = ns
if (compToAdd.ns === 'html') {
newEl = parentEl
// newEl.outerHTML = compToAdd.slot
// parentEl.innerHTML = compToAdd.slot
this.replaceSlot(parentEl, compToAdd.slot)
} else if (compToAdd.ns === 'svg') {
newEl = Ui.doc.createElementNS('http://www.w3.org/2000/svg', compToAdd.type)
// Updates newEl
this._uiComposeComponent(newEl, compToAdd)
parentEl.appendChild(newEl)
} else {
newEl = Ui.doc.createElement(compToAdd.type === 'html' ? 'div' : compToAdd.type)
// Updates newEl
this._uiComposeComponent(newEl, compToAdd)
parentEl.appendChild(newEl)
}
// If nested components, go again - but don't pass payload to sub-components
if (compToAdd.components) {
this._uiExtendEl(newEl, compToAdd.components, compToAdd.ns)
}
})
}
// TODO Add more error handling and parameter validation
/** Handle incoming _ui load requests
* Can load JavaScript modules, JavaScript scripts and CSS.
* @param {*} ui Standardised msg._ui property object. Note that payload and topic are appended to this object
*/
_uiLoad(ui) {
// Self-loading ECMA Modules (e.g. web components)
if (ui.components) {
if (!Array.isArray(ui.components)) ui.components = [ui.components]
ui.components.forEach(async component => {
// NOTE: This happens asynchronously but we don't wait
import(component)
})
}
// Remote Scripts
if (ui.srcScripts) {
if (!Array.isArray(ui.srcScripts)) ui.srcScripts = [ui.srcScripts]
ui.srcScripts.forEach(script => {
this.loadScriptSrc(script)
})
}
// Scripts passed as text
if (ui.txtScripts) {
if (!Array.isArray(ui.txtScripts)) ui.txtScripts = [ui.txtScripts]
this.loadScriptTxt(ui.txtScripts.join('\n'))
}
// Remote Stylesheets
if (ui.srcStyles) {
if (!Array.isArray(ui.srcStyles)) ui.srcStyles = [ui.srcStyles]
ui.srcStyles.forEach(sheet => {
this.loadStyleSrc(sheet)
})
}
// Styles passed as text
if (ui.txtStyles) {
if (!Array.isArray(ui.txtStyles)) ui.txtStyles = [ui.txtStyles]
this.loadStyleTxt(ui.txtStyles.join('\n'))
}
} // --- end of _uiLoad ---
/** Handle incoming _ui messages and loaded UI JSON files
* Called from start()
* @param {*} msg Standardised msg object containing a _ui property object
*/
_uiManager(msg) {
if (!msg._ui) return
// Make sure that _ui is an array
if (!Array.isArray(msg._ui)) msg._ui = [msg._ui]
msg._ui.forEach((ui, i) => {
if (ui.mode && !ui.method) ui.method = ui.mode
if (!ui.method) {
Ui.log('error', 'Ui:_uiManager', `No method defined for msg._ui[${i}]. Ignoring. `, ui)()
return
}
ui.payload = msg.payload
ui.topic = msg.topic
switch (ui.method) {
case 'add': {
this._uiAdd(ui, false)
break
}
case 'remove': {
this._uiRemove(ui, false)
break
}
case 'removeAll': {
this._uiRemove(ui, true)
break
}
case 'replace': {
this._uiReplace(ui)
break
}
case 'update': {
this._uiUpdate(ui)
break
}
case 'load': {
this._uiLoad(ui)
break
}
case 'reload': {
this._uiReload()
break
}
case 'notify': {
this.showDialog('notify', ui, msg)
break
}
case 'alert': {
this.showDialog('alert', ui, msg)
break
}
default: {
Ui.log('error', 'Ui:_uiManager', `Invalid msg._ui[${i}].method (${ui.method}). Ignoring`)()
break
}
}
})
} // --- end of _uiManager ---
/** Handle a reload request */
_uiReload() {
Ui.log('trace', 'Ui:uiManager:reload', 'reloading')()
location.reload()
}
// TODO Add better tests for failures (see comments)
/** Handle incoming _ui remove requests
* @param {*} ui Standardised msg._ui property object. Note that payload and topic are appended to this object
* @param {boolean} all Optional, default=false. If true, will remove ALL found elements, otherwise only the 1st is removed
*/
_uiRemove(ui, all = false) {
ui.components.forEach((compToRemove) => {
let els
if (all !== true) els = [Ui.doc.querySelector(compToRemove)]
else els = Ui.doc.querySelectorAll(compToRemove)
els.forEach(el => {
try {
el.remove()
} catch (err) {
// Could not remove. Cannot read properties of null <= no need to report this one
// Could not remove. Failed to execute 'querySelector' on 'Ui.doc': '##testbutton1' is not a valid selector
Ui.log('trace', 'Ui:_uiRemove', `Could not remove. ${err.message}`)()
}
})
})
} // --- end of _uiRemove ---
/** Handle incoming _ui replace requests
* @param {*} ui Standardised msg._ui property object. Note that payload and topic are appended to this object
*/
_uiReplace(ui) {
Ui.log('trace', 'Ui:_uiReplace', 'Starting')()
ui.components.forEach((compToReplace, /** @type {number} */ i) => {
Ui.log('trace', `Ui:_uiReplace:components-forEach:${i}`, 'Component to replace: ', compToReplace)()
/** @type {HTMLElement} */
let elToReplace
// Either the id, CSS selector, name or type (element type) must be given in order to identify the element to change. FIRST element matching is updated.
if (compToReplace.id) {
elToReplace = Ui.doc.getElementById(compToReplace.id) // .querySelector(`#${compToReplace.id}`)
} else if (compToReplace.selector || compToReplace.select) {
elToReplace = Ui.doc.querySelector(compToReplace.selector)
} else if (compToReplace.name) {
elToReplace = Ui.doc.querySelector(`[name="${compToReplace.name}"]`)
} else if (compToReplace.type) {
elToReplace = Ui.doc.querySelector(compToReplace.type)
}
Ui.log('trace', `Ui:_uiReplace:components-forEach:${i}`, 'Element to replace: ', elToReplace)()
// Nothing was found so ADD the element instead
if (elToReplace === undefined || elToReplace === null) {
Ui.log('trace', `Ui:_uiReplace:components-forEach:${i}:noReplace`, 'Cannot find the DOM element. Adding instead.', compToReplace)()
this._uiAdd({ components: [compToReplace], }, false)
return
}
/** @type {*} Create the new component - some kind of HTML element */
let newEl
switch (compToReplace.type) {
// If trying to insert raw html, wrap in a div
case 'html': {
compToReplace.ns = 'html'
newEl = Ui.doc.createElement('div')
break
}
// If trying to insert raw svg, need to create in namespace
case 'svg': {
compToReplace.ns = 'svg'
newEl = Ui.doc.createElementNS('http://www.w3.org/2000/svg', 'svg')
break
}
default: {
compToReplace.ns = 'dom'
newEl = Ui.doc.createElement(compToReplace.type)
break
}
}
// Updates the newEl and maybe the ui
this._uiComposeComponent(newEl, compToReplace)
// Replace the current element
elToReplace.replaceWith(newEl)
// If nested components, go again - but don't pass payload to sub-components
if (compToReplace.components) {
this._uiExtendEl(newEl, compToReplace.components, compToReplace.ns)
}
})
} // --- end of _uiReplace ---
// TODO Allow single add without using components array
// TODO Allow sub-components
// TODO Add multi-slot capability
/** Handle incoming _ui update requests
* @param {*} ui Standardised msg._ui property object. Note that payload and topic are appended to this object
*/
_uiUpdate(ui) {
Ui.log('trace', 'UI:_uiUpdate:update', 'Starting _uiUpdate', ui)()
// We allow an update not to actually need to spec a component
if (!ui.components) ui.components = [Object.assign({}, ui)]
ui.components.forEach((compToUpd, i) => {
Ui.log('trace', '_uiUpdate:components-forEach', `Start loop #${i}`, compToUpd)()
/** @type {NodeListOf<Element>} */
let elToUpd
// If a parent element is passed, use that as the update target (only allowed internally)
// Otherwise either the id, CSS selector, name or type (element type) must be given in order to identify the element to change. ALL elements matching are updated.
if (compToUpd.parentEl) {
elToUpd = compToUpd.parentEl
} else if (compToUpd.id) {
// NB We don't use get by id because this way the code is simpler later on
elToUpd = Ui.doc.querySelectorAll(`#${compToUpd.id}`)
} else if (compToUpd.selector || compToUpd.select) {
elToUpd = Ui.doc.querySelectorAll(compToUpd.selector)
} else if (compToUpd.name) {
elToUpd = Ui.doc.querySelectorAll(`[name="${compToUpd.name}"]`)
} else if (compToUpd.type) {
elToUpd = Ui.doc.querySelectorAll(compToUpd.type)
}
// @ts-ignore Nothing was found so give up
if (elToUpd === undefined || elToUpd.length < 1) {
Ui.log('warn', 'Ui:_uiManager:update', 'Cannot find the DOM element. Ignoring.', compToUpd)()
return
}
Ui.log('trace', '_uiUpdate:components-forEach', `Element(s) to update. Count: ${elToUpd.length}`, elToUpd)()
// If slot not specified but payload is, use the payload in the slot
if (!compToUpd.slot && compToUpd.payload) compToUpd.slot = compToUpd.payload
// Might have >1 element to update - so update them all
elToUpd.forEach((el, j) => {
Ui.log('trace', '_uiUpdate:components-forEach', `Updating element #${j}`, el)()
this._uiComposeComponent(el, compToUpd)
// Try to go down another level of nesting if needed
// ! NOT CONVINCED THIS ACTUALLY WORKS !
if (compToUpd.components) {
Ui.log('trace', '_uiUpdate:nested-component', `Element #${j} - nested-component`, compToUpd, el)()
const nc = { _ui: [], }
compToUpd.components.forEach((nestedComp, k) => {
const method = nestedComp.method || compToUpd.method || ui.method
if (nestedComp.method) delete nestedComp.method
if (!Array.isArray(nestedComp)) nestedComp = [nestedComp]
// nestedComp.parentEl = el
// nestedComp.components = [nestedComp]
Ui.log('trace', '_uiUpdate:nested-component', `Element #${j} - nested-component #${k}`, nestedComp)()
nc._ui.push( {
method: method,
parentEl: el,
components: nestedComp,
})
})
Ui.log('trace', '_uiUpdate:nested-component', `Element #${j} - nested-component new manager`, nc)()
this._uiManager(nc)
}
})
// If nested components, apply to every found element - but don't pass payload to sub-components
// if (compToUpd.components) {
// compToUpd.components.forEach((el, k) => {
// Ui.log('trace', '_uiUpdate:nested-component', `Updating nested-component #${k}`, el)()
// this._uiUpdate({
// method: el.method || ui.method,
// parentEl: el,
// components: el.components,
// })
// })
// }
})
} // --- end of _uiUpdate ---
//#endregion ---- -------- ----
//#region ---- External Methods ----
/** 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
* @param {"el"|"text"|"html"|"attributes"|"attr"} [output] Optional. What type of output to return. Defaults to "el", the DOM element reference
* @param {HTMLElement} [context] Optional. The context to search within. Defaults to the document. Must be a DOM element.
* @returns {HTMLElement|string|Array|null} Selected HTML DOM element, innerText, innerHTML, attribute list or null
*/
$(cssSelector, output, context) {
if (!context) context = Ui.doc
if (!output) output = 'el'
// if context is not a valid htmlelement, return null
if (!context || !context.nodeType) {
Ui.log(1, 'Uib:$', `Invalid context element. Must be a valid HTML element.`, context)()
return null
}
/** @type {HTMLElement} Some kind of HTML element */
let el = (context).querySelector(cssSelector)
// if no element found or is not a valid htmlelement, return null
if (!el || !el.nodeType) {
Ui.log(1, 'Uib:$', `No element found or element is not an HTML element for CSS selector ${cssSelector}`)()
return null
}
if ( el.nodeName === 'TEMPLATE' ) {
el = el.content.firstElementChild
if (!el) {
Ui.log(0, 'Uib:$', `Template selected for CSS selector ${cssSelector} but it is empty`)()
return null
}
}
let out
try {
switch (output.toLowerCase()) {
case 'text': {
out = el.innerText
break
}
case 'html': {
out = el.innerHTML
break
}
case 'attr':
case 'attributes': {
out = {}
for (const attr of el.attributes) {
out[attr.name] = attr.value
}
break
}
default: {
out = el
break
}
}
} catch (e) {
out = el
Ui.log(1, 'Uib:$', `Could not process output type "${output}" for CSS selector ${cssSelector}, returned the DOM element. ${e.message}`, e)()
}
return out
}
/** 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
* @param {HTMLElement} [context] Optional. The context to search within. Defaults to the document. Must be a DOM element.
* @returns {HTMLElement[]} Array of DOM elements/nodes. Array is empty if selector is not found.
*/
$$(cssSelector, context) {
if (!context) context = Ui.doc
// if context is not a valid htmlelement, return null
if (!context || !context.nodeType) {
Ui.log(1, 'Uib:$$', `Invalid context element. Must be a valid HTML element.`, context)()
return null
}
return Array.from((context).querySelectorAll(cssSelector))
}
/** 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(classNames, el) {
if (!Array.isArray(classNames)) classNames = [classNames]
if (el) el.classList.add(...classNames)
}
/** Apply a source template tag to a target html element
* NOTES:
* - Any attributes are only applied to the 1ST ELEMENT of the template content. Use a wrapper div if you need to apply to multiple elements.
* - When using 'wrap' mode, the target content is placed into the template's 1ST <slot> only (if present).
* - styles in ALL templates are accessible to all templates & impact the whole page.
* - 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 {string} sourceId The HTML ID of the source element
* @param {string} targetId The HTML ID of the target element
* @param {object} config Configuration options
* @param {boolean=} config.onceOnly If true, the source will be adopted (the source is moved)
* @param {object=} config.attributes A set of key:value pairs that will be applied as attributes to the 1ST ELEMENT ONLY of the target
* @param {'insert'|'replace'|'wrap'} config.mode How to apply the template. Default is 'insert'. 'replace' will replace the targets innerHTML. 'wrap' is like 'replace' but will put any target content into the template's 1ST <slot> (if present).
*/
applyTemplate(sourceId, targetId, config) {
if (!config) config = {}
if (!config.onlyOnce) config.onlyOnce = false
if (!config.mode) config.mode = 'insert'
const template = Ui.doc.getElementById(sourceId)
if (!template || template.tagName !== 'TEMPLATE') {
Ui.log('error', 'Ui:applyTemplate', `Source must be a <template>. id='${sourceId}'`)()
return
}
const target = Ui.doc.getElementById(targetId)
if (!target) {
Ui.log('error', 'Ui:applyTemplate', `Target not found: id='${targetId}'`)()
return
}
const targetContent = target.innerHTML ?? ''
if (targetContent && config.mode === 'replace') {
Ui.log('warn', 'Ui:applyTemplate', `Target element is not empty, content is replaced. id='${targetId}'`)()
}
let templateContent
if (config.onceOnly === true) templateContent = Ui.doc.adoptNode(template.content) // NB content.childElementCount = 0 after adoption
else templateContent = Ui.doc.importNode(template.content, true)
if (templateContent) {
// Apply config.attributes to the 1ST ELEMENT ONLY of the template content
if (config.attributes) {
const el = templateContent.firstElementChild
Object.keys(config.attributes).forEach( attrib => {
// Apply each attribute and value
el.setAttribute(attrib, config.attributes[attrib])
})
}
if (config.mode === 'insert') {
target.appendChild(templateContent)
} else if (config.mode === 'replace') {
target.innerHTML = ''
target.appendChild(templateContent)
} else if (config.mode === 'wrap') {
target.innerHTML = ''
target.appendChild(templateContent)
if (targetContent) {
const slot = target.getElementsByTagName('slot')
if (slot.length > 0) {
slot[0].innerHTML = targetContent
}
}
}
} else {
Ui.log('warn', 'Ui:applyTemplate', `No valid content found in template`)()
}
}
/** 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) {
if (!mdText) return ''
if (!Ui.win['markdownit']) return mdText
if (!Ui.md) this._markDownIt() // To handle case where the library is late loaded
// Convert from markdown to HTML
try {
return Ui.md.render(mdText.trim())
} catch (e) {
Ui.log(0, 'uibuilder:convertMarkdown', `Could not render Markdown. ${e.message}`, e)()
return '<p class="border error">Could not render Markdown<p>'
}
}
/** 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')
* @returns {Promise<any>} Status
*/
async include(url, uiOptions) {
// TODO: src, id, parent must all be a strings
if (!fetch) {
Ui.log(0, 'Ui:include', 'Current environment does not include `fetch`, skipping.')()
return 'Current environment does not include `fetch`, skipping.'
}
if (!url) {
Ui.log(0, 'Ui:include', 'url parameter must be provided, skipping.')()
return 'url parameter must be provided, skipping.'
}
if (!uiOptions || !uiOptions.id) {
Ui.log(0, 'Ui:include', 'uiOptions parameter MUST be provided and must contain at least an `id` property, skipping.')()
return 'uiOptions parameter MUST be provided and must contain at least an `id` property, skipping.'
}
// Try to get the content via the URL
let response
try {
response = await fetch(url)
} catch (error) {
Ui.log(0, 'Ui:include', `Fetch of file '${url}' failed. `, error.message)()
return error.message
}
if (!response.ok) {
Ui.log(0, 'Ui:include', `Fetch of file '${url}' failed. Status='${response.statusText}'`)()
return response.statusText
}
// Work out what type of data we got
const contentType = await response.headers.get('content-type')
let type = null
if (contentType) {
if (contentType.includes('text/html')) {
type = 'html'
} else if (contentType.includes('application/json')) {
type = 'json'
} else if (contentType.includes('multipart/form-data')) {
type = 'form'
} else if (contentType.includes('image/')) {
type = 'image'
} else if (contentType.includes('video/')) {
type = 'video'
} else if (contentType.includes('application/pdf')) {
type = 'pdf'
} else if (contentType.includes('text/plain')) {
type = 'text'
} // else type = null
}
// Create the HTML to include on the page based on type
let slot = ''
let txtReturn = 'Include successful'
let data
switch (type) {
case 'html': {
data = await response.text()
slot = data
break
}
case 'json': {
data = await response.json()
slot = '<pre class="syntax-highlight">'
slot += this.syntaxHighlight(data)
slot += '</pre>'
break
}
case 'form': {
data = await response.formData()
slot = '<pre class="syntax-highlight">'
slot += this.syntaxHighlight(data)
slot += '</pre>'
break
}
case 'image': {
data = await response.blob()
slot = `<img src="${URL.createObjectURL(data)}">`
if (Ui.win['DOMPurify']) {
txtReturn = 'Include successful. BUT DOMPurify loaded which may block its use.'
Ui.log('warn', 'Ui:include:image', txtReturn)()
}
break
}
case 'video': {
data = await response.blob()
slot = `<video controls autoplay><source src="${URL.createObjectURL(data)}"></video>`
if (Ui.win['DOMPurify']) {
txtReturn = 'Include successful. BUT DOMPurify loaded which may block its use.'
Ui.log('warn', 'Ui:include:video', txtReturn)()
}
break
}
case 'pdf':
case 'text':
default: {
data = await response.blob()
slot = `<iframe style="resize:both;width:inherit;height:inherit;" src="${URL.createObjectURL(data)}">`
if (Ui.win['DOMPurify']) {
txtReturn = 'Include successful. BUT DOMPurify loaded which may block its use.'
Ui.log('warn', `Ui:include:${type}`, txtReturn)()
}
break
}
}
// Wrap it all in a <div id="..." class="included">
uiOptions.type = 'div'
uiOptions.slot = slot
if (!uiOptions.parent) uiOptions.parent = 'body'
if (!uiOptions.attributes) uiOptions.attributes = { class: 'included', }
// Use uibuilder's standard ui processing to turn the instructions into HTML
this._uiReplace({
components: [
uiOptions
],
})
Ui.log('trace', `Ui:include:${type}`, txtReturn)()
return txtReturn
} // ---- End of include() ---- //
/** Attach a new remote script to the end of HEAD synchronously
* NOTE: It takes too long for most scripts to finish loading
* so this is pretty useless to work with the dynamic UI features directly.
* @param {string} url The url to be used in the script src attribute
*/
loadScriptSrc(url) {
const newScript = Ui.doc.createElement('script')
newScript.src = url
newScript.async = false
Ui.doc.head.appendChild(newScript)
}
/** Attach a new text script to the end of HEAD synchronously
* NOTE: It takes too long for most scripts to finish loading
* so this is pretty useless to work with the dynamic UI features directly.
* @param {string} textFn The text to be loaded as a script
*/
loadScriptTxt(textFn) {
const newScript = Ui.doc.createElement('script')
newScript.async = false
newScript.textContent = textFn
Ui.doc.head.appendChild(newScript)
}
/** Attach a new remote stylesheet link to the end of HEAD synchronously
* NOTE: It takes too long for most scripts to finish loading
* so this is pretty useless to work with the dynamic UI features directly.
* @param {string} url The url to be used in the style link href attribute
*/
loadStyleSrc(url) {
const newStyle = Ui.doc.createElement('link')
newStyle.href = url
newStyle.rel = 'stylesheet'
newStyle.type = 'text/css'
Ui.doc.head.appendChild(newStyle)
}
/** Attach a new text stylesheet to the end of HEAD synchronously
* NOTE: It takes too long for most scripts to finish loading
* so this is pretty useless to work with the dynamic UI features directly.
* @param {string} textFn The text to be loaded as a stylesheet
*/
loadStyleTxt(textFn) {
const newStyle = Ui.doc.createElement('style')
newStyle.textContent = textFn
Ui.doc.head.appendChild(newStyle)
}
/** Load a dynamic UI from a JSON web reponse
* @param {string} url URL that will return the ui JSON
*/
loadui(url) {
if (!fetch) {
Ui.log(0, 'Ui:loadui', 'Current environment does not include `fetch`, skipping.')()
return
}
if (!url) {
Ui.log(0, 'Ui:loadui', 'url parameter must be provided, skipping.')()
return
}
fetch(url)
.then(response => {
if (response.ok === false) {
// Ui.log('warn', 'Ui:loadui:then1', `Could not load '${url}'. Status ${response.status}, Error: ${response.statusText}`)()
throw new Error(`Could not load '${url}'. Status ${response.status}, Error: ${response.statusText}`)
}
Ui.log('trace', 'Ui:loadui:then1', `Loaded '${url}'. Status ${response.status}, ${response.statusText}`)()
// Did we get json?
const contentType = response.headers.get('content-type')
if (!contentType || !contentType.includes('application/json')) {
throw new TypeError(`Fetch '${url}' did not return JSON, ignoring`)
}
// Returns parsed json to next .then
return response.json()
})
.then(data => {
if (data !== undefined) {
Ui.log('trace', 'Ui:loadui:then2', 'Parsed JSON successfully obtained')()
// Call the _uiManager
this._uiManager({ _ui: data, })
return true
}
return false
})
.catch(err => {
Ui.log('warn', 'Ui:loadui:catch', 'Error. ', err)()
})
} // --- end of loadui
/** ! NOT COMPLETE Move an element from one position to another
* @param {object} opts Options
* @param {string} opts.sourceSelector Required, CSS Selector that identifies the element to be moved
* @param {string} opts.targetSelector Required, CSS Selector that identifies the element to be moved
*/
moveElement(opts) {
const { sourceSelector, targetSelector, moveType, position, } = opts
const sourceEl = document.querySelector(sourceSelector)
if (!sourceEl) {
Ui.log(0, 'Ui:moveElement', 'Source element not found')()
return
}
const targetEl = document.querySelector(targetSelector)
if (!targetEl) {
Ui.log(0, 'Ui:moveElement', 'Target element not found')()
return
}
}
/** Get standard data from a DOM node.
* @param {*} node DOM node to examine
* @param {string} cssSelector Identify the DOM element to get data from
* @returns {object} Standardised data object
*/
nodeGet(node, cssSelector) {
const thisOut = {
id: node.id === '' ? undefined : node.id,
name: node.name,
children: node.childNodes.length,
type: node.nodeName,
attributes: undefined,
isUserInput: node.validity ? true : false,