Something went wrong on our end
Select Git revision
replaceSkypack.mjs
-
Volker Schukai authoredVolker Schukai authored
api-button.mjs 13.79 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 { 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);