/** * 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 { buildMap } from "../../data/buildmap.mjs"; import { Pathfinder } from "../../data/pathfinder.mjs"; import { addAttributeToken } from "../../dom/attributes.mjs"; import { ATTRIBUTE_ERRORMESSAGE, ATTRIBUTE_ROLE, } from "../../dom/constants.mjs"; import { assembleMethodSymbol, registerCustomElement, } from "../../dom/customelement.mjs"; import { findTargetElementFromEvent, fireCustomEvent, } from "../../dom/events.mjs"; import { isArray, isPrimitive, isIterable } from "../../types/is.mjs"; import { validateString } from "../../types/validate.mjs"; import { ActionButton } from "./action-button.mjs"; import { ApiButtonStyleSheet } from "./stylesheet/api-button.mjs"; import { isObject, isFunction } from "../../types/is.mjs"; import { getGlobal } from "../../types/global.mjs"; import { Formatter } from "../../text/formatter.mjs"; export { ApiButton }; /** * @private * @type {symbol} */ const containerElementSymbol = Symbol("containerElement"); /** * The ApiButton is a button that opens a popper element with possible actions. * * <img src="./images/api-button.png"> * * Dependencies: the system uses functions of the [monsterjs](https://monsterjs.org/) library * as well as [pooperjs](https://popper.js.org/docs/v2/). * * You can create this control either by specifying the HTML tag <monster-action-button />` directly in the HTML or using * Javascript via the `document.createElement('monster-action-button');` method. * * ```html * <monster-action-button></monster-action-button> * ``` * * Or you can create this CustomControl directly in Javascript: * * ```js * import {PopperButton} from '@schukai/component-form/source/action-button.js'; * document.createElement('monster-action-button'); * ``` * * The `data-monster-button-class` attribute can be used to change the CSS class of the button. * * @startuml api-button.png * skinparam monochrome true * skinparam shadowing false * HTMLElement <|-- CustomElement * CustomElement <|-- CustomControl * CustomControl <|-- Button * Button <|-- ActionButton * ActionButton <|-- ApiButton * @enduml * * @copyright schukai GmbH * @summary A button that opens a popper element with possible actions. */ /** * An API button with icons * * @fragments /fragments/components/form/api-button/ * * @example /examples/components/form/api-button-simple * * @since 3.32.0 * @copyright schukai GmbH * @summary A api button control * @fires monster-button-set * @fires monster-api-button-click * @fires monster-api-button-successful * @fires monster-api-button-failed */ class ApiButton extends ActionButton { /** * This method is called by the `instanceof` operator. * @return {symbol} */ static get [instanceSymbol]() { return Symbol.for("@schukai/monster/components/form/api-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} mapping - The mapping object. * @property {string} mapping.selector - The selector to find the buttons in the response. * @property {string} mapping.labelSelector - The selector to find the label for the button. * @property {string} mapping.labelTemplate - The template to create the label for the button. * @property {string} mapping.apiTemplate - The template to create the api for the button. * @property {string} mapping.urlTemplate - The template to create the url for the button. * @property {function} mapping.filter - The filter function to filter the buttons. * @property {string} url - The url to fetch the data. * @property {object} api - The api options. * @property {object} api.fetch - The fetch options. * @property {string} api.body - The body template. * @property {object} callbacks - The callbacks object. * @property {function} callbacks.beforeApi - The beforeApi callback. * @property {object} fetch - The fetch options. * @property {string} fetch.redirect - The redirect option. * @property {string} fetch.method - The method option. * @property {string} fetch.mode - The mode option. * @property {string} fetch.credentials - The credentials option. * @property {object} fetch.headers - The headers option. * @property {string} fetch.headers.accept - The accept option. * @extends {ActionButton.defaults} */ get defaults() { const opts = Object.assign({}, super.defaults, { mapping: { selector: "*", labelSelector: "", labelTemplate: "", apiTemplate: "", urlTemplate: "", filter: "", }, api: { fetch: { method: "POST", redirect: "error", mode: "same-origin", credentials: "same-origin", headers: { accept: "application/json", }, }, body: {}, }, url: "", callbacks: { beforeApi: null, }, fetch: { redirect: "error", method: "GET", mode: "same-origin", credentials: "same-origin", headers: { accept: "application/json", }, }, }); opts["actions"]["execute"] = executeAPIButton.bind(self); return opts; } /** * * @return {Promise} */ fetch(url) { if (url instanceof URL) { url = url.toString(); } if (url !== undefined) { url = validateString(url); } return fetchData.call(this, url).then((map) => { if ( isObject(map) || isArray(map) | (map instanceof Set) || map instanceof Map ) { this.importButtons(map); } }); } /** * Import buttons from a map. * * @param {array|object|Map|Set} data * @return {Monster.Components.Form.ApiButton} * @throws {Error} map is not iterable * @throws {Error} missing label configuration */ importButtons(data) { const mappingOptions = this.getOption("mapping", {}); const selector = mappingOptions?.["selector"]; const labelSelector = mappingOptions?.["labelSelector"]; const labelTemplate = mappingOptions?.["labelTemplate"]; const apiTemplate = mappingOptions?.["apiTemplate"]; let urlTemplate = mappingOptions?.["urlTemplate"]; const filter = mappingOptions?.["filter"]; let flag = false; let apiEqualUrl = false; if (labelTemplate === "") { addAttributeToken(this, ATTRIBUTE_ERRORMESSAGE, "empty label template"); flag = true; } if (apiTemplate === "") { addAttributeToken(this, ATTRIBUTE_ERRORMESSAGE, "empty api template"); flag = true; } if (urlTemplate === "") { urlTemplate = apiTemplate; apiEqualUrl = true; } if (flag === true) { throw new Error("missing label or api configuration"); } if (isPrimitive(labelSelector) && labelSelector !== "") { const finder = new Pathfinder(data); const label = finder.getVia(labelSelector); this.setOption("labels.button", label); this.value = label; } let labelMap; const urlMap = buildMap(data, selector, urlTemplate, apiTemplate, filter); if (apiEqualUrl === true) { labelMap = urlMap; } else { labelMap = buildMap(data, selector, labelTemplate, apiTemplate, filter); } const buttons = []; if (!isIterable(urlMap)) { throw new Error("map is not iterable"); } for (const [iterKey] of urlMap) { const vmUrl = urlMap.get(iterKey); const vmLabel = labelMap.get(iterKey); buttons.push({ label: vmLabel, class: "monster-button-outline-primary monster-border-0", action: this.getOption("actions.execute"), url: vmUrl, cmd: iterKey, }); } try { this.updateI18n(); } catch (e) { addAttributeToken(this, ATTRIBUTE_ERRORMESSAGE, e.message); } this.setOption("buttons", buttons); fireCustomEvent(this, "monster-button-set", { buttons: buttons, }); return this; } /** * * @return {Monster.Components.Form.Popper} */ [assembleMethodSymbol]() { super[assembleMethodSymbol](); initControlReferences.call(this); initEventHandler.call(this); return this; } /** * @return {string} */ static getTag() { return "monster-api-button"; } /** * @return {Array<CSSStyleSheet>} */ static getCSSStyleSheet() { const styles = super.getCSSStyleSheet(); styles.push(ApiButtonStyleSheet); return styles; } } /** * * @param {Event} event * @param {HTMLElement} button * @param {Monster.Components.Form.ApiButton} element */ function executeAPIButton(event, button, element) { const self = element; const fetchOptions = self.getOption("api.fetch", {}); const callback = self.getOption("callbacks.beforeApi"); if (isFunction(callback)) { callback.call(self, fetchOptions); } let url = undefined; let label = undefined; let key = undefined; const attr = button.getAttribute("data-monster-insert-reference"); if (attr) { const index = attr.split("-")[1]; const b = self.getOption("buttons." + index); url = b?.["url"]; label = b?.["label"]; key = b?.["cmd"]; } const body = self.getOption("api.body"); if (isObject(body)) { const bodyString = JSON.stringify(body); const obj = { url: url, label: label, value: self.getOption("value"), key: key, id: self.getOption("id"), }; fetchOptions.body = new Formatter(obj, {}).format(bodyString); } if (button instanceof HTMLElement) { button.setState("activity"); } fireCustomEvent(self, "monster-api-button-click", { button, }); const global = getGlobal(); global .fetch(url, fetchOptions) .then((response) => { if (!response.ok) { if (button instanceof HTMLElement) { button.setState("successful", 4000); } return Promise.reject(response); } const contentType = response?.headers?.get("content-type"); if (contentType && contentType.indexOf("application/json") !== -1) { return response .text() .then((text) => { try { const data = JSON.parse(text); // Try to parse the response as JSON if (button instanceof HTMLElement) { button.setState("successful", 4000); } fireCustomEvent(self, "monster-api-button-successful", { button, data, response, contentType: response.headers.get("Content-Type"), }); } catch (error) { if (button instanceof HTMLElement) { button.setState("failed", 4000); button.setMessage(error.message).showMessage(2000); } fireCustomEvent(self, "monster-api-button-failed", { button, error, response, contentType: response.headers.get("Content-Type"), }); } }) .catch((error) => { if (button instanceof HTMLElement) { button.setState("failed", 4000); button.setMessage("request failed").showMessage(2000); } fireCustomEvent(self, "monster-api-button-failed", { button, error, response, contentType: response.headers.get("Content-Type"), }); }); } else { return response .blob() .then((data) => { fireCustomEvent(self, "monster-api-button-successful", { button, data, response, contentType: response.headers.get("Content-Type"), }); }) .catch((error) => { if (button instanceof HTMLElement) { button.setState("failed", 4000); button.setMessage("request failed").showMessage(2000); } fireCustomEvent(self, "monster-api-button-failed", { button, error, response, contentType: response.headers.get("Content-Type"), }); }); } }) .catch((error) => { if (button instanceof HTMLElement) { button.setState("failed", 4000); button.setMessage(error.message).showMessage(2000); } fireCustomEvent(self, "monster-api-button-failed", { button, error, }); }); } /** * @private * @param {string} url * @return {Promise} * @throws {TypeError} the result cannot be parsed * @throws {TypeError} unsupported response */ function fetchData(url) { if (!url) url = this.getOption("url"); if (!url) return Promise.resolve(); const fetchOptions = this.getOption("fetch", {}); const global = getGlobal(); return global .fetch(url, fetchOptions) .then((response) => { const contentType = response.headers.get("content-type"); if (contentType && contentType.indexOf("application/json") !== -1) { return response.text(); } throw new TypeError(`unsupported response ${contentType}`); }) .then((text) => { try { return Promise.resolve(JSON.parse(text)); } catch (e) { throw new TypeError("the result cannot be parsed"); } }); } /** * @private * @return {Monster.Components.Form.Popper} */ function initEventHandler() { this[containerElementSymbol].addEventListener("click", (event) => { const element = findTargetElementFromEvent( event, "data-monster-insert-reference", ); const attr = element.getAttribute("data-monster-insert-reference"); if (attr) { const index = attr.split("-")[1]; const b = this.getOption("buttons." + index); if (isObject(b) && isFunction(b?.action)) { b.action(event, element, this); } } }); return this; } /** * @private * @return {Monster.Components.Form.ApiButton} */ function initControlReferences() { this[containerElementSymbol] = this.shadowRoot.querySelector( `[${ATTRIBUTE_ROLE}=container]`, ); return this; } registerCustomElement(ApiButton);