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,193 lines (1,192 loc) 65.2 kB
var __defProp = Object.defineProperty; var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value; var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value); // src/front-end-module/ui.js var _a; var Ui = (_a = class { //#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) { //#region --- Class variables --- __publicField(this, "version", "7.2.0-src"); // List of tags and attributes not in sanitise defaults but allowed in uibuilder. __publicField(this, "sanitiseExtraTags", ["uib-var"]); __publicField(this, "sanitiseExtraAttribs", ["variable", "report", "undefined"]); /** Optional Markdown-IT Plugins */ __publicField(this, "ui_md_plugins"); if (win) _a.win = win; else { throw new Error("Ui:constructor. Current environment does not include `window`, UI functions cannot be used."); } _a.doc = _a.win.document; if (extLog) _a.log = extLog; else _a.log = function() { return function() { }; }; if (jsonHighlight) this.syntaxHighlight = jsonHighlight; else this.syntaxHighlight = function() { }; if (_a.win["markdownit"]) { _a.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 (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 { } } return `<pre class="hljs border"><code>${_a.md.utils.escapeHtml(str).trim()}</code></pre>`; } }; _a.md = _a.win["markdownit"](_a.mdOpts); } } //#region ---- Internal Methods ---- _markDownIt() { if (!_a.win["markdownit"]) return; if (!this.ui_md_plugins && _a.win["uibuilder"] && _a.win["uibuilder"].ui_md_plugins) this.ui_md_plugins = _a.win["uibuilder"].ui_md_plugins; _a.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 { } } 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 { } } } return `<pre><code class="border">${_a.md.utils.escapeHtml(str).trim()}</code></pre>`; } }; _a.md = _a.win["markdownit"](_a.mdOpts); if (this.ui_md_plugins) { if (!Array.isArray(this.ui_md_plugins)) { _a.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") { _a.md.use(_a.win[plugin]); } else { const name2 = Object.keys(plugin)[0]; _a.md.use(_a.win[name2], plugin[name2]); } }); } } /** 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."; try { const notify = new Notification(config.title, config); return new Promise((resolve, reject) => { notify.addEventListener("close", (ev) => { ev.currentTarget.userAction = "close"; resolve(ev); }); notify.addEventListener("click", (ev) => { ev.currentTarget.userAction = "click"; resolve(ev); }); notify.addEventListener("error", (ev) => { 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) { _a.log("trace", "Ui:_uiManager:add", "Starting _uiAdd")(); ui.components.forEach((compToAdd, i) => { _a.log("trace", `Ui:_uiAdd:components-forEach:${i}`, "Component to add: ", compToAdd)(); let newEl; switch (compToAdd.type) { // If trying to insert raw html, wrap in a div case "html": { compToAdd.ns = "html"; newEl = _a.doc.createElement("div"); break; } // If trying to insert raw svg, need to create in namespace case "svg": { compToAdd.ns = "svg"; newEl = _a.doc.createElementNS("http://www.w3.org/2000/svg", "svg"); break; } default: { compToAdd.ns = "dom"; newEl = _a.doc.createElement(compToAdd.type); break; } } if (!compToAdd.slot && ui.payload) compToAdd.slot = ui.payload; this._uiComposeComponent(newEl, compToAdd); let elParent; if (compToAdd.parentEl) { elParent = compToAdd.parentEl; } else if (ui.parentEl) { elParent = ui.parentEl; } else if (compToAdd.parent) { elParent = _a.doc.querySelector(compToAdd.parent); } else if (ui.parent) { elParent = _a.doc.querySelector(ui.parent); } if (!elParent) { _a.log("info", "Ui:_uiAdd", "No parent found, adding to body")(); elParent = _a.doc.querySelector("body"); } if (compToAdd.position && compToAdd.position === "first") { elParent.insertBefore(newEl, elParent.firstChild); } else if (compToAdd.position && Number.isInteger(Number(compToAdd.position))) { elParent.insertBefore(newEl, elParent.children[compToAdd.position]); } else { elParent.appendChild(newEl); } if (compToAdd.components) { 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) { if (comp.attributes) { Object.keys(comp.attributes).forEach((attrib) => { if (attrib === "class" && Array.isArray(comp.attributes[attrib])) comp.attributes[attrib].join(" "); _a.log("trace", "_uiComposeComponent:attributes-forEach", `Attribute: '${attrib}', value: '${comp.attributes[attrib]}'`)(); 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]); }); } if (comp.id) el.setAttribute("id", comp.id); 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"); } if (comp.events) { Object.keys(comp.events).forEach((type) => { if (type.toLowerCase === "onclick") type = "click"; try { el.addEventListener(type, (evt) => { new Function("evt", `${comp.events[type]}(evt)`)(evt); }); } catch (err) { _a.log("error", "Ui:_uiComposeComponent", `Add event '${type}' for element '${comp.type}': Cannot add event handler. ${err.message}`)(); } }); } if (comp.properties) { Object.keys(comp.properties).forEach((prop) => { el[prop] = comp.properties[prop]; if (["value", "checked"].includes(prop)) { el.dispatchEvent(new Event("input")); el.dispatchEvent(new Event("change")); } }); } if (comp.slot) { this.replaceSlot(el, comp.slot); } if (comp.slotMarkdown) { this.replaceSlotMarkdown(el, comp); } } /** 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) => { _a.log("trace", `Ui:_uiExtendEl:components-forEach:${i}`, compToAdd)(); let newEl; compToAdd.ns = ns; if (compToAdd.ns === "html") { newEl = parentEl; this.replaceSlot(parentEl, compToAdd.slot); } else if (compToAdd.ns === "svg") { newEl = _a.doc.createElementNS("http://www.w3.org/2000/svg", compToAdd.type); this._uiComposeComponent(newEl, compToAdd); parentEl.appendChild(newEl); } else { newEl = _a.doc.createElement(compToAdd.type === "html" ? "div" : compToAdd.type); this._uiComposeComponent(newEl, compToAdd); parentEl.appendChild(newEl); } 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) { if (ui.components) { if (!Array.isArray(ui.components)) ui.components = [ui.components]; ui.components.forEach(async (component) => { import(component); }); } if (ui.srcScripts) { if (!Array.isArray(ui.srcScripts)) ui.srcScripts = [ui.srcScripts]; ui.srcScripts.forEach((script) => { this.loadScriptSrc(script); }); } if (ui.txtScripts) { if (!Array.isArray(ui.txtScripts)) ui.txtScripts = [ui.txtScripts]; this.loadScriptTxt(ui.txtScripts.join("\n")); } if (ui.srcStyles) { if (!Array.isArray(ui.srcStyles)) ui.srcStyles = [ui.srcStyles]; ui.srcStyles.forEach((sheet) => { this.loadStyleSrc(sheet); }); } 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; 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) { _a.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: { _a.log("error", "Ui:_uiManager", `Invalid msg._ui[${i}].method (${ui.method}). Ignoring`)(); break; } } }); } // --- end of _uiManager --- /** Handle a reload request */ _uiReload() { _a.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 = [_a.doc.querySelector(compToRemove)]; else els = _a.doc.querySelectorAll(compToRemove); els.forEach((el) => { try { el.remove(); } catch (err) { _a.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) { _a.log("trace", "Ui:_uiReplace", "Starting")(); ui.components.forEach((compToReplace, i) => { _a.log("trace", `Ui:_uiReplace:components-forEach:${i}`, "Component to replace: ", compToReplace)(); let elToReplace; if (compToReplace.id) { elToReplace = _a.doc.getElementById(compToReplace.id); } else if (compToReplace.selector || compToReplace.select) { elToReplace = _a.doc.querySelector(compToReplace.selector); } else if (compToReplace.name) { elToReplace = _a.doc.querySelector(`[name="${compToReplace.name}"]`); } else if (compToReplace.type) { elToReplace = _a.doc.querySelector(compToReplace.type); } _a.log("trace", `Ui:_uiReplace:components-forEach:${i}`, "Element to replace: ", elToReplace)(); if (elToReplace === void 0 || elToReplace === null) { _a.log("trace", `Ui:_uiReplace:components-forEach:${i}:noReplace`, "Cannot find the DOM element. Adding instead.", compToReplace)(); this._uiAdd({ components: [compToReplace] }, false); return; } let newEl; switch (compToReplace.type) { // If trying to insert raw html, wrap in a div case "html": { compToReplace.ns = "html"; newEl = _a.doc.createElement("div"); break; } // If trying to insert raw svg, need to create in namespace case "svg": { compToReplace.ns = "svg"; newEl = _a.doc.createElementNS("http://www.w3.org/2000/svg", "svg"); break; } default: { compToReplace.ns = "dom"; newEl = _a.doc.createElement(compToReplace.type); break; } } this._uiComposeComponent(newEl, compToReplace); elToReplace.replaceWith(newEl); 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) { _a.log("trace", "UI:_uiUpdate:update", "Starting _uiUpdate", ui)(); if (!ui.components) ui.components = [Object.assign({}, ui)]; ui.components.forEach((compToUpd, i) => { _a.log("trace", "_uiUpdate:components-forEach", `Start loop #${i}`, compToUpd)(); let elToUpd; if (compToUpd.parentEl) { elToUpd = compToUpd.parentEl; } else if (compToUpd.id) { elToUpd = _a.doc.querySelectorAll(`#${compToUpd.id}`); } else if (compToUpd.selector || compToUpd.select) { elToUpd = _a.doc.querySelectorAll(compToUpd.selector); } else if (compToUpd.name) { elToUpd = _a.doc.querySelectorAll(`[name="${compToUpd.name}"]`); } else if (compToUpd.type) { elToUpd = _a.doc.querySelectorAll(compToUpd.type); } if (elToUpd === void 0 || elToUpd.length < 1) { _a.log("warn", "Ui:_uiManager:update", "Cannot find the DOM element. Ignoring.", compToUpd)(); return; } _a.log("trace", "_uiUpdate:components-forEach", `Element(s) to update. Count: ${elToUpd.length}`, elToUpd)(); if (!compToUpd.slot && compToUpd.payload) compToUpd.slot = compToUpd.payload; elToUpd.forEach((el, j) => { _a.log("trace", "_uiUpdate:components-forEach", `Updating element #${j}`, el)(); this._uiComposeComponent(el, compToUpd); if (compToUpd.components) { _a.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]; _a.log("trace", "_uiUpdate:nested-component", `Element #${j} - nested-component #${k}`, nestedComp)(); nc._ui.push({ method, parentEl: el, components: nestedComp }); }); _a.log("trace", "_uiUpdate:nested-component", `Element #${j} - nested-component new manager`, nc)(); this._uiManager(nc); } }); }); } // --- 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 = _a.doc; if (!output) output = "el"; if (!context || !context.nodeType) { _a.log(1, "Uib:$", `Invalid context element. Must be a valid HTML element.`, context)(); return null; } let el = context.querySelector(cssSelector); if (!el || !el.nodeType) { _a.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) { _a.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; _a.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 = _a.doc; if (!context || !context.nodeType) { _a.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 = _a.doc.getElementById(sourceId); if (!template || template.tagName !== "TEMPLATE") { _a.log("error", "Ui:applyTemplate", `Source must be a <template>. id='${sourceId}'`)(); return; } const target = _a.doc.getElementById(targetId); if (!target) { _a.log("error", "Ui:applyTemplate", `Target not found: id='${targetId}'`)(); return; } const targetContent = target.innerHTML ?? ""; if (targetContent && config.mode === "replace") { _a.log("warn", "Ui:applyTemplate", `Target element is not empty, content is replaced. id='${targetId}'`)(); } let templateContent; if (config.onceOnly === true) templateContent = _a.doc.adoptNode(template.content); else templateContent = _a.doc.importNode(template.content, true); if (templateContent) { if (config.attributes) { const el = templateContent.firstElementChild; Object.keys(config.attributes).forEach((attrib) => { 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 { _a.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 (!_a.win["markdownit"]) return mdText; if (!_a.md) this._markDownIt(); try { return _a.md.render(mdText.trim()); } catch (e) { _a.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) { if (!fetch) { _a.log(0, "Ui:include", "Current environment does not include `fetch`, skipping.")(); return "Current environment does not include `fetch`, skipping."; } if (!url) { _a.log(0, "Ui:include", "url parameter must be provided, skipping.")(); return "url parameter must be provided, skipping."; } if (!uiOptions || !uiOptions.id) { _a.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."; } let response; try { response = await fetch(url); } catch (error) { _a.log(0, "Ui:include", `Fetch of file '${url}' failed. `, error.message)(); return error.message; } if (!response.ok) { _a.log(0, "Ui:include", `Fetch of file '${url}' failed. Status='${response.statusText}'`)(); return response.statusText; } 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"; } } 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 (_a.win["DOMPurify"]) { txtReturn = "Include successful. BUT DOMPurify loaded which may block its use."; _a.log("warn", "Ui:include:image", txtReturn)(); } break; } case "video": { data = await response.blob(); slot = `<video controls autoplay><source src="${URL.createObjectURL(data)}"></video>`; if (_a.win["DOMPurify"]) { txtReturn = "Include successful. BUT DOMPurify loaded which may block its use."; _a.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 (_a.win["DOMPurify"]) { txtReturn = "Include successful. BUT DOMPurify loaded which may block its use."; _a.log("warn", `Ui:include:${type}`, txtReturn)(); } break; } } uiOptions.type = "div"; uiOptions.slot = slot; if (!uiOptions.parent) uiOptions.parent = "body"; if (!uiOptions.attributes) uiOptions.attributes = { class: "included" }; this._uiReplace({ components: [ uiOptions ] }); _a.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 = _a.doc.createElement("script"); newScript.src = url; newScript.async = false; _a.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 = _a.doc.createElement("script"); newScript.async = false; newScript.textContent = textFn; _a.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 = _a.doc.createElement("link"); newStyle.href = url; newStyle.rel = "stylesheet"; newStyle.type = "text/css"; _a.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 = _a.doc.createElement("style"); newStyle.textContent = textFn; _a.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) { _a.log(0, "Ui:loadui", "Current environment does not include `fetch`, skipping.")(); return; } if (!url) { _a.log(0, "Ui:loadui", "url parameter must be provided, skipping.")(); return; } fetch(url).then((response) => { if (response.ok === false) { throw new Error(`Could not load '${url}'. Status ${response.status}, Error: ${response.statusText}`); } _a.log("trace", "Ui:loadui:then1", `Loaded '${url}'. Status ${response.status}, ${response.statusText}`)(); const contentType = response.headers.get("content-type"); if (!contentType || !contentType.includes("application/json")) { throw new TypeError(`Fetch '${url}' did not return JSON, ignoring`); } return response.json(); }).then((data) => { if (data !== void 0) { _a.log("trace", "Ui:loadui:then2", "Parsed JSON successfully obtained")(); this._uiManager({ _ui: data }); return true; } return false; }).catch((err) => { _a.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) { _a.log(0, "Ui:moveElement", "Source element not found")(); return; } const targetEl = document.querySelector(targetSelector); if (!targetEl) { _a.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 === "" ? void 0 : node.id, name: node.name, children: node.childNodes.length, type: node.nodeName, attributes: void 0, isUserInput: node.validity ? true : false, userInput: !node.validity ? void 0 : { value: node.value, validity: void 0, willValidate: node.willValidate, valueAsDate: node.valueAsDate, valueAsNumber: node.valueAsNumber, type: node.type } }; if (["UL", "OL"].includes(node.nodeName)) { const listEntries = _a.doc.querySelectorAll(`${cssSelector} li`); if (listEntries) { thisOut.list = { "entries": listEntries.length }; } } if (node.nodeName === "DL") { const listEntries = _a.doc.querySelectorAll(`${cssSelector} dt`); if (listEntries) { thisOut.list = { "entries": listEntries.length }; } } if (node.nodeName === "TABLE") { const bodyEntries = _a.doc.querySelectorAll(`${cssSelector} > tbody > tr`); const headEntries = _a.doc.querySelectorAll(`${cssSelector} > thead > tr`); const cols = _a.doc.querySelectorAll(`${cssSelector} > tbody > tr:last-child > *`); if (bodyEntries || headEntries || cols) { thisOut.table = { "headRows": headEntries ? headEntries.length : 0, "bodyRows": bodyEntries ? bodyEntries.length : 0, "columns": cols ? cols.length : 0 }; } } if (node.nodeName !== "#text" && node.attributes && node.attributes.length > 0) { thisOut.attributes = {}; for (const attrib of node.attributes) { if (attrib.name !== "id") { thisOut.attributes[attrib.name] = node.attributes[attrib.name].value; } if (attrib.name === "class") thisOut.classes = Array.from(node.classList); } } if (node.nodeName === "#text") { thisOut.text = node.textContent; } if (node.validity) thisOut.userInput.validity = {}; for (const v in node.validity) { thisOut.userInput.validity[v] = node.validity[v]; } return thisOut; } // --- end of nodeGet --- // /** Show a browser notification if possible. Returns a promise * Config can be a simple string, a Node-RED msg (topic as title, payload as body) * or a Notifications API options object + config.title string. * Config ref: https://developer.mozilla.org/en-US/docs/Web/API/Notification/Notification * @param {object|string} config Notification config object or simple message string * @returns {Promise} Resolves on close or click event, returns the event. */ async notification(config) { if (typeof config === "string") { config = { body: config }; } if (typeof Notification === "undefined") return Promise.reject(new Error("Notifications not available in this browser")); let permit = Notification.permission; if (permit === "denied") { return Promise.reject(new Error("Notifications not permitted by user")); } else if (permit === "granted") { return this._showNotification(config); } permit = await Notification.requestPermission(); if (permit === "granted") { return this._showNotification(config); } return Promise.reject(new Error("Notifications not permitted by user")); } /** Remove All, 1 or more class names from an element * @param {undefined|null|""|string|string[]} classNames Single or array of classnames. If undefined, "" or null, remove all classes * @param {HTMLElement} el HTML Element to add class(es) to */ removeClass(classNames, el) { if (!classNames) { el.removeAttribute("class"); return; } if (!Array.isArray(classNames)) classNames = [classNames]; if (el) el.classList.remove(...classNames); } /** Replace or add an HTML element's slot from text or an HTML string * WARNING: Executes <script> tags! And will process <style> tags. * Will use DOMPurify if that library has been loaded to window. * param {*} ui Single entry from the msg._ui property * @param {Element} el Reference to the element that we want to update * @param {*} slot The slot content we are trying to add/replace (defaults to empty string) */ replaceSlot(el, slot) { if (!el) return; if (!slot) slot = ""; slot = this.sanitiseHTML(slot); const tempFrag = _a.doc.createRange().createContextualFragment(slot); const elRange = _a.doc.createRange(); elRange.selectNodeContents(el); elRange.deleteContents(); el.append(tempFrag); } /** Replace or add an HTML element's slot from a Markdown string * Only does something if the markdownit library has been loaded to window. * Will use DOMPurify if that library has been loaded to window. * @param {Element} el Reference to the element that we want to update * @param {*} component The component we are trying to add/replace */ replaceSlotMarkdown(el, component) { if (!el) return; if (!component.slotMarkdown) return; component.slotMarkdown = this.convertMarkdown(component.slotMarkdown); component.slotMarkdown = this.sanitiseHTML(component.slotMarkdown); el.innerHTML = component.slotMarkdown; } /** Sanitise HTML to make it safe - if the DOMPurify library is loaded * Otherwise just returns that HTML as-is. * @param {string} html The input HTML string * @returns {string} The sanitised HTML or the original if DOMPurify not loaded */ sanitiseHTML(html) { if (!_a.win["DOMPurify"]) return html; return _a.win["DOMPurify"].sanitize(html, { ADD_TAGS: this.sanitiseExtraTags, ADD_ATTR: this.sanitiseExtraAttribs }); } /** Show a pop-over "toast" dialog or a modal alert // TODO - Allow notify to sit in corners rather than take over the screen * Refs: https://www.w3.org/WAI/ARIA/apg/example-index/dialog-modal/alertdialog.html, * https://www.w3.org/WAI/ARIA/apg/example-index/dialog-modal/dialog.html, * https://www.w3.org/WAI/ARIA/apg/patterns/dialogmodal/ * @param {"notify"|"alert"} type Dialog type * @param {object} ui standardised ui data * @param {object} [msg] msg.payload/msg.topic - only used if a string. Optional. * @returns {void} */ showDialog(type, ui, msg) { let content = ""; if (msg.payload && typeof msg.payload === "string") content += `<div>${msg.payload}</div>`; if (ui.content) content += `<div>${ui.content}</div>`; if (content === "") { _a.log(1, "Ui:showDialog", "Toast content is blank. Not shown.")(); return; } if (!ui.title && msg.topic) ui.title = msg.topic; if (ui.title) content = `<p class="toast-head">${ui.title}</p><p>${content}</p>`; if (ui.noAutohide) ui.noAutoHide = ui.noAutohide; if (ui.noAutoHide) ui.autohide = !ui.noAutoHide; if (ui.autoHideDelay) { if (!ui.autohide) ui.autohide = true; ui.delay = ui.autoHideDelay; } else ui.autoHideDelay = 1e4; if (!Object.prototype.hasOwnProperty.call(ui, "autohide")) ui.autohide = true; if (type === "alert") { ui.modal = true; ui.autohide = false; content = `<svg viewBox="0 0 192.146 192.146" style="width:30;background-color:transparent;"><path d="M108.186 144.372c0 7.054-4.729 12.32-12.037 12.32h-.254c-7.054 0-11.92-5.266-11.92-12.32 0-7.298 5.012-12.31 12.174-12.31s11.91 4.992 12.037 12.31zM88.44 125.301h15.447l2.951-61.298H85.46l2.98 61.298zm101.932 51.733c-2.237 3.664-6.214 5.921-10.493 5.921H12.282c-4.426 0-8.51-2.384-10.698-6.233a12.34 12.34 0 0 1 .147-12.349l84.111-149.22c2.208-3.722 6.204-5.96 10.522-5.96h.332c4.445.107 8.441 2.618 10.513 6.546l83.515 149.229c1.993 3.8 1.905 8.363-.352 12.066zm-10.493-6.4L96.354 21.454l-84.062 149.18h167.587z" /></svg> ${content}`; } let toaster = _a.doc.getElementById("toaster"); if (toaster === null) { toaster = _a.doc.createElement("div"); toaster.id = "toaster"; toaster.title = "Click to clear all notifcations"; toaster.setAttribute("class", "toaster"); toaster.setAttribute("role", "dialog"); toaster.setAttribute("arial-label", "Toast message"); toaster.onclick = function() { toaster.remove(); }; _a.doc.body.insertAdjacentElement("afterbegin", toaster); } const toast = _a.doc.createElement("div"); toast.title = "Click to clear this notifcation"; toast.setAttribute("class", `toast ${ui.variant ? ui.variant : ""} ${type}`); toast.innerHTML = content; toast.setAttribute("role", "alertdialog"); if (ui.modal) toast.setAttribute("aria-modal", ui.modal); toast.onclick = function(evt) { evt.stopPropagation(); toast.remove(); if (toaster.childElementCount < 1) toaster.remove(); }; if (type === "alert") { } toaster.insertAdjacentElement(ui.appendToast === true ? "beforeend" : "afterbegin", toast); if (ui.autohide === true) { setInterval(() => { toast.remove(); if (toaster.childElementCount < 1) toaster.remove(); }, ui.autoHideDelay); } } // --- End of showDialog --- /** Directly manage UI via JSON * @param {object} json Either an object containing {_ui: {}} or simply simple {} containing ui instructions */ ui(json) { let msg = {}; if (json._ui) msg = json; else msg._ui = json; this._uiManager(msg); } /** Get data from the DOM. Returns selection of useful props unless a specific prop requested. * @param {string} cssSelector Identify the DOM element to get data from * @param {string} [propName] Optional. Specific name of property to get from the element * @returns {Array<*>} Array of objects containing either specific requested property or a selection of useful properties */ uiGet(cssSelector, propName = null) { const selection = ( /** @type {NodeListOf<HTMLInputElement>} */ _a.doc.querySelectorAll(cssSelector) ); const out = []; selection.forEach((node) => { if (propName) { if (propName === "classes") propName = "class"; let prop = node.getAttribute(propName); if (prop === void 0 || prop === null) { try { prop = node[propName]; } catch (error) { } } if (prop === void 0 || prop === null) { if (propName.toLowerCase() === "value") out.push(node.innerText); else out.push(`Property '${propName}' not found`); } else { const p = {}; const cType = prop.constructor.name.toLowerCase(); if (cType === "namednodemap") { for (const key of prop) { p[key.name] = prop[key.name].value; } } else if (!cType.includes("map")) { p[propName] = prop; } else { const p2 = {}; for (const key in prop) { p2[key] = prop[key]; } } if (p.class) p.classes = Array.from(node.classList); out.push(p); } } else { out.push(this.nodeGet(node, cssSelector)); } }); return out; } // --- end of uiGet --- // /** External alias for _uiComposeComponent * @param {*} el HTML Element to enhance * @param {*} comp Individual uibuilder ui component spec */ uiEnhanceElement(el, comp) { this._uiComposeComponent(el, comp); } //#region --- table handling --- /** 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? */ /** 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. 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" }) { if (!opts.parent) throw new Error("[ui.js:createTable] opts.parent must be provided"); this.buildHtmlTable(data, opts); } // TODO ... /** Builds & returns an HTML table element from an array (or object) of objects * 1st row is used for columns unless you pass opts.cols to describe them. * If an object of objects, inner keys are used to populate th/td `data-col-name` attribs. Outer keys applied as row ID's. * * TODO * - Allow optional caption, heading, footers, optional collapse * - Multiple headings, footers * - colspans, rowspans * - multiple tbody * * @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 Table options * @param {Array<columnDefinition>=} opts.cols Column metadata. If not pr