From 0a97623a470929c1a7c059865f099be2958a58a7 Mon Sep 17 00:00:00 2001 From: Volker Schukai <volker.schukai@schukai.com> Date: Mon, 1 Jul 2024 11:24:00 +0200 Subject: [PATCH] fix: debouncing form handling --- source/components/form/form.mjs | 608 ++++++++++++++++---------------- 1 file changed, 301 insertions(+), 307 deletions(-) diff --git a/source/components/form/form.mjs b/source/components/form/form.mjs index adc86a3ed..7301bc02b 100644 --- a/source/components/form/form.mjs +++ b/source/components/form/form.mjs @@ -12,27 +12,28 @@ * SPDX-License-Identifier: AGPL-3.0 */ -import { internalSymbol } from "../../constants.mjs"; -import { Pathfinder } from "../../data/pathfinder.mjs"; +import {internalSymbol} from "../../constants.mjs"; +import {Pathfinder} from "../../data/pathfinder.mjs"; import { - ATTRIBUTE_FORM_BIND, - ATTRIBUTE_FORM_BIND_TYPE, - ATTRIBUTE_UPDATER_BIND, + ATTRIBUTE_FORM_BIND, + ATTRIBUTE_FORM_BIND_TYPE, + ATTRIBUTE_UPDATER_BIND, } from "../../dom/constants.mjs"; -import { findTargetElementFromEvent } from "../../dom/events.mjs"; -import { clone } from "../../util/clone.mjs"; -import { DeadMansSwitch } from "../../util/deadmansswitch.mjs"; -import { DataSet } from "../datatable/dataset.mjs"; +import {findTargetElementFromEvent} from "../../dom/events.mjs"; +import {ID} from "../../types/id.mjs"; +import {clone} from "../../util/clone.mjs"; +import {DeadMansSwitch} from "../../util/deadmansswitch.mjs"; +import {DataSet} from "../datatable/dataset.mjs"; import { - assembleMethodSymbol, - registerCustomElement, - getSlottedElements, + assembleMethodSymbol, + registerCustomElement, + getSlottedElements, } from "../../dom/customelement.mjs"; -import { FormStyleSheet } from "./stylesheet/form.mjs"; -import { diff } from "../../data/diff.mjs"; -import { isString } from "../../types/is.mjs"; +import {FormStyleSheet} from "./stylesheet/form.mjs"; +import {diff} from "../../data/diff.mjs"; +import {isString} from "../../types/is.mjs"; -export { Form }; +export {Form}; /** * @private @@ -47,205 +48,198 @@ const debounceWriteBackSymbol = Symbol("debounceWriteBack"); const debounceBindSymbol = Symbol("debounceBind"); class Form extends DataSet { - /** - * - * @property {Object} templates Template definitions - * @property {string} templates.main Main template - * @property {Object} classes Class definitions - * @property {string} classes.form Form class - * @property {Object} writeBack Write back definitions - * @property {string[]} writeBack.events Write back events - * @property {Object} bind Bind definitions - * @property {string[]} bind.events Bind events - * @property {Object} reportValidity Report validity definitions - * @property {string} reportValidity.selector Report validity selector - * @property {boolean} features.mutationObserver Mutation observer feature - * @property {boolean} features.writeBack Write back feature - * @property {boolean} features.bind Bind feature - */ - get defaults() { - const obj = Object.assign({}, super.defaults, { - templates: { - main: getTemplate(), - }, - - classes: { - form: "", - }, - - writeBack: { - events: ["keyup", "click", "change", "drop", "touchend", "input"] - }, - - bind: { - events: ["keyup", "click", "change", "drop", "touchend", "input"] - }, - - reportValidity: { - selector: "input,select,textarea", - }, - }); - - obj["features"]["mutationObserver"] = false; - obj["features"]["writeBack"] = true; - obj["features"]["bind"] = true; - - return obj; - } - - /** - * - * @return {string} - */ - static getTag() { - return "monster-form"; - } - - /** - * @return {CSSStyleSheet[]} - */ - static getCSSStyleSheet() { - return [FormStyleSheet]; - } - - /** - * - */ - [assembleMethodSymbol]() { - super[assembleMethodSymbol](); - - initControlReferences.call(this); - initEventHandler.call(this); - initDataSourceHandler.call(this); - } - - /** - * This method is called when the component is created. - * @since 3.70.0 - * @returns {DataSet} - */ - refresh() { - this.write(); - super.refresh(); - return this; - } - - /** - * Run reportValidation on all child html form controls. - * - * @since 2.10.0 - * @returns {boolean} - */ - reportValidity() { - let valid = true; - - const selector = this.getOption("reportValidity.selector"); - const nodes = getSlottedElements.call(this, selector); - - nodes.forEach((node) => { - if (typeof node.reportValidity === "function") { - if (node.reportValidity() === false) { - valid = false; - } - } - }); - - return valid; - } + /** + * + * @property {Object} templates Template definitions + * @property {string} templates.main Main template + * @property {Object} classes Class definitions + * @property {string} classes.form Form class + * @property {Object} writeBack Write back definitions + * @property {string[]} writeBack.events Write back events + * @property {Object} bind Bind definitions + * @property {string[]} bind.events Bind events + * @property {Object} reportValidity Report validity definitions + * @property {string} reportValidity.selector Report validity selector + * @property {boolean} features.mutationObserver Mutation observer feature + * @property {boolean} features.writeBack Write back feature + * @property {boolean} features.bind Bind feature + */ + get defaults() { + const obj = Object.assign({}, super.defaults, { + templates: { + main: getTemplate(), + }, + + classes: { + form: "", + }, + + writeBack: { + events: ["keyup", "click", "change", "drop", "touchend", "input"] + }, + + bind: { + events: ["keyup", "click", "change", "drop", "touchend", "input"] + }, + + reportValidity: { + selector: "input,select,textarea", + }, + }); + + obj["features"]["mutationObserver"] = false; + obj["features"]["writeBack"] = true; + obj["features"]["bind"] = true; + + return obj; + } + + /** + * + * @return {string} + */ + static getTag() { + return "monster-form"; + } + + /** + * @return {CSSStyleSheet[]} + */ + static getCSSStyleSheet() { + return [FormStyleSheet]; + } + + /** + * + */ + [assembleMethodSymbol]() { + super[assembleMethodSymbol](); + + initControlReferences.call(this); + initEventHandler.call(this); + initDataSourceHandler.call(this); + } + + /** + * This method is called when the component is created. + * @since 3.70.0 + * @returns {DataSet} + */ + refresh() { + this.write(); + super.refresh(); + return this; + } + + /** + * Run reportValidation on all child html form controls. + * + * @since 2.10.0 + * @returns {boolean} + */ + reportValidity() { + let valid = true; + + const selector = this.getOption("reportValidity.selector"); + const nodes = getSlottedElements.call(this, selector); + + nodes.forEach((node) => { + if (typeof node.reportValidity === "function") { + if (node.reportValidity() === false) { + valid = false; + } + } + }); + + return valid; + } } -function initDataSourceHandler() {} +function initDataSourceHandler() { +} /** * @private * @returns {initEventHandler} */ function initEventHandler() { - this[debounceBindSymbol] = {}; - - if (this.getOption("features.bind") === true) { - const events = this.getOption("bind.events"); - - for (const event of events) { - this.addEventListener(event, (e) => { - const element = findTargetElementFromEvent(e, ATTRIBUTE_FORM_BIND); - - if (!(element instanceof HTMLElement)) { - return; - } - - let elementID = element.id; - - if (elementID === "") { - elementID = element.getAttribute("name"); - } - - if (elementID === "") { - elementID = element.getAttribute("data-monster-attributes"); - } - - if (elementID === "") { - elementID = element.innerText.substring(0, 20); - } - - elementID = elementID.replace(/\s/g, "_") + this[debounceBindSymbol] = {}; + + if (this.getOption("features.bind") === true) { + const events = this.getOption("bind.events"); + + for (const event of events) { + this.addEventListener(event, (e) => { + const element = findTargetElementFromEvent(e, ATTRIBUTE_FORM_BIND); + + if (!(element instanceof HTMLElement)) { + return; + } + + let elementID + if (!element.hasAttribute("data-monster-debounce-id")) { + elementID = new ID('debounce').toString(); + element.setAttribute("data-monster-debounce-id", elementID); + } else { + elementID = element.getAttribute("data-monster-debounce-id"); + } - if (this[debounceBindSymbol][elementID] instanceof DeadMansSwitch) { - try { - this[debounceBindSymbol][elementID].touch(); - return; - } catch (e) { - if (e.message !== "has already run") { - throw e; - } - - delete this[debounceBindSymbol][elementID]; - } - } - - this[debounceBindSymbol][elementID] = new DeadMansSwitch(200, () => { - delete this[debounceBindSymbol][elementID]; - retrieveAndSetValue.call(this, element); - }); - }); - } - } - - if (this.getOption("features.writeBack") === true) { - const events = this.getOption("writeBack.events"); - for (const event of events) { - this.addEventListener(event, (e) => { - if (!this.reportValidity()) { - this.classList.add("invalid"); - setTimeout(() => { - this.classList.remove("invalid"); - }, 1000); - - return; - } - - if (this[debounceWriteBackSymbol] instanceof DeadMansSwitch) { - try { - this[debounceWriteBackSymbol].touch(); - return; - } catch (e) { - if (e.message !== "has already run") { - throw e; - } - delete this[debounceWriteBackSymbol]; - } - } - - this[debounceWriteBackSymbol] = new DeadMansSwitch(200, () => { - setTimeout(() => { - this.write(); - }, 0); - }); - }); - } - } - - return this; + if (this[debounceBindSymbol][elementID] instanceof DeadMansSwitch) { + try { + this[debounceBindSymbol][elementID].touch(); + return; + } catch (e) { + if (e.message !== "has already run") { + throw e; + } + + delete this[debounceBindSymbol][elementID]; + } + } + + this[debounceBindSymbol][elementID] = new DeadMansSwitch(200, () => { + delete this[debounceBindSymbol][elementID]; + retrieveAndSetValue.call(this, element); + }); + }); + } + } + + if (this.getOption("features.writeBack") === true) { + const events = this.getOption("writeBack.events"); + for (const event of events) { + this.addEventListener(event, (e) => { + if (!this.reportValidity()) { + this.classList.add("invalid"); + setTimeout(() => { + this.classList.remove("invalid"); + }, 1000); + + return; + } + + if (this[debounceWriteBackSymbol] instanceof DeadMansSwitch) { + try { + this[debounceWriteBackSymbol].touch(); + return; + } catch (e) { + if (e.message !== "has already run") { + throw e; + } + delete this[debounceWriteBackSymbol]; + } + } + + this[debounceWriteBackSymbol] = new DeadMansSwitch(200, () => { + setTimeout(() => { + this.write(); + }, 0); + }); + }); + } + } + + return this; } /** @@ -253,10 +247,10 @@ function initEventHandler() { * @return {FilterButton} */ function initControlReferences() { - if (!this.shadowRoot) { - throw new Error("no shadow-root is defined"); - } - return this; + if (!this.shadowRoot) { + throw new Error("no shadow-root is defined"); + } + return this; } /** @@ -267,101 +261,101 @@ function initControlReferences() { * @private */ function retrieveAndSetValue(element) { - let path = element.getAttribute(ATTRIBUTE_FORM_BIND); - if (path === null) - throw new Error("the bind argument must start as a value with a path"); - - if (path.indexOf("path:") !== 0) { - throw new Error("the bind argument must start as a value with a path"); - } - - path = path.substring(5); // remove path: from the string - - 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 custom elements - } else if ( - (element?.constructor?.prototype && - !!Object.getOwnPropertyDescriptor( - element.constructor.prototype, - "value", - )?.["get"]) || - element.hasOwnProperty("value") - ) { - value = element?.["value"]; - } else { - throw new Error("unsupported object"); - } - - if (isString(value)) { - const type = element.getAttribute(ATTRIBUTE_FORM_BIND_TYPE); - switch (type) { - case "number": - case "int": - case "float": - case "integer": - value = Number(value); - if (isNaN(value)) { - value = 0; - } - break; - case "boolean": - case "bool": - case "checkbox": - value = value === "true" || value === "1" || value === "on"; - break; - case "array": - case "list": - value = value.split(","); - break; - case "object": - case "json": - value = JSON.parse(value); - break; - default: - break; - } - } - - const copy = clone(this[internalSymbol].getRealSubject()?.options); - - const pf = new Pathfinder(copy); - pf.setVia(path, value); - - const diffResult = diff(copy, this[internalSymbol].getRealSubject()?.options); - - if (diffResult.length > 0) { - setTimeout(() => { - this.setOption(path, value); - }, 50); - } + let path = element.getAttribute(ATTRIBUTE_FORM_BIND); + if (path === null) + throw new Error("the bind argument must start as a value with a path"); + + if (path.indexOf("path:") !== 0) { + throw new Error("the bind argument must start as a value with a path"); + } + + path = path.substring(5); // remove path: from the string + + 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 custom elements + } else if ( + (element?.constructor?.prototype && + !!Object.getOwnPropertyDescriptor( + element.constructor.prototype, + "value", + )?.["get"]) || + element.hasOwnProperty("value") + ) { + value = element?.["value"]; + } else { + throw new Error("unsupported object"); + } + + if (isString(value)) { + const type = element.getAttribute(ATTRIBUTE_FORM_BIND_TYPE); + switch (type) { + case "number": + case "int": + case "float": + case "integer": + value = Number(value); + if (isNaN(value)) { + value = 0; + } + break; + case "boolean": + case "bool": + case "checkbox": + value = value === "true" || value === "1" || value === "on"; + break; + case "array": + case "list": + value = value.split(","); + break; + case "object": + case "json": + value = JSON.parse(value); + break; + default: + break; + } + } + + const copy = clone(this[internalSymbol].getRealSubject()?.options); + + const pf = new Pathfinder(copy); + pf.setVia(path, value); + + const diffResult = diff(copy, this[internalSymbol].getRealSubject()?.options); + + if (diffResult.length > 0) { + setTimeout(() => { + this.setOption(path, value); + }, 50); + } } /** @@ -369,8 +363,8 @@ function retrieveAndSetValue(element) { * @return {string} */ function getTemplate() { - // language=HTML - return ` + // language=HTML + return ` <div data-monster-role="control" part="control"> <form data-monster-attributes="disabled path:disabled | if:true, class path:classes.form" data-monster-role="form" -- GitLab