UNPKG

@schukai/monster

Version:

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

824 lines (729 loc) 20.8 kB
/** * Copyright © Volker Schukai 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 Volker Schukai. * * SPDX-License-Identifier: AGPL-3.0 */ import { instanceSymbol } from "../../constants.mjs"; import { diff } from "../../data/diff.mjs"; import { addAttributeToken, getLinkedObjects, hasObjectLink, } from "../../dom/attributes.mjs"; import { ATTRIBUTE_ERRORMESSAGE, customElementUpdaterLinkSymbol, } from "../../dom/constants.mjs"; import { assembleMethodSymbol, CustomElement, registerCustomElement, } from "../../dom/customelement.mjs"; import { findElementWithSelectorUpwards, getDocument, } from "../../dom/util.mjs"; import { isString, isArray, isObject } 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.for( "@schukai/monster/components/datatable/save-button@@originValues", ); /** * @private * @type {symbol} */ const badgeElementSymbol = Symbol("badgeElement"); const saveInFlightSymbol = Symbol("saveInFlight"); const pendingResetSymbol = Symbol("pendingReset"); const fetchInFlightSymbol = Symbol("fetchInFlight"); const originInitializedSymbol = Symbol("originInitialized"); const internalDisableTimeoutSymbol = Symbol("internalDisableTimeout"); const internalDisableVersionSymbol = Symbol("internalDisableVersion"); /** * 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 Volker Schukai * @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 {boolean} disableWhenNoChanges Disable button when there are no changes * @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: "", ignoreChanges: [], data: {}, disabled: false, disableWhenNoChanges: false, disableTimeout: 15000, 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", () => { if (self[saveInFlightSymbol]) { self[pendingResetSymbol] = true; return; } self[fetchInFlightSymbol] = true; clearOriginValues.call(self); }); element.addEventListener("monster-datasource-fetched", () => { self[fetchInFlightSymbol] = false; setOriginValues.call( self, clone(self[datasourceLinkedElementSymbol].data), ); updateChangesState.call(self); }); element.addEventListener("monster-datasource-error", () => { self[fetchInFlightSymbol] = false; }); } this[datasourceLinkedElementSymbol] = element; element.datasource.attachObserver( new Observer(handleDataSourceChanges.bind(this)), ); clearOriginValues.call(self); element.datasource.attachObserver( new Observer(function () { if (self[fetchInFlightSymbol] === true) { return; } if (!getOriginValues.call(self)) { setOriginValues.call( self, clone(self[datasourceLinkedElementSymbol].data), ); } updateChangesState.call(self); }), ); syncOriginValues.call(self); } this.attachObserver( new Observer(() => { handleDataSourceChanges.call(this); }), ); } /** * * @return [CSSStyleSheet] */ static getCSSStyleSheet() { return [SaveButtonStyleSheet, BadgeStyleSheet]; } } /** * @private */ function syncOriginValues() { if (getOriginValues.call(this)) { return; } const data = this[datasourceLinkedElementSymbol]?.data; if (!data) { return; } setOriginValues.call(this, clone(data)); updateChangesState.call(this); } /** * @private * @return {*} */ function getOriginValues() { const datasource = this[datasourceLinkedElementSymbol]; return datasource ? datasource[originValuesSymbol] : null; } /** * @private * @param {*} value * @return {void} */ function setOriginValues(value) { const datasource = this[datasourceLinkedElementSymbol]; if (datasource) { datasource[originValuesSymbol] = value; } this[originInitializedSymbol] = true; } /** * @private * @return {void} */ function clearOriginValues() { const datasource = this[datasourceLinkedElementSymbol]; if (datasource) { datasource[originValuesSymbol] = null; } this[originInitializedSymbol] = false; } 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(() => { ensureChangedState.call(this); this[stateButtonElementSymbol].removeState(); this[stateButtonElementSymbol].setOption( "disabled", shouldDisableWhenNoChanges.call(this), ); this[stateButtonElementSymbol].setOption( "labels.button", this.getOption("labels.button"), ); }); } return this; } /** * @private */ function initEventHandler() { queueMicrotask(() => { this[stateButtonElementSymbol].setOption("actions.click", () => { if (this[saveInFlightSymbol]) { return; } this[saveInFlightSymbol] = true; this[stateButtonElementSymbol].setOption("disabled", true); scheduleInternalDisableTimeout.call(this); flushLinkedForms .call(this) .then(() => this[datasourceLinkedElementSymbol].write()) .then(() => { clearOriginValues.call(this); setOriginValues.call( this, clone(this[datasourceLinkedElementSymbol].data), ); this[stateButtonElementSymbol].removeState(); this[stateButtonElementSymbol].setOption( "disabled", shouldDisableWhenNoChanges.call(this), ); this.setOption("changes", ""); this.setOption( "classes.badge", new TokenList(this.getOption("classes.badge")) .add("hidden") .toString(), ); }) .catch((error) => { addAttributeToken(this, ATTRIBUTE_ERRORMESSAGE, error.toString()); this[stateButtonElementSymbol].setOption("disabled", false); }) .finally(() => { this[saveInFlightSymbol] = false; clearInternalDisableTimeout.call(this); if (this[pendingResetSymbol]) { this[pendingResetSymbol] = false; clearOriginValues.call(this); } }); }); }); } /** * @private * @return {Promise<void>} */ function flushLinkedForms() { const datasource = this[datasourceLinkedElementSymbol]; if (!datasource) { return Promise.resolve(); } const roots = new Set(); let current = this; while (current) { const root = current.getRootNode?.(); if (root && typeof root.querySelectorAll === "function") { roots.add(root); } if (root instanceof ShadowRoot) { current = root.host; } else { break; } } const doc = getDocument(); if (doc) { roots.add(doc); } const forms = new Set(); for (const scope of roots) { const nodes = scope.querySelectorAll?.("monster-form") || []; for (const node of nodes) { forms.add(node); } } const writes = []; for (const form of forms) { if (!(form instanceof HTMLElement)) { continue; } if (form[datasourceLinkedElementSymbol] !== datasource) { continue; } if (hasObjectLink(form, customElementUpdaterLinkSymbol)) { const updaters = getLinkedObjects(form, customElementUpdaterLinkSymbol); for (const list of updaters) { for (const updater of list) { updater.retrieve(); } } } if (typeof form.write === "function") { writes.push(form.write()); } } if (writes.length === 0) { return Promise.resolve(); } return Promise.all(writes).then(() => {}); } /** * @private */ function updateChangesState() { const currentValues = this[datasourceLinkedElementSymbol]?.datasource?.get(); const ignoreChanges = this.getOption("ignoreChanges"); const originValues = getOriginValues.call(this); const disableWhenNoChanges = shouldDisableWhenNoChanges.call(this); if ( this[originInitializedSymbol] !== true || this[fetchInFlightSymbol] === true || originValues === null || originValues === undefined || (isObject(originValues) && Object.keys(originValues).length === 0 && isObject(currentValues) && Object.keys(currentValues).length === 0) ) { this.setOption("changes", ""); this.setOption( "classes.badge", new TokenList(this.getOption("classes.badge")).add("hidden").toString(), ); this[stateButtonElementSymbol].setOption("disabled", disableWhenNoChanges); clearInternalDisableTimeout.call(this); return; } let result = diff(originValues, currentValues); if ( this.getOption("logLevel") === "debug" || location.search.includes("logLevel=debug") ) { console.groupCollapsed("SaveButton"); console.log("originValues", JSON.parse(JSON.stringify(originValues))); console.log("currentValues", 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); } } } const changeCount = countChanges(result); if (changeCount > 0) { ensureChangedState.call(this); this[stateButtonElementSymbol].setState("changed"); this[stateButtonElementSymbol].setOption("disabled", false); clearInternalDisableTimeout.call(this); this.setOption("changes", changeCount); this.setOption( "classes.badge", new TokenList(this.getOption("classes.badge")) .remove("hidden") .toString(), ); } else { this[stateButtonElementSymbol].removeState(); this[stateButtonElementSymbol].setOption("disabled", disableWhenNoChanges); clearInternalDisableTimeout.call(this); this.setOption("changes", ""); this.setOption( "classes.badge", new TokenList(this.getOption("classes.badge")).add("hidden").toString(), ); } } /** * @private * @param {Array} changes * @return {number} */ function countChanges(changes) { if (!isArray(changes) || changes.length === 0) { return 0; } let total = 0; for (const change of changes) { if (change?.operator === "add") { const nonEmpty = countNonEmptyLeafValues(change?.second?.value); total += Math.max(1, nonEmpty); continue; } if (change?.operator === "remove") { const nonEmpty = countNonEmptyLeafValues(change?.first?.value); total += Math.max(1, nonEmpty); continue; } total += 1; } return total; } /** * @private * @param {*} value * @return {number} */ function countNonEmptyLeafValues(value) { if (value === null || value === undefined) { return 0; } if (value instanceof Date) { return 1; } if (typeof value === "string") { return value.trim() === "" ? 0 : 1; } if (typeof value === "number") { return Number.isFinite(value) ? 1 : 0; } if (typeof value === "boolean") { return 1; } if (isArray(value)) { if (value.length === 0) { return 0; } return value.reduce((sum, item) => sum + countNonEmptyLeafValues(item), 0); } if (isObject(value)) { const keys = Object.keys(value); if (keys.length === 0) { return 0; } return keys.reduce( (sum, key) => sum + countNonEmptyLeafValues(value[key]), 0, ); } return 1; } /** * @private * @return {void} */ function ensureChangedState() { const stateButton = this[stateButtonElementSymbol]; if (!stateButton || typeof stateButton.getOption !== "function") { return; } if (stateButton.getOption("states.changed")) { return; } const states = Object.assign({}, stateButton.getOption("states") || {}, { changed: getChangedState(), }); stateButton.setOption("states", states); } /** * @private */ function scheduleInternalDisableTimeout() { const timeout = this.getOption("disableTimeout"); if (!Number.isInteger(timeout) || timeout <= 0) { return; } clearInternalDisableTimeout.call(this); const version = (this[internalDisableVersionSymbol] || 0) + 1; this[internalDisableVersionSymbol] = version; this[internalDisableTimeoutSymbol] = setTimeout(() => { if (this[internalDisableVersionSymbol] !== version) { return; } if (this[saveInFlightSymbol] !== true) { return; } if ( this.getOption("disabled", false) === true || this.hasAttribute("disabled") ) { return; } const changes = Number(this.getOption("changes") || 0); if (!Number.isFinite(changes) || changes <= 0) { return; } this[stateButtonElementSymbol]?.setOption("disabled", false); }, timeout); } /** * @private */ function clearInternalDisableTimeout() { if (this[internalDisableTimeoutSymbol]) { clearTimeout(this[internalDisableTimeoutSymbol]); this[internalDisableTimeoutSymbol] = null; } } /** * @private * @return {boolean} */ function shouldDisableWhenNoChanges() { return this.getOption("disableWhenNoChanges") === true; } /** * @private * @return {State} */ function getChangedState() { return 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>", ); } /** * @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"></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);