Skip to content
Snippets Groups Projects
Select Git revision
  • 06fc2bd06b85b0c85d74ce62c3b4305e424d8640
  • master default protected
  • 1.31
  • 4.24.3
  • 4.24.2
  • 4.24.1
  • 4.24.0
  • 4.23.6
  • 4.23.5
  • 4.23.4
  • 4.23.3
  • 4.23.2
  • 4.23.1
  • 4.23.0
  • 4.22.3
  • 4.22.2
  • 4.22.1
  • 4.22.0
  • 4.21.0
  • 4.20.1
  • 4.20.0
  • 4.19.0
  • 4.18.0
23 results

index.html

Blame
  • rest.mjs 14.47 KiB
    /**
     * 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 { diff } from "../../../data/diff.mjs";
    import { addAttributeToken } from "../../../dom/attributes.mjs";
    import { ATTRIBUTE_ERRORMESSAGE } from "../../../dom/constants.mjs";
    import { isArray } from "../../../types/is.mjs";
    import { Datasource, dataSourceSymbol } from "../datasource.mjs";
    import { DatasourceStyleSheet } from "../stylesheet/datasource.mjs";
    import { instanceSymbol } from "../../../constants.mjs";
    import {
    	assembleMethodSymbol,
    	registerCustomElement,
    } from "../../../dom/customelement.mjs";
    import { RestAPI } from "../../../data/datasource/server/restapi.mjs";
    import { Formatter } from "../../../text/formatter.mjs";
    import { clone } from "../../../util/clone.mjs";
    import { validateBoolean } from "../../../types/validate.mjs";
    import { findElementWithIdUpwards } from "../../../dom/util.mjs";
    import { Observer } from "../../../types/observer.mjs";
    import { Pathfinder } from "../../../data/pathfinder.mjs";
    import { fireCustomEvent } from "../../../dom/events.mjs";
    
    export { Rest };
    
    /**
     * @private
     * @type {symbol}
     */
    const intersectionObserverHandlerSymbol = Symbol("intersectionObserverHandler");
    
    /**
     * @private
     * Original at source/components/datatable/datasource/rest.mjs
     * @type {symbol}
     */
    const rawDataSymbol = Symbol.for(
    	"@schukai/monster/data/datasource/server/restapi/rawdata",
    );
    
    /**
     * @private
     * @type {symbol}
     */
    const intersectionObserverObserverSymbol = Symbol(
    	"intersectionObserverObserver",
    );
    
    /**
     * @private
     * @type {symbol}
     */
    const filterObserverSymbol = Symbol("filterObserver");
    
    /**
     * A rest api datasource
     *
     * @fragments /fragments/components/datatable/datasource/rest
     *
     * @example /examples/components/datatable/datasource-rest-simple Simple Rest datasource
     * @example /examples/components/datatable/datasource-rest-auto-init Auto init
     * @example /examples/components/datatable/datasource-rest-do-fetch Rest datasource with fetch
     *
     * @issue https://localhost.alvine.dev:8443/development/issues/closed/272.html
     *
     * @copyright schukai GmbH
     * @summary A rest api datasource for the datatable or other components
     */
    class Rest extends Datasource {
    	/**
    	 * the constructor of the class
    	 */
    	constructor() {
    		super();
    		this[dataSourceSymbol] = new RestAPI();
    	}
    
    	/**
    	 * This method is called by the `instanceof` operator.
    	 * @return {symbol}
    	 */
    	static get [instanceSymbol]() {
    		return Symbol.for("@schukai/monster/components/datasource/rest@@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} features Feature definitions
    	 * @property {boolean} features.autoInit If true, the component is initialized automatically
    	 * @property {boolean} features.filter If true, the component is initialized automatically
    	 * @property {Object} autoInit Auto init definitions
    	 * @property {boolean} autoInit.intersectionObserver If true, the intersection observer is initialized automatically
    	 * @property {boolean} autoInit.oneTime If true, the intersection observer is initialized only once
    	 * @property {Object} filter Filter definitions
    	 * @property {string} filter.id The id of the filter control
    	 * @property {Object} response Response definitions
    	 * @property {Object} response.path Path definitions (changed in 3.56.0)
    	 * @property {string} response.path.message Path to the message (changed in 3.56.0)
    	 * @property {Object} read Read configuration
    	 * @property {string} read.url The url of the rest api
    	 * @property {string} read.method The method of the rest api
    	 * @property {Object} read.parameters The parameters of the rest api
    	 * @property {Object} read.parameters.filter The filter of the rest api
    	 * @property {Object} read.parameters.orderBy The order by of the rest api
    	 * @property {Object} read.parameters.page The page of the rest api
    	 * @property {string} read.mapping.currentPage The current page
    	 * @property {Object} write Write configuration
    	 * @property {string} write.url The url of the rest api
    	 * @property {string} write.method The method of the rest api
    	 * @property {Object} write Write configuration
    	 */
    	get defaults() {
    		const restOptions = new RestAPI().defaults;
    
    		restOptions.read.parameters = {
    			filter: null,
    			oderBy: null,
    			page: "1",
    		};
    
    		restOptions.read.mapping.currentPage = "sys.pagination.currentPage";
    
    		return Object.assign({}, super.defaults, restOptions, {
    			templates: {
    				main: getTemplate(),
    			},
    
    			features: {
    				autoInit: false,
    				filter: false,
    			},
    
    			autoInit: {
    				intersectionObserver: false,
    				oneTime: true,
    			},
    
    			filter: {
    				id: null,
    			},
    
    			/*datatable: {
                    id: undefined,  // not used?
                },    */
    
    			response: {
    				path: {
    					message: "sys.message",
    					code: "sys.code",
    				},
    			},
    		});
    	}
    
    	/**
    	 * With this method, you can set the parameters for the rest api. The parameters are
    	 * used for building the url.
    	 *
    	 * @param {string} page
    	 * @param {string} query
    	 * @param {string} orderBy
    	 * @return {Rest}
    	 */
    	setParameters({ page, query, orderBy }) {
    		const parameters = this.getOption("read.parameters");
    		if (query !== undefined) {
    			parameters.query = `${query}`;
    			parameters.page = "1";
    		}
    
    		// after a query the page is set to 1, so if the page is not set, it is set to 1
    		if (page !== undefined) parameters.page = `${page}`;
    		if (orderBy !== undefined) parameters.order = `${orderBy}`;
    		this.setOption("read.parameters", parameters);
    		return this;
    	}
    
    	/**
    	 * @private
    	 * @return {void}
    	 */
    	[assembleMethodSymbol]() {
    		super[assembleMethodSymbol]();
    		initEventHandler.call(this);
    		initAutoInit.call(this);
    	}
    
    	/**
    	 * This method reloads the data from the rest api, this method is deprecated.
    	 * You should use the method `read` instead.
    	 *
    	 * @deprecated 2023-06-25
    	 * @return {Promise<never>|*}
    	 */
    	reload() {
    		return this.read();
    	}
    
    	/**
    	 * Fetches the data from the rest api, this method is deprecated.
    	 * You should use the method `read` instead.
    	 *
    	 * @deprecated 2024-12-24
    	 * @return {Promise<never>|*}
    	 */
    	fetch() {
    		return this.read();
    	}
    
    	/**
    	 *
    	 * @return {CSSStyleSheet[]}
    	 */
    	static getCSSStyleSheet() {
    		return [DatasourceStyleSheet];
    	}
    
    	/**
    	 * @private
    	 * @return {string}
    	 */
    	static getTag() {
    		return "monster-datasource-rest";
    	}
    
    	/**
    	 * This method activates the intersection observer manually.
    	 * For this purpose, the option `autoInit.intersectionObserver` must be set to `false`.
    	 *
    	 * @return {Rest}
    	 */
    	initIntersectionObserver() {
    		initIntersectionObserver.call(this);
    		return this;
    	}
    
    	/**
    	 * @private
    	 */
    	connectedCallback() {
    		super.connectedCallback();
    
    		queueMicrotask(() => {
    			if (this.getOption("features.filter", false) === true) {
    				initFilter.call(this);
    			}
    		});
    	}
    
    	/**
    	 * @private
    	 */
    	disconnectedCallback() {
    		super.disconnectedCallback();
    		removeFilter.call(this);
    	}
    
    	/**
    	 * This method reads the data from the rest api.
    	 * The data is stored in the internal dataset object.
    	 *
    	 * @return {Promise}
    	 * @fires monster-datasource-fetch
    	 * @fires monster-datasource-fetched
    	 * @fires monster-datasource-error
    	 */
    	read() {
    		const opt = clone(this.getOption("read"));
    		this[dataSourceSymbol].setOption("read", opt);
    
    		let url = this.getOption("read.url");
    		const formatter = new Formatter(this.getOption("read.parameters"));
    
    		if (!url) {
    			return Promise.reject(new Error("No url defined"));
    		}
    
    		url = formatter.format(url);
    
    		this[dataSourceSymbol].setOption("read.url", url);
    
    		return new Promise((resolve, reject) => {
    			fireCustomEvent(this, "monster-datasource-fetch", {
    				datasource: this,
    			});
    
    			queueMicrotask(() => {
    				this[dataSourceSymbol]
    					.read()
    					.then((response) => {
    						fireCustomEvent(this, "monster-datasource-fetched", {
    							datasource: this,
    						});
    
    						resolve(response);
    					})
    					.catch((error) => {
    						fireCustomEvent(this, "monster-datasource-error", {
    							error: error,
    						});
    
    						addAttributeToken(this, ATTRIBUTE_ERRORMESSAGE, error.toString());
    						reject(error);
    					});
    			});
    		});
    	}
    
    	/**
    	 * Fetches the data from the rest api.
    	 * @return {Promise}
    	 */
    	write() {
    		const opt = clone(this.getOption("write"));
    		this[dataSourceSymbol].setOption("write", opt);
    
    		let url = this.getOption("write.url");
    		const formatter = new Formatter(this.getOption("write.parameters"));
    
    		if (!url) {
    			return Promise.reject(new Error("No url defined"));
    		}
    
    		url = formatter.format(url);
    
    		this[dataSourceSymbol].setOption("write.url", url);
    
    		return new Promise((resolve, reject) => {
    			fireCustomEvent(this, "monster-datasource-fetch", {
    				datasource: this,
    			});
    
    			queueMicrotask(() => {
    				this[dataSourceSymbol]
    					.write()
    					.then((response) => {
    						fireCustomEvent(this, "monster-datasource-fetched", {
    							datasource: this,
    						});
    
    						resolve(response);
    					})
    					.catch((error) => {
    						fireCustomEvent(this, "monster-datasource-error", {
    							error: error,
    						});
    
    						addAttributeToken(this, ATTRIBUTE_ERRORMESSAGE, error.toString());
    						reject(error);
    					});
    			});
    		});
    	}
    
    	// /**
    	//  * @return {int}
    	//  */
    	// currentPage() {
    	//
    	//     const key = this.getOption("read.mapping.currentPage")
    	//     if (key === undefined) {
    	//         return 1;
    	//     }
    	//
    	//     const pf = new Pathfinder(this.data);
    	//     if (pf.exists(key)) {
    	//         return parseInt(pf.getVia(key), 10);
    	//     }
    	//
    	//     return 1;
    	//
    	// }
    }
    
    /**
     * @private
     */
    function removeFilter() {
    	const filterID = this.getOption("filter.id", undefined);
    	if (!filterID) return;
    
    	const filterControl = findElementWithIdUpwards(this, filterID);
    
    	if (filterControl && this[filterObserverSymbol]) {
    		filterControl?.detachObserver(this[filterObserverSymbol]);
    	}
    }
    
    /**
     * @private
     */
    function initFilter() {
    	const filterID = this.getOption("filter.id", undefined);
    
    	if (!filterID)
    		throw new Error("filter feature is enabled but no filter id is defined");
    
    	const filterControl = findElementWithIdUpwards(this, filterID);
    	if (!filterControl)
    		throw new Error(
    			"filter feature is enabled but no filter control with id " +
    				filterID +
    				" is found",
    		);
    
    	this[filterObserverSymbol] = new Observer(() => {
    		const query = filterControl.getOption("query");
    		if (query === undefined) {
    			return;
    		}
    		this.setParameters({ query: query });
    		this.fetch()
    			.then((response) => {
    				if (!(response instanceof Response)) {
    					throw new Error("Response is not an instance of Response");
    				}
    
    				if (response?.ok === true) {
    					this.dispatchEvent(new CustomEvent("reload", { bubbles: true }));
    					filterControl?.showSuccess();
    				}
    
    				if (response.bodyUsed === true) {
    					return handleIntersectionObserver.call(
    						this,
    						response[rawDataSymbol],
    						response,
    						filterControl,
    					);
    				}
    
    				response
    					.text()
    					.then((jsonAsText) => {
    						let json;
    						try {
    							json = JSON.parse(jsonAsText);
    						} catch (e) {
    							const message = e instanceof Error ? e.message : `${e}`;
    							filterControl?.showFailureMessage(message);
    							return Promise.reject(e);
    						}
    
    						return handleIntersectionObserver.call(
    							this,
    							json,
    							response,
    							filterControl,
    						);
    					})
    					.catch((e) => {
    						filterControl?.showFailureMessage(e.message);
    					});
    			})
    			.catch((e) => {
    				this.dispatchEvent(
    					new CustomEvent("error", { bubbles: true, detail: e }),
    				);
    
    				if (!(e instanceof Error)) {
    					e = new Error(e);
    				}
    
    				filterControl?.showFailureMessage(e.message);
    				return Promise.reject(e);
    			});
    	});
    
    	filterControl.attachObserver(this[filterObserverSymbol]);
    }
    
    /**
     * @private
     * @param json
     * @param response
     * @param filterControl
     * @returns {Promise<never>|Promise<Awaited<unknown>>}
     */
    function handleIntersectionObserver(json, response, filterControl) {
    	const path = new Pathfinder(json);
    
    	const codePath = this.getOption("response.path.code");
    
    	if (path.exists(codePath)) {
    		const code = `${path.getVia(codePath)}`;
    		if (code && code === "200") {
    			filterControl?.showSuccess();
    			return Promise.resolve(response);
    		}
    
    		const messagePath = this.getOption("response.path.message");
    		if (path.exists(messagePath)) {
    			const message = path.getVia(messagePath);
    			filterControl?.showFailureMessage(message);
    			return Promise.reject(new Error(message));
    		}
    
    		return Promise.reject(new Error("Response code is not 200"));
    	}
    }
    
    /**
     * @private
     */
    function initAutoInit() {
    	const autoInit = this.getOption("features.autoInit");
    	validateBoolean(autoInit);
    
    	if (autoInit !== true) return;
    
    	if (this.getOption("autoInit.intersectionObserver") === true) {
    		initIntersectionObserver.call(this);
    		return;
    	}
    
    	queueMicrotask(() => {
    		this.fetch().catch(() => {});
    	});
    }
    
    /**
     * @private
     */
    function initEventHandler() {
    	this[intersectionObserverHandlerSymbol] = (entries) => {
    		entries.forEach((entry) => {
    			if (entry.isIntersecting) {
    				if (entry.intersectionRatio > 0) {
    					this.fetch();
    				}
    
    				// only load once
    				if (
    					this.getOption("autoInit.oneTime") === true &&
    					this[intersectionObserverObserverSymbol] !== undefined
    				) {
    					this[intersectionObserverObserverSymbol].unobserve(this);
    				}
    			}
    		});
    	};
    }
    
    /**
     * @private
     */
    function initIntersectionObserver() {
    	this.classList.add("intersection-observer");
    
    	const options = {
    		root: null,
    		rootMargin: "0px",
    		threshold: 0.1,
    	};
    
    	this[intersectionObserverObserverSymbol] = new IntersectionObserver(
    		this[intersectionObserverHandlerSymbol],
    		options,
    	);
    
    	this[intersectionObserverObserverSymbol].observe(this);
    }
    
    /**
     * @private
     * @return {string}
     */
    function getTemplate() {
    	// language=HTML
    	return `
            <slot></slot>`;
    }
    
    registerCustomElement(Rest);