Skip to content
Snippets Groups Projects
Select Git revision
  • ce1dd137678aaee721c92dd3cfdf88a52001f9af
  • master default protected
  • 1.31
  • 4.34.1
  • 4.34.0
  • 4.33.1
  • 4.33.0
  • 4.32.2
  • 4.32.1
  • 4.32.0
  • 4.31.0
  • 4.30.1
  • 4.30.0
  • 4.29.1
  • 4.29.0
  • 4.28.0
  • 4.27.0
  • 4.26.0
  • 4.25.5
  • 4.25.4
  • 4.25.3
  • 4.25.2
  • 4.25.1
23 results

extend.mjs

Blame
  • form.mjs 13.41 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 { instanceSymbol } from "../../constants.mjs";
    import { internalSymbol } from "../../constants.mjs";
    import { Datasource } from "../../data/datasource.mjs";
    import { RestAPI } from "../../data/datasource/server/restapi.mjs";
    import { WebConnect } from "../../data/datasource/server/webconnect.mjs";
    import { WriteError } from "../../data/datasource/server/restapi/writeerror.mjs";
    import { LocalStorage } from "../../data/datasource/storage/localstorage.mjs";
    import { SessionStorage } from "../../data/datasource/storage/sessionstorage.mjs";
    import {
    	ATTRIBUTE_DISABLED,
    	ATTRIBUTE_ERRORMESSAGE,
    	ATTRIBUTE_PREFIX,
    	ATTRIBUTE_UPDATER_ATTRIBUTES,
    	ATTRIBUTE_UPDATER_INSERT,
    	ATTRIBUTE_UPDATER_REMOVE,
    	ATTRIBUTE_UPDATER_REPLACE,
    } from "../../dom/constants.mjs";
    import {
    	assembleMethodSymbol,
    	CustomElement,
    	registerCustomElement,
    } from "../../dom/customelement.mjs";
    import { addObjectWithUpdaterToElement } from "../../dom/updater.mjs";
    import { isFunction, isString } from "../../types/is.mjs";
    import { Observer } from "../../types/observer.mjs";
    import { ProxyObserver } from "../../types/proxyobserver.mjs";
    import { Processing } from "../../util/processing.mjs";
    import { MessageStateButton } from "./message-state-button.mjs";
    import {
    	ATTRIBUTE_FORM_DATASOURCE,
    	ATTRIBUTE_FORM_DATASOURCE_ARGUMENTS,
    } from "./constants.mjs";
    import { StateButton } from "./state-button.mjs";
    import { getSlottedElements } from "../../dom/customelement.mjs";
    import { FormStyleSheet } from "./stylesheet/form.mjs";
    
    export { Form };
    
    /**
     * @private
     * @since 3.1.0
     * @type {string}
     */
    const ATTRIBUTE_FORM_DATASOURCE_ACTION = `${ATTRIBUTE_PREFIX}datasource-action`;
    
    /**
     * Form data is the internal representation of the form data
     *
     * @private
     * @type {symbol}
     * @since 1.7.0
     */
    const formDataSymbol = Symbol.for("@schukai/component-form/form@@formdata");
    
    /**
     * @private
     * @type {symbol}
     * @since 2.8.0
     */
    const formDataUpdaterSymbol = Symbol.for(
    	"@schukai/component-form/form@@formdata-updater-link",
    );
    
    /**
     * @private
     * @type {symbol}
     * @since 1.7.0
     */
    const formElementSymbol = Symbol.for(
    	"@schukai/component-form/form@@form-element",
    );
    
    /**
     * @private
     * @type {symbol}
     * @since 2.5.0
     */
    const registeredDatasourcesSymbol = Symbol.for(
    	"@schukai/component-form/form@@registered-datasources",
    );
    
    /**
     * @private
     * @since 1.7.0
     * @type {string}
     */
    const PROPERTY_VALIDATION_KEY = "__validation";
    
    /**
     * This CustomControl creates a form element with a variety of options.
     *
     * <img src="./images/form.png">
     *
     * Dependencies: the system uses functions of the [monsterjs](https://monsterjs.org/) library.
     *
     * You can create this control either by specifying the HTML tag `<monster-form />` directly in the HTML or using
     * Javascript via the `document.createElement('monster-form');` method.
     *
     * ```html
     * <monster-form></monster-form>
     * ```
     *
     * Or you can create this CustomControl directly in Javascript:
     *
     * ```js
     * import {Form} from '@schukai/component-form/source/form.js';
     * document.createElement('monster-form');
     * ```
     *
     * @startuml form.png
     * skinparam monochrome true
     * skinparam shadowing false
     * HTMLElement <|-- CustomElement
     * CustomElement <|-- Form
     * @enduml
     *
     * @since 1.6.0
     * @copyright schukai GmbH
     * @memberOf Monster.Components.Form
     * @summary A configurable form control
     */
    class Form extends CustomElement {
    	/**
    	 * @throws {Error} the options attribute does not contain a valid json definition.
    	 * @since 1.7.0
    	 */
    	constructor() {
    		super();
    		this[formDataSymbol] = new ProxyObserver({});
    	}
    
    	/**
    	 * This method is called by the `instanceof` operator.
    	 * @returns {symbol}
    	 * @since 2.1.0
    	 */
    	static get [instanceSymbol]() {
    		return Symbol.for("@schukai/component-form/form");
    	}
    
    	/**
    	 * To set the options via the html tag the attribute `data-monster-options` must be used.
    	 * @see {@link https://monsterjs.org/en/doc/#configurate-a-monster-control}
    	 *
    	 * The individual configuration values can be found in the table.
    	 *
    	 * @property {Object} templates Template definitions
    	 * @property {string} templates.main Main template
    	 * @property {Datasource} datasource data source
    	 * @property {Object} reportValidity
    	 * @property {string} reportValidity.selector which element should be used to report the validity
    	 * @property {function} reportValidity.errorHandler function to handle the error
    	 * @property {Object} classes
    	 * @property {string} classes.button class for the form
    	 */
    	get defaults() {
    		return Object.assign(
    			{},
    			super.defaults,
    			{
    				templates: {
    					main: getTemplate(),
    				},
    				datasource: undefined,
    				reportValidity: {
    					selector: "input,select,textarea",
    					errorHandler: undefined,
    				},
    				classes: {
    					form: "monster-form",
    				},
    			},
    			initOptionsFromArguments.call(this),
    		);
    	}
    
    	/**
    	 * Called every time the element is inserted into the DOM. Useful for running setup code, such as
    	 * fetching resources or rendering. Generally, you should try to delay work until this time.
    	 *
    	 * @return {void}
    	 */
    	connectedCallback() {
    		super["connectedCallback"]();
    	}
    
    	/**
    	 * The refresh method is called to update the control after a change with fresh data.
    	 *
    	 * Therefore, the data source is called again and the data is updated.
    	 *
    	 * If you have updated the data source with `setOption('datasource',datasource), you must call this method.
    	 *
    	 * @return {Form}
    	 * @throws {Error} undefined datasource
    	 */
    	refresh() {
    		try {
    			this.setAttribute(ATTRIBUTE_DISABLED, "");
    			const datasource = this.getOption("datasource");
    
    			if (!(datasource instanceof Datasource)) {
    				throw new Error("undefined datasource");
    			}
    
    			return datasource
    				.read()
    				.then(() => {
    					this[formDataSymbol].setSubject(datasource.get());
    				})
    				.then(() => {
    					new Processing(() => {
    						this.removeAttribute(ATTRIBUTE_DISABLED);
    					}).run();
    				})
    				.catch((e) => {
    					this.removeAttribute(ATTRIBUTE_DISABLED);
    					this.setAttribute(ATTRIBUTE_ERRORMESSAGE, e.toString());
    				});
    		} catch (e) {
    			this.setAttribute(ATTRIBUTE_ERRORMESSAGE, e.toString());
    			this.removeAttribute(ATTRIBUTE_DISABLED);
    			throw e;
    		}
    	}
    
    	/**
    	 *
    	 * @return {Monster.Components.Form.Form}
    	 */
    	[assembleMethodSymbol]() {
    		super[assembleMethodSymbol]();
    
    		initControlReferences.call(this);
    		initDatasource.call(this);
    		initUpdater.call(this);
    		initObserver.call(this);
    
    		return this;
    	}
    
    	/**
    	 *
    	 * @return {*}
    	 */
    	getValues() {
    		return this[formDataSymbol].getSubject();
    	}
    
    	/**
    	 *
    	 * @return {string}
    	 */
    	static getTag() {
    		return "monster-form";
    	}
    
    	/**
    	 *
    	 * @return {CSSStyleSheet}
    	 */
    	static getCSSStyleSheet() {
    		return [FormStyleSheet];
    	}
    
    	static [registeredDatasourcesSymbol] = new Map([
    		["restapi", RestAPI],
    		["localstorage", LocalStorage],
    		["sessionstorage", SessionStorage],
    		["webconnect", WebConnect],
    	]);
    
    	/**
    	 * Register a new datasource
    	 *
    	 * @param {string} name
    	 * @param {Monster.Data.Datasource} datasource
    	 */
    	static registerDatasource(name, datasource) {
    		Form[registeredDatasourcesSymbol].set(name, datasource);
    	}
    
    	/**
    	 * Unregister a registered datasource
    	 *
    	 * @param {string} name
    	 */
    	static unregisterDatasource(name) {
    		Form[registeredDatasourcesSymbol].delete(name);
    	}
    
    	/**
    	 * Get registered data sources
    	 *
    	 * @return {Map}
    	 */
    	static getDatasources() {
    		return Form[registeredDatasourcesSymbol];
    	}
    
    	/**
    	 * 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;
    	}
    }
    
    /**
     * @private
     */
    function initUpdater() {
    	if (!this.shadowRoot) {
    		throw new Error("no shadow-root is defined");
    	}
    
    	const slots = this.shadowRoot.querySelectorAll("slot");
    	for (const [, slot] of Object.entries(slots)) {
    		for (const [, node] of Object.entries(slot.assignedNodes())) {
    			if (!(node instanceof HTMLElement)) {
    				continue;
    			}
    
    			const query = `[${ATTRIBUTE_UPDATER_ATTRIBUTES}],[${ATTRIBUTE_UPDATER_REPLACE}],[${ATTRIBUTE_UPDATER_REMOVE}],[${ATTRIBUTE_UPDATER_INSERT}]`;
    			const controls = node.querySelectorAll(query);
    
    			const list = new Set([...controls]);
    
    			if (node.matches(query)) {
    				list.add(node);
    			}
    
    			if (list.size === 0) {
    				continue;
    			}
    
    			addObjectWithUpdaterToElement.call(
    				node,
    				list,
    				formDataUpdaterSymbol,
    				this[formDataSymbol],
    			);
    		}
    	}
    }
    
    /**
     * @private
     */
    function initDatasource() {
    	if (!this.shadowRoot) {
    		throw new Error("no shadow-root is defined");
    	}
    
    	const slots = this.shadowRoot.querySelectorAll("slot");
    	for (const [, slot] of Object.entries(slots)) {
    		for (const [, node] of Object.entries(slot.assignedNodes())) {
    			if (!(node instanceof HTMLElement)) {
    				continue;
    			}
    
    			const query = `[${ATTRIBUTE_FORM_DATASOURCE_ACTION}=write]`;
    			const controls = node.querySelectorAll(query);
    
    			const list = new Set([...controls]);
    
    			if (node.matches(query)) {
    				list.add(node);
    			}
    
    			if (list.size === 0) {
    				continue;
    			}
    
    			initWriteActions.call(this, list);
    		}
    	}
    }
    
    /**
     * @private
     * @param elements
     */
    function initWriteActions(elements) {
    	elements.forEach((element) => {
    		if (element instanceof HTMLElement) {
    			element.addEventListener("click", () => {
    				runWriteCallback.call(this, element);
    			});
    
    			const g = element?.getOption;
    			if (!isFunction(g)) {
    				return;
    			}
    
    			const s = element?.setOption;
    			if (!isFunction(s)) {
    				return;
    			}
    
    			const fn = element.getOption("actions.click");
    
    			if (!isFunction(fn)) {
    				return;
    			}
    
    			// disable console.log of standard click event
    			element.setOption("actions.click", function () {
    				// do nothing!
    			});
    		}
    	});
    }
    
    function runWriteCallback(button) {
    	if (typeof this.reportValidity === "function") {
    		if (this.reportValidity() === false) {
    			if (
    				button instanceof StateButton ||
    				button instanceof MessageStateButton
    			) {
    				button.setState("failed");
    			}
    			return;
    		}
    	}
    
    	const datasource = this.getOption("datasource");
    	if (!(datasource instanceof Datasource)) {
    		return;
    	}
    
    	if (button instanceof StateButton || button instanceof MessageStateButton) {
    		button.setState("activity");
    	}
    
    	//const data = form?.[formDataSymbol]?.getRealSubject();
    	const writePromise = datasource
    		.set(this[formDataSymbol].getRealSubject())
    		.write();
    	if (!(writePromise instanceof Promise)) {
    		throw new Error("datasource.write() must return a promise");
    	}
    
    	writePromise
    		.then((r) => {
    			if (
    				button instanceof StateButton ||
    				button instanceof MessageStateButton
    			) {
    				button.setState("successful");
    			}
    			this[formDataSymbol].getSubject()[PROPERTY_VALIDATION_KEY] = {};
    		})
    		.catch((e) => {
    			if (e instanceof WriteError) {
    				this[formDataSymbol].getSubject()[PROPERTY_VALIDATION_KEY] =
    					e.getValidation();
    			}
    
    			if (
    				button instanceof StateButton ||
    				button instanceof MessageStateButton
    			) {
    				button.setState("failed");
    			}
    
    			if (button instanceof MessageStateButton) {
    				button.setMessage(e.message);
    				button.showMessage();
    			}
    		});
    }
    
    /**
     * This attribute can be used to pass a URL to this select.
     *
     * ```
     * <monster-form data-monster-datasource="restapi:....."></monster-form>
     * ```
     *
     * @private
     * @return {object}
     */
    function initOptionsFromArguments() {
    	const options = {};
    
    	const datasource = this.getAttribute(ATTRIBUTE_FORM_DATASOURCE);
    	if (isString(datasource)) {
    		for (const [key, classObject] of Form.getDatasources()) {
    			if (datasource === key) {
    				let args = this.getAttribute(ATTRIBUTE_FORM_DATASOURCE_ARGUMENTS);
    
    				try {
    					args = JSON.parse(args);
    				} catch (e) {
    					this.setAttribute(ATTRIBUTE_ERRORMESSAGE, e.toString());
    					continue;
    				}
    
    				try {
    					options["datasource"] = new classObject(args);
    				} catch (e) {
    					this.setAttribute(ATTRIBUTE_ERRORMESSAGE, e.toString());
    					continue;
    				}
    
    				break;
    			}
    
    			if (options["datasource"] instanceof Datasource) {
    				break;
    			}
    		}
    	}
    
    	return options;
    }
    
    /**
     * @private
     * @this Form
     */
    function initObserver() {
    	const self = this;
    
    	let lastDatasource = null;
    	self[internalSymbol].attachObserver(
    		new Observer(function () {
    			const datasource = self.getOption("datasource");
    			if (datasource !== lastDatasource) {
    				new Processing(100, function () {
    					self.refresh();
    				}).run();
    			}
    
    			lastDatasource = datasource;
    		}),
    	);
    }
    
    /**
     * @private
     * @return {Monster.Components.Form.Form}
     */
    function initControlReferences() {
    	if (!this.shadowRoot) {
    		throw new Error("no shadow-root is defined");
    	}
    
    	this[formElementSymbol] = this.shadowRoot.querySelector(
    		"[data-monster-role=form]",
    	);
    	return this;
    }
    
    /**
     * @private
     * @return {string}
     */
    function getTemplate() {
    	// 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"
                      part="form">
                    <slot data-monster-role="slot"></slot>
                </form>
            </div>
        `;
    }
    
    registerCustomElement(Form);