/** * 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 } from "../../constants.mjs"; import { Pathfinder } from "../../data/pathfinder.mjs"; import { assembleMethodSymbol, CustomElement, attributeObserverSymbol, registerCustomElement, } from "../../dom/customelement.mjs"; import { findElementWithSelectorUpwards, getDocument, getWindow, } from "../../dom/util.mjs"; import { isString } from "../../types/is.mjs"; import { Observer } from "../../types/observer.mjs"; import { ATTRIBUTE_DATASOURCE_SELECTOR, ATTRIBUTE_DATATABLE_INDEX, } from "./constants.mjs"; import { Datasource } from "./datasource.mjs"; import { DatasetStyleSheet } from "./stylesheet/dataset.mjs"; import { handleDataSourceChanges, datasourceLinkedElementSymbol, } from "./util.mjs"; import { FormStyleSheet } from "../stylesheet/form.mjs"; import { addErrorAttribute } from "../../dom/error.mjs"; export { DataSet }; /** * A data set component * * @fragments /fragments/components/datatable/dataset * * @example /examples/components/datatable/dataset-dom Dom dataset * @example /examples/components/datatable/dataset-rest Rest dataset * * @issue https://localhost.alvine.dev:8440/development/issues/closed/272.html * * @copyright schukai GmbH * @summary A dataset component that can be used to show the data of a data source */ class DataSet extends CustomElement { /** * This method is called by the `instanceof` operator. * @return {symbol} */ static get [instanceSymbol]() { return Symbol.for( "@schukai/monster/components/datatable/dataset@@instance", ); } /** * This method determines which attributes are to be monitored by `attributeChangedCallback()`. * * @return {string[]} * @since 1.15.0 */ static get observedAttributes() { const attributes = super.observedAttributes; attributes.push(ATTRIBUTE_DATATABLE_INDEX); attributes.push("data-monster-option-mapping-index"); return attributes; } /** * 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 {object} mapping The mapping * @property {string} mapping.data The data * @property {number} mapping.index The index * @property {object} features The features * @property {boolean} features.refreshOnMutation Refresh on mutation * @property {object} refreshOnMutation The refresh on mutation * @property {string} refreshOnMutation.selector The selector */ get defaults() { const obj = Object.assign({}, super.defaults, { templates: { main: getTemplate(), }, datasource: { selector: null, }, mapping: { data: "dataset", index: 0, }, features: { /** * @since 3.70.0 * @type {boolean} */ refreshOnMutation: true, }, /** * @since 3.70.0 * @type {boolean} */ refreshOnMutation: { selector: "input, select, textarea, monster-select, monster-toggle-switch", }, data: {}, }); updateOptionsFromArguments.call(this, obj); return obj; } /** * * @return {string} */ static getTag() { return "monster-dataset"; } /** * This method is called when the component is created. * @since 3.70.0 * @return {Promise} */ refresh() { // makes sure that handleDataSourceChanges is called return new Promise((resolve) => { this.setOption("data", {}); queueMicrotask(() => { handleDataSourceChanges.call(this); resolve(); }); }); } /** * * @return {Promise<unknown>} */ write() { return new Promise((resolve, reject) => { if (!this[datasourceLinkedElementSymbol]) { reject(new Error("No datasource")); return; } const internalUpdateCloneData = this.getInternalUpdateCloneData(); if (!internalUpdateCloneData) { reject(new Error("No update data")); return; } const internalData = internalUpdateCloneData?.["data"]; if ( internalData === undefined || internalData === null || internalData === "" ) { reject(new Error("No data")); return; } queueMicrotask(() => { const path = this.getOption("mapping.data"); const index = this.getOption("mapping.index"); let pathWithIndex; if (isString(path) && path !== "") { pathWithIndex = path + "." + index; } else { pathWithIndex = String(index); } const data = this[datasourceLinkedElementSymbol]?.data; if (!data) { reject(new Error("No data")); return; } const unref = JSON.stringify(data); const ref = JSON.parse(unref); new Pathfinder(ref).setVia(pathWithIndex, internalData); this[datasourceLinkedElementSymbol].data = ref; resolve(); }); }); } /** * 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](); setTimeout(() => { if (!this[datasourceLinkedElementSymbol]) { const selector = this.getOption("datasource.selector"); if (isString(selector)) { const element = findElementWithSelectorUpwards(this, selector); if (element === null) { addErrorAttribute( this, "the selector must match exactly one element", ); return; } if (!(element instanceof Datasource)) { addErrorAttribute(this, "the element must be a datasource"); return; } this[datasourceLinkedElementSymbol] = element; element.datasource.attachObserver( new Observer(handleDataSourceChanges.bind(this)), ); handleDataSourceChanges.call(this); } else { addErrorAttribute( this, "the datasource selector is missing or invalid", ); return; } } if ( this.getOption("features.refreshOnMutation") && this.getOption("refreshOnMutation.selector") ) { initMutationObserver.call(this); } initEventHandler.call(this); }, 10); } /** * @return [CSSStyleSheet] */ static getCSSStyleSheet() { return [FormStyleSheet, DatasetStyleSheet]; } } /** * @private */ function initEventHandler() { this[attributeObserverSymbol][ATTRIBUTE_DATATABLE_INDEX] = () => { // @deprecated use data-monster-option-mapping-index const index = this.getAttribute(ATTRIBUTE_DATATABLE_INDEX); if (index) { this.setOption("mapping.index", parseInt(index, 10)); handleDataSourceChanges.call(this); } }; this[attributeObserverSymbol]["data-monster-option-mapping-index"] = () => { const index = this.getAttribute("data-monster-option-mapping-index"); if (index !== null && index !== undefined && index !== "") { this.setOption("mapping.index", parseInt(index, 10)); handleDataSourceChanges.call(this); } }; if (this[datasourceLinkedElementSymbol] instanceof Datasource) { this[datasourceLinkedElementSymbol]?.datasource?.attachObserver( new Observer(() => { let index = 0; if ( typeof this[datasourceLinkedElementSymbol]?.currentPage === "function" ) { const page = this[datasourceLinkedElementSymbol].currentPage(); if (page !== null && page !== undefined && page !== "") { index = parseInt(page, 10) - 1; } } this.setOption("mapping.index", index); handleDataSourceChanges.call(this); }), ); this[datasourceLinkedElementSymbol]?.attachObserver( new Observer(() => { let index = 0; if ( typeof this[datasourceLinkedElementSymbol]?.currentPage === "function" ) { const page = this[datasourceLinkedElementSymbol].currentPage(); if (page !== null && page !== undefined && page !== "") { index = parseInt(page, 10) - 1; } } this.setOption("mapping.index", index); handleDataSourceChanges.call(this); }), ); handleDataSourceChanges.call(this); } } /** * * @param {Object} options */ function updateOptionsFromArguments(options) { const index = this.getAttribute(ATTRIBUTE_DATATABLE_INDEX); // @deprecated use data-monster-option-mapping-index if (index !== null && index !== undefined) { options.mapping.index = parseInt(index, 10); } const selector = this.getAttribute(ATTRIBUTE_DATASOURCE_SELECTOR); if (selector) { options.datasource.selector = selector; } } /** * @private */ function initMutationObserver() { const config = { attributes: false, childList: true, subtree: true }; const callback = (mutationList, observer) => { if (mutationList.length === 0) { return; } let doneFlag = false; for (const mutation of mutationList) { if (mutation.type === "childList") { for (const node of mutation.addedNodes) { if ( node instanceof HTMLElement && node.matches(this.getOption("refreshOnMutation.selector")) ) { doneFlag = true; break; } } if (doneFlag) { break; } } } if (doneFlag) { this.refresh(); } }; const observer = new MutationObserver(callback); observer.observe(this, config); } /** * @private * @return {string} */ function getTemplate() { // language=HTML return ` <div data-monster-role="control" part="control"> <slot></slot> </div> `; } registerCustomElement(DataSet);