UNPKG

@schukai/monster

Version:

Monster is a simple library for creating fast, robust and lightweight websites.

1,690 lines (1,497 loc) 43.1 kB
/** * Copyright © Volker Schukai 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 Volker Schukai. * * SPDX-License-Identifier: AGPL-3.0 */ import { instanceSymbol } from "../../constants.mjs"; import { findTargetElementFromEvent, fireCustomEvent, } from "../../dom/events.mjs"; import { findElementWithIdUpwards, findElementWithSelectorUpwards, } from "../../dom/util.mjs"; import { assembleMethodSymbol, CustomElement, getSlottedElements, registerCustomElement, } from "../../dom/customelement.mjs"; import { ID } from "../../types/id.mjs"; import { Settings } from "./filter/settings.mjs"; import { FilterStyleSheet } from "./stylesheet/filter.mjs"; import { getDocument, getWindow } from "../../dom/util.mjs"; import { getGlobal } from "../../types/global.mjs"; import { isInstance, isFunction, isObject, isArray, isString, } from "../../types/is.mjs"; import { Host } from "../host/host.mjs"; import { addAttributeToken } from "../../dom/attributes.mjs"; import { ATTRIBUTE_ERRORMESSAGE } from "../../dom/constants.mjs"; import "../form/message-state-button.mjs"; import { Formatter } from "../../text/formatter.mjs"; import { generateRangeComparisonExpression } from "../../text/util.mjs"; import { parseBracketedKeyValueHash, createBracketedKeyValueHash, } from "../../text/bracketed-key-value-hash.mjs"; import { ThemeStyleSheet } from "../stylesheet/theme.mjs"; import { SpaceStyleSheet } from "../stylesheet/space.mjs"; import { FormStyleSheet } from "../stylesheet/form.mjs"; import { getStoredFilterConfigKey, getFilterConfigKey, parseDateInput, } from "./filter/util.mjs"; import "./filter/select.mjs"; import "../form/button.mjs"; import "../form/select.mjs"; import "../form/popper-button.mjs"; import "../form/toggle-switch.mjs"; import "../form/context-help.mjs"; import "../form/context-error.mjs"; import "../form/message-state-button.mjs"; import { getLocaleOfDocument } from "../../dom/locale.mjs"; import { normalizeNumber } from "../../i18n/util.mjs"; export { Filter }; /** * @private * @type {symbol} */ const filterSelectElementSymbol = Symbol("filterSelectElement"); /** * @private * @type {symbol} */ const searchButtonElementSymbol = Symbol("searchButtonElement"); /** * @private * @type {symbol} */ const resetButtonElementSymbol = Symbol("resetButtonElement"); /** * @private * @type {symbol} */ const saveButtonElementSymbol = Symbol("saveButtonElement"); /** * @private * @type {symbol} */ const filterControlElementSymbol = Symbol("filterControlElement"); /** * @private * @type {symbol} */ const filterSaveActionButtonElementSymbol = Symbol( "filterSaveActionButtonElement", ); /** * @private * @type {symbol} */ const filterTabElementSymbol = Symbol("filterTabElement"); /** * @private * @type {symbol} */ const locationChangeHandlerSymbol = Symbol("locationChangeHandler"); /** * @private * @type {symbol} */ const settingsSymbol = Symbol("settings"); /** * @private * @type {symbol} */ const resizeObserverSymbol = Symbol("resizeObserver"); /** * @private * @type {symbol} */ const sizeDataSymbol = Symbol("sizeData"); /** * @private * @type {symbol} */ const debounceSizeSymbol = Symbol("debounceSize"); /** * @private * @type {symbol} */ const hashChangeSymbol = Symbol("hashChange"); /** * The Filter component is used to show and handle the filter values. * * @fragments /fragments/components/datatable/filter * * @example /examples/components/datatable/filter-simple First filter * @example /examples/components/datatable/filter-advanced Advanced filter * @example /examples/components/datatable/filter-store Store filter * * @issue https://localhost.alvine.dev:8440/development/issues/closed/272.html * * @copyright Volker Schukai * @summary The Filter component is used to show and handle the filter values. */ class Filter extends CustomElement { /** * */ constructor() { super(); this[settingsSymbol] = new Settings(); // debounce the hash change event if doSearch is called by click the search button this[hashChangeSymbol] = 0; } /** * This method is called by the `instanceof` operator. * @return {symbol} */ static get [instanceSymbol]() { return Symbol.for("@schukai/monster/components/filter@@instance"); } /** * * @param {string} message * @return {Filter} */ showFailureMessage(message) { this[searchButtonElementSymbol].setState( "failed", this.getOption("timeouts.message", 4000), ); this[searchButtonElementSymbol] .setMessage(message.toString()) .showMessage(this.getOption("timeouts.message", 4000)); return this; } /** * * @return {Filter} */ resetFailureMessage() { this[searchButtonElementSymbol].hideMessage(); this[searchButtonElementSymbol].removeState(); return this; } /** * * @return {Filter} */ showSuccess() { this[searchButtonElementSymbol].setState( "successful", this.getOption("timeouts.message", 4000), ); return this; } /** * 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} labels Label definitions * @property {string} labels.search Search button label * @property {string} labels.reset Reset button label * @property {string} labels.save Save button label * @property {string} labels.filter-name Filter name label * @property {string} labels.empty-query-and-no-default Empty query and no default query label * @property {string} labels.query-not-changed Query not changed label * @property {Object} formatter Formatter definitions * @property {Object} formatter.marker Marker definitions * @property {Object} formatter.marker.open Marker open * @property {Object} formatter.marker.close Marker close * @property {Object} features Feature definitions * @property {boolean} features.storedConfig Stored configuration, this replaces the setting `storedConfig.enabled` @since 3.97.0 * @property {boolean} features.autoFilter Auto filter @since 3.100.0 * @property {boolean} features.preventSameQuery Prevent same query @since 3.103.0 * @property {Object} storedConfig Stored configuration * @property {boolean} storedConfig.enabled The store has been enabled, this option will no longer have any effect. @deprecated 20250101 * @property {string} storedConfig.selector Selector * @property {Object} timeouts Timeout definitions * @property {number} timeouts.message Message timeout * @property {Object} queries Query definitions * @property {Function} queries.wrap Wrap callback * @property {Function} queries.join Join callback * @property {string} query Query * @property {string} defaultQuery Default query * @property {boolean} eventProcessing Event processing */ get defaults() { return Object.assign({}, super.defaults, { templates: { main: getTemplate(), }, formatter: { marker: { open: null, close: null, }, }, labels: getTranslations(), templateMapping: { "filter-save-label": null, "filter-name-label": name, }, features: { storedConfig: false, autoFilter: true, preventSameQuery: false, }, storedConfig: { enabled: true, selector: "", }, timeouts: { message: 4000, }, queries: { wrap: (value, definition) => { return value; }, join: (queries) => { if (queries.length === 0) { return ""; } return queries.join(" AND "); }, }, query: null, defaultQuery: null, eventProcessing: true, templateFormatter: { marker: { open: null, close: null, }, i18n: true, }, }); } /** * * @return {string} */ static getTag() { return "monster-datatable-filter"; } /** * @return {FilterButton} * @fires monster-filter-initialized */ [assembleMethodSymbol]() { const self = this; this.setOption( "templateMapping.filter-save-label", this.getOption("labels.save"), ); this.setOption( "templateMapping.filter-name-label", this.getOption("labels.filter-name"), ); super[assembleMethodSymbol](); initControlReferences.call(self); getWindow().requestAnimationFrame(() => { initEventHandler.call(self); }); initFromConfig .call(self) .then(() => {}) .catch((error) => { addAttributeToken(self, ATTRIBUTE_ERRORMESSAGE, error?.message); }) .finally(() => { initFilter.call(self); updateFilterTabs.call(self); if (self.getOption("features.autoFilter") === true) { doSearch .call(self, { showEffect: false }) .then(() => { fireCustomEvent(self, "monster-filter-initialized"); }) .catch(() => {}); } }); } /** * */ connectedCallback() { super.connectedCallback(); getWindow().addEventListener( "hashchange", this[locationChangeHandlerSymbol], ); } /** * */ disconnectedCallback() { super.disconnectedCallback(); getWindow().removeEventListener( "hashchange", this[locationChangeHandlerSymbol], ); } /** * @return {CSSStyleSheet[]} */ static getCSSStyleSheet() { return [FilterStyleSheet, FormStyleSheet, ThemeStyleSheet, SpaceStyleSheet]; } } /** * @private * @returns {object} */ function getTranslations() { const locale = getLocaleOfDocument(); switch (locale.language) { case "de": return { search: "Suchen", reset: "Zurücksetzen", save: "Speichern", "filter-name": "Filtername", "empty-query-and-no-default": "Die Abfrage ist leer und es gibt keine Standardabfrage.", "query-not-changed": "Die Suchanfrage hat sich nicht verändert, daher ist keine Suche erforderlich.", }; case "fr": return { search: "Chercher", reset: "Réinitialiser", save: "Sauvegarder", "filter-name": "Nom du filtre", "empty-query-and-no-default": "La requête est vide et il n'y a pas de requête par défaut.", "query-not-changed": "La requête de recherche n'a pas changé, donc aucune recherche n'est nécessaire.", }; case "es": return { search: "Buscar", reset: "Restablecer", save: "Guardar", "filter-name": "Nombre del filtro", "empty-query-and-no-default": "La consulta está vacía y no hay una consulta predeterminada.", "query-not-changed": "La solicitud de búsqueda no ha cambiado, por lo que no se requiere búsqueda.", }; case "it": return { search: "Cerca", reset: "Reimposta", save: "Salva", "filter-name": "Nome del filtro", "empty-query-and-no-default": "La query è vuota e non c'è una query predefinita.", "query-not-changed": "La richiesta di ricerca non è cambiata, quindi non è necessaria una ricerca.", }; case "pl": return { search: "Szukaj", reset: "Resetuj", save: "Zapisz", "filter-name": "Nazwa filtra", "empty-query-and-no-default": "Zapytanie jest puste i nie ma domyślnego zapytania.", "query-not-changed": "Żądanie wyszukiwania nie zmieniło się, więc wyszukiwanie nie jest wymagane.", }; case "no": return { search: "Søk", reset: "Tilbakestill", save: "Lagre", "filter-name": "Filternavn", "empty-query-and-no-default": "Spørringen er tom og det er ingen standardspørring.", "query-not-changed": "Søkeforespørselen har ikke endret seg, så ingen søk er nødvendig.", }; case "da": return { search: "Søg", reset: "Nulstil", save: "Gem", "filter-name": "Filternavn", "empty-query-and-no-default": "Forespørgslen er tom og der er ingen standardforespørgsel.", "query-not-changed": "Søgeanmodningen er ikke ændret, så ingen søgning er nødvendig.", }; case "sv": return { search: "Sök", reset: "Återställ", save: "Spara", "filter-name": "Filternamn", "empty-query-and-no-default": "Förfrågan är tom och det finns ingen standardförfrågan.", "query-not-changed": "Sökförfrågan har inte ändrats, så ingen sökning krävs.", }; case "nl": return { search: "Zoeken", reset: "Resetten", save: "Opslaan", "filter-name": "Filternaam", "empty-query-and-no-default": "De zoekopdracht is leeg en er is geen standaardzoekopdracht.", "query-not-changed": "De zoekopdracht is niet gewijzigd, dus zoeken is niet nodig.", }; case "fi": return { search: "Haku", reset: "Palauta", save: "Tallenna", "filter-name": "Suodattimen nimi", "empty-query-and-no-default": "Hakukysely on tyhjä eikä oletushakua ole määritetty.", "query-not-changed": "Hakupyyntö ei ole muuttunut, joten hakua ei tarvita.", }; case "cs": return { search: "Hledat", reset: "Resetovat", save: "Uložit", "filter-name": "Název filtru", "empty-query-and-no-default": "Dotaz je prázdný a není nastavena žádná výchozí hodnota.", "query-not-changed": "Dotaz na hledání se nezměnil, takže hledání není nutné.", }; case "pt": return { search: "Buscar", reset: "Redefinir", save: "Salvar", "filter-name": "Nome do filtro", "empty-query-and-no-default": "A consulta está vazia e não há uma consulta padrão.", "query-not-changed": "A solicitação de pesquisa não foi alterada, portanto, nenhuma pesquisa é necessária.", }; case "ru": return { search: "Поиск", reset: "Сброс", save: "Сохранить", "filter-name": "Имя фильтра", "empty-query-and-no-default": "Запрос пуст и нет запроса по умолчанию.", "query-not-changed": "Поисковый запрос не изменился, поэтому поиск не требуется.", }; case "zh": return { search: "搜索", reset: "重置", save: "保存", "filter-name": "过滤器名称", "empty-query-and-no-default": "查询为空,且没有默认查询。", "query-not-changed": "搜索请求没有更改,因此不需要进行搜索。", }; case "hi": return { search: "खोजें", reset: "रीसेट करें", save: "सहेजें", "filter-name": "फ़िल्टर नाम", "empty-query-and-no-default": "क्वेरी खाली है और कोई डिफ़ॉल्ट क्वेरी नहीं है।", "query-not-changed": "खोज अनुरोध में कोई बदलाव नहीं हुआ है, इसलिए खोज आवश्यक नहीं है।", }; case "bn": return { search: "অনুসন্ধান", reset: "রিসেট", save: "সংরক্ষণ করুন", "filter-name": "ফিল্টারের নাম", "empty-query-and-no-default": "কোয়েরি খালি এবং কোনো ডিফল্ট কোয়েরি নেই।", "query-not-changed": "অনুসন্ধানের অনুরোধ পরিবর্তন হয়নি, তাই অনুসন্ধান প্রয়োজন নয়।", }; case "ja": return { search: "検索", reset: "リセット", save: "保存", "filter-name": "フィルター名", "empty-query-and-no-default": "クエリが空で、デフォルトクエリがありません。", "query-not-changed": "検索リクエストに変更がないため、検索は不要です。", }; case "pa": return { search: "ਖੋਜੋ", reset: "ਰੀਸੈੱਟ ਕਰੋ", save: "ਸੇਵ ਕਰੋ", "filter-name": "ਫਿਲਟਰ ਦਾ ਨਾਂ", "empty-query-and-no-default": "ਕੁਐਰੀ ਖਾਲੀ ਹੈ ਅਤੇ ਕੋਈ ਡਿਫੌਲਟ ਕੁਐਰੀ ਨਹੀਂ ਹੈ।", "query-not-changed": "ਖੋਜ ਦੀ ਬੇਨਤੀ ਵਿੱਚ ਕੋਈ ਤਬਦੀਲੀ ਨਹੀਂ ਆਈ ਹੈ, ਇਸ ਲਈ ਖੋਜ ਦੀ ਲੋੜ ਨਹੀਂ ਹੈ।", }; default: case "en": return { search: "Search", reset: "Reset", save: "Save", "filter-name": "Filter name", "empty-query-and-no-default": "The query is empty and there is no default query.", "query-not-changed": "The search request has not changed, so no search is required.", }; } } /** * @private * @return {FilterButton} */ function initControlReferences() { if (!this.shadowRoot) { throw new Error("no shadow-root is defined"); } this[filterControlElementSymbol] = this.shadowRoot.querySelector( "[data-monster-role=control]", ); this[filterSelectElementSymbol] = this.shadowRoot.querySelector( "[data-monster-role=filter-select]", ); this[searchButtonElementSymbol] = this.shadowRoot.querySelector( "[data-monster-role=search-button]", ); this[resetButtonElementSymbol] = this.shadowRoot.querySelector( "[data-monster-role=reset-button]", ); this[saveButtonElementSymbol] = this.shadowRoot.querySelector( "[data-monster-role=save-button]", ); this[filterSaveActionButtonElementSymbol] = this.shadowRoot.querySelector( "[data-monster-role=save-action-button]", ); this[filterTabElementSymbol] = findElementWithSelectorUpwards( this, this.getOption("storedConfig.selector", ""), ); return this; } /** * @private */ function updateFilterSelections() { queueMicrotask(() => { const options = this[settingsSymbol].getOptions(); this[filterSelectElementSymbol].setOption("options", options); setTimeout(() => { this[filterSelectElementSymbol].value = this[settingsSymbol].getSelected(); }, 10); }); } /** * @private * @throws {Error} no filter label is defined */ function initFilter() { const storedSetting = this[settingsSymbol]; this[settingsSymbol] = new Settings(); const result = parseBracketedKeyValueHash(getGlobal().location.hash); let valuesFromHash = {}; if (isObject(result) && result?.[this.id]) { valuesFromHash = result[this.id]; } getSlottedElements .call(this, "label[data-monster-label]") .forEach((element) => { const label = element.getAttribute("data-monster-label"); if (!label) { addAttributeToken( this, ATTRIBUTE_ERRORMESSAGE, "no filter label is defined", ); return; } let value = element.id; if (!value) { const prefix = label.replace(/\W/g, "-"); prefix.charAt(0).match(/[\d_]/g)?.length ? `f${prefix}` : prefix; value = new ID(prefix + "-").toString(); element.id = value; } let setting = storedSetting.get(value); if (setting) { this[settingsSymbol].set(setting); } if (valuesFromHash?.[element.id]) { const v = valuesFromHash[element.id]; const searchInput = element.firstElementChild; try { searchInput.value = normalizeFilterValue(v); } catch (error) {} } setting = this[settingsSymbol].get(value); let visible = false; if (setting) { setSlotAttribute(element, setting.visible); visible = setting.visible; } else { visible = getVisibilityFromSlotAttribute(element); } this[settingsSymbol].set({ value, label, visible }); }); updateFilterSelections.call(this); } /** * @private * @param {*} value * @return {*} */ function normalizeFilterValue(value) { if (!isString(value)) { return value; } const trimmed = value.trim(); if (trimmed.length < 2) { return value; } const first = trimmed[0]; const last = trimmed[trimmed.length - 1]; if ((first === '"' && last === '"') || (first === "'" && last === "'")) { return trimmed.slice(1, -1); } return value; } /** * @private * @param {string} input * @return {*} */ function escapeAttributeValue(input) { if (input === undefined || input === null) { return input; } return input .replace(/&/g, "&amp;") .replace(/"/g, "&quot;") .replace(/'/g, "&#x27;") .replace(/</g, "&lt;") .replace(/>/g, "&gt;"); } /** * * @param {HTMLElement} element * @return {boolean} */ function getVisibilityFromSlotAttribute(element) { return !( element.hasAttribute("slot") && element.getAttribute("slot") === "hidden" ); } /** * @private * @param {HTMLElement} element * @param {boolean} visible */ function setSlotAttribute(element, visible) { if (visible) { element.removeAttribute("slot"); return; } element.setAttribute("slot", "hidden"); } /** * @private */ function initEventHandler() { const self = this; let lastHash = getGlobal().location.hash; self[locationChangeHandlerSymbol] = () => { if (lastHash === getGlobal().location.hash) { return; } /** * debounce the hash change event if doSearch * is called by click the search button */ if (self[hashChangeSymbol] > 0) { self[hashChangeSymbol]--; return; } initFilter.call(this); doSearch .call(self) .then(() => {}) .catch((error) => {}) .finally(() => { lastHash = getGlobal().location.hash; }); }; /** * Monster.Components.Form.event:monster-selection-cleared */ if (self[filterSelectElementSymbol]) { self[filterSelectElementSymbol].addEventListener( "monster-selection-cleared", function () { const settings = self[settingsSymbol].getOptions(); for (const setting of settings) { const filterElement = findElementWithIdUpwards(self, setting.value); if (filterElement) { setSlotAttribute(filterElement, false); self[settingsSymbol].set({ value: setting.value, visible: false }); } } updateConfig.call(self); }, ); self[filterSelectElementSymbol].addEventListener( "monster-changed", function (event) { const filterElement = findElementWithIdUpwards( self, event.detail.value, ); if (filterElement) { setSlotAttribute(filterElement, event.detail.checked); } self[settingsSymbol].set({ value: event.detail.value, visible: event.detail.checked, }); updateConfig.call(self); }, ); } /** save the current filter */ if (self[filterSaveActionButtonElementSymbol]) { self[filterSaveActionButtonElementSymbol].setOption( "actions.click", function (event) { const button = findTargetElementFromEvent( event, "data-monster-role", "save-action-button", ); const form = button.closest("[data-monster-role=form]"); if (!form) { button.setState("failed", self.getOption("timeouts.message", 4000)); return; } const input = form.querySelector("input[name=filter-name]"); if (!input) { button.setState("failed", self.getOption("timeouts.message", 4000)); return; } const name = input.value; if (!name) { button.setState("failed", self.getOption("timeouts.message", 4000)); button.setMessage("Please enter a name").showMessage(); return; } doSearch .call(self, { showEffect: false }) .then(() => { const configKey = getStoredFilterConfigKey.call(self); const host = getDocument().querySelector("monster-host"); if (!host) { return; } const query = self.getOption("query"); if (!query) { button.setState( "failed", self.getOption( "timeouts.message", self.getOption("timeouts.message", 4000), ), ); button .setMessage("No query found") .showMessage(self.getOption("timeouts.message", 4000)); return; } host .hasConfig(configKey) .then((hasConfig) => { return new Promise((resolve, reject) => { if (hasConfig) { host.getConfig(configKey).then(resolve).catch(reject); return; } return resolve({}); }); }) .then((config) => { config[name] = query; return host.setConfig(configKey, { ...config, }); }) .then(() => { button.setState( "successful", self.getOption("timeouts.message", 4000), ); updateFilterTabs.call(self); }) .catch((error) => { button.setState( "failed", self.getOption("timeouts.message", 4000), ); button .setMessage(error.message) .showMessage(self.getOption("timeouts.message", 4000)); }); }) .catch((error) => { button.setState("failed", self.getOption("timeouts.message", 4000)); const msg = error.message || error; button .setMessage(msg) .showMessage(self.getOption("timeouts.message", 4000)); }); }, ); } self[searchButtonElementSymbol].setOption("actions.click", () => { self[hashChangeSymbol] = 1; doSearch .call(self) .then(() => {}) .catch((error) => {}); }); // the reset button should reset the filter and the search query // all input elements should be reset to their default values // which is the empty string. we search for all input elements // in the filter and reset them to their default value self[resetButtonElementSymbol].setOption("actions.click", () => { getSlottedElements .call(self, "label[data-monster-label]") .forEach((element) => { const label = element.getAttribute("data-monster-label"); if (!label) { return; } const input = element.firstElementChild; if (input) { input.value = ""; } }); doSearch .call(self, { showEffect: false }) .then(() => {}) .catch((e) => addAttributeToken(self, ATTRIBUTE_ERRORMESSAGE, e.message)); }); self.addEventListener("keyup", (event) => { const path = event.composedPath(); if (path.length === 0) { return; } if (!(path[0] instanceof HTMLInputElement)) { return; } if (event.keyCode === 13) { doSearch .call(self, { showEffect: false }) .then(() => {}) .catch((error) => {}); } }); // tabs const element = this[filterTabElementSymbol]; if (element) { initTabEvents.call(this); } } function initTabEvents() { const self = this; this[filterTabElementSymbol].addEventListener( "monster-tab-changed", (event) => { const query = event?.detail?.data?.["data-monster-query"]; const q = this.getOption("query"); if (query !== q) { this.setOption("query", query); } }, ); this[filterTabElementSymbol].addEventListener( "monster-tab-remove", (event) => { const labels = []; const buttons = this[filterTabElementSymbol].getOption("buttons"); const keys = ["popper", "standard"]; for (let i = 0; i < keys.length; i++) { const key = keys[i]; for (const button of buttons[key]) { if (button.label !== event.detail.label) { labels.push(button.label); } } } const host = findElementWithSelectorUpwards(this, "monster-host"); if (!(host && this.id)) { return; } const configKey = getStoredFilterConfigKey.call(this); host .hasConfig(configKey) .then((hasConfig) => { if (!hasConfig) { return; } return host.getConfig(configKey); }) .then((config) => { for (const [name, query] of Object.entries(config)) { if (labels.includes(name)) { continue; } delete config[name]; } return host.setConfig(configKey, { ...config, }); }); }, ); } /** * @private */ function updateFilterTabs() { const element = this[filterTabElementSymbol]; if (!element) { return; } const host = findElementWithSelectorUpwards(this, "monster-host"); if (!(host && this.id)) { return; } const configKey = getStoredFilterConfigKey.call(this); host .hasConfig(configKey) .then((hasConfig) => { if (!hasConfig) { return; } return host.getConfig(configKey); }) .then((config) => { for (const [name, query] of Object.entries(config)) { const found = element.querySelector( `[data-monster-button-label="${name}"]`, ); if (found) { continue; } if (query === undefined || query === null) { continue; } const escapedQuery = escapeAttributeValue(query); element.insertAdjacentHTML( "beforeend", `<div data-monster-button-label="${name}" data-monster-removable="true" data-monster-query="${escapedQuery}" data-monster-role="filter-tab" > </div>`, ); } }) .catch((error) => { if (error instanceof Error) { addAttributeToken( this, ATTRIBUTE_ERRORMESSAGE, error.message + " " + error.stack, ); } else { addAttributeToken(this, ATTRIBUTE_ERRORMESSAGE, error + ""); } }); } /** * @private * @param showEffect * @return {Promise} */ function doSearch({ showEffect } = { showEffect: true }) { this.resetFailureMessage(); return collectSearchQueries .call(this) .then((query) => { const buildQuery = buildSearchQuery.call(this, query); if (buildQuery === null) { const msg = this.getOption("labels.empty-query-and-no-default"); if (showEffect) { this[searchButtonElementSymbol].removeState(); this[searchButtonElementSymbol] .setMessage(msg) .showMessage(this.getOption("timeouts.message", 4000)); } return Promise.reject(new Error(msg)); } if (buildQuery === "" && this.getOption("defaultQuery") === null) { const msg = this.getOption("labels.empty-query-and-no-default"); if (showEffect) { this[searchButtonElementSymbol].removeState(); this[searchButtonElementSymbol] .setMessage(msg) .showMessage(this.getOption("timeouts.message", 4000)); } return Promise.reject(new Error(msg)); } if ( this.getOption("features.preventSameQuery") && buildQuery === this.getOption("query") ) { const msg = this.getOption("labels.query-not-changed"); if (showEffect) { this[searchButtonElementSymbol].removeState(); this[searchButtonElementSymbol] .setMessage(msg) .showMessage(this.getOption("timeouts.message", 4000)); } return Promise.reject(new Error(msg)); } if (showEffect) { this[searchButtonElementSymbol].removeState(); this[searchButtonElementSymbol].setState( "activity", this.getOption("timeouts.message", 4000), ); } this.setOption("query", buildSearchQuery.call(this, query)); return Promise.resolve(); }) .catch((error) => { if (error instanceof Error) { addAttributeToken( this, ATTRIBUTE_ERRORMESSAGE, error.message + " " + error.stack, ); } else { addAttributeToken(this, ATTRIBUTE_ERRORMESSAGE, String(error)); } if (showEffect) { this[searchButtonElementSymbol].setState( "failed", this.getOption("timeouts.message", 4000), ); this[searchButtonElementSymbol].setMessage(error.message).showMessage(); } return Promise.reject(error); }); } /** * @private * @param queries * @return {*|string} */ function buildSearchQuery(queries) { if (!isArray(queries) || queries.length === 0) { return this.getOption("defaultQuery"); } const joinCallback = this.getOption("queries.join"); if (isFunction(joinCallback)) { return joinCallback(queries); } const q = queries.join(" ").trim(); if (q.length === 0) { return this.getOption("defaultQuery"); } return q; } /** * @private * @return {Promise<unknown>} */ function collectSearchQueries() { const currentHash = parseBracketedKeyValueHash(getGlobal().location.hash); const self = this; return new Promise((resolve, reject) => { const query = []; const wrapCallback = this.getOption("queries.wrap"); let hasNoIdError = false; getSlottedElements .call(this, "label[data-monster-label]") .forEach((element) => { const label = element.getAttribute("data-monster-label"); if (!label) { throw new Error("no filter label is defined"); } const id = element.id; if (!id) { hasNoIdError = true; return; } const visible = getVisibilityFromSlotAttribute(element); if (!visible) { return; } let template = element.getAttribute("data-monster-template"); if (!template) { template = "${id}=${value}"; } const controlValue = getControlValuesFromLabel(element); const hasEmptyArrayValue = isArray(controlValue) && controlValue.length === 0; if (!controlValue || hasEmptyArrayValue) { if ( (controlValue === "" || hasEmptyArrayValue) && currentHash?.[this.id]?.[id] ) { delete currentHash[this.id][id]; } return; } if (!isObject(currentHash[this.id])) { currentHash[this.id] = {}; } currentHash[this.id][id] = controlValue; const formatterValue = isArray(controlValue) ? controlValue.join(",") : controlValue; const mapping = { id, value: formatterValue, label, }; const formatter = new Formatter(mapping, { callbacks: { range: (value, key) => { return generateRangeComparisonExpression(value, key, { urlEncode: true, andOp: "AND", orOp: "OR", eqOp: "=", gtOp: ">", ltOp: "<", }); }, "tag-list": (value, key) => { if (isString(value)) { value = value.split(","); } if (!isArray(value)) { return ""; } return ( key + " IN " + value .map((v) => { return `"${encodeURIComponent(v)}"`; }) .join(",") ); }, "list-tag": (value, key) => { if (isString(value)) { value = value.split(","); } if (!isArray(value)) { return ""; } return ( value .map((v) => { return `"${encodeURIComponent(v)}"`; }) .join(",") + " IN " + encodeURIComponent(key) ); }, "tags-in-list": (value, key, op) => { if (isString(value)) { value = value.split(","); } if (!isArray(value)) { return ""; } if (!op || !isString(op)) op = "OR"; op = " " + op.toUpperCase().trim() + " "; let query = ""; value.forEach((v) => { if (query.length > 0) { query += op; } query += `${encodeURIComponent(key)} IN "${encodeURIComponent(v)}"`; }); return query; }, "list-not-in-tags": (value, key, op) => { if (isString(value)) { value = value.split(","); } if (!isArray(value)) { return ""; } if (!op || !isString(op)) op = "OR"; op = " " + op.toUpperCase().trim() + " "; let query = ""; value.forEach((v) => { if (query.length > 0) { query += op; } query += `"${encodeURIComponent(key)}" NOT IN ${encodeURIComponent(v)}`; }); return query; }, "list-in-tags": (value, key, op) => { if (isString(value)) { value = value.split(","); } if (!isArray(value)) { return ""; } if (!op || !isString(op)) op = "OR"; op = " " + op.toUpperCase().trim() + " "; let query = ""; value.forEach((v) => { if (query.length > 0) { query += op; } query += `"${encodeURIComponent(v)}" IN ${encodeURIComponent(key)}`; }); return query; }, "array-list": (value, key) => { if (isString(value)) { value = value.split(","); } if (!isArray(value)) { return ""; } return ( key + "=" + value .map((v) => { return `"${encodeURIComponent(v)}"`; }) .join(",") ); }, "date-range": (value, key) => { const query = parseDateInput(value, key); if (!query || query === "false") { return ""; } // return query as url encoded return encodeURIComponent(query); }, "to-int-2": (value, key) => { const query = normalizeNumber(value); if (Number.isNaN(query)) { return key + " IS NULL"; } return key + "=" + encodeURIComponent(Math.round(query * 100)); }, "to-int-3": (value, key) => { const query = normalizeNumber(value); if (Number.isNaN(query)) { return ""; } return key + "=" + encodeURIComponent(Math.round(query * 1000)); }, "to-int-4": (value, key) => { const query = normalizeNumber(value); if (Number.isNaN(query)) { return ""; } return key + "=" + encodeURIComponent(Math.round(query * 10000)); }, }, }); if (self.getOption("formatter.marker.open")) { formatter.setMarker( self.getOption("formatter.marker.open"), self.getOption("formatter.marker.close"), ); } let queryPart = formatter.format(template); if (queryPart) { if (isFunction(wrapCallback)) { queryPart = wrapCallback(queryPart, mapping); } query.push(queryPart); } }); if (hasNoIdError) { reject(new Error("some or all filter elements have no id")); return; } getGlobal().location.hash = createBracketedKeyValueHash(currentHash); resolve(query); }); } /** * @private * @param label * @return {null|Array|undefined|string} */ function getControlValuesFromLabel(label) { // finde das erste Kind-Element vom type input // wenn es ein input-Element ist, dann @todo const foundControl = label.firstElementChild; if (foundControl) { if (foundControl.tagName === "INPUT") { if (foundControl.type === "checkbox") { const checkedControls = label.querySelectorAll( `${foundControl}:checked`, ); const values = []; checkedControls.forEach((checkedControl) => { values.push(checkedControl.value); }); return values; } else if (foundControl.type === "radio") { const checkedControl = label.querySelector(`${foundControl}:checked`); if (checkedControl) { return checkedControl.value; } else { return null; } } else { return foundControl.value; } } else { return foundControl.value; } } return null; } /** * @private * @return {Promise<unknown>} */ function initFromConfig() { const host = findElementWithSelectorUpwards(this, "monster-host"); if (!(isInstance(host, Host) && this.id)) { return Promise.resolve(); } const configKey = getFilterConfigKey.call(this); return new Promise((resolve, reject) => { host .getConfig(configKey) .then((config) => { if ((config && isObject(config)) || isArray(config)) { this[settingsSymbol].setOptions(config); } resolve(); }) .catch((error) => { if (error === undefined) { resolve(); return; } // config not written if (error?.message?.match(/is not defined/)) { resolve(); return; } addAttributeToken( this, ATTRIBUTE_ERRORMESSAGE, error?.message || error, ); reject(error); }); }); } /** * @private */ function updateConfig() { const host = findElementWithSelectorUpwards(this, "monster-host"); if (!(host && this.id)) { return; } const configKey = getFilterConfigKey.call(this); try { host.setConfig(configKey, this[settingsSymbol].getOptions()); } catch (error) { addAttributeToken(this, ATTRIBUTE_ERRORMESSAGE, error?.message || error); } } /** * @private * @return {string} */ function getTemplate() { // language=HTML return ` <div data-monster-role="control" part="control"> <div data-monster-role="container"> <div data-monster-role="layout"> <div data-monster-role="filter"> <slot></slot> <slot name="hidden"></slot> </div> <div data-monster-role="select-and-search"> <monster-button-bar style="max-width: max-content"> <monster-message-state-button data-monster-role="search-button" class="stretched-control">i18n{search}</monster-message-state-button> <monster-select class="stretched-control" data-monster-selected-template="summary" data-monster-option-type="checkbox" data-monster-option-filter-mode="options" data-monster-option-filter-position="popper" data-monster-role="filter-select"></monster-select> <monster-popper-button data-monster-role="save-button" class="stretched-control" data-monster-attributes="data-monster-visible path:features.storedConfig"> <div slot="button">\${filter-save-label}</div> <div class="monster-form" data-monster-role="form"> <label for="filter-name">\${filter-name-label} <input name="filter-name" type="search" class="monster-margin-bottom-5"></label> <monster-message-state-button data-monster-role="save-action-button" data-monster-option-labels-button="\${filter-save-label}"> </monster-message-state-button> </div> </monster-popper-button> <monster-button data-monster-role="reset-button" class="stretched-control">i18n{reset}</monster-button> </monster-button> </monster-button-bar> </div> </div> <input class="hidden" name="query" data-monster-role="query" data-monster-attributes="value path:query"> </div> </div> `; } registerCustomElement(Filter);