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