/** * 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. */ import {instanceSymbol} from "../../constants.mjs"; import {addAttributeToken} from "../../dom/attributes.mjs"; import { ATTRIBUTE_ERRORMESSAGE, ATTRIBUTE_ROLE, } from "../../dom/constants.mjs"; import { assembleMethodSymbol, registerCustomElement, } from "../../dom/customelement.mjs"; import {isArray, isFunction, isString, isIterable, isObject, isPrimitive} from "../../types/is.mjs"; import {fireCustomEvent} from "../../dom/events.mjs"; import {ButtonBar} from "./button-bar.mjs"; import {validateString} from "../../types/validate.mjs"; import {Pathfinder} from "../../data/pathfinder.mjs"; import {buildMap} from "../../data/buildmap.mjs"; import {ApiButtonStyleSheet} from "./stylesheet/api-button.mjs"; import {Formatter} from "../../text/formatter.mjs"; import {getGlobal} from "../../types/global.mjs"; import "./button.mjs"; import "./message-state-button.mjs"; import "./state-button.mjs"; import {MessageStateButton} from "./message-state-button.mjs"; import {StateButton} from "./state-button.mjs"; export {ApiBar}; /** * A ApiBar * * @fragments /fragments/components/form/api-bar/ * * @example /examples/components/form/api-bar-simple * * @since 3.90.0 * @copyright schukai GmbH * @summary A beautiful ApiBar that can make your life easier and also looks good. */ class ApiBar extends ButtonBar { /** * This method is called by the `instanceof` operator. * @returns {symbol} */ static get [instanceSymbol]() { return Symbol.for("@schukai/monster/components/form/api-bar@@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 key to find the api value in the response. * @property {string} mapping.urlTemplate - The key to find the url value in the response, if empty the api value is used. * @property {function} mapping.filter - The filter function to filter the buttons. * @property {string} url - The url to fetch the data. * @property {string} buttonTag - The tag name of the button * @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.beforeApiCall - The beforeApiCall callback called before the api request is made. * @property {function} callbacks.failedApiCall - The failedApiCall callback called when the api request failed. * @property {function} callbacks.successfulApiCall - The successfulApiCall callback called when the api request was successful. * @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 acceptance option. * @property {object} actions - The actions object. * @property {function} actions.execute - The execute action. * @property {object} data - The data object, this can be used to store some data and send it with the request. * @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: null, }, url: "", buttonTag: "monster-message-state-button", callbacks: { beforeApiCal: null, failedApiCall: null, successfulApiCall: null, }, fetch: { redirect: "error", method: "GET", mode: "same-origin", credentials: "same-origin", headers: { accept: "application/json", }, }, actions: { execute: executeAPIButton }, data: null }); 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 {ApiButton} * @throws {Error} map is not iterable * @throws {Error} missing label configuration */ importButtons(data) { const self = this; const currentButtons = self.querySelectorAll(`[${ATTRIBUTE_ROLE}="api-button"]`); for (const btnElement of currentButtons) { btnElement.remove(); } 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, check the error attribute"); } 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"); } const buttonTag = this.getOption("buttonTag"); const executerCallback = this.getOption("actions.execute"); for (const [iterKey] of urlMap) { const vmUrl = urlMap.get(iterKey); const vmLabel = labelMap.get(iterKey); const button = getGlobal().document.createElement(buttonTag); button.setAttribute(ATTRIBUTE_ROLE, `api-button`); button.setOption("labels.button", vmLabel); button.setOption("actions.click", (event) => { if (isFunction(executerCallback)) { executerCallback.call(this, event, { key: iterKey, url: vmUrl, label: vmLabel, button: button, }) } }); this.appendChild(button); } 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 {void} */ [assembleMethodSymbol]() { super[assembleMethodSymbol](); } /** * @return {string} */ static getTag() { return "monster-api-bar"; } /** * @return {Array<CSSStyleSheet>} */ static getCSSStyleSheet() { const styles = super.getCSSStyleSheet(); styles.push(ApiButtonStyleSheet); return styles; } } /** * * @param {Event} event * @param {object} opts */ function executeAPIButton(event, opts) { const self = this; if (!isObject(opts)) { opts = {}; } const button = opts?.["button"]; const fetchOptions = self.getOption("api.fetch", {}); const callback = self.getOption("callbacks.beforeApiCall"); if (isFunction(callback)) { callback.call(self, fetchOptions); } const successfulApiCall = self.getOption("callbacks.successfulApiCall"); const failedApiCall = self.getOption("callbacks.failedApiCall"); let url = opts?.["url"]; let label = opts?.["label"]; let key = opts?.["key"]; let body = self.getOption("api.body"); if (isString(body)) { try { body = JSON.parse(body); } catch (e) { body = {}; } } if (isObject(body)) { const bodyString = JSON.stringify(body); const obj = { url: url, label: label, key: key, data: self.getOption("data"), }; fetchOptions.body = new Formatter(obj, {}).format(bodyString); } if (button instanceof HTMLElement) { button?.setState("activity"); } fireCustomEvent(self, "monster-api-bar-click", { button, }); const global = getGlobal(); global .fetch(url, fetchOptions) .then((response) => { if (!response.ok) { if (button instanceof MessageStateButton || button instanceof StateButton) { button.setState("failed", 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 MessageStateButton || button instanceof StateButton) { button.setState("successful", 4000); } fireCustomEvent(self, "monster-api-bar-successful", { button, data, response, contentType: response.headers.get("Content-Type"), }); if (isFunction(successfulApiCall)) { successfulApiCall.call(self, data, response); } } catch (error) { if (button instanceof HTMLElement) { button.setState("failed", 4000); button.setMessage(error.message).showMessage(2000); } fireCustomEvent(self, "monster-api-bar-failed", { button, error, response, contentType: response.headers.get("Content-Type"), }); } }) .catch((error) => { button.setState("failed", 4000); if (isFunction(failedApiCall)) { failedApiCall.call(self, error, response); } else if (button instanceof MessageStateButton || button instanceof StateButton) { button.setMessage("request failed").showMessage(2000); } fireCustomEvent(self, "monster-api-bar-failed", { button, error, response, contentType: response.headers.get("Content-Type"), }); }); } else { return response .blob() .then((data) => { if (button instanceof MessageStateButton || button instanceof StateButton) { button.setState("successful", 4000); } fireCustomEvent(self, "monster-api-bar-successful", { button, data, response, contentType: response.headers.get("Content-Type"), }); if (isFunction(successfulApiCall)) { successfulApiCall.call(self, data, response); } }) .catch((error) => { if (button instanceof MessageStateButton || button instanceof StateButton) { button.setState("failed", 4000); } if (isFunction(failedApiCall)) { failedApiCall.call(self, error, response); } else if (button instanceof MessageStateButton || button instanceof StateButton) { if (error instanceof Response) { error = new Error(error.statusText); } button.setMessage("request failed").showMessage(2000); } fireCustomEvent(self, "monster-api-bar-failed", { button, error, response, contentType: response.headers.get("Content-Type"), }); }); } }) .catch((error) => { if (button instanceof MessageStateButton || button instanceof StateButton) { button.setState("failed", 4000); } if (isFunction(failedApiCall)) { failedApiCall.call(self, button, error, response); } else if (button instanceof MessageStateButton || button instanceof StateButton) { if (error instanceof Response) { error = new Error(error.statusText); } button?.setMessage(error.message).showMessage(3000); } fireCustomEvent(self, "monster-api-bar-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"); } }); } registerCustomElement(ApiBar);