@schukai/monster
Version:
Monster is a simple library for creating fast, robust and lightweight websites.
824 lines (729 loc) • 20.8 kB
JavaScript
/**
* 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);