"use strict"; // VERSION info var VERSION = "1.3.0"; // typical [[1,2,3], [6,7,8]] to [[1, 6], [2, 7], [3, 8]] converter var transpose = m => m[0].map((x, i) => m.map(x => x[i])); // single items -> Array with item with length == 1 var listify = obj => ((obj instanceof Array) ? obj : [obj]); // mk - added sorting for numbers and IP addresses var compare = function(a, b) { if (/^(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/.test(a)){ var a1 = a.split('.').reduce(function(ipInt, octet) { return (ipInt<<8) + parseInt(octet, 10)}, 0) >>> 0; if (b === null) var b1 = 0 else var b1 = b.split('.').reduce(function(ipInt, octet) { return (ipInt<<8) + parseInt(octet, 10)}, 0) >>> 0; return (a1 - b1); } else if (isNaN(a)) return a.toString().localeCompare(b, navigator.language); else if (isNaN(b)) return -1 * b.toString().localeCompare(a, navigator.language); else return parseFloat(a) - parseFloat(b); } // version(string) compare function compareVersion(vers1, vers2) { // only accept strings as versions if (typeof vers1 !== "string") return false; if (typeof vers2 !== "string") return false; vers1 = vers1.split("."); vers2 = vers2.split("."); // iterate in parallel from left-most (major version part) to right-most (minor version part) for (let i=0; i vers2[i]) return 1; if (vers1[i] < vers2[i]) return -1; // else (both are equal, continue to next minor-er version token) } // if this point is reached, return 0 (equal) if lengths are equal, otherwise the one with // more minor version numbers is considered 'newer' return (vers1.length == vers2.length) ? 0 : (vers1.length < vers2.length ? -1 : 1); } class CellFormatters { constructor() { this.failed = false; } number(data) { return parseFloat(data); } full_datetime(data) { return Date.parse(data); } hours_passed(data) { return Math.round((Date.now() - Date.parse(data)) / 36000.) / 100; } hours_mins_passed(data) { const hourDiff = (Date.now() - Date.parse(data)); //const secDiff = hourDiff / 1000; const minDiff = hourDiff / 60 / 1000; const hDiff = hourDiff / 3600 / 1000; const hours = Math.floor(hDiff); const minutes = minDiff - 60 * hours; const minr = Math.floor(minutes); return (!isNaN(hours) && !isNaN(minr)) ? hours + " hours " + minr + " minutes" : null; } time_passed(data) { const diffMs = Date.now() - Date.parse(data); if (isNaN(diffMs)) return null; // Convert to seconds. let remaining = Math.floor(diffMs / 1000); const weeks = Math.floor(remaining / 604800); remaining -= weeks * 604800; const days = Math.floor(remaining / 86400); remaining -= days * 86400; const hours = Math.floor(remaining / 3600); remaining -= hours * 3600; const minutes = Math.floor(remaining / 60); remaining -= minutes * 60; const seconds = remaining; const units = [ { value: weeks, name: 'week' }, { value: days, name: 'day' }, { value: hours, name: 'hour' }, { value: minutes, name: 'minute' }, { value: seconds, name: 'second' } ]; const parts = []; for (const unit of units) { if (unit.value > 0) { const unitName = unit.value === 1 ? unit.name : unit.name + 's'; parts.push(unit.value + ' ' + unitName); if (parts.length === 2) break; } } return parts.length > 0 ? parts.join(' ') : '0 second'; } duration(data) { let h = (data >= 3600) ? Math.floor(data / 3600).toString() + ':' : ''; let m = (data >= 60) ? Math.floor((data % 3600) / 60).toString().padStart(2, 0) + ':' : ''; let s = (data >= 0) ? Math.floor((data % 3600) % 60).toString() : ''; if (m) s = s.padStart(2, 0); return h + m + s; } duration_h(data) { let d = (data >= 86400) ? Math.floor(data / 86400).toString() + 'd ' : ''; let h = (data >= 3600) ? Math.floor((data % 86400) / 3600) : '' h = (d) ? h.toString().padStart(2,0) + ':' : ((h) ? h.toString() + ':' : ''); let m = (data >= 60) ? Math.floor((data % 3600) / 60).toString().padStart(2, 0) + ':' : ''; let s = (data >= 0) ? Math.floor((data % 3600) % 60).toString() : ''; if (m) s = s.padStart(2, 0); return d + h + m + s; } icon(data) { return ``; } device_connections(data) { // Used to format device connections such as Bluetooth and MAC addresses if (data == "-") return data; let parsed = JSON.parse(data); let formatted = []; for (const connection of parsed) { switch (connection[0]) { case "bluetooth": formatted.push("Bluetooth: " + connection[1]); break; case "mac": formatted.push("MAC: " + connection[1].toUpperCase()); break; default: formatted.push(connection); } } return formatted.join("
"); } device_connections_bt(data) { // Used to format Bluetooth address of device connections if (data == "-") return data; let parsed = JSON.parse(data); let formatted = []; for (const connection of parsed) { switch (connection[0]) { case "bluetooth": formatted.push(connection[1]); break; default: } } return formatted.join("
") || "-"; } device_connections_mac(data) { // Used to format MAC address of device connections if (data == "-") return data; let parsed = JSON.parse(data); let formatted = []; for (const connection of parsed) { switch (connection[0]) { case "mac": formatted.push(connection[1].toUpperCase()); break; default: } } return formatted.join("
") || "-"; } device_identifiers(data) { // Used to format device identifiers if (data == "-") return data; let parsed = JSON.parse(data); let formatted = []; for (const identifier of parsed) { formatted.push(identifier[0] + ": " + identifier[1]); } return formatted.join("
"); } } /** flex-table data representation and keeper */ class DataTable { constructor(cfg) { this.cfg = cfg; this.sort_by = cfg.sort_by; // Provide default column name option if not supplied this.cols = cfg.columns.map((col, idx) => { return { name: col.name || `Col${idx}`, ...col } }); this.headers = this.cols.filter(col => !col.hidden).map( (col, idx) => new Object({ id: "Col" + idx, name: col.name, icon: col.icon || null })); this.rows = []; } add(...rows) { this.rows.push(...rows.map(row => row.render_data(this.cols))); } clear_rows() { this.rows = []; } is_empty() { return (this.rows.length == 0); } get_rows() { // sorting is allowed asc/desc for multiple columns if (this.sort_by) { let sort_cols = listify(this.sort_by); let sort_conf = sort_cols.map((sort_col) => { let out = { dir: 1, col: sort_col, idx: null }; if (["-", "+"].includes(sort_col.slice(-1))) { // "-" => descending, "+" => ascending out.dir = (((sort_col.slice(-1)) == "-") ? -1 : +1); out.col = sort_col.slice(0, -1); } // DEPRECATION CHANGES ALSO TO BE DONE HERE: // determine col-by-idx to be sorted with... out.idx = this.cols.findIndex((col) => ["id", "attr", "prop", "attr_as_list", "data", "name"].some(attr => attr in col && out.col == col[attr])); return out; }); // sort conf checks sort_conf = sort_conf.filter((conf) => conf.idx !== -1 && conf.idx !== null); if (sort_conf.length > 0) { this.rows.sort((x, y) => sort_conf.reduce((out, conf) => out || conf.dir * compare( x.data[conf.idx] && (x.data[conf.idx].sort_unmodified ? x.data[conf.idx].raw_content : x.data[conf.idx].content), y.data[conf.idx] && (y.data[conf.idx].sort_unmodified ? y.data[conf.idx].raw_content : y.data[conf.idx].content)), false ) ); } else { console.error("cannot sort, no applicable columns found"); } } // mark rows to be hidden due to 'strict' property this.rows = this.rows.filter(row => !row.hidden); // truncate shown rows to 'max rows', if configured if ("max_rows" in this.cfg && this.cfg.max_rows > -1) this.rows = this.rows.slice(0, this.cfg.max_rows); return this.rows; } updateSortBy(idx) { let new_sort = this.headers[idx].name; if (this.sort_by && new_sort === this.sort_by.slice(0, -1)) { this.sort_by = new_sort + (this.sort_by.slice(-1) === "-" ? "+" : "-"); } else { this.sort_by = new_sort + "+"; } } } /** just for the deprecation warning (spam avoidance) */ var show_deprecation = true; function show_deprecation_message() { if (!show_deprecation) return; // console.log(), console.warn(), console.error(), alert() console.log("DEPRECATION WARNING: 'multi', 'attr', 'attr_as_list', 'prop' as column " + "data source selector is deprecated, use 'data' instead! You can simply replace all " + "occurences of the above with 'data' and it will work _and_ this message will vanish! " + "THIS IS THE FIRST DEPRECATION WARNING, more severe will follow before removal! " + "For more details: https://github.com/custom-cards/flex-table-card"); show_deprecation = false; } /** One level down, data representation for each row (including all cells) */ class DataRow { constructor(entity, strict, raw_data=null) { this.entity = entity; this.hidden = false; this.strict = strict; this.raw_data = raw_data; this.data = null; this.has_multiple = false; //this.colspan = null; } get_raw_data(col_cfgs, config, hass) { this.raw_data = col_cfgs.map((col) => { /* collect pairs of 'column_type' and 'column_key' */ let col_getter = new Array(); // newest and soon to be the only way to reference data sources! // effectively a merge of all the 'classic' selectors, including 'multi' // -> 'prop' will be used, if one of 'name', 'object_id' or 'key in this.entity' // -> otherwise 'attr' will be assumed // -> expansion from a list (as 'attr_as_list') will be automatically applied // (by testing for Array.isArray(this.entity.attributes[col.data])) // -> 'multi' can be done by simply separating each data-src with "," // THIS WILL BE BREAKING OLD STUFF, INTRODUCE DEPRECATION WARNINGS!!!!! if ("data" in col) { for (let tok of col.data.split(",")) col_getter.push(["auto", tok.trim()]); // OLD data source selection: CALL DEPRECATION WARNING HERE!!! // start with console.log(), continue with console.warn(), console.error() // and final phase: alert() } else if ("multi" in col) { show_deprecation_message(); for(let item of col.multi) col_getter.push([item[0], item[1]]); } else if ("attr" in col || "prop" in col || "attr_as_list" in col ) { show_deprecation_message(); if ("attr" in col) col_getter.push(["attr", col.attr]); else if ("prop" in col) col_getter.push(["prop", col.prop]); else if ("attr_as_list" in col) col_getter.push(["attr_as_list", col.attr_as_list]); } else console.error(`no 'data' found for col: ${col.name} - skipping...`); /* fill each result for 'col_[type,key]' pair into 'raw_content' */ var raw_content = new Array(); for (let item of col_getter) { let col_type = item[0]; let col_key = item[1]; // newest stuff, automatically dispatch to correct data source if (col_type == "auto") { if (col_key === "name") { // "smart" name determination if ("friendly_name" in this.entity.attributes) raw_content.push(this.entity.attributes.friendly_name); else if ("name" in this.entity) raw_content.push(this.entity.name); else if ("name" in this.entity.attributes) raw_content.push(this.entity.attributes.name); else raw_content.push(this.entity.entity_id); } else if (col_key === "object_id") { // return Object ID ('entity_id' after 1st dot) raw_content.push(this.entity.entity_id.split(".").slice(1).join(".")); } else if (col_key === "_state" && "state" in this.entity.attributes) { // '_state' denotes 'attributes.state' raw_content.push(this.entity.attributes.state); } else if (col_key === "_name" && "name" in this.entity.attributes) { // '_name' denotes 'attributes.name' raw_content.push(this.entity.attributes.name); } else if (col_key === "icon") { // 'icon' will show the entity's default icon let _icon = this.entity.attributes.icon; raw_content.push(``); } else if (col_key === "state" && config.auto_format && !col.no_auto_format) { // format entity state raw_content.push(hass.formatEntityState(this.entity)); } else if (col_key in this.entity) { // easy direct member of entity, unformatted raw_content.push(this.entity[col_key]); } else if (col_key in this.entity.attributes) { // finally fall back to '.attributes' member if (config.auto_format && !col.no_auto_format) { raw_content.push(hass.formatEntityAttributeValue(this.entity, col_key)); } else { raw_content.push(this.entity.attributes[col_key]); } } else if (col_key === "area") { // 'area' will show the entity's or its device's assigned area, if any raw_content.push(this._get_area_name(this.entity.entity_id, hass)); } else if (col_key === "floor") { // 'floor' will show the entity's area's floor, if any raw_content.push(this._get_floor_name(this.entity.entity_id, hass)); } else if (col_key === "device") { // 'device' will show the entity's device name, if any raw_content.push(this._get_device_name(this.entity.entity_id, hass)); } else if (col_key === "device_configuration_url") { // 'device_configuration_url' will show the entity's device configuration URL, if any raw_content.push(this._get_device_value(this.entity.entity_id, hass, "configuration_url")); } else if (col_key === "device_connections") { // 'device_connections' will show the entity's device connections, if any let _dc = this._get_device_value(this.entity.entity_id, hass, "connections"); raw_content.push(Array.isArray(_dc) ? JSON.stringify(_dc) : _dc); } else if (col_key === "device_hw_version") { // 'device_hw_version' will show the entity's device hardware version, if any raw_content.push(this._get_device_value(this.entity.entity_id, hass, "hw_version")); } else if (col_key === "device_identifiers") { // 'device_identifiers' will show the entity's device identifiers, if any let _di = this._get_device_value(this.entity.entity_id, hass, "identifiers"); raw_content.push(Array.isArray(_di) ? JSON.stringify(_di) : _di); } else if (col_key === "device_manufacturer") { // 'device_manufacturer' will show the entity's device manufacturer, if any raw_content.push(this._get_device_value(this.entity.entity_id, hass, "manufacturer")); } else if (col_key === "device_model") { // 'device_model' will show the entity's device model, if any raw_content.push(this._get_device_value(this.entity.entity_id, hass, "model")); } else if (col_key === "device_serial_number") { // 'device_serial_number' will show the entity's device serial number, if any raw_content.push(this._get_device_value(this.entity.entity_id, hass, "serial_number")); } else if (col_key === "device_sw_version") { // 'device_sw_version' will show the entity's device software version, if any raw_content.push(this._get_device_value(this.entity.entity_id, hass, "sw_version")); } else if (col_key === "device_via_device") { // 'device_via_device' will show the entity's device via name, if any raw_content.push(this._get_device_via(this.entity.entity_id, hass)); } else if (col_key === "platform") { // 'platform' will show the entity's platform (domain), if any raw_content.push(this._get_platform(this.entity.entity_id, hass)); } else { // no matching data found, complain: //raw_content.push("[[ no match ]]"); let pos = col_key.indexOf('.'); if (pos < 0) { raw_content.push(null); } else { // if the col_key field contains a dotted object (eg: day.monday) // then traverse each object to ensure that it exists // until the final object value is found. // if at any point in the traversal, the object is not found // then null will be used as the value. // Works for arrays as well as single values. let objs = col_key.split('.'); let struct = this.entity.attributes; let values = []; if (struct) { for (let idx = 0; struct && idx < objs.length; idx++) { if (Array.isArray(struct) && isNaN(objs[idx])) { struct.forEach(function (item, index) { values.push(struct[index][objs[idx]]); }); } else { struct = (objs[idx] in struct) ? struct[objs[idx]] : null; } } // If no array found, single value is in struct. if (values.length == 0) values = struct; } raw_content.push(values); } } // @todo: not really nice to clean `raw_content` up here, why // putting garbage in it in the 1st place? Need to check // if this is ever executed, since the data-collection // improvements... /*raw_content = raw_content.filter( (item) => item !== undefined && item.slice(0, 9) !== 'undefined' );*/ // technically all of the above might be handled as list this.has_multiple = Array.isArray(raw_content.slice(-1)[0]); ////////// ALL OF THE FOLLOWING TO BE REMOVED ONCE DEPRECATION IS REALIZED.... // // collect the "raw" data from the requested source(s) } else if(col_type == "attr") { raw_content.push(((col_key in this.entity.attributes) ? this.entity.attributes[col_key] : null)); } else if (col_type == "prop") { // 'object_id' and 'name' not working -> make them work: if (col_key == "object_id") { raw_content.push(this.entity.entity_id.split(".").slice(1).join(".")); // 'name' automagically resolves to most verbose name } else if (col_key == "name") { if ("friendly_name" in this.entity.attributes) raw_content.push(this.entity.attributes.friendly_name); else if ("name" in this.entity) raw_content.push(this.entity.name); else if ("name" in this.entity.attributes) raw_content.push(this.entity.attributes.name); else raw_content.push(this.entity.entity_id); // other state properties seem to work as expected... (no multiples allowed!) } else raw_content.push((col_key in this.entity) ? this.entity[col_key] : null); } else if (col_type == "attr_as_list") { this.has_multiple = true; raw_content.push(this.entity.attributes[col_key]); // ////////// ... REMOVAL UNTIL THIS POINT HERE (DUE TO DEPRECATION) } else { console.error(`no selector found for col: ${col.name} - skipping...`); //raw_content.push("[failed selecting data]"); } } /* finally concat all raw_contents together using 'col.multi_delimiter' */ let delim = (col.multi_delimiter) ? col.multi_delimiter : " "; ////////// REMOVE ON DEPRECATION: if ("multi" in col && col.multi.length > 1) raw_content = raw_content.map((obj) => String(obj)).join(delim); // new approach, KEEP AFTER DEPRECATION: (maybe without 'else' working anyways?!) else if ("data" in col && raw_content.length > 1) raw_content = raw_content.map((obj) => String(obj)).join(delim); else raw_content = raw_content[0]; return ([null, undefined].every(x => raw_content !== x)) ? raw_content : new Array(); }); return null; } _get_device_name(entity_id, hass) { var device_id; if (hass.entities[entity_id] != null) { device_id = hass.entities[entity_id].device_id; } return device_id == null ? "-" : hass.devices[device_id].name_by_user || hass.devices[device_id].name; } _get_device_via(entity_id, hass) { var device_id; var via_device_id; if (hass.entities[entity_id] != null) { device_id = hass.entities[entity_id].device_id; } if (device_id != null) { via_device_id = hass.devices[device_id].via_device_id } return via_device_id == null ? "-" : hass.devices[via_device_id].name_by_user || hass.devices[via_device_id].name; } _get_device_value(entity_id, hass, parameter) { var device_id; var device_parameter; if (hass.entities[entity_id] != null) { device_id = hass.entities[entity_id].device_id; } if (device_id != null) { device_parameter = hass.devices[device_id][parameter]; } return device_id == null || device_parameter == null || (Array.isArray(device_parameter) && device_parameter.length == 0) ? "-" : hass.devices[device_id][parameter]; } _get_area_name(entity_id, hass) { var area_id; if (hass.entities[entity_id] != null) { area_id = hass.entities[entity_id].area_id; if (area_id == null) { let device_id = hass.entities[entity_id].device_id; if (device_id != null) area_id = hass.devices[device_id].area_id; } } return area_id == null || hass.areas[area_id] == null ? "-" : hass.areas[area_id].name; } _get_floor_name(entity_id, hass) { var area_id; var floor_id; if (hass.entities[entity_id] != null) { area_id = hass.entities[entity_id].area_id; if (area_id == null) { let device_id = hass.entities[entity_id].device_id; if (device_id != null) area_id = hass.devices[device_id].area_id; } if (area_id != null) floor_id = hass.areas[area_id].floor_id; } return floor_id == null || hass.floors[floor_id] == null ? "-" : hass.floors[floor_id].name; } _get_platform(entity_id, hass) { var entity = hass.entities[entity_id] return entity == null || entity.platform == null ? "-" : entity.platform; } render_data(col_cfgs) { // apply passed "modify" configuration setting by using eval() // assuming the data is available inside the function as "x" this.data = this.raw_data.map((raw, idx) => { let x = raw; let cfg = col_cfgs[idx]; let fmt = new CellFormatters(); if (cfg.fmt) { x = fmt[cfg.fmt](x); if (fmt.failed) x = null; } let content = (cfg.modify) ? eval(cfg.modify) : x; // check for undefined/null values and omit if strict set if (content === "undefined" || typeof content === "undefined" || content === null || content == "null" || (Array.isArray(content) && content.length == 0)) return ((this.strict) ? null : "n/a"); return new Object({ content: content, pre: cfg.prefix || "", suf: cfg.suffix || "", css: cfg.align || "left", hide: cfg.hidden, raw_content: raw, sort_unmodified: cfg.sort_unmodified, tap_action: cfg.tap_action, double_tap_action: cfg.double_tap_action, hold_action: cfg.hold_action, edit_action: cfg.edit_action, }); }); this.hidden = this.data.some(data => (data === null)); return this; }; } // Replace cell references with actual data. function getRefs(source, row_data, row_cells) { function _replace_col(match, p1) { return row_data[p1].content; } function _replace_cell(match, p1) { return row_cells[p1].innerText == "\n" ? "" : row_cells[p1].innerText; // empty cell contains
} function _replace_text(value) { const regex_col = /col\[(\d+)\]/gm; const regex_cell = /cell\[(\d+)\]/gm; value = String(value); let modify = value.replace(regex_col, _replace_col); modify = modify.replace(regex_cell, _replace_cell); return modify; } // Search for col and cell references (e.g. "col[3]", "cell[2]") and replace with actual data values. if (source) { if (typeof source === "object") { return JSON.parse(_replace_text(JSON.stringify(source))); } else { // Process simple string. return _replace_text(source); } } else { return ""; } } // Used for feedback during mouse/touch hold var holdDiskDiam = 98; var rippleDuration = 600; // in ms /** The HTMLElement, which is used as a base for the Lovelace custom card */ class FlexTableCard extends HTMLElement { constructor() { super(); this.attachShadow({ mode: 'open' }); this.card_height = 1; this.tbl = null; } // Used to detect changes requiring a table refresh. #old_last_updated = ""; #old_rowcount = 0; #last_config = null; _getRegEx(pats, invert=false) { // compile and convert wildcardish-regex to real RegExp const real_pats = pats.map((pat) => pat.replace(/\*/g, '.*')); const merged = real_pats.map((pat) => `(${pat})`).join("|"); if (invert) return new RegExp(`^(?:(?!${merged}).)*$`, 'gi'); else return new RegExp(`^${merged}$`, 'gi'); } _getEntities(hass, entities, incl, excl) { const format_entities = (e) => { if(!e) return null; if(typeof(e) === "string") return {entity: e.trim()} return e; } if (!incl && !excl && entities) { entities = entities.map(format_entities); return entities.map(e => hass.states[e.entity]); } // apply inclusion regex const incl_re = listify(incl).map(pat => this._getRegEx([pat])); // make sure to respect the incl-implied order: no (incl-)regex-stiching, collect // results for each include and finally reduce to a single list of state-keys let keys = incl_re.map((regex) => Object.keys(hass.states).filter(e_id => e_id.match(regex))). reduce((out, item) => out.concat(item), []); if (excl) { // apply exclusion, if applicable (here order is not affecting the output(s)) const excl_re = this._getRegEx(listify(excl), true); keys = keys.filter(e_id => e_id.match(excl_re)); } return keys.map(key => hass.states[key]); } setConfig(config) { // get & keep card-config and hass-interface if (!config.entities) { throw new Error('Please provide the "entities" option as a list.'); } if (!config.columns) { throw new Error('Please provide the "columns" option as a list.'); } if (config.action || config.service) { const action_config = config.action ? config.action.split('.') : config.service.split('.'); if (action_config.length != 2) { throw new Error('Please specify action in "domain.action" format.'); } } const root = this.shadowRoot; if (root.lastChild) root.removeChild(root.lastChild); const cfg = Object.assign({}, config); // assemble html const card = document.createElement('ha-card'); card.header = cfg.title; const content = document.createElement('div'); const style = document.createElement('style'); this.tbl = new DataTable(cfg); // CSS styles as assoc-data to allow seperate updates by key, i.e., css-selector var css_styles = { ".type-custom-flex-table-card": "overflow: auto;", "table": `width: 100%; padding: 16px; ${cfg.selectable ? "user-select: text;" : ""} `, "thead th": "height: 1em;", "tr td": "padding-left: 0.5em; padding-right: 0.5em; position: relative; overflow: hidden; ", "th": "padding-left: 0.5em; padding-right: 0.5em; ", "tr td.left": "text-align: left; ", "th.left": "text-align: left; ", "tr td.center": "text-align: center; ", "th.center": "text-align: center; ", "tr td.right": "text-align: right; ", "th.right": "text-align: right; ", ".headerSortDown::after, .headerSortUp::after": "content: ''; position: relative; left: 2px; border: 6px solid transparent; ", ".headerSortDown::after": "top: 12px; border-top-color: var(--primary-text-color); ", ".headerSortUp::after": "bottom: 12px; border-bottom-color: var(--primary-text-color); ", ".headerSortDown, .headerSortUp": "text-decoration: underline; ", "tbody tr:nth-child(odd)": "background-color: var(--table-row-background-color); ", "tbody tr:nth-child(even)": "background-color: var(--table-row-alternative-background-color); ", "th ha-icon": "height: 1em; vertical-align: top; ", "tfoot *": "border-style: solid none solid none;", "td.enable-hover:hover": "background-color: rgba(var(--rgb-secondary-text-color), 0.2); ", ".mouseheld::after": `content: ''; opacity: 0.7; z-index: 999; position: absolute; display: inline-block; animation: disc 200ms linear; top: var(--after-top, 0); left: var(--after-left, 0); width: ${holdDiskDiam}px; height: ${holdDiskDiam}px; border-radius: 50%; background-color: rgba(var(--rgb-primary-color), 0.285); `, "@keyframes disc": "0% { transform: scale(0); opacity: 0; } 100% {transform: scale(1); opacity: 0.7; }", "span.ripple": `position: absolute; border-radius: 50%; transform: scale(0); animation: ripple ${rippleDuration}ms linear; background-color: rgba(127, 127, 127, 0.7); `, "@keyframes ripple": "to { transform: scale(4); opacity: 0; } ", ".search-box": "align-items: center; padding: 14px; border-bottom: 1px solid var(--divider-color); background-color: var(--primary-background-color); ", ".input-wrapper": "display: flex; border: 1px solid var(--outline-color); height: 30px; border-radius: 10px; cursor: text; background-color: var(--card-background-color); ", ".input-wrapper:hover": "border: 1px solid var(--outline-hover-color); ", ".input-wrapper:focus-within": "border: 1px solid var(--primary-color); ", ".input": "border: none; width: -webkit-fill-available; background-color: var(--card-background-color); ", "input:focus": "outline: none; ", ".icon": "padding: 6px; fill: var(--primary-text-color); ", ".icon.trailing": "cursor: pointer; position: relative; overflow: hidden; visibility: hidden; width: 12px; height: 12px; margin-right: 2px; padding-right: 16px; padding-bottom: 12px; padding-left: 4px; ", ".icon.trailing:hover": "background-color: var(--primary-background-color); border-radius: 50%; ", ".svg-trailing": "margin-left: 2px; ", } // apply CSS-styles from configuration // ("+" suffix to key means "append" instead of replace) if ("css" in cfg) { for(var key in cfg.css) { var is_append = (key.slice(-1) == "+"); var css_key = (is_append) ? key.slice(0, -1) : key; if(is_append && css_key in css_styles) css_styles[css_key] += cfg.css[key]; else css_styles[css_key] = cfg.css[key]; } } // assemble final CSS style data, every item within `css_styles` will be translated to: // { } style.textContent = ""; for(var key in css_styles) style.textContent += key + " { " + css_styles[key] + " } \n"; // temporary for generated header html stuff let my_headers = this.tbl.headers.map((obj, idx) => new Object({ th_html_begin: ``, th_html_end: `${obj.name}`, icon_html: ((obj.icon) ? `` : "") })); // search filter box, if configured const search_box = ` `; // table skeleton, body identified with: 'flextbl', footer with 'flexfoot' content.innerHTML = ` ${cfg.enable_search ? search_box : ""} ${my_headers.map((obj, idx) => `${obj.th_html_begin}${obj.icon_html}${obj.th_html_end}`).join("")}
`; // push css-style & table as content into the card's DOM tree card.appendChild(style); card.appendChild(content); // append card to _root_ node... root.appendChild(card); // add sorting click handler to header elements, if allowed if (!config.disable_header_sort) { this.tbl.headers.map((obj, idx) => { root.getElementById(obj.id).onclick = (click) => { // remove previous sort by this.tbl.headers.map((obj, idx) => { root.getElementById(obj.id).classList.remove("headerSortDown"); root.getElementById(obj.id).classList.remove("headerSortUp"); }); this.tbl.updateSortBy(idx); if (this.tbl.sort_by.indexOf("+") != -1) { root.getElementById(obj.id).classList.add("headerSortUp"); } else { root.getElementById(obj.id).classList.add("headerSortDown"); } this._updateContent( root.getElementById("flextbl"), this.tbl.get_rows() ); }; }); // Add event listeners for Search feature if (config.enable_search) { const inputText = this.shadowRoot.getElementById('search-input'); const clearButton = this.shadowRoot.getElementById('clear-input'); const table = this.shadowRoot.getElementById('flextbl'); inputText.addEventListener("input", () => _filterRows(this, inputText, clearButton, table)); clearButton.addEventListener("click", () => _clearSearch(inputText)); inputText.addEventListener('keydown', (event) => { _handle_keydown(event, inputText); }); } function _filterRows(flex_table_card, inputText, clearButton, table) { // Update visibility of clear button based on existence of text clearButton.style.visibility = inputText.value.length > 0 ? 'visible' : 'hidden'; // Filter table rows based on search text const table_rows = table.querySelectorAll('tbody tr'); const filter = inputText.value.trim().toLowerCase(); table_rows.forEach((row) => { const rowText = row.textContent.toLowerCase(); row.hidden = !rowText.includes(filter); }); // Update footer with only unfiltered rows if (cfg.display_footer) { const footer = root.getElementById('flexfoot'); const data_rows = flex_table_card.tbl.get_rows(); flex_table_card._updateFooter(footer, cfg, table_rows, data_rows); } } function _clearSearch(inputText) { inputText.value = ""; inputText.dispatchEvent(new Event('input', { bubbles: true, cancelable: true })); } function _handle_keydown(e, inputText) { // Clear search text on Escape pressed if (e.key === 'Escape' || e.keyCode === 27) { _clearSearch(inputText); } } } this._config = cfg; } _setup_cell_for_editing(elem, row, col, index) { function _handle_lost_focus(e) { // Check if user changed text. if (this.textContent != this.dataset.original) { const actionConfig = { tap_action: { action: "perform-action", perform_action: col.edit_action.perform_action, data: getRefs(col.edit_action.data, row.data, elem.cells), target: col.edit_action.target ?? { entity_id: row.entity.entity_id }, confirmation: getRefs(col.edit_action.confirmation, row.data, elem.cells) }, }; let ev = new Event("hass-action", { bubbles: true, cancelable: false, composed: true }); ev.detail = { config: actionConfig, action: "tap", }; this.dispatchEvent(ev); this.dataset.original = this.textContent; } } function _handle_keydown(e) { // Discard edit on Escape pressed if (e.key === 'Escape' || e.keyCode === 27) { this.textContent = this.dataset.original; } else if (e.key === 'Enter' || e.keyCode === 13) { // Accept edit on Enter pressed (lose focus) this.blur(); e.preventDefault(); } } let cell = elem.cells[index]; cell.classList.add("enable-hover"); cell.addEventListener("blur", _handle_lost_focus); cell.addEventListener("keydown", _handle_keydown); } _get_html_for_editable_cell(cell) { if (cell.edit_action) { return 'contenteditable="true" data-original="' + cell.pre + cell.content + cell.suf + '"' } else { return ""; } } _updateContent(table, rows) { // callback for updating the cell-contents table.innerHTML = rows.map((row, index) => `${row.data.map( (cell) => ((!cell.hide) ? `${cell.pre}${cell.content}${cell.suf}` : "") ).join("")}`).join(""); function _fireEvent(obj, action_type, actionConfig) { let ev = new Event("hass-action", { bubbles: true, cancelable: false, composed: true }); let atype = action_type.replace("_action", ""); ev.detail = { config: actionConfig, action: atype, }; obj.dispatchEvent(ev); } // Define handlers for cell actions. function _handle_more_info(obj, action_type, elem, row, col) { const actionConfig = { [action_type]: { action: "more-info", entity: getRefs(col[action_type].entity, row.data, elem.cells) || row.entity.entity_id, confirmation: getRefs(col[action_type].confirmation, row.data, elem.cells) }, }; _fireEvent(obj, action_type, actionConfig); } function _handle_toggle(obj, action_type, elem, row, col) { const actionConfig = { [action_type]: { action: "toggle", confirmation: getRefs(col[action_type].confirmation, row.data, elem.cells) }, entity: getRefs(col[action_type].entity, row.data, elem.cells) || row.entity.entity_id }; _fireEvent(obj, action_type, actionConfig); } function _handle_perform_action(obj, action_type, elem, row, col) { const actionConfig = { [action_type]: { action: "perform-action", perform_action: col[action_type].perform_action, data: getRefs(col[action_type].data, row.data, elem.cells), target: getRefs(col[action_type].target, row.data, elem.cells) || { entity_id: row.entity.entity_id }, confirmation: getRefs(col[action_type].confirmation, row.data, elem.cells) }, }; _fireEvent(obj, action_type, actionConfig); } function _handle_navigate(obj, action_type, elem, row, col) { const actionConfig = { [action_type]: { action: "navigate", navigation_path: getRefs(col[action_type].navigation_path || col.content, row.data, elem.cells), navigation_replace: col[action_type].navigation_replace, confirmation: getRefs(col[action_type].confirmation, row.data, elem.cells) }, }; _fireEvent(obj, action_type, actionConfig); } function _handle_url(obj, action_type, elem, row, col) { const actionConfig = { [action_type]: { action: "url", url_path: getRefs(col[action_type].url_path || col.content, row.data, elem.cells) .normalize("NFD").replace(/[\u0300-\u036f]/g, ""), confirmation: getRefs(col[action_type].confirmation, row.data, elem.cells) }, }; _fireEvent(obj, action_type, actionConfig); } function _handle_assist(obj, action_type, elem, row, col) { const actionConfig = { [action_type]: { action: "assist", start_listening: col[action_type].start_listening, pipeline_id: col[action_type].pipeline_id, confirmation: getRefs(col[action_type].confirmation, row.data, elem.cells) }, }; _fireEvent(obj, action_type, actionConfig); } function _handle_fire_dom_event(obj, action_type, elem, row, col) { const actionConfig = { [action_type]: getRefs(col[action_type], row.data, elem.cells) }; _fireEvent(obj, action_type, actionConfig); } function _handle_action(obj, action_type, elem, row, col) { let action; switch (action_type) { case "tap_action": action = col.tap_action; break; case "double_tap_action": action = col.double_tap_action; break; case "hold_action": action = col.hold_action; break; default: throw new Error(`Expected one of tap_action, double_tap_action, hold_action, but received: ${action_type}`) } switch (action["action"]) { case "more-info": _handle_more_info(obj, action_type, elem, row, col); break; case "toggle": _handle_toggle(obj, action_type, elem, row, col); break; case "perform-action": _handle_perform_action(obj, action_type, elem, row, col); break; case "navigate": _handle_navigate(obj, action_type, elem, row, col); break; case "url": _handle_url(obj, action_type, elem, row, col); break; case "assist": _handle_assist(obj, action_type, elem, row, col); break; case "fire-dom-event": _handle_fire_dom_event(obj, action_type, elem, row, col); break; case "edit": _handle_edit(obj, action_type, elem, row, col); break; case "none": break; default: throw new Error(`Expected one of none, toggle, more-info, perform-action, url, navigate, assist, fire-dom-event, but received: ${action["action"]}`); } } rows.forEach((row, index) => { const elem = this.shadowRoot.getElementById(`entity_row_${row.entity.entity_id}_${index}`); let colindex = -1; function _do_ripple(target) { const circle = document.createElement("span"); const diameter = Math.max(target.clientWidth, target.clientHeight); circle.style.width = circle.style.height = `${diameter}px`; circle.style.left = "0px"; circle.style.top = "0px"; circle.classList.add("ripple"); target.appendChild(circle); const timerId = setTimeout(() => { circle.remove(); }, rippleDuration); } // Setup any actionable columns row.data.forEach((col) => { if (!col.hide) { colindex++; let cell = elem.cells[colindex]; let clickTimer = 0; let holdTimer = 0; let isHolding = false; if (col.tap_action) { const clickWait = col.double_tap_action? 300 : 0; function handleClick(e) { let event = e; if (clickTimer == 0 && holdTimer == 0) { // Set timer to perform action after waiting for possible double click clickTimer = setTimeout(() => { _do_ripple(this); _handle_action(this, "tap_action", elem, row, col); clickTimer = 0; }, clickWait); } // Double click or hold happening instead of click else { clearTimeout(clickTimer); clickTimer = 0; } } cell.classList.add("enable-hover"); cell.addEventListener("click", handleClick); }; if (col.double_tap_action) { function handleDoubleClick(e) { clearTimeout(clickTimer); clickTimer = 0; _do_ripple(e.target); _handle_action(this, "double_tap_action", elem, row, col); } cell.classList.add("enable-hover"); cell.addEventListener("dblclick", handleDoubleClick); }; if (col.hold_action) { const holdDuration = 500; var targetRect; function handleMouseDown(e) { isHolding = false; targetRect = e.target.getBoundingClientRect(); var xpt; var ypt; if (e instanceof MouseEvent) { xpt = e.clientX; ypt = e.clientY; } else { xpt = e.targetTouches[0].clientX; ypt = e.targetTouches[0].clientY; } var x = xpt - targetRect.left - (holdDiskDiam/2); var y = ypt - targetRect.top - (holdDiskDiam / 2); holdTimer = setTimeout(() => { isHolding = true; cell.style.setProperty('--after-left', `${x}px`); cell.style.setProperty('--after-top', `${y}px`); cell.style.setProperty('overflow', 'visible'); cell.classList.add("mouseheld"); }, holdDuration); } function handleMouseUp(e) { if (isHolding) { isHolding = false; _do_ripple(e.target); _handle_action(this, "hold_action", elem, row, col); e.preventDefault(); } else { clearTimeout(holdTimer); holdTimer = 0; } } function handleCancel(e) { if (e instanceof TouchEvent && e.targetTouches.length > 0 && targetRect) { var xpt = e.targetTouches[0].clientX; var ypt = e.targetTouches[0].clientY; // If touch within original target, do nothing. if ((targetRect.left <= xpt && xpt <= targetRect.right) && (targetRect.top <= ypt && ypt <= targetRect.bottom)) { return; } } cell.style.setProperty('overflow', 'hidden'); cell.classList.remove("mouseheld"); isHolding = false; } // Add event listeners cell.classList.add("enable-hover"); cell.addEventListener('mousedown', handleMouseDown); cell.addEventListener('touchstart', handleMouseDown); cell.addEventListener('mouseup', handleMouseUp); cell.addEventListener('touchend', handleMouseUp); window.addEventListener('mouseup', handleCancel); cell.addEventListener('touchend', handleCancel); window.addEventListener('touchmove', handleCancel); }; if (col.edit_action) { this._setup_cell_for_editing(elem, row, col, colindex); } } }); // if configured, set clickable row to show entity popup-dialog // bind click()-handler to row (if configured) elem.onclick = (this.tbl.cfg.clickable) ? (function(clk_ev) { // create and fire 'details-view' signal let ev = new Event("hass-more-info", { bubbles: true, cancelable: false, composed: true }); ev.detail = { entityId: row.entity.entity_id }; this.dispatchEvent(ev); }) : null; }); // If search enabled, may need to re-hide rows. Simulate text entry in search box. if (this.tbl.cfg.enable_search) { const inputText = this.shadowRoot.getElementById('search-input'); inputText.dispatchEvent(new Event('input', { bubbles: true, cancelable: true })); } } _updateFooter(footer, config, table_rows, data_rows) { var innerHTML = ''; var colnum = -1; var raw = ""; var colspan_remainder = 0 config.columns.map((col, idx) => { if (!col.hidden) { colnum++; if (colspan_remainder > 0) // Skip column if previous colspan would overlap it colspan_remainder--; else { var cfg = config.columns[idx]; if (col.footer_type) { switch (col.footer_type) { case 'sum': raw = this._sumColumn(table_rows, data_rows, colnum); break; case 'average': raw = this._avgColumn(table_rows, data_rows, colnum); break; case 'count': raw = Array.from(table_rows).filter(row => !row.hidden).length; break; case 'max': raw = this._maxColumn(table_rows, data_rows, colnum); break; case 'min': raw = this._minColumn(table_rows, data_rows, colnum); break; case 'text': raw = col.footer_text; break; default: console.log("Invalid footer_type: ", col.footer_type); } let x = raw; let value = cfg.footer_modify ? eval(cfg.footer_modify) : x; if (col.footer_type == 'text') { let colspan = cfg.footer_colspan ? cfg.footer_colspan : 1; innerHTML += `${value}`; colspan_remainder = colspan - 1; } else innerHTML += `${cfg.prefix || ""}${value}${cfg.suffix || ""}`; } else { innerHTML += '' } } } }); innerHTML += ''; footer.innerHTML = innerHTML; } _sumColumn(table_rows, data_rows, colnum) { var sum = 0; for (var i = 0; i < data_rows.length; i++) { if (!table_rows[i].hidden) { let cellValue = this._findNumber(data_rows[i].data[colnum].sort_unmodified ? data_rows[i].data[colnum].raw_content : data_rows[i].data[colnum].content); if (!Number.isNaN(cellValue)) sum += cellValue; } } return sum; } _avgColumn(table_rows, data_rows, colnum) { var sum = 0; var count = 0; for (var i = 0; i < data_rows.length; i++) { if (!table_rows[i].hidden) { let cellValue = this._findNumber(data_rows[i].data[colnum].sort_unmodified ? data_rows[i].data[colnum].raw_content : data_rows[i].data[colnum].content); if (!Number.isNaN(cellValue)) { sum += cellValue; count++; } } } return sum / count; } _maxColumn(table_rows, data_rows, colnum) { var max = Number.MIN_VALUE; for (var i = 0; i < data_rows.length; i++) { if (!table_rows[i].hidden) { let cellValue = this._findNumber(data_rows[i].data[colnum].sort_unmodified ? data_rows[i].data[colnum].raw_content : data_rows[i].data[colnum].content); if (!Number.isNaN(cellValue)) { if (cellValue > max) max = cellValue; } } } return max == Number.MIN_VALUE ? Number.NaN : max; } _minColumn(table_rows, data_rows, colnum) { var min = Number.MAX_VALUE; for (var i = 0; i < data_rows.length; i++) { if (!table_rows[i].hidden) { let cellValue = this._findNumber(data_rows[i].data[colnum].sort_unmodified ? data_rows[i].data[colnum].raw_content : data_rows[i].data[colnum].content); if (!Number.isNaN(cellValue)) { if (cellValue < min) min = cellValue; } } } return min == Number.MAX_VALUE ? Number.NaN : min; } // Trim whitespace and leading non-numeric, but not minus sign _findNumber(val) { if (typeof val === "number") { return val; } else { let value = val.trim(); return (Number.isNaN(parseFloat(value[0])) && value[0] !== '-') ? parseFloat(value.substring(1)) : parseFloat(value); } } set hass(hass) { const config = this._config; const root = this.shadowRoot; if (config.static_data) { // Use static data to populate if (config !== this.#last_config) { this.#last_config = config; let entities = new Array(); let static_data = { "entity_id": "None", "attributes": config.static_data }; entities.push(static_data); this._fill_card(entities, config, root, hass); } return; } // get "data sources / origins" i.e, entities let entities = this._getEntities(hass, config.entities, config.entities.include, config.entities.exclude); // Check for changes requiring a table refresh. // Return if no changes detected. let rowcount = entities.length; if (rowcount == this.#old_rowcount) { let last_updated_arr = entities.map(a => a.last_updated); let max = last_updated_arr.sort().slice(-1)[0]; if (max == this.#old_last_updated) return; this.#old_last_updated = max; } this.#old_rowcount = rowcount; if (config.action || config.service) { // Use action to populate const action_config = config.action ? config.action.split('.') : config.service.split('.'); let domain = action_config[0]; let action = action_config[1]; let action_data = config.action_data || config.service_data; let entity_list = entities.map((entity) => entity.entity_id ); hass.callWS({ "type": "call_service", "domain": domain, "service": action, "service_data": action_data, "target": entity_list.length ? { "entity_id": entity_list } : undefined, "return_response": true, }).then(return_response => { const entities = new Array(); Object.keys(return_response.response).forEach((entity_id, idx) => { let resp_obj = {}; if (entity_list.length > 0) { // Return payload(s) below entity key(s). const entity_key = (Object.keys(return_response.response))[idx]; resp_obj = { "entity_id": entity_id, "attributes": return_response.response[entity_key] }; } else { // Return entire response payload. resp_obj = { "entity_id": entity_id, "attributes": return_response.response }; } entities.push(resp_obj); }) this._fill_card(entities, config, root, hass); }); } else { // Use entities to populate this._fill_card(entities, config, root, hass); } } _fill_card(entities, config, root, hass) { // `raw_rows` to be filled with data here, due to 'attr_as_list' it is possible to have // multiple data `raw_rows` acquired into one cell(.raw_data), so re-iterate all rows // to---if applicable---spawn new DataRow objects for these accordingly let raw_rows = entities.map(e => new DataRow(e, config.strict)); raw_rows.forEach(e => e.get_raw_data(config.columns, config, hass)) // now add() the raw_data rows to the DataTable this.tbl.clear_rows(); raw_rows.forEach(row_obj => { if (!row_obj.has_multiple) this.tbl.add(row_obj); else this.tbl.add(...transpose(row_obj.raw_data).map(new_raw_data => new DataRow(row_obj.entity, row_obj.strict, new_raw_data))); }); // finally set card height and insert card this._setCardSize(this.tbl.rows.length); // all preprocessing / rendering will be done here inside DataTable::get_rows() const data_rows = this.tbl.get_rows(); const table = root.getElementById('flextbl'); this._updateContent(table, data_rows); const table_rows = table.querySelectorAll('tbody tr'); if (config.display_footer) this._updateFooter(root.getElementById("flexfoot"), config, table_rows, data_rows); } _setCardSize(num_rows) { this.card_height = parseInt(num_rows * 0.5); } getCardSize() { return this.card_height; } getGridOptions() { return { columns: "full", }; } } customElements.define('flex-table-card', FlexTableCard); window.customCards = window.customCards || []; window.customCards.push({ type: "flex-table-card", name: "Flex Table Card", description: "The Flex Table card gives you the ability to visualize tabular data." // optional }); console.info(`%c FLEX-TABLE-CARD %c Version ${VERSION} `, "font-weight: bold; color: #000; background: #aeb", "font-weight: bold; color: #000; background: #ddd");