/** * 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, getSlottedElements, } 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 { 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/monster/components/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/monster/components/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);