From 5f226d1809ac7264413127ab02fbf5c5a7b5ade9 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 16 Jun 2018 17:29:18 -0400 Subject: [PATCH] Add experimental UI (#1205) * Add experimental UI * Allow theming a view * Name it css * Allow applying themes * Add filter card * Add normal column layout logic --- src/layouts/partial-panel-resolver.js | 4 + .../ha-panel-experimental-ui.js | 96 +++++++++ .../experimental-ui/hui-entities-card.js | 114 +++++++++++ .../experimental-ui/hui-entity-filter-card.js | 55 ++++++ src/panels/experimental-ui/hui-view.js | 182 ++++++++++++++++++ 5 files changed, 451 insertions(+) create mode 100644 src/panels/experimental-ui/ha-panel-experimental-ui.js create mode 100644 src/panels/experimental-ui/hui-entities-card.js create mode 100644 src/panels/experimental-ui/hui-entity-filter-card.js create mode 100644 src/panels/experimental-ui/hui-view.js diff --git a/src/layouts/partial-panel-resolver.js b/src/layouts/partial-panel-resolver.js index 0a7c9afe4d..cd50f90e08 100644 --- a/src/layouts/partial-panel-resolver.js +++ b/src/layouts/partial-panel-resolver.js @@ -49,6 +49,10 @@ function ensureLoaded(panel) { imported = import(/* webpackChunkName: "panel-dev-template" */ '../panels/dev-template/ha-panel-dev-template.js'); break; + case 'experimental-ui': + imported = import(/* webpackChunkName: "panel-experimental-ui" */ '../panels/experimental-ui/ha-panel-experimental-ui.js'); + break; + case 'history': imported = import(/* webpackChunkName: "panel-history" */ '../panels/history/ha-panel-history.js'); break; diff --git a/src/panels/experimental-ui/ha-panel-experimental-ui.js b/src/panels/experimental-ui/ha-panel-experimental-ui.js new file mode 100644 index 0000000000..e48e57041b --- /dev/null +++ b/src/panels/experimental-ui/ha-panel-experimental-ui.js @@ -0,0 +1,96 @@ +import '@polymer/app-layout/app-header-layout/app-header-layout.js'; +import '@polymer/app-layout/app-header/app-header.js'; +import '@polymer/app-layout/app-toolbar/app-toolbar.js'; +import '@polymer/paper-icon-button/paper-icon-button.js'; + +import { html } from '@polymer/polymer/lib/utils/html-tag.js'; +import { PolymerElement } from '@polymer/polymer/polymer-element.js'; + +import './hui-view.js'; + +class ExperimentalUI extends PolymerElement { + static get template() { + return html` + + + + + +
Experimental UI
+ +
+
+ + +
+ `; + } + + static get properties() { + return { + hass: Object, + + narrow: { + type: Boolean, + value: false, + }, + + showMenu: { + type: Boolean, + value: false, + }, + + _columns: { + type: Number, + value: 1, + }, + + _config: { + type: Object, + value: null, + observer: '_configChanged', + }, + + _curView: Object + }; + } + + ready() { + super.ready(); + this._fetchConfig(); + this._handleWindowChange = this._handleWindowChange.bind(this); + this.mqls = [300, 600, 900, 1200].map((width) => { + const mql = matchMedia(`(min-width: ${width}px)`); + mql.addListener(this._handleWindowChange); + return mql; + }); + this._handleWindowChange(); + } + + _handleWindowChange() { + const matchColumns = this.mqls.reduce((cols, mql) => cols + mql.matches, 0); + // Do -1 column if the menu is docked and open + this._columns = Math.max(1, matchColumns - (!this.narrow && this.showMenu)); + } + + _fetchConfig() { + this.hass.connection.sendMessagePromise({ type: 'frontend/experimental_ui' }) + .then((conf) => { this._config = conf.result; }); + } + + _configChanged(config) { + if (!config) return; + // Currently hardcode to first view. + this._curView = config.views[0]; + } +} + +customElements.define('ha-panel-experimental-ui', ExperimentalUI); diff --git a/src/panels/experimental-ui/hui-entities-card.js b/src/panels/experimental-ui/hui-entities-card.js new file mode 100644 index 0000000000..2c126beed4 --- /dev/null +++ b/src/panels/experimental-ui/hui-entities-card.js @@ -0,0 +1,114 @@ +import '@polymer/iron-flex-layout/iron-flex-layout-classes.js'; +import { html } from '@polymer/polymer/lib/utils/html-tag.js'; +import { PolymerElement } from '@polymer/polymer/polymer-element.js'; + +import stateCardType from '../../common/entity/state_card_type.js'; + +import '../../components/ha-card.js'; + +// just importing this now as shortcut to import correct state-card-* +import '../../state-summary/state-card-content.js'; + +// Support for overriding type from attributes should be removed +// Instead, it should be coded inside entity config. +// stateCardType requires `hass` because we check if a service exists. +// This should also be determined during runtime. +function stateElement(hass, entityId, stateObj) { + if (!stateObj) { + return 'state-card-display'; + } else if (stateObj.attributes && 'custom_ui_state_card' in stateObj.attributes) { + return stateObj.attributes.custom_ui_state_card; + } + return 'state-card-' + stateCardType(hass, stateObj); +} + +class HuiEntitiesCard extends PolymerElement { + static get template() { + return html` + + + +
+
[[_computeTitle(config)]]
+
+
+
+`; + } + + static get properties() { + return { + hass: { + type: Object, + observer: '_hassChanged', + }, + config: { + type: Object, + observer: '_configChanged', + } + }; + } + + constructor() { + super(); + this._elements = []; + } + + getCardSize() { + // +1 for the header + return 1 + (this.config ? this.config.entities.length : 0); + } + + _computeTitle(config) { + return config.title; + } + + _configChanged(config) { + const root = this.$.states; + + while (root.lastChild) { + root.removeChild(root.lastChild); + } + + this._elements = []; + + for (let i = 0; i < config.entities.length; i++) { + const entityId = config.entities[i]; + const stateObj = this.hass.states[entityId]; + const tag = stateElement(this.hass, entityId, stateObj); + const element = document.createElement(tag); + element.stateObj = stateObj; + element.hass = this.hass; + this._elements.push({ entityId, element }); + root.appendChild(element); + } + } + + _hassChanged(hass) { + for (let i = 0; i < this._elements.length; i++) { + const { entityId, element } = this._elements[i]; + const stateObj = hass.states[entityId]; + element.stateObj = stateObj; + element.hass = hass; + } + } +} +customElements.define('hui-entities-card', HuiEntitiesCard); diff --git a/src/panels/experimental-ui/hui-entity-filter-card.js b/src/panels/experimental-ui/hui-entity-filter-card.js new file mode 100644 index 0000000000..2ee2b87087 --- /dev/null +++ b/src/panels/experimental-ui/hui-entity-filter-card.js @@ -0,0 +1,55 @@ +import { html } from '@polymer/polymer/lib/utils/html-tag.js'; +import { PolymerElement } from '@polymer/polymer/polymer-element.js'; + +import './hui-entities-card.js'; + +import computeStateDomain from '../../common/entity/compute_state_domain.js'; + +class HuiEntitiesCard extends PolymerElement { + static get template() { + return html` + +`; + } + + static get properties() { + return { + hass: Object, + config: Object, + }; + } + + getCardSize() { + // +1 for the header + return 1 + this._getEntities(this.hass, this.config.filter).length; + } + + // Return a list of entities based on a filter. + _getEntities(hass, filter) { + const filters = []; + + if (filter.domain) { + const domain = filter.domain; + filters.push(stateObj => computeStateDomain(stateObj) === domain); + } + + if (filter.state) { + const state = filter.state; + filters.push(stateObj => stateObj.state === state); + } + + return Object.values(hass.states) + .filter(stateObj => filters.every(filterFunc => filterFunc(stateObj))) + .map(stateObj => stateObj.entity_id); + } + + _computeCardConfig(hass, config) { + return Object.assign({}, config.card_config || {}, { + entities: this._getEntities(hass, config.filter), + }); + } +} +customElements.define('hui-entity-filter-card', HuiEntitiesCard); diff --git a/src/panels/experimental-ui/hui-view.js b/src/panels/experimental-ui/hui-view.js new file mode 100644 index 0000000000..c10f9970cf --- /dev/null +++ b/src/panels/experimental-ui/hui-view.js @@ -0,0 +1,182 @@ +import { html } from '@polymer/polymer/lib/utils/html-tag.js'; +import { PolymerElement } from '@polymer/polymer/polymer-element.js'; + +import './hui-entities-card.js'; +import './hui-entity-filter-card.js'; + +import applyThemesOnElement from '../../common/dom/apply_themes_on_element.js'; + +const VALID_TYPES = ['entities', 'entity-filter']; +const CUSTOM_TYPE_PREFIX = 'custom:'; + +function cardElement(type) { + if (VALID_TYPES.includes(type)) { + return `hui-${type}-card`; + } else if (type.startsWith(CUSTOM_TYPE_PREFIX)) { + return type.substr(CUSTOM_TYPE_PREFIX.length); + } + return null; +} + +class HaView extends PolymerElement { + static get template() { + return html` + +
+ `; + } + static get properties() { + return { + hass: { + type: Object, + observer: '_hassChanged', + }, + + columns: { + type: Number, + observer: '_configChanged', + }, + + config: { + type: Object, + observer: '_configChanged', + }, + }; + } + + constructor() { + super(); + this._elements = []; + } + + _getElements(cards) { + const elements = []; + + for (let i = 0; i < cards.length; i++) { + const cardConfig = cards[i]; + const tag = cardElement(cardConfig.type); + if (!tag) { + // eslint-disable-next-line + console.error('Unknown type encountered:', cardConfig.type); + continue; + } + const element = document.createElement(tag); + element.config = cardConfig; + element.hass = this.hass; + elements.push(element); + } + + return elements; + } + + _configChanged() { + const root = this.$.columns; + const config = this.config; + + while (root.lastChild) { + root.removeChild(root.lastChild); + } + + if (!config) { + this._elements = []; + return; + } + + const elements = this._getElements(config.cards); + + let columns = []; + const columnEntityCount = []; + for (let i = 0; i < this.columns; i++) { + columns.push([]); + columnEntityCount.push(0); + } + + // Find column with < 5 entities, else column with lowest count + function getColumnIndex(size) { + let minIndex = 0; + for (let i = 0; i < columnEntityCount.length; i++) { + if (columnEntityCount[i] < 5) { + minIndex = i; + break; + } + if (columnEntityCount[i] < columnEntityCount[minIndex]) { + minIndex = i; + } + } + + columnEntityCount[minIndex] += size; + + return minIndex; + } + + elements.forEach(el => + columns[getColumnIndex(el.getCardSize())].push(el)); + + // Remove empty columns + columns = columns.filter(val => val.length > 0); + + columns.forEach((column) => { + const columnEl = document.createElement('div'); + columnEl.classList.add('column'); + column.forEach(el => columnEl.appendChild(el)); + root.appendChild(columnEl); + }); + + this._elements = elements; + + if ('theme' in config) { + applyThemesOnElement(root, this.hass.themes, config.theme); + } + } + + _hassChanged(hass) { + for (let i = 0; i < this._elements.length; i++) { + this._elements[i].hass = hass; + } + } +} + +customElements.define('hui-view', HaView);