Something went wrong on our end
Select Git revision
Monster.Logging.Handler.html
-
Volker Schukai authoredVolker Schukai authored
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);