UNPKG

@schukai/monster

Version:

Monster is a simple library for creating fast, robust and lightweight websites.

446 lines (396 loc) 12.8 kB
/** * Copyright © schukai GmbH and all contributing authors, {{copyRightYear}}. All rights reserved. * Node module: @schukai/monster * * This source code is licensed under the GNU Affero General Public License version 3 (AGPLv3). * The full text of the license can be found at: https://www.gnu.org/licenses/agpl-3.0.en.html * * For those who do not wish to adhere to the AGPLv3, a commercial license is available. * Acquiring a commercial license allows you to use this software without complying with the AGPLv3 terms. * For more information about purchasing a commercial license, please contact schukai GmbH. * * SPDX-License-Identifier: AGPL-3.0 */ import { instanceSymbol, internalSymbol } from "../../constants.mjs"; import { diff } from "../../data/diff.mjs"; import { addAttributeToken } from "../../dom/attributes.mjs"; import { ATTRIBUTE_ERRORMESSAGE } from "../../dom/constants.mjs"; import { assembleMethodSymbol, CustomElement, registerCustomElement, } from "../../dom/customelement.mjs"; import { findElementWithSelectorUpwards } from "../../dom/util.mjs"; import { isString, isArray } from "../../types/is.mjs"; import { Observer } from "../../types/observer.mjs"; import { TokenList } from "../../types/tokenlist.mjs"; import { clone } from "../../util/clone.mjs"; import { State } from "../form/types/state.mjs"; import { ATTRIBUTE_DATASOURCE_SELECTOR } from "./constants.mjs"; import { Datasource } from "./datasource.mjs"; import { Rest as RestDatasource } from "./datasource/rest.mjs"; import { BadgeStyleSheet } from "../stylesheet/badge.mjs"; import { SaveButtonStyleSheet } from "./stylesheet/save-button.mjs"; import "../form/state-button.mjs"; import { handleDataSourceChanges, datasourceLinkedElementSymbol, } from "./util.mjs"; import { getLocaleOfDocument } from "../../dom/locale.mjs"; export { SaveButton }; /** * @private * @type {symbol} */ const stateButtonElementSymbol = Symbol("stateButtonElement"); /** * @private * @type {symbol} */ const originValuesSymbol = Symbol("originValues"); /** * @private * @type {symbol} */ const badgeElementSymbol = Symbol("badgeElement"); /** * A save button component * * @fragments /fragments/components/datatable/save-button * * @example /examples/components/datatable/save-button-simple Simple example * * @issue https://localhost.alvine.dev:8440/development/issues/closed/274.html * * @copyright schukai GmbH * @summary This is a save button component that can be used to save changes to a datasource. */ class SaveButton extends CustomElement { /** * This method is called by the `instanceof` operator. * @return {symbol} */ static get [instanceSymbol]() { return Symbol.for( "@schukai/monster/components/datatable/save-button@@instance", ); } /** * 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 {object} datasource The datasource * @property {string} datasource.selector The selector of the datasource * @property {string} labels.button The button label * @property {Object} classes The classes * @property {string} classes.bar The bar class * @property {string} classes.badge The badge class * @property {Array} ignoreChanges The ignore changes (regex) * @property {Array} data The data * @property {boolean} disabled The disabled state * @property {string} logLevel The log level (off, debug) * @return {Object} */ get defaults() { const obj = Object.assign({}, super.defaults, { templates: { main: getTemplate(), }, labels: getTranslations(), classes: { bar: "monster-button-primary", badge: "monster-badge-secondary hidden", }, datasource: { selector: null, }, changes: "0", ignoreChanges: [], data: {}, disabled: false, logLevel: "off", }); updateOptionsFromArguments.call(this, obj); return obj; } /** * * @return {string} */ static getTag() { return "monster-datasource-save-button"; } /** * This method is responsible for assembling the component. * * It calls the parent's assemble method first, then initializes control references and event handlers. * If the `datasource.selector` option is provided and is a string, it searches for the corresponding * element in the DOM using that selector. * * If the selector matches exactly one element, it checks if the element is an instance of the `Datasource` class. * * If it is, the component's `datasourceLinkedElementSymbol` property is set to the element, and the component * attaches an observer to the datasource's changes. * * The observer is a function that calls the `handleDataSourceChanges` method in the context of the component. * Additionally, the component attaches an observer to itself, which also calls the `handleDataSourceChanges` * method in the component's context. */ [assembleMethodSymbol]() { super[assembleMethodSymbol](); const self = this; initControlReferences.call(this); initEventHandler.call(this); const selector = this.getOption("datasource.selector"); if (isString(selector)) { const element = findElementWithSelectorUpwards(this, selector); if (element === null) { throw new Error("the selector must match exactly one element"); } if (!(element instanceof Datasource)) { throw new TypeError("the element must be a datasource"); } if (element instanceof RestDatasource) { element.addEventListener("monster-datasource-fetch", (event) => { self[originValuesSymbol] = null; }); } this[datasourceLinkedElementSymbol] = element; element.datasource.attachObserver( new Observer(handleDataSourceChanges.bind(this)), ); self[originValuesSymbol] = null; element.datasource.attachObserver( new Observer(function () { if (!self[originValuesSymbol]) { self[originValuesSymbol] = clone( self[datasourceLinkedElementSymbol].data, ); } const currentValues = this.getRealSubject(); const ignoreChanges = self.getOption("ignoreChanges"); const result = diff(self[originValuesSymbol], currentValues); if (self.getOption("logLevel") === "debug") { console.groupCollapsed("SaveButton"); console.log( "originValues", JSON.parse(JSON.stringify(currentValues)), ); console.log("result of diff", result); console.log("ignoreChanges", ignoreChanges); if (isArray(result) && result.length > 0) { const formattedDiff = result.map((change) => ({ Operator: change?.operator, Path: change?.path?.join("."), "First Value": change?.first?.value, "First Type": change?.first?.type, "Second Value": change?.second?.value, "Second Type": change?.second?.type, })); console.table(formattedDiff); } else { console.log("There are no changes to save"); } console.groupEnd(); } if (isArray(ignoreChanges) && ignoreChanges.length > 0) { const itemsToRemove = []; for (const item of result) { for (const ignorePattern of ignoreChanges) { const p = new RegExp(ignorePattern); let matchPath = item.path; if (isArray(item.path)) { matchPath = item.path.join("."); } if (p.test(matchPath)) { itemsToRemove.push(item); break; } } } for (const itemToRemove of itemsToRemove) { const index = result.indexOf(itemToRemove); if (index > -1) { result.splice(index, 1); } } } if (isArray(result) && result.length > 0) { self[stateButtonElementSymbol].setState("changed"); self[stateButtonElementSymbol].setOption("disabled", false); self.setOption("changes", result.length); self.setOption( "classes.badge", new TokenList(self.getOption("classes.badge")) .remove("hidden") .toString(), ); } else { self[stateButtonElementSymbol].removeState(); self[stateButtonElementSymbol].setOption("disabled", true); self.setOption("changes", 0); self.setOption( "classes.badge", new TokenList(self.getOption("classes.badge")) .add("hidden") .toString(), ); } }), ); } this.attachObserver( new Observer(() => { handleDataSourceChanges.call(this); }), ); } /** * * @return [CSSStyleSheet] */ static getCSSStyleSheet() { return [SaveButtonStyleSheet, BadgeStyleSheet]; } } function getTranslations() { const locale = getLocaleOfDocument(); switch (locale.language) { case "de": return { button: "Speichern", }; case "fr": return { button: "Enregistrer", }; case "sp": return { button: "Guardar", }; case "it": return { button: "Salva", }; case "pl": return { button: "Zapisz", }; case "no": return { button: "Lagre", }; case "dk": return { button: "Gem", }; case "sw": return { button: "Spara", }; default: case "en": return { button: "Save", }; } } /** * @private * @return {SaveButton} * @throws {Error} no shadow-root is defined * @throws {TypeError} the element must be a datasource * @throws {Error} the selector must match exactly one element * @throws {Error} the selector must match exactly one element * @throws {TypeError} the element must be a datasource * @throws {Error} the selector must match exactly one element */ function initControlReferences() { if (!this.shadowRoot) { throw new Error("no shadow-root is defined"); } this[stateButtonElementSymbol] = this.shadowRoot.querySelector( "[data-monster-role=state-button]", ); this[badgeElementSymbol] = this.shadowRoot.querySelector( "[data-monster-role=badge]", ); if (this[stateButtonElementSymbol]) { queueMicrotask(() => { const states = { changed: new State( "changed", '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-cloud-arrow-up" viewBox="0 0 16 16">' + '<path fill-rule="evenodd" d="M7.646 5.146a.5.5 0 0 1 .708 0l2 2a.5.5 0 0 1-.708.708L8.5 6.707V10.5a.5.5 0 0 1-1 0V6.707L6.354 7.854a.5.5 0 1 1-.708-.708z"/>' + '<path d="M4.406 3.342A5.53 5.53 0 0 1 8 2c2.69 0 4.923 2 5.166 4.579C14.758 6.804 16 8.137 16 9.773 16 11.569 14.502 13 12.687 13H3.781C1.708 13 0 11.366 0 9.318c0-1.763 1.266-3.223 2.942-3.593.143-.863.698-1.723 1.464-2.383m.653.757c-.757.653-1.153 1.44-1.153 2.056v.448l-.445.049C2.064 6.805 1 7.952 1 9.318 1 10.785 2.23 12 3.781 12h8.906C13.98 12 15 10.988 15 9.773c0-1.216-1.02-2.228-2.313-2.228h-.5v-.5C12.188 4.825 10.328 3 8 3a4.53 4.53 0 0 0-2.941 1.1z"/>' + "</svg>", ), }; this[stateButtonElementSymbol].removeState(); this[stateButtonElementSymbol].setOption("disabled", "disabled"); this[stateButtonElementSymbol].setOption("states", states); this[stateButtonElementSymbol].setOption( "labels.button", this.getOption("labels.button"), ); }); } return this; } /** * @private */ function initEventHandler() { queueMicrotask(() => { this[stateButtonElementSymbol].setOption("actions.click", () => { this[datasourceLinkedElementSymbol] .write() .then(() => { this[originValuesSymbol] = null; this[stateButtonElementSymbol].removeState(); this[stateButtonElementSymbol].setOption("disabled", true); this.setOption("changes", 0); this.setOption( "classes.badge", new TokenList(this.getOption("classes.badge")) .add("hidden") .toString(), ); }) .catch((error) => { addAttributeToken(this, ATTRIBUTE_ERRORMESSAGE, error.toString()); }); }); }); } /** * @param {Object} options * @deprecated 2024-12-31 */ function updateOptionsFromArguments(options) { const selector = this.getAttribute(ATTRIBUTE_DATASOURCE_SELECTOR); if (selector) { options.datasource.selector = selector; } } /** * @private * @return {string} */ function getTemplate() { // language=HTML return ` <div data-monster-role="control" part="control" data-monster-attributes="disabled path:disabled | if:true"> <monster-state-button data-monster-role="state-button">save</monster-state-button> <div data-monster-attributes="disabled path:disabled | if:true, class path:classes.badge" data-monster-role="badge" data-monster-replace="path:changes"></div> </div> `; } registerCustomElement(SaveButton);