Something went wrong on our end
Select Git revision
updater.mjs
-
Volker Schukai authored
git s
Volker Schukai authoredgit s
updater.mjs 27.25 KiB
/**
* Copyright schukai GmbH and contributors 2023. All Rights Reserved.
* Node module: @schukai/monster
* This file is licensed under the AGPLv3 License.
* License text available at https://www.gnu.org/licenses/agpl-3.0.en.html
*/
import {internalSymbol} from "../constants.mjs";
import {diff} from "../data/diff.mjs";
import {Pathfinder} from "../data/pathfinder.mjs";
import {Pipe} from "../data/pipe.mjs";
import {
ATTRIBUTE_ERRORMESSAGE,
ATTRIBUTE_UPDATER_ATTRIBUTES,
ATTRIBUTE_UPDATER_BIND,
ATTRIBUTE_UPDATER_INSERT,
ATTRIBUTE_UPDATER_INSERT_REFERENCE,
ATTRIBUTE_UPDATER_REMOVE,
ATTRIBUTE_UPDATER_REPLACE,
ATTRIBUTE_UPDATER_SELECT_THIS,
ATTRIBUTE_UPDATER_INSERT_TEMPLATE_ID
} from "../dom/constants.mjs";
import {Base} from "../types/base.mjs";
import {isArray, isInstance, isIterable} from "../types/is.mjs";
import {Observer} from "../types/observer.mjs";
import {ProxyObserver} from "../types/proxyobserver.mjs";
import {validateArray, validateInstance} from "../types/validate.mjs";
import {clone} from "../util/clone.mjs";
import {trimSpaces} from "../util/trimspaces.mjs";
import {addToObjectLink} from "./attributes.mjs";
import {findTargetElementFromEvent} from "./events.mjs";
import {findDocumentTemplate} from "./template.mjs";
export {Updater, addObjectWithUpdaterToElement};
/**
* The updater class connects an object with the dom. In this way, structures and contents in the DOM can be programmatically adapted via attributes.
*
* For example, to include a string from an object, the attribute `data-monster-replace` can be used.
* a further explanation can be found under {@tutorial dom-based-templating-implementation}.
*
* Changes to attributes are made only when the direct values are changed. If you want to assign changes to other values
* as well, you have to insert the attribute `data-monster-select-this`. This should be done with care, as it can reduce performance.
*
* @externalExample ../../example/dom/updater.mjs
* @license AGPLv3
* @since 1.8.0
* @copyright schukai GmbH
* @memberOf Monster.DOM
* @throws {Error} the value is not iterable
* @throws {Error} pipes are not allowed when cloning a node.
* @throws {Error} no template was found with the specified key.
* @throws {Error} the maximum depth for the recursion is reached.
* @throws {TypeError} value is not a object
* @throws {TypeError} value is not an instance of HTMLElement
* @summary The updater class connects an object with the dom
*/
class Updater extends Base {
/**
* @since 1.8.0
* @param {HTMLElement} element
* @param {object|ProxyObserver|undefined} subject
* @throws {TypeError} value is not a object
* @throws {TypeError} value is not an instance of HTMLElement
* @see {@link Monster.DOM.findDocumentTemplate}
*/
constructor(element, subject) {
super();
/**
* @type {HTMLElement}
*/
if (subject === undefined) subject = {};
if (!isInstance(subject, ProxyObserver)) {
subject = new ProxyObserver(subject);
}
this[internalSymbol] = {
element: validateInstance(element, HTMLElement),
last: {},
callbacks: new Map(),
eventTypes: ["keyup", "click", "change", "drop", "touchend", "input"],
subject: subject,
};
this[internalSymbol].callbacks.set("checkstate", getCheckStateCallback.call(this));
this[internalSymbol].subject.attachObserver(
new Observer(() => {
const s = this[internalSymbol].subject.getRealSubject();
const diffResult = diff(this[internalSymbol].last, s);
this[internalSymbol].last = clone(s);
for (const [, change] of Object.entries(diffResult)) {
removeElement.call(this, change);
insertElement.call(this, change);
updateContent.call(this, change);
updateAttributes.call(this, change);
}
}),
);
}
/**
* Defaults: 'keyup', 'click', 'change', 'drop', 'touchend'
*
* @see {@link https://developer.mozilla.org/de/docs/Web/Events}
* @since 1.9.0
* @param {Array} types
* @return {Updater}
*/
setEventTypes(types) {
this[internalSymbol].eventTypes = validateArray(types);
return this;
}
/**
* With this method, the eventlisteners are hooked in and the magic begins.
*
* ```
* updater.run().then(() => {
* updater.enableEventProcessing();
* });
* ```
*
* @since 1.9.0
* @return {Updater}
* @throws {Error} the bind argument must start as a value with a path
*/
enableEventProcessing() {
this.disableEventProcessing();
for (const type of this[internalSymbol].eventTypes) {
// @see https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener
this[internalSymbol].element.addEventListener(type, getControlEventHandler.call(this), {
capture: true,
passive: true,
});
}
return this;
}
/**
* This method turns off the magic or who loves it more profane it removes the eventListener.
*
* @since 1.9.0
* @return {Updater}
*/
disableEventProcessing() {
for (const type of this[internalSymbol].eventTypes) {
this[internalSymbol].element.removeEventListener(type, getControlEventHandler.call(this));
}
return this;
}
/**
* The run method must be called for the update to start working.
* The method ensures that changes are detected.
*
* ```
* updater.run().then(() => {
* updater.enableEventProcessing();
* });
* ```
*
* @summary Let the magic begin
* @return {Promise}
*/
run() {
// the key __init__has no further meaning and is only
// used to create the diff for empty objects.
this[internalSymbol].last = {__init__: true};
return this[internalSymbol].subject.notifyObservers();
}
/**
* Gets the values of bound elements and changes them in subject
*
* @since 1.27.0
* @return {Monster.DOM.Updater}
*/
retrieve() {
retrieveFromBindings.call(this);
return this;
}
/**
* If you have passed a ProxyObserver in the constructor, you will get the object that the ProxyObserver manages here.
* However, if you passed a simple object, here you will get a proxy for that object.
*
* For changes the ProxyObserver must be used.
*
* @since 1.8.0
* @return {Proxy}
*/
getSubject() {
return this[internalSymbol].subject.getSubject();
}
/**
* This method can be used to register commands that can be called via call: instruction.
* This can be used to provide a pipe with its own functionality.
*
* @param {string} name
* @param {function} callback
* @returns {Transformer}
* @throws {TypeError} value is not a string
* @throws {TypeError} value is not a function
*/
setCallback(name, callback) {
this[internalSymbol].callbacks.set(name, callback);
return this;
}
}
/**
* @private
* @license AGPLv3
* @since 1.9.0
* @return {function
* @this Updater
*/
function getCheckStateCallback() {
const self = this;
return function (current) {
// this is a reference to the current object (therefore no array function here)
if (this instanceof HTMLInputElement) {
if (["radio", "checkbox"].indexOf(this.type) !== -1) {
return `${this.value}` === `${current}` ? "true" : undefined;
}
} else if (this instanceof HTMLOptionElement) {
if (isArray(current) && current.indexOf(this.value) !== -1) {
return "true";
}
return undefined;
}
};
}
/**
* @private
*/
const symbol = Symbol("@schukai/monster/updater@@EventHandler");
/**
* @private
* @return {function}
* @this Updater
* @throws {Error} the bind argument must start as a value with a path
*/
function getControlEventHandler() {
const self = this;
if (self[symbol]) {
return self[symbol];
}
/**
* @throws {Error} the bind argument must start as a value with a path.
* @throws {Error} unsupported object
* @param {Event} event
*/
self[symbol] = (event) => {
const element = findTargetElementFromEvent(event, ATTRIBUTE_UPDATER_BIND);
if (element === undefined) {
return;
}
retrieveAndSetValue.call(self, element);
};
return self[symbol];
}
/**
* @throws {Error} the bind argument must start as a value with a path
* @param {HTMLElement} element
* @return void
* @memberOf Monster.DOM
* @private
*/
function retrieveAndSetValue(element) {
const self = this;
const pathfinder = new Pathfinder(self[internalSymbol].subject.getSubject());
let path = element.getAttribute(ATTRIBUTE_UPDATER_BIND);
if (path.indexOf("path:") !== 0) {
throw new Error("the bind argument must start as a value with a path");
}
path = path.substring(5);
let value;
if (element instanceof HTMLInputElement) {
switch (element.type) {
case "checkbox":
value = element.checked ? element.value : undefined;
break;
default:
value = element.value;
break;
}
} else if (element instanceof HTMLTextAreaElement) {
value = element.value;
} else if (element instanceof HTMLSelectElement) {
switch (element.type) {
case "select-one":
value = element.value;
break;
case "select-multiple":
value = element.value;
let options = element?.selectedOptions;
if (options === undefined) options = element.querySelectorAll(":scope option:checked");
value = Array.from(options).map(({value}) => value);
break;
}
// values from customelements
} else if (
(element?.constructor?.prototype &&
!!Object.getOwnPropertyDescriptor(element.constructor.prototype, "value")?.["get"]) ||
element.hasOwnProperty("value")
) {
value = element?.["value"];
} else {
throw new Error("unsupported object");
}
const copy = clone(self[internalSymbol].subject.getRealSubject());
const pf = new Pathfinder(copy);
pf.setVia(path, value);
const diffResult = diff(copy, self[internalSymbol].subject.getRealSubject());
if (diffResult.length > 0) {
pathfinder.setVia(path, value);
}
}
/**
* @license AGPLv3
* @since 1.27.0
* @return void
* @private
*/
function retrieveFromBindings() {
const self = this;
if (self[internalSymbol].element.matches(`[${ATTRIBUTE_UPDATER_BIND}]`)) {
retrieveAndSetValue.call(self, self[internalSymbol].element);
}
for (const [, element] of self[internalSymbol].element.querySelectorAll(`[${ATTRIBUTE_UPDATER_BIND}]`).entries()) {
retrieveAndSetValue.call(self, element);
}
}
/**
* @private
* @license AGPLv3
* @since 1.8.0
* @param {object} change
* @return {void}
*/
function removeElement(change) {
const self = this;
for (const [, element] of self[internalSymbol].element
.querySelectorAll(`:scope [${ATTRIBUTE_UPDATER_REMOVE}]`)
.entries()) {
element.parentNode.removeChild(element);
}
}
/**
* @private
* @license AGPLv3
* @since 1.8.0
* @param {object} change
* @return {void}
* @throws {Error} the value is not iterable
* @throws {Error} pipes are not allowed when cloning a node.
* @throws {Error} no template was found with the specified key.
* @throws {Error} the maximum depth for the recursion is reached.
* @this Updater
*/
function insertElement(change) {
const self = this;
const subject = self[internalSymbol].subject.getRealSubject();
let mem = new WeakSet();
let wd = 0;
const container = self[internalSymbol].element;
while (true) {
let found = false;
wd++;
let p = clone(change?.["path"]);
if (!isArray(p)) return self;
while (p.length > 0) {
const current = p.join(".");
let iterator = new Set();
const query = `[${ATTRIBUTE_UPDATER_INSERT}*="path:${current}"]`;
const e = container.querySelectorAll(query);
if (e.length > 0) {
iterator = new Set([...e]);
}
if (container.matches(query)) {
iterator.add(container);
}
for (const [, containerElement] of iterator.entries()) {
if (mem.has(containerElement)) continue;
mem.add(containerElement);
found = true;
const attributes = containerElement.getAttribute(ATTRIBUTE_UPDATER_INSERT);
let def = trimSpaces(attributes);
let i = def.indexOf(" ");
let key = trimSpaces(def.substr(0, i));
let refPrefix = `${key}-`;
let cmd = trimSpaces(def.substr(i));
// this case is actually excluded by the query but is nevertheless checked again here
if (cmd.indexOf("|") > 0) {
throw new Error("pipes are not allowed when cloning a node.");
}
let pipe = new Pipe(cmd);
self[internalSymbol].callbacks.forEach((f, n) => {
pipe.setCallback(n, f);
});
let value;
try {
containerElement.removeAttribute(ATTRIBUTE_ERRORMESSAGE);
value = pipe.run(subject);
} catch (e) {
containerElement.setAttribute(ATTRIBUTE_ERRORMESSAGE, e.message);
}
let dataPath = cmd.split(":").pop();
let insertPoint;
if (containerElement.hasChildNodes()) {
insertPoint = containerElement.lastChild;
}
if (!isIterable(value)) {
throw new Error("the value is not iterable");
}
let available = new Set();
for (const [i, obj] of Object.entries(value)) {
let ref = refPrefix + i;
let currentPath = `${dataPath}.${i}`;
available.add(ref);
let refElement = containerElement.querySelector(`[${ATTRIBUTE_UPDATER_INSERT_REFERENCE}="${ref}"]`);
if (refElement instanceof HTMLElement) {
insertPoint = refElement;
continue;
}
appendNewDocumentFragment(containerElement, key, ref, currentPath);
}
let nodes = containerElement.querySelectorAll(
`[${ATTRIBUTE_UPDATER_INSERT_REFERENCE}*="${refPrefix}"]`,
);
for (const [, node] of Object.entries(nodes)) {
if (!available.has(node.getAttribute(ATTRIBUTE_UPDATER_INSERT_REFERENCE))) {
try {
containerElement.removeChild(node);
} catch (e) {
containerElement.setAttribute(
ATTRIBUTE_ERRORMESSAGE,
`${containerElement.getAttribute(ATTRIBUTE_ERRORMESSAGE)}, ${e.message}`.trim(),
);
}
}
}
}
p.pop();
}
if (found === false) break;
if (wd++ > 200) {
throw new Error("the maximum depth for the recursion is reached.");
}
}
}
/**
* @private
* @param container
* @param key
* @param ref
* @param path
* @returns {any}
*/
function internalTemplateLookUp(container, key, ref, path) {
let templateID = key;
let template;
if (container.hasAttribute(ATTRIBUTE_UPDATER_INSERT_TEMPLATE_ID)) {
templateID = container.getAttribute(ATTRIBUTE_UPDATER_INSERT_TEMPLATE_ID);
template = findDocumentTemplate(templateID, container);
if (template instanceof HTMLTemplateElement) {
return template;
}
}
if (container.closest(`[${ATTRIBUTE_UPDATER_INSERT_TEMPLATE_ID}]`)) {
templateID = container.closest(`[${ATTRIBUTE_UPDATER_INSERT_TEMPLATE_ID}]`).getAttribute(ATTRIBUTE_UPDATER_INSERT_TEMPLATE_ID);
template = findDocumentTemplate(templateID, container);
if (template instanceof HTMLTemplateElement) {
return template;
}
}
return findDocumentTemplate(templateID, container);
}
/**
*
* @private
* @license AGPLv3
* @since 1.8.0
* @param {HTMLElement} container
* @param {string} key
* @param {string} ref
* @param {string} path
* @throws {Error} no template was found with the specified key.
*/
function appendNewDocumentFragment(container, key, ref, path) {
let template = internalTemplateLookUp(container, key, ref, path);
let nodes = template.createDocumentFragment();
for (const [, node] of Object.entries(nodes.childNodes)) {
if (node instanceof HTMLElement) {
applyRecursive(node, key, path);
node.setAttribute(ATTRIBUTE_UPDATER_INSERT_REFERENCE, ref);
}
container.appendChild(node);
}
}
/**
* @private
* @license AGPLv3
* @since 1.10.0
* @param {HTMLElement} node
* @param {string} key
* @param {string} path
* @return {void}
*/
function applyRecursive(node, key, path) {
if (node instanceof HTMLElement) {
if (node.hasAttribute(ATTRIBUTE_UPDATER_REPLACE)) {
let value = node.getAttribute(ATTRIBUTE_UPDATER_REPLACE);
node.setAttribute(ATTRIBUTE_UPDATER_REPLACE, value.replaceAll(`path:${key}`, `path:${path}`));
}
if (node.hasAttribute(ATTRIBUTE_UPDATER_ATTRIBUTES)) {
let value = node.getAttribute(ATTRIBUTE_UPDATER_ATTRIBUTES);
node.setAttribute(ATTRIBUTE_UPDATER_ATTRIBUTES, value.replaceAll(`path:${key}`, `path:${path}`));
}
for (const [, child] of Object.entries(node.childNodes)) {
applyRecursive(child, key, path);
}
}
}
/**
* @private
* @license AGPLv3
* @since 1.8.0
* @param {object} change
* @return {void}
* @this Updater
*/
function updateContent(change) {
const self = this;
const subject = self[internalSymbol].subject.getRealSubject();
let p = clone(change?.["path"]);
runUpdateContent.call(this, this[internalSymbol].element, p, subject);
const slots = this[internalSymbol].element.querySelectorAll("slot");
if (slots.length > 0) {
for (const [, slot] of Object.entries(slots)) {
for (const [, element] of Object.entries(slot.assignedNodes())) {
runUpdateContent.call(this, element, p, subject);
}
}
}
}
/**
* @private
* @license AGPLv3
* @since 1.8.0
* @param {HTMLElement} container
* @param {array} parts
* @param {object} subject
* @return {void}
*/
function runUpdateContent(container, parts, subject) {
if (!isArray(parts)) return;
if (!(container instanceof HTMLElement)) return;
parts = clone(parts);
let mem = new WeakSet();
while (parts.length > 0) {
const current = parts.join(".");
parts.pop();
// Unfortunately, static data is always changed as well, since it is not possible to react to changes here.
const query = `[${ATTRIBUTE_UPDATER_REPLACE}^="path:${current}"], [${ATTRIBUTE_UPDATER_REPLACE}^="static:"], [${ATTRIBUTE_UPDATER_REPLACE}^="i18n:"]`;
const e = container.querySelectorAll(`${query}`);
const iterator = new Set([...e]);
if (container.matches(query)) {
iterator.add(container);
}
/**
* @type {HTMLElement}
*/
for (const [element] of iterator.entries()) {
if (mem.has(element)) return;
mem.add(element);
const attributes = element.getAttribute(ATTRIBUTE_UPDATER_REPLACE);
let cmd = trimSpaces(attributes);
let pipe = new Pipe(cmd);
this[internalSymbol].callbacks.forEach((f, n) => {
pipe.setCallback(n, f);
});
let value;
try {
element.removeAttribute(ATTRIBUTE_ERRORMESSAGE);
value = pipe.run(subject);
} catch (e) {
element.setAttribute(ATTRIBUTE_ERRORMESSAGE, e.message);
}
if (value instanceof HTMLElement) {
while (element.firstChild) {
element.removeChild(element.firstChild);
}
try {
element.appendChild(value);
} catch (e) {
element.setAttribute(
ATTRIBUTE_ERRORMESSAGE,
`${element.getAttribute(ATTRIBUTE_ERRORMESSAGE)}, ${e.message}`.trim(),
);
}
} else {
element.innerHTML = value;
}
}
}
}
/**
* @private
* @license AGPLv3
* @since 1.8.0
* @param {string} path
* @param {object} change
* @return {void}
*/
function updateAttributes(change) {
const subject = this[internalSymbol].subject.getRealSubject();
let p = clone(change?.["path"]);
runUpdateAttributes.call(this, this[internalSymbol].element, p, subject);
}
/**
* @private
* @param {HTMLElement} container
* @param {array} parts
* @param {object} subject
* @return {void}
* @this Updater
*/
function runUpdateAttributes(container, parts, subject) {
const self = this;
if (!isArray(parts)) return;
parts = clone(parts);
let mem = new WeakSet();
while (parts.length > 0) {
const current = parts.join(".");
parts.pop();
let iterator = new Set();
const query = `[${ATTRIBUTE_UPDATER_SELECT_THIS}], [${ATTRIBUTE_UPDATER_ATTRIBUTES}*="path:${current}"], [${ATTRIBUTE_UPDATER_ATTRIBUTES}^="static:"], [${ATTRIBUTE_UPDATER_ATTRIBUTES}^="i18n:"]`;
const e = container.querySelectorAll(query);
if (e.length > 0) {
iterator = new Set([...e]);
}
if (container.matches(query)) {
iterator.add(container);
}
for (const [element] of iterator.entries()) {
if (mem.has(element)) return;
mem.add(element);
const attributes = element.getAttribute(ATTRIBUTE_UPDATER_ATTRIBUTES);
for (let [, def] of Object.entries(attributes.split(","))) {
def = trimSpaces(def);
let i = def.indexOf(" ");
let name = trimSpaces(def.substr(0, i));
let cmd = trimSpaces(def.substr(i));
let pipe = new Pipe(cmd);
self[internalSymbol].callbacks.forEach((f, n) => {
pipe.setCallback(n, f, element);
});
let value;
try {
element.removeAttribute(ATTRIBUTE_ERRORMESSAGE);
value = pipe.run(subject);
} catch (e) {
element.setAttribute(ATTRIBUTE_ERRORMESSAGE, e.message);
}
if (value === undefined) {
element.removeAttribute(name);
} else if (element.getAttribute(name) !== value) {
element.setAttribute(name, value);
}
handleInputControlAttributeUpdate.call(this, element, name, value);
}
}
}
}
/**
* @private
* @param {HTMLElement|*} element
* @param {string} name
* @param {string|number|undefined} value
* @return {void}
* @this Updater
*/
function handleInputControlAttributeUpdate(element, name, value) {
const self = this;
if (element instanceof HTMLSelectElement) {
switch (element.type) {
case "select-multiple":
for (const [index, opt] of Object.entries(element.options)) {
if (value.indexOf(opt.value) !== -1) {
opt.selected = true;
} else {
opt.selected = false;
}
}
break;
case "select-one":
// Only one value may be selected
for (const [index, opt] of Object.entries(element.options)) {
if (opt.value === value) {
element.selectedIndex = index;
break;
}
}
break;
}
} else if (element instanceof HTMLInputElement) {
switch (element.type) {
case "radio":
if (name === "checked") {
if (value !== undefined) {
element.checked = true;
} else {
element.checked = false;
}
}
break;
case "checkbox":
if (name === "checked") {
if (value !== undefined) {
element.checked = true;
} else {
element.checked = false;
}
}
break;
case "text":
default:
if (name === "value") {
element.value = value === undefined ? "" : value;
}
break;
}
} else if (element instanceof HTMLTextAreaElement) {
if (name === "value") {
element.value = value === undefined ? "" : value;
}
}
}
/**
* @param {NodeList|HTMLElement|Set<HTMLElement>} elements
* @param {Symbol} symbol
* @param {object} object
* @return {Promise[]}
* @license AGPLv3
* @since 1.23.0
* @memberOf Monster.DOM
* @throws {TypeError} elements is not an instance of NodeList, HTMLElement or Set
* @throws {TypeError} the context of the function is not an instance of HTMLElement
* @throws {TypeError} symbol must be an instance of Symbol
*/
function addObjectWithUpdaterToElement(elements, symbol, object) {
const self = this;
if (!(self instanceof HTMLElement)) {
throw new TypeError("the context of this function must be an instance of HTMLElement");
}
if (!(typeof symbol === "symbol")) {
throw new TypeError("symbol must be an instance of Symbol");
}
const updaters = new Set();
if (elements instanceof NodeList) {
elements = new Set([...elements]);
} else if (elements instanceof HTMLElement) {
elements = new Set([elements]);
} else if (elements instanceof Set) {
} else {
throw new TypeError(`elements is not a valid type. (actual: ${typeof elements})`);
}
let result = [];
elements.forEach((element) => {
if (!(element instanceof HTMLElement)) return;
if (element instanceof HTMLTemplateElement) return;
const u = new Updater(element, object);
updaters.add(u);
result.push(
u.run().then(() => {
return u.enableEventProcessing();
}),
);
});
if (updaters.size > 0) {
addToObjectLink(self, symbol, updaters);
}
return result;
}