/** * Copyright schukai GmbH and contributors 2023. All Rights Reserved. * Node module: @schukai/monster * This file is licensed under the AGPLv3 License. * License text available at https://www.gnu.org/licenses/agpl-3.0.en.html */ import { instanceSymbol } from "../../constants.mjs"; import { addAttributeToken } from "../../dom/attributes.mjs"; import { ATTRIBUTE_ERRORMESSAGE, ATTRIBUTE_ROLE, } from "../../dom/constants.mjs"; import { assembleMethodSymbol, attributeObserverSymbol, CustomElement, initMethodSymbol, registerCustomElement, } from "../../dom/customelement.mjs"; import { isString } from "../../types/is.mjs"; import { ATTRIBUTE_FORM_RELOAD, ATTRIBUTE_FORM_URL } from "./constants.mjs"; import { loadAndAssignContent } from "./util/fetch.mjs"; export { Reload }; /** * @private * @type {symbol} */ const intersectionObserverWasInitialized = Symbol("wasInitialized"); /** * This CustomControl reloads the content of a url and embeds it into the dom. * * <img src="./images/reload.png"> * * You can create this control either by specifying the HTML tag `<monster-reload />` directly in the HTML or using * Javascript via the `document.createElement('monster-reload');` method. * * ```html * <monster-reload></monster-reload> * * <script type="module"> * import {Reload} from '@schukai/component-form/source/reload.js'; * document.createElement('monster-reload'); * </script> * ``` * * A simple configuration can look like this * * ```html * <script id="config" * type="application/json"> * { * "url": "./content.html", * } * </script> * * <monster-reload data-monster-options-selector="#config"> * </monster-reload> * ``` * * If you want to display a loader, you can insert a div with the attribute `data-monster-role="container"`. * The content of this div will be replaced by the loaded code. * * ```html * <monster-reload data-monster-options-selector="#config"> * <div data-monster-role="container"> * LOADER ... * </div> * </monster-reload> * ``` * * If you need additional structure, you can simply specify it. * * ```html * <monster-reload data-monster-options-selector="#config"> * <div class="row"> * <div class="col" data-monster-role="container"> * LOADER ... * </div> * </div> * </monster-reload> * ``` * * @startuml reload.png * skinparam monochrome true * skinparam shadowing false * HTMLElement <|-- CustomElement * CustomElement <|-- CustomControl * CustomControl <|-- Reload * @enduml * * @since 1.13.0 * @copyright schukai GmbH * @memberOf Monster.Components.Form * @summary A reload control * @fires Monster.Components.Form.event:monster-fetched */ class Reload extends CustomElement { /** * This method is called by the `instanceof` operator. * @returns {symbol} * @since 2.1.0 */ static get [instanceSymbol]() { return Symbol.for("@schukai/component-form/reload"); } /** * 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 {string} url=undefined * @property {string} reload=undefined currently the values defined are `onshow` and `always`. The default `onshow` removes the IntersectionObserver. This means that the content is only loaded once. reloading of the content does not occur. * @property {string} filter=undefined dom selectors to search for elements, if undefined then everything is taken * @property {Monster.Components.Form.Processor[]} processors * @property {Object} fetch Fetch [see Using Fetch mozilla.org](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API/Using_Fetch) * @property {String} fetch.redirect=error * @property {String} fetch.method=GET * @property {String} fetch.mode=same-origin * @property {String} fetch.credentials=same-origin * @property {Object} fetch.headers={"accept":"text/html"}} */ get defaults() { return Object.assign( {}, super.defaults, { templates: { main: getTemplate.call(this), }, shadowMode: false, url: undefined, reload: "onshow", filter: undefined, fetch: { redirect: "error", method: "GET", mode: "same-origin", credentials: "same-origin", headers: { accept: "text/html", }, }, }, initOptionsFromArguments.call(this), ); } /** * This method determines which attributes are to be monitored by `attributeChangedCallback()`. * * @return {string[]} */ static get observedAttributes() { const list = super.observedAttributes; list.push(ATTRIBUTE_FORM_URL); return list; } /** * */ [initMethodSymbol]() { super[initMethodSymbol](); // data-monster-options this[attributeObserverSymbol][ATTRIBUTE_FORM_URL] = (url) => { if (this.hasAttribute(ATTRIBUTE_FORM_URL)) { this.setOption("url", new URL(url, document.location).toString()); } else { this.setOption("url", undefined); } }; } /** * This method is called internal and should not be called directly. * @throws {Error} missing default slot * @throws {Error} no shadow-root is defined * @throws {Error} missing url * @throws {Error} we won't be able to read the data * @throws {Error} request failed * @throws {Error} not found * @throws {Error} undefined status or type * @fires Monster.Components.Form.event:monster-fetched * @return {Monster.Components.Form.Form} */ [assembleMethodSymbol]() { super[assembleMethodSymbol](); initIntersectionObserver.call(this); } /** * This method is called internal and should not be called directly. * * @return {string} */ static getTag() { return "monster-reload"; } /** * load content from url * * It is important to know that with this function the loading is executed * directly. it is loaded as well when the element is not visible. * * @param {string|undefined} url */ fetch(url) { if (isString(url) || url instanceof URL) { this.setAttribute(ATTRIBUTE_FORM_URL, `${url}`); } return loadContent.call(this); } } /** * This attribute can be used to pass a URL to this select. * * ``` * <monster-select data-monster-url="https://example.com/"></monster-select> * ``` * * @private * @return {object} */ function initOptionsFromArguments() { const options = {}; const url = this.getAttribute(ATTRIBUTE_FORM_URL); if (isString(url)) { options["url"] = new URL(url, document.location).toString(); } if (this.hasAttribute(ATTRIBUTE_FORM_RELOAD)) { options["reload"] = this.getAttribute(ATTRIBUTE_FORM_RELOAD).toLowerCase(); } return options; } /** * @private * @throws {Error} missing default slot * @throws {Error} no shadow-root is defined * @throws {Error} missing url * @throws {Error} we won't be able to read the data * @throws {Error} request failed * @throws {Error} not found * @throws {Error} undefined status or type * @fires Monster.Components.Form.event:monster-fetched */ function initIntersectionObserver() { if (this[intersectionObserverWasInitialized] === true) { return; } this[intersectionObserverWasInitialized] = true; const options = { threshold: [0.5], }; const callback = (entries, observer) => { for (const [, entry] of entries.entries()) { if (entry.isIntersecting === true) { // undefined or always do the same if (this.getOption("reload") === "onshow") { observer.disconnect(); } try { loadContent.call(this).catch((e) => { addAttributeToken(this, ATTRIBUTE_ERRORMESSAGE, e.toString()); }); } catch (e) { addAttributeToken(this, ATTRIBUTE_ERRORMESSAGE, e.toString()); } } } }; const observer = new IntersectionObserver(callback, options); observer.observe(this); } /** * @private * @throws {Error} missing default slot * @throws {Error} no shadow-root is defined * @throws {Error} missing url * @throws {Error} we won't be able to read the data * @throws {Error} request failed * @throws {Error} not found * @throws {Error} undefined status or type * @throws {Error} client error * @throws {Error} undefined status or type * @throws {TypeError} value is not an instance of * @throws {TypeError} value is not a string * @fires Monster.Components.Form.event:monster-fetched * @return {Promise} */ function loadContent() { const url = this.getOption("url", undefined); if (!isString(url) || url === "") { throw new Error("missing url"); } const options = this.getOption("fetch", {}); let parentNode = this; if (this.shadowRoot) { parentNode = this.shadowRoot; } let container = parentNode.querySelector(`[${ATTRIBUTE_ROLE}=container]`); let currentDisplayMode = container?.style?.display; if (currentDisplayMode === undefined) { currentDisplayMode = "inherit"; } if (!(container instanceof HTMLElement)) { container = document.createElement("div"); container.style.display = "none"; container.setAttribute(ATTRIBUTE_ROLE, "container"); parentNode.appendChild(container); } return loadAndAssignContent(container, url, options, this.getOption("filter")) .then(() => { if (currentDisplayMode !== undefined) { container.style.display = currentDisplayMode; } }) .catch((e) => { throw e; }); } /** * @private * @return {string} */ function getTemplate() { return this.innerHTML; } registerCustomElement(Reload);