/** * Copyright 2023 schukai GmbH * SPDX-License-Identifier: AGPL-3.0 */ import {Datasource} from "./datasource.mjs"; import { assembleMethodSymbol, CustomElement, registerCustomElement, getSlottedElements, } from "../../dom/customelement.mjs"; import {findTargetElementFromEvent, fireCustomEvent} from "../../dom/events.mjs"; import {clone} from "../../util/clone.mjs"; import { isString, isFunction, isInstance, isObject, isArray, } from "../../types/is.mjs"; import {validateArray, validateInteger, validateObject} from "../../types/validate.mjs"; import {Observer} from "../../types/observer.mjs"; import { ATTRIBUTE_DATATABLE_HEAD, ATTRIBUTE_DATATABLE_GRID_TEMPLATE, ATTRIBUTE_DATASOURCE_SELECTOR, ATTRIBUTE_DATATABLE_ALIGN, ATTRIBUTE_DATATABLE_SORTABLE, ATTRIBUTE_DATATABLE_MODE, ATTRIBUTE_DATATABLE_INDEX, ATTRIBUTE_DATATABLE_MODE_HIDDEN, ATTRIBUTE_DATATABLE_MODE_VISIBLE, ATTRIBUTE_DATATABLE_RESPONSIVE_BREAKPOINT, ATTRIBUTE_DATATABLE_MODE_FIXED, } from "./constants.mjs"; import {instanceSymbol} from "../../constants.mjs"; import { Header, createOrderStatement, DIRECTION_ASC, DIRECTION_DESC, DIRECTION_NONE, } from "./datatable/header.mjs"; import {DatatableStyleSheet} from "./stylesheet/datatable.mjs"; import { handleDataSourceChanges, datasourceLinkedElementSymbol, } from "./util.mjs"; import "./columnbar.mjs"; import "./filter-button.mjs"; import {getDocument, getWindow} from "../../dom/util.mjs"; import {addAttributeToken} from "../../dom/attributes.mjs"; import {ATTRIBUTE_ERRORMESSAGE} from "../../dom/constants.mjs"; import {getDocumentTranslations} from "../../i18n/translations.mjs"; import "../state/state.mjs"; import "../host/collapse.mjs"; import {generateUniqueConfigKey} from "../host/util.mjs"; import "./datasource/dom.mjs"; import "./datasource/rest.mjs"; export {DataTable}; /** * @private * @type {symbol} */ const gridElementSymbol = Symbol("gridElement"); /** * @private * @type {symbol} */ const gridHeadersElementSymbol = Symbol("gridHeadersElement"); /** * @private * @type {symbol} */ const columnBarElementSymbol = Symbol("columnBarElement"); /** * The DataTable component is used to show the data from a data source. * * <img src="./images/datatable.png"> * * Dependencies: the system uses functions of the [monsterjs](https://monsterjs.org/) library * * You can create this control either by specifying the HTML tag <monster-datatable />` directly in the HTML or using * Javascript via the `document.createElement('monster-datatable');` method. * * ```html * <monster-datatable></monster-datatable> * ``` * * Or you can create this CustomControl directly in Javascript: * * ```js * import '@schukai/component-datatable/source/datatable.mjs'; * document.createElement('monster-datatable'); * ``` * * The Body should have a class "hidden" to ensure that the styles are applied correctly. * * ```css * body.hidden { * visibility: hidden; * } * ``` * * @startuml datatable.png * skinparam monochrome true * skinparam shadowing false * HTMLElement <|-- CustomElement * CustomElement <|-- DataTable * @enduml * * @copyright schukai GmbH * @memberOf Monster.Components.Datatable * @summary A data table * @fire Monster.Components.Datatable.event:monster-datatable-row-copied * @fire Monster.Components.Datatable.event:monster-datatable-row-removed * @fire Monster.Components.Datatable.event:monster-datatable-row-added */ class DataTable extends CustomElement { /** * This method is called by the `instanceof` operator. * @returns {symbol} */ static get [instanceSymbol]() { return Symbol.for("@schukai/monster/components/datatable@@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} templates Template definitions * @property {string} templates.main Main template * @property {Object} datasource Datasource configuration * @property {string} datasource.selector Selector for the datasource * @property {Object} mapping Mapping configuration * @property {string} mapping.data Data mapping * @property {Array} data Data * @property {Array} headers Headers * @property {Object} responsive Responsive configuration * @property {number} responsive.breakpoint Breakpoint for responsive mode * @property {Object} labels Labels * @property {string} labels.theListContainsNoEntries Label for empty state * @property {Object} classes Classes * @property {string} classes.container Container class * @property {Object} features Features * @property {boolean} features.settings Settings feature * @property {boolean} features.footer Footer feature * @property {boolean} features.autoInit Auto init feature (init datasource automatically) * @property {Object} templateMapping Template mapping * @property {string} templateMapping.row-key Row key * @property {string} templateMapping.filter-id Filter id **/ get defaults() { return Object.assign( {}, super.defaults, { templates: { main: getTemplate(), emptyState: getEmptyTemplate(), }, datasource: { selector: null, }, mapping: { data: "dataset", }, data: [], headers: [], responsive: { breakpoint: 800, }, labels: { theListContainsNoEntries: "The list contains no entries", }, classes : { control: "monster-theme-table-container-1", container: "monster-theme-table-container-1", row: "monster-theme-table-row-1", }, features: { settings: true, footer: true, autoInit: true, }, templateMapping: { "row-key": null, "filter-id": null, }, }, initOptionsFromArguments.call(this), ); } /** * * @param {string} selector * @returns {NodeListOf<*>} */ getGridElements(selector) { return this[gridElementSymbol].querySelectorAll(selector); } /** * * @return {string} */ static getTag() { return "monster-datatable"; } /** * * @return {Monster.Components.Form.Form} */ [assembleMethodSymbol]() { const rawKey = this.getOption("templateMapping.row-key"); if (rawKey === null) { if (this.id !== null && this.id !== "") { const rawKey = this.getOption("templateMapping.row-key"); if (rawKey === null) { this.setOption("templateMapping.row-key", this.id + "-row"); } } else { this.setOption("templateMapping.row-key", "row"); } } if (this.id !== null && this.id !== "") { this.setOption("templateMapping.filter-id", "" + this.id + "-filter"); } else { this.setOption("templateMapping.filter-id", "filter"); } super[assembleMethodSymbol](); initControlReferences.call(this); initEventHandler.call(this); const selector = this.getOption("datasource.selector"); if (isString(selector)) { const elements = document.querySelectorAll(selector); if (elements.length !== 1) { throw new Error("the selector must match exactly one element"); } const element = elements[0]; if (!isInstance(element, Datasource)) { throw new TypeError("the element must be a datasource"); } this[datasourceLinkedElementSymbol] = element; setTimeout(() => { handleDataSourceChanges.call(this); element.datasource.attachObserver( new Observer(handleDataSourceChanges.bind(this)), ); }, 0); } getHostConfig .call(this, getColumnVisibilityConfigKey) .then((config) => { const headerOrderMap = new Map(); getHostConfig .call(this, getStoredOrderConfigKey) .then((orderConfig) => { if (isArray(orderConfig) || orderConfig.length > 0) { for (let i = 0; i < orderConfig.length; i++) { const item = orderConfig[i]; const parts = item.split(" "); const field = parts[0]; const direction = parts[1] || DIRECTION_ASC; headerOrderMap.set(field, direction); } } }) .then(() => { try { initGridAndStructs.call(this, config, headerOrderMap); } catch (error) { addAttributeToken( this, ATTRIBUTE_ERRORMESSAGE, error?.message || error.toString(), ); } updateColumnBar.call(this); }) .catch((error) => { addAttributeToken( this, ATTRIBUTE_ERRORMESSAGE, error?.message || error.toString(), ); }); }) .catch((error) => { addAttributeToken( this, ATTRIBUTE_ERRORMESSAGE, error?.message || error.toString(), ); }); } /** * * @return {CSSStyleSheet[]} */ static getCSSStyleSheet() { return [DatatableStyleSheet]; } /** * Copy a row from the datatable * @param {number} fromIndex * @param {number} toIndex * @returns {Monster.Components.Datatable.DataTable} * @fire Monster.Components.Datatable.event:monster-datatable-row-copied */ copyRow(fromIndex, toIndex) { const datasource = this[datasourceLinkedElementSymbol]; if (!datasource) { return this; } let d = datasource.data; let c = clone(d); let rows = c const mapping = this.getOption("mapping.data"); if (mapping) { rows=c?.[mapping]; } if (rows === undefined || rows === null) { rows = []; } if (toIndex===undefined) { toIndex = rows.length; } fromIndex = parseInt(fromIndex); toIndex = parseInt(toIndex); if (toIndex < 0 || toIndex > rows.length) { throw new RangeError("index out of bounds"); } validateArray(rows); validateInteger(fromIndex); validateInteger(toIndex); if (fromIndex < 0 || fromIndex >= rows.length) { throw new RangeError("index out of bounds"); } rows.splice(toIndex, 0, clone(rows[fromIndex])); datasource.data=c; fireCustomEvent(this, "monster-datatable-row-copied", { index: toIndex, }); return this; } /** * Remove a row from the datatable * @param index * @returns {Monster.Components.Datatable.DataTable} * @fire Monster.Components.Datatable.event:monster-datatable-row-removed */ removeRow(index) { const datasource = this[datasourceLinkedElementSymbol]; if (!datasource) { return this; } let d = datasource.data; let c = clone(d); let rows = c const mapping = this.getOption("mapping.data"); if (mapping) { rows=c?.[mapping]; } if (rows === undefined || rows === null) { rows = []; } index = parseInt(index); validateArray(rows); validateInteger(index); if (index < 0 || index >= rows.length) { throw new RangeError("index out of bounds"); } if (mapping) { rows=c?.[mapping]; } rows.splice(index, 1) datasource.data=c; fireCustomEvent(this, "monster-datatable-row-removed", { index: index, }); return this; } /** * Add a row to the datatable * @param {Object} data * @returns {Monster.Components.Datatable.DataTable} * @fire Monster.Components.Datatable.event:monster-datatable-row-added **/ addRow(data) { const datasource = this[datasourceLinkedElementSymbol]; if (!datasource) { return this; } let d = datasource.data; let c = clone(d); let rows = c const mapping = this.getOption("mapping.data"); if (mapping) { rows=c?.[mapping]; } if (rows === undefined || rows === null) { rows = []; } validateArray(rows); validateObject(data); rows.push(data) datasource.data=c; fireCustomEvent(this, "monster-datatable-row-added", { index: rows.length - 1, }); return this; } } /** * @private * @returns {string} */ function getColumnVisibilityConfigKey() { return generateUniqueConfigKey("datatable", this?.id, "columns-visibility"); } /** * @private * @returns {string} */ function getFilterConfigKey() { return generateUniqueConfigKey("datatable", this?.id, "filter"); } /** * @private * @returns {Promise} */ function getHostConfig(callback) { const document = getDocument(); const host = document.querySelector("monster-host"); if (!(host && this.id)) { return Promise.resolve({}); } if (!host || !isFunction(host?.getConfig)) { throw new TypeError("the host must be a monster-host"); } const configKey = callback.call(this); return host.hasConfig(configKey).then((hasConfig) => { if (hasConfig) { return host.getConfig(configKey); } else { return {}; } }); } /** * @private */ function updateColumnBar() { if (!this[columnBarElementSymbol]) { return; } const columns = []; for (const header of this.getOption("headers")) { const mode = header.getInternal("mode"); if (mode === ATTRIBUTE_DATATABLE_MODE_FIXED) { continue; } columns.push({ visible: mode !== ATTRIBUTE_DATATABLE_MODE_HIDDEN, name: header.label, index: header.index, }); } this[columnBarElementSymbol].setOption("columns", columns); } /** * @private */ function updateHeaderFromColumnBar() { if (!this[columnBarElementSymbol]) { return; } const options = this[columnBarElementSymbol].getOption("columns"); if (!isArray(options)) return; const invisibleMap = {}; for (let i = 0; i < options.length; i++) { const option = options[i]; invisibleMap[option.index] = option.visible; } for (const header of this.getOption("headers")) { const mode = header.getInternal("mode"); if (mode === ATTRIBUTE_DATATABLE_MODE_FIXED) { continue; } if (invisibleMap[header.index] === false) { header.setInternal("mode", ATTRIBUTE_DATATABLE_MODE_HIDDEN); } else { header.setInternal("mode", ATTRIBUTE_DATATABLE_MODE_VISIBLE); } } } /** * @private */ function updateConfigColumnBar() { if (!this[columnBarElementSymbol]) { return; } const options = this[columnBarElementSymbol].getOption("columns"); if (!isArray(options)) return; const map = {}; for (let i = 0; i < options.length; i++) { const option = options[i]; map[option.name] = option.visible; } const document = getDocument(); const host = document.querySelector("monster-host"); if (!(host && this.id)) { return; } const configKey = getColumnVisibilityConfigKey.call(this); try { host.setConfig(configKey, map); } catch (error) { addAttributeToken(this, ATTRIBUTE_ERRORMESSAGE, String(error)); } } /** * @private */ function initEventHandler() { const self = this; getWindow().addEventListener("resize", (event) => { updateGrid.call(self); }); self[columnBarElementSymbol].attachObserver( new Observer((e) => { updateHeaderFromColumnBar.call(self); updateGrid.call(self); updateConfigColumnBar.call(self); }), ); self[gridHeadersElementSymbol].addEventListener("click", function (event) { let element = null; const datasource = self[datasourceLinkedElementSymbol]; if (!datasource) { return; } element = findTargetElementFromEvent(event, ATTRIBUTE_DATATABLE_SORTABLE); if (element) { const index = element.parentNode.getAttribute(ATTRIBUTE_DATATABLE_INDEX); const headers = self.getOption("headers"); event.preventDefault(); headers[index].changeDirection(); setTimeout(function () { /** hotfix, normally this should be done via the updater, no idea why this is not possible. */ element.setAttribute( ATTRIBUTE_DATATABLE_SORTABLE, `${headers[index].field} ${headers[index].direction}`, ); storeOrderStatement.call(self, true); }, 0); } }); } /** * @private */ function initGridAndStructs(hostConfig, headerOrderMap) { const rowID = this.getOption("templateMapping.row-key"); if (!this[gridElementSymbol]) { throw new Error("no grid element is defined"); } let template; getSlottedElements.call(this).forEach((e) => { if (e instanceof HTMLTemplateElement && e.id === rowID) { template = e; } }); if (!template) { throw new Error("no template is defined"); } const rowCount = template.content.children.length; const headers = []; for (let i = 0; i < rowCount; i++) { let hClass = ""; const row = template.content.children[i]; let mode = ""; if (row.hasAttribute(ATTRIBUTE_DATATABLE_MODE)) { mode = row.getAttribute(ATTRIBUTE_DATATABLE_MODE); } let grid = row.getAttribute(ATTRIBUTE_DATATABLE_GRID_TEMPLATE); if (!grid || grid === "" || grid === "auto") { grid = "minmax(0, 1fr)"; } let label = ""; let labelKey = ""; if (row.hasAttribute(ATTRIBUTE_DATATABLE_HEAD)) { label = row.getAttribute(ATTRIBUTE_DATATABLE_HEAD); labelKey = label; try { if (label.startsWith("i18n:")) { label = label.substring(5, label.length); label = getDocumentTranslations().getText(label, label); } } catch (e) { label = "i18n error " + label; } } if (!label) { label = i + 1 + ""; mode = ATTRIBUTE_DATATABLE_MODE_FIXED; labelKey = label; } if (isObject(hostConfig) && hostConfig.hasOwnProperty(label)) { if (hostConfig[label] === false) { mode = ATTRIBUTE_DATATABLE_MODE_HIDDEN; } else { mode = ATTRIBUTE_DATATABLE_MODE_VISIBLE; } } let align = ""; if (row.hasAttribute(ATTRIBUTE_DATATABLE_ALIGN)) { align = row.getAttribute(ATTRIBUTE_DATATABLE_ALIGN); } switch (align) { case "center": hClass = "flex-center"; break; case "end": hClass = "flex-end"; break; case "start": hClass = "flex-start"; break; default: hClass = "flex-start"; } let field = ""; let direction = DIRECTION_NONE; if (row.hasAttribute(ATTRIBUTE_DATATABLE_SORTABLE)) { field = row.getAttribute(ATTRIBUTE_DATATABLE_SORTABLE).trim(); const parts = field.split(" ").map((item) => item.trim()); field = parts[0]; if (headerOrderMap.has(field)) { direction = headerOrderMap.get(field); } else if ( parts.length === 2 && [DIRECTION_ASC, DIRECTION_DESC].indexOf(parts[1]) !== -1 ) { direction = parts[1]; } } if (mode === ATTRIBUTE_DATATABLE_MODE_HIDDEN) { hClass += " hidden"; } const header = new Header(); header.setInternals({ field: field, label: label, classes: hClass, index: i, mode: mode, grid: grid, labelKey: labelKey, direction: direction, }); headers.push(header); } this.setOption("headers", headers); setTimeout(() => { storeOrderStatement.call(this, this.getOption("features.autoInit")); }, 0); } /** * @private * @returns {string} */ export function getStoredOrderConfigKey() { return generateUniqueConfigKey("datatable", this?.id, "stored-order"); } /** * @private */ function storeOrderStatement(doFetch) { const headers = this.getOption("headers"); const statement = createOrderStatement(headers); setDataSource.call(this, {orderBy: statement}, doFetch); const document = getDocument(); const host = document.querySelector("monster-host"); if (!(host && this.id)) { return; } const configKey = getStoredOrderConfigKey.call(this); // statement explode with , and remove all empty const list = statement.split(",").filter((item) => item.trim() !== ""); if (list.length === 0) { return; } host.setConfig(configKey, list); } /** * @private */ function updateGrid() { if (!this[gridElementSymbol]) { throw new Error("no grid element is defined"); } let gridTemplateColumns = ""; const headers = this.getOption("headers"); let styles = ""; for (let i = 0; i < headers.length; i++) { const header = headers[i]; if (header.mode === ATTRIBUTE_DATATABLE_MODE_HIDDEN) { styles += `[data-monster-role=datatable]>[data-monster-head="${header.labelKey}"] { display: none; }\n`; styles += `[data-monster-role=datatable-headers]>[data-monster-index="${header.index}"] { display: none; }\n`; } else { gridTemplateColumns += `${header.grid} `; } } const sheet = new CSSStyleSheet(); if (styles !== "") sheet.replaceSync(styles); this.shadowRoot.adoptedStyleSheets = [...DataTable.getCSSStyleSheet(), sheet]; const bodyWidth = getDocument().body.getBoundingClientRect().width; const breakpoint = this.getOption("responsive.breakpoint"); if (bodyWidth > breakpoint) { this[ gridElementSymbol ].style.gridTemplateColumns = `${gridTemplateColumns}`; this[ gridHeadersElementSymbol ].style.gridTemplateColumns = `${gridTemplateColumns}`; } else { this[gridElementSymbol].style.gridTemplateColumns = "auto"; this[gridHeadersElementSymbol].style.gridTemplateColumns = "auto"; } } /** * @private * @param {Monster.Components.Datatable.Header[]} headers * @param {bool} doFetch */ function setDataSource({orderBy}, doFetch) { const datasource = this[datasourceLinkedElementSymbol]; if (!datasource) { return; } if (isFunction(datasource?.setParameters)) { datasource.setParameters({orderBy}); } if (doFetch !== false && isFunction(datasource?.fetch)) { datasource.fetch(); } } /** * @private * @return {Monster.Components.Datatable.Form} */ function initControlReferences() { if (!this.shadowRoot) { throw new Error("no shadow-root is defined"); } this[gridElementSymbol] = this.shadowRoot.querySelector( "[data-monster-role=datatable]", ); this[gridHeadersElementSymbol] = this.shadowRoot.querySelector( "[data-monster-role=datatable-headers]", ); this[columnBarElementSymbol] = this.shadowRoot.querySelector("monster-column-bar"); return this; } /** * @private * @return {object} * @throws {TypeError} incorrect arguments passed for the datasource * @throws {Error} the datasource could not be initialized */ function initOptionsFromArguments() { const options = {}; const selector = this.getAttribute(ATTRIBUTE_DATASOURCE_SELECTOR); if (selector) { options.datasource = {selector: selector}; } const breakpoint = this.getAttribute( ATTRIBUTE_DATATABLE_RESPONSIVE_BREAKPOINT, ); if (breakpoint) { options.responsive = {}; options.responsive.breakpoint = parseInt(breakpoint); } return options; } /** * @private * @return {string} */ function getEmptyTemplate() { return `<monster-state data-monster-role="empty-without-action"> <div part="visual"> <style> path { fill: var(--monster-bg-color-primary-4); } </style> <svg width="4rem" height="4rem" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"> <path d="m21.5 22h-19c-1.378 0-2.5-1.121-2.5-2.5v-7c0-.07.015-.141.044-.205l3.969-8.82c.404-.896 1.299-1.475 2.28-1.475h11.414c.981 0 1.876.579 2.28 1.475l3.969 8.82c.029.064.044.135.044.205v7c0 1.379-1.122 2.5-2.5 2.5zm-20.5-9.393v6.893c0 .827.673 1.5 1.5 1.5h19c.827 0 1.5-.673 1.5-1.5v-6.893l-3.925-8.723c-.242-.536-.779-.884-1.368-.884h-11.414c-.589 0-1.126.348-1.368.885z"/> <path d="m16.807 17h-9.614c-.622 0-1.186-.391-1.404-.973l-1.014-2.703c-.072-.194-.26-.324-.468-.324h-3.557c-.276 0-.5-.224-.5-.5s.224-.5.5-.5h3.557c.622 0 1.186.391 1.405.973l1.013 2.703c.073.194.261.324.468.324h9.613c.208 0 .396-.13.468-.324l1.013-2.703c.22-.582.784-.973 1.406-.973h3.807c.276 0 .5.224.5.5s-.224.5-.5.5h-3.807c-.208 0-.396.13-.468.324l-1.013 2.703c-.219.582-.784.973-1.405.973z"/> </svg> </div> <div part="content" data-monster-replace="path:labels.theListContainsNoEntries"> The list contains no entries. </div> </monster-state>`; } /** * @private * @return {string} */ function getTemplate() { // language=HTML return ` <div data-monster-role="control" part="control" data-monster-attributes="class path:classes.control"> <template id="headers-row"> <div data-monster-attributes="class path:headers-row.classes, data-monster-index path:headers-row.index" data-monster-replace="path:headers-row.html"></div> </template> <slot></slot> <div data-monster-attributes="class path:classes.container" data-monster-role="table-container" part="table-container"> <div class="filter"> <slot name="filter"></slot> </div> <div class="bar"> <monster-column-bar data-monster-attributes="class path:features.settings | ?::hidden"></monster-column-bar> <slot name="bar"></slot> </div> <div data-monster-role="datatable-headers" data-monster-insert="headers-row path:headers"></div> <div data-monster-replace="path:templates.emptyState" data-monster-attributes="class path:data | has-entries | ?:hidden:empty-state-container"></div> <div data-monster-role="datatable" data-monster-insert="\${row-key} path:data"> </div> </div> <div data-monster-role="footer" data-monster-select-this="true" data-monster-attributes="class path:data | has-entries | ?::hidden"> <slot name="footer" data-monster-attributes="class path:features.footer | ?::hidden"></slot> </div> </div> `; } registerCustomElement(DataTable);