Files
hassos_config/www/community/flex-table-card/flex-table-card.js
2026-03-26 12:10:21 +01:00

1522 lines
67 KiB
JavaScript

"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<Math.min(vers1.length, vers2.length); ++i) {
vers1[i] = parseInt(vers1[i], 10);
vers2[i] = parseInt(vers2[i], 10);
if (vers1[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;
}
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 `<ha-icon icon="${data}"></ha-icon>`;
}
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("<br>");
}
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("<br>") || "-";
}
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("<br>") || "-";
}
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("<br>");
}
}
/** 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(`<ha-icon id="icon" icon="${_icon}"></ha-icon>`);
} 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 <br>
}
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:
// <key> { <any-string-value> }
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 class="${cfg.columns[idx].align || 'left'}" id="${obj.id}">`,
th_html_end: `${obj.name}</th>`,
icon_html: ((obj.icon) ? `<ha-icon id='icon' icon='${obj.icon}'></ha-icon>` : "")
}));
// search filter box, if configured
const search_box = `
<div class="search-box">
<div id="search-wrapper" class="input-wrapper">
<div class="icon leading">
<svg width="18"; height="18"; focusable="false" role="img" viewBox="0 0 24 24">
<g>
<path class="primary-path" d="M9.5,3A6.5,6.5 0 0,1 16,9.5C16,11.11 15.41,12.59 14.44,13.73L14.71,14H15.5L20.5,19L19,20.5L14,15.5V14.71L13.73,14.44C12.59,15.41 11.11,16 9.5,16A6.5,6.5 0 0,1 3,9.5A6.5,6.5 0 0,1 9.5,3M9.5,5C7,5 5,7 5,9.5C5,12 7,14 9.5,14C12,14 14,12 14,9.5C14,7 12,5 9.5,5Z"></path>
</g>
</svg>
</div>
<input id="search-input" class="input" placeholder="Search..." type="text" autocomplete="off">
<div id="clear-input" class="icon trailing">
<svg class="svg-trailing" width="18"; height="18"; focusable="false" role="img" viewBox="0 0 24 24">
<g>
<path class="primary-path" d="M19,6.41L17.59,5L12,10.59L6.41,5L5,6.41L10.59,12L5,17.59L6.41,19L12,13.41L17.59,19L19,17.59L13.41,12L19,6.41Z"></path>
</g>
</svg>
</div>
</div>
</div >
`;
// table skeleton, body identified with: 'flextbl', footer with 'flexfoot'
content.innerHTML = `
${cfg.enable_search ? search_box : ""}
<table>
<thead>
<tr>
${my_headers.map((obj, idx) =>
`${obj.th_html_begin}${obj.icon_html}${obj.th_html_end}`).join("")}
</tr>
</thead>
<tbody id='flextbl'></tbody>
<tfoot id='flexfoot'></tfoot>
</table>
`;
// 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) =>
`<tr id="entity_row_${row.entity.entity_id}_${index}">${row.data.map(
(cell) => ((!cell.hide) ?
`<td class="${cell.css}" ${this._get_html_for_editable_cell(cell)}>${cell.pre}${cell.content}${cell.suf}</td>` : "")
).join("")}</tr>`).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 = '<tr>';
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 += `<th id="tfootcol${colnum}" colspan=${colspan}>${value}</th>`;
colspan_remainder = colspan - 1;
}
else
innerHTML += `<td id="tfootcol${colnum}" class="${cfg.align || ""}">${cfg.prefix || ""}${value}${cfg.suffix || ""}</td>`;
}
else {
innerHTML += '<td></td>'
}
}
}
});
innerHTML += '</tr>';
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");