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,191 loc) • 76.1 kB
JavaScript
var __create = Object.create;
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __getProtoOf = Object.getPrototypeOf;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
}) : x)(function(x) {
if (typeof require !== "undefined") return require.apply(this, arguments);
throw Error('Dynamic require of "' + x + '" is not supported');
});
var __copyProps = (to, from, except, desc) => {
if (from && typeof from === "object" || typeof from === "function") {
for (let key of __getOwnPropNames(from))
if (!__hasOwnProp.call(to, key) && key !== except)
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
}
return to;
};
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
// If the importer is in node compatibility mode or this is not an ESM
// file that has been converted to a CommonJS file using a Babel-
// compatible transform (i.e. "__esModule" has not been set), then set
// "default" to the CommonJS "module.exports" for node compatibility.
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
mod
));
var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value);
// src/front-end-module/libs/show-overlay.mjs
function showOverlay(options = {}) {
const {
content = "",
title = "",
icon = "",
type = "info",
showDismiss,
autoClose = 5,
time = true
} = options;
const overlayContainerId = "uib-info-overlay";
let overlayContainer = document.getElementById(overlayContainerId);
if (!overlayContainer) {
overlayContainer = document.createElement("div");
overlayContainer.id = overlayContainerId;
document.body.appendChild(overlayContainer);
console.log(">> SHOW OVERLAY >>", options, document.getElementById(overlayContainerId));
}
const entryId = "overlay-entry-".concat(Date.now(), "-").concat(Math.random().toString(36).substr(2, 9));
const overlayEntry = document.createElement("div");
overlayEntry.id = entryId;
overlayEntry.style.marginBottom = "0.5rem";
const typeStyles = {
info: {
iconDefault: "\u2139\uFE0F",
titleDefault: "Information",
color: "hsl(188.2deg 77.78% 40.59%)"
},
success: {
iconDefault: "\u2705",
titleDefault: "Success",
color: "hsl(133.7deg 61.35% 40.59%)"
},
warning: {
iconDefault: "\u26A0\uFE0F",
titleDefault: "Warning",
color: "hsl(35.19deg 84.38% 62.35%)"
},
error: {
iconDefault: "\u274C",
titleDefault: "Error",
color: "hsl(2.74deg 92.59% 62.94%)"
}
};
const currentTypeStyle = typeStyles[type] || typeStyles.info;
const shouldShowDismiss = showDismiss !== void 0 ? showDismiss : autoClose === null;
const iconHtml = icon || currentTypeStyle.iconDefault;
const titleText = title || currentTypeStyle.titleDefault;
let timeHtml = "";
if (time) {
const now = /* @__PURE__ */ new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, "0");
const day = String(now.getDate()).padStart(2, "0");
const hours = String(now.getHours()).padStart(2, "0");
const minutes = String(now.getMinutes()).padStart(2, "0");
const seconds = String(now.getSeconds()).padStart(2, "0");
const timestamp = "".concat(year, "-").concat(month, "-").concat(day, " ").concat(hours, ":").concat(minutes, ":").concat(seconds);
timeHtml = '<div class="uib-overlay-time" style="font-size: 0.8em; color: var(--text3, #999); margin-left: auto; margin-right: '.concat(shouldShowDismiss ? "0.5rem" : "0", ';">').concat(timestamp, "</div>");
}
overlayEntry.innerHTML = /* html */
'\n <div class="uib-overlay-entry" style="--callout-color:'.concat(currentTypeStyle.color, ';">\n <div class="uib-overlay-header">\n <div class="uib-overlay-icon">').concat(iconHtml, '</div>\n <div class="uib-overlay-title">').concat(titleText, "</div>\n ").concat(timeHtml, "\n ").concat(shouldShowDismiss ? '<button class="uib-overlay-dismiss" data-entry-id="'.concat(entryId, '" title="Close">\xD7</button>') : "", '\n </div>\n <div class="uib-overlay-content">\n ').concat(content, "\n </div>\n </div>\n ");
if (overlayContainer.children.length > 0) {
overlayContainer.insertBefore(overlayEntry, overlayContainer.firstChild);
} else {
overlayContainer.appendChild(overlayEntry);
}
const closeOverlayEntry = () => {
const entry = document.getElementById(entryId);
if (!entry) return;
entry.style.animation = "slideOut 0.3s ease-in";
setTimeout(() => {
if (entry.parentNode) {
entry.remove();
}
}, 300);
};
const dismissBtn = overlayEntry.querySelector(".uib-overlay-dismiss");
if (dismissBtn) {
dismissBtn.addEventListener("click", closeOverlayEntry);
}
let autoCloseTimer = null;
if (autoClose !== null && autoClose > 0) {
autoCloseTimer = setTimeout(closeOverlayEntry, autoClose * 1e3);
}
return {
close: () => {
if (autoCloseTimer) {
clearTimeout(autoCloseTimer);
}
closeOverlayEntry();
},
id: entryId
};
}
// src/front-end-module/ui.mjs
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.5.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="">\n <code class="hljs border">'.concat(window["hljs"].highlight(str, { language: lang, ignoreIllegals: true }).value, "</code></pre>");
} finally {
}
}
return '<pre class="hljs border"><code>'.concat(_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-'.concat(lang, '" data-language="').concat(lang, '" title="Source language: \'').concat(lang, "'\">").concat(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-'.concat(high.language, '" data-language="').concat(high.language, '" title="Source language estimated by HighlightJS: \'').concat(high.language, "'\">").concat(high.value, "</code></pre>");
} finally {
}
}
}
return '<pre><code class="border">'.concat(_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:".concat(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: '".concat(attrib, "', value: '").concat(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", "".concat(comp.events[type], "(evt)"))(evt);
});
} catch (err) {
_a.log("error", "Ui:_uiComposeComponent", "Add event '".concat(type, "' for element '").concat(comp.type, "': Cannot add event handler. ").concat(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:".concat(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) => {
Promise.resolve().then(() => __toESM(__require(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[".concat(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[".concat(i, "].method (").concat(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. ".concat(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:".concat(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="'.concat(compToReplace.name, '"]'));
} else if (compToReplace.type) {
elToReplace = _a.doc.querySelector(compToReplace.type);
}
_a.log("trace", "Ui:_uiReplace:components-forEach:".concat(i), "Element to replace: ", elToReplace)();
if (elToReplace === void 0 || elToReplace === null) {
_a.log("trace", "Ui:_uiReplace:components-forEach:".concat(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 #".concat(i), compToUpd)();
let elToUpd;
if (compToUpd.parentEl) {
elToUpd = compToUpd.parentEl;
} else if (compToUpd.id) {
elToUpd = _a.doc.querySelectorAll("#".concat(compToUpd.id));
} else if (compToUpd.selector || compToUpd.select) {
elToUpd = _a.doc.querySelectorAll(compToUpd.selector);
} else if (compToUpd.name) {
elToUpd = _a.doc.querySelectorAll('[name="'.concat(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: ".concat(elToUpd.length), elToUpd)();
if (!compToUpd.slot && compToUpd.payload) compToUpd.slot = compToUpd.payload;
elToUpd.forEach((el, j) => {
_a.log("trace", "_uiUpdate:components-forEach", "Updating element #".concat(j), el)();
this._uiComposeComponent(el, compToUpd);
if (compToUpd.components) {
_a.log("trace", "_uiUpdate:nested-component", "Element #".concat(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 #".concat(j, " - nested-component #").concat(k), nestedComp)();
nc._ui.push({
method,
parentEl: el,
components: nestedComp
});
});
_a.log("trace", "_uiUpdate:nested-component", "Element #".concat(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 ".concat(cssSelector))();
return null;
}
if (el.nodeName === "TEMPLATE") {
el = el.content.firstElementChild;
if (!el) {
_a.log(0, "Uib:$", "Template selected for CSS selector ".concat(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 "'.concat(output, '" for CSS selector ').concat(cssSelector, ", returned the DOM element. ").concat(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) {
var _a2;
if (!config) config = {};
if (!config.onceOnly) config.onceOnly = 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='".concat(sourceId, "'"))();
return;
}
const target = _a.doc.getElementById(targetId);
if (!target) {
_a.log("error", "Ui:applyTemplate", "Target not found: id='".concat(targetId, "'"))();
return;
}
const targetContent = (_a2 = target.innerHTML) != null ? _a2 : "";
if (targetContent && config.mode === "replace") {
_a.log("warn", "Ui:applyTemplate", "Target element is not empty, content is replaced. id='".concat(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. ".concat(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 '".concat(url, "' failed. "), error.message)();
return error.message;
}
if (!response.ok) {
_a.log(0, "Ui:include", "Fetch of file '".concat(url, "' failed. Status='").concat(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="'.concat(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="'.concat(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="'.concat(URL.createObjectURL(data), '">');
if (_a.win["DOMPurify"]) {
txtReturn = "Include successful. BUT DOMPurify loaded which may block its use.";
_a.log("warn", "Ui:include:".concat(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:".concat(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 '".concat(url, "'. Status ").concat(response.status, ", Error: ").concat(response.statusText));
}
_a.log("trace", "Ui:loadui:then1", "Loaded '".concat(url, "'. Status ").concat(response.status, ", ").concat(response.statusText))();
const contentType = response.headers.get("content-type");
if (!contentType || !contentType.includes("application/json")) {
throw new TypeError("Fetch '".concat(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("".concat(cssSelector, " li"));
if (listEntries) {
thisOut.list = {
entries: listEntries.length
};
}
}
if (node.nodeName === "DL") {
const listEntries = _a.doc.querySelectorAll("".concat(cssSelector, " dt"));
if (listEntries) {
thisOut.list = {
entries: listEntries.length
};
}
}
if (node.nodeName === "TABLE") {
const bodyEntries = _a.doc.querySelectorAll("".concat(cssSelector, " > tbody > tr"));
const headEntries = _a.doc.querySelectorAll("".concat(cssSelector, " > thead > tr"));
const cols = _a.doc.querySelectorAll("".concat(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);
if (el.nodeName === "TEMPLATE") {
el.innerHTML = slot;
return;
}
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 });
}
// TODO - Allow notify to sit in corners rather than take over the screen
/** Show a pop-over "toast" dialog or a modal alert
* 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"|null} type Dialog type. If null, invalid or not provided, defaults to "notify".
* @param {object|null} ui Standardised ui data. If not provided, defaults to {noAutohide:true,modal:true,appendToast:false}
* @param {object} [msg] msg.payload/msg.topic - only used if payload is a string. Optional.
* @returns {HTMLDivElement|null} The toast element (which may disappear after a timeout) or null if no content
* @example
* Ui.showDialog('notify', { title: 'Hello', content: 'This is a notification', noAutohide: true, appendToast: true })
* @example
* Ui.showDialog('alert', null, msg)
*/
showDialog(type, ui, msg) {
if (!type || !["notify", "alert"].includes(type)) {
type = "notify";
}
if (!ui) {
ui = {
noAutohide: true,
modal: true,
appendToast: false
};
}
let body = "";
if (msg.payload && typeof msg.payload === "string") body += "<div>".concat(msg.payload, "</div>");
if (ui.content) body += "<div>".concat(ui.content, "</div>");
if (body === "") {
_a.log(1, "Ui:showDialog", "Toast content is blank. Not shown.")();
return null;
}
let title = "";
if (ui.title) title = ui.title;
else if (msg.topic) title = msg.topic;
if (ui.noAutohide) ui.noAutoHide = ui.noAutohide;
if (ui.noAutoHide) ui.autohide = !ui.noAutoHide;
if (ui.autoHideDelay) {
if (!ui.autohide) ui.autohide = true;