diff --git a/src/data/lovelace.ts b/src/data/lovelace.ts index d3ea76c430..994227b87c 100644 --- a/src/data/lovelace.ts +++ b/src/data/lovelace.ts @@ -3,6 +3,8 @@ import { HomeAssistant } from "../types"; export interface LovelaceConfig { title?: string; views: LovelaceViewConfig[]; + background?: string; + resources?: Array<{ type: "css" | "js" | "module" | "html"; url: string }>; } export interface LovelaceViewConfig { @@ -13,6 +15,8 @@ export interface LovelaceViewConfig { path?: string; icon?: string; theme?: string; + panel?: boolean; + background?: string; } export interface LovelaceCardConfig { diff --git a/src/panels/lovelace/hui-editor.ts b/src/panels/lovelace/hui-editor.ts index 202de5b0bb..42b65dbc79 100644 --- a/src/panels/lovelace/hui-editor.ts +++ b/src/panels/lovelace/hui-editor.ts @@ -131,4 +131,10 @@ class LovelaceFullConfigEditor extends hassLocalizeLitMixin(LitElement) { } } +declare global { + interface HTMLElementTagNameMap { + "hui-editor": LovelaceFullConfigEditor; + } +} + customElements.define("hui-editor", LovelaceFullConfigEditor); diff --git a/src/panels/lovelace/hui-root.js b/src/panels/lovelace/hui-root.js deleted file mode 100644 index 415855e555..0000000000 --- a/src/panels/lovelace/hui-root.js +++ /dev/null @@ -1,454 +0,0 @@ -import "@polymer/app-layout/app-header-layout/app-header-layout"; -import "@polymer/app-layout/app-header/app-header"; -import "@polymer/app-layout/app-scroll-effects/effects/waterfall"; -import "@polymer/app-layout/app-toolbar/app-toolbar"; -import "@polymer/app-route/app-route"; -import "@polymer/paper-icon-button/paper-icon-button"; -import "@polymer/paper-button/paper-button"; -import "@polymer/paper-item/paper-item"; -import "@polymer/paper-listbox/paper-listbox"; -import "@polymer/paper-menu-button/paper-menu-button"; -import "@polymer/paper-tabs/paper-tab"; -import "@polymer/paper-tabs/paper-tabs"; - -import { html } from "@polymer/polymer/lib/utils/html-tag"; -import { PolymerElement } from "@polymer/polymer/polymer-element"; - -import scrollToTarget from "../../common/dom/scroll-to-target"; - -import EventsMixin from "../../mixins/events-mixin"; -import localizeMixin from "../../mixins/localize-mixin"; -import NavigateMixin from "../../mixins/navigate-mixin"; - -import "../../layouts/ha-app-layout"; -import "../../components/ha-start-voice-button"; -import "../../components/ha-icon"; -import { loadModule, loadCSS, loadJS } from "../../common/dom/load_resource"; -import { subscribeNotifications } from "../../data/ws-notifications"; -import { computeNotifications } from "./common/compute-notifications"; -import "./components/notifications/hui-notification-drawer"; -import "./components/notifications/hui-notifications-button"; -import "./hui-unused-entities"; -import "./hui-view"; -import debounce from "../../common/util/debounce"; -import createCardElement from "./common/create-card-element"; -import { showEditViewDialog } from "./editor/view-editor/show-edit-view-dialog"; - -// CSS and JS should only be imported once. Modules and HTML are safe. -const CSS_CACHE = {}; -const JS_CACHE = {}; - -class HUIRoot extends NavigateMixin( - EventsMixin(localizeMixin(PolymerElement)) -) { - static get template() { - return html` - - - - - - - - -
- - - - -
-
-
- - `; - } - - static get properties() { - return { - narrow: Boolean, - showMenu: Boolean, - hass: { type: Object, observer: "_hassChanged" }, - config: { - type: Object, - computed: "_computeConfig(lovelace)", - observer: "_configChanged", - }, - lovelace: { type: Object }, - columns: { type: Number, observer: "_columnsChanged" }, - _curView: { type: Number, value: 0 }, - route: { type: Object, observer: "_routeChanged" }, - notificationsOpen: { type: Boolean, value: false }, - _persistentNotifications: { type: Array, value: [] }, - _notifications: { - type: Array, - computed: "_updateNotifications(hass.states, _persistentNotifications)", - }, - _yamlMode: { - type: Boolean, - computed: "_computeYamlMode(lovelace)", - }, - _storageMode: { - type: Boolean, - computed: "_computeStorageMode(lovelace)", - }, - _editMode: { - type: Boolean, - value: false, - computed: "_computeEditMode(lovelace)", - observer: "_editModeChanged", - }, - routeData: Object, - }; - } - - constructor() { - super(); - this._debouncedConfigChanged = debounce( - () => this._selectView(this._curView), - 100 - ); - } - - connectedCallback() { - super.connectedCallback(); - this._unsubNotifications = subscribeNotifications( - this.hass.connection, - (notifications) => { - this._persistentNotifications = notifications; - } - ); - } - - disconnectedCallback() { - super.disconnectedCallback(); - if (typeof this._unsubNotifications === "function") { - this._unsubNotifications(); - } - } - - _updateNotifications(states, persistent) { - if (!states) return persistent; - - const configurator = computeNotifications(states); - return persistent.concat(configurator); - } - - _routeChanged(route) { - const views = this.config && this.config.views; - if (route.path === "" && route.prefix === "/lovelace" && views) { - this.navigate(`/lovelace/${views[0].path || 0}`, true); - } else if (this.routeData.view) { - const view = this.routeData.view; - let index = 0; - for (let i = 0; i < views.length; i++) { - if (views[i].path === view || i === parseInt(view)) { - index = i; - break; - } - } - if (index !== this._curView) this._selectView(index); - } - } - - _computeViewPath(path, index) { - return path || index; - } - - _computeTitle(config) { - return config.title || "Home Assistant"; - } - - _computeTabsHidden(views, editMode) { - return views.length < 2 && !editMode; - } - - _computeTabTitle(title) { - return title || "Unnamed view"; - } - - _handleRefresh() { - this.fire("config-refresh"); - } - - _handleUnusedEntities() { - this._selectView("unused"); - } - - _deselect(ev) { - ev.target.selected = null; - } - - _handleHelp() { - window.open("https://www.home-assistant.io/lovelace/", "_blank"); - } - - _handleFullEditor() { - this.lovelace.enableFullEditMode(); - } - - _editModeEnable() { - if (this._yamlMode) { - window.alert("The edit UI is not available when in YAML mode."); - return; - } - this.lovelace.setEditMode(true); - if (this.config.views.length < 2) { - this.$.view.classList.remove("tabs-hidden"); - this.fire("iron-resize"); - } - } - - _editModeDisable() { - this.lovelace.setEditMode(false); - if (this.config.views.length < 2) { - this.$.view.classList.add("tabs-hidden"); - this.fire("iron-resize"); - } - } - - _editModeChanged() { - this._selectView(this._curView); - } - - _editView() { - showEditViewDialog(this, { - lovelace: this.lovelace, - viewIndex: this._curView, - }); - } - - _addView() { - showEditViewDialog(this, { - lovelace: this.lovelace, - }); - } - - _handleViewSelected(ev) { - const index = ev.detail.selected; - this._navigateView(index); - } - - _navigateView(viewIndex) { - if (viewIndex !== this._curView) { - const path = this.config.views[viewIndex].path || viewIndex; - this.navigate(`/lovelace/${path}`); - } - scrollToTarget(this, this.$.layout.header.scrollTarget); - } - - _selectView(viewIndex) { - this._curView = viewIndex; - - // Recreate a new element to clear the applied themes. - const root = this.$.view; - if (root.lastChild) { - root.removeChild(root.lastChild); - } - - let view; - let background = this.config.background || ""; - - if (viewIndex === "unused") { - view = document.createElement("hui-unused-entities"); - view.setConfig(this.config); - } else { - const viewConfig = this.config.views[this._curView]; - if (!viewConfig) { - this._editModeEnable(); - return; - } - if (viewConfig.panel) { - view = createCardElement(viewConfig.cards[0]); - view.isPanel = true; - } else { - view = document.createElement("hui-view"); - view.lovelace = this.lovelace; - view.config = viewConfig; - view.columns = this.columns; - view.index = viewIndex; - } - if (viewConfig.background) background = viewConfig.background; - } - - this.$.view.style.background = background; - - view.hass = this.hass; - root.appendChild(view); - } - - _hassChanged(hass) { - if (!this.$.view.lastChild) return; - this.$.view.lastChild.hass = hass; - } - - _configChanged(config) { - this._loadResources(config.resources || []); - // On config change, recreate the view from scratch. - this._selectView(this._curView); - this.$.view.classList.toggle("tabs-hidden", config.views.length < 2); - } - - _columnsChanged(columns) { - if (!this.$.view.lastChild) return; - this.$.view.lastChild.columns = columns; - } - - _loadResources(resources) { - resources.forEach((resource) => { - switch (resource.type) { - case "css": - if (resource.url in CSS_CACHE) break; - CSS_CACHE[resource.url] = loadCSS(resource.url); - break; - - case "js": - if (resource.url in JS_CACHE) break; - JS_CACHE[resource.url] = loadJS(resource.url); - break; - - case "module": - loadModule(resource.url); - break; - - case "html": - import(/* webpackChunkName: "import-href-polyfill" */ "../../resources/html-import/import-href").then( - ({ importHref }) => importHref(resource.url) - ); - break; - - default: - // eslint-disable-next-line - console.warn("Unknown resource type specified: ${resource.type}"); - } - }); - } - - _computeConfig(lovelace) { - return lovelace ? lovelace.config : null; - } - - _computeYamlMode(lovelace) { - return lovelace ? lovelace.mode === "yaml" : false; - } - - _computeStorageMode(lovelace) { - return lovelace ? lovelace.mode === "storage" : false; - } - - _computeEditMode(lovelace) { - return lovelace ? lovelace.editMode : false; - } -} -customElements.define("hui-root", HUIRoot); diff --git a/src/panels/lovelace/hui-root.ts b/src/panels/lovelace/hui-root.ts new file mode 100644 index 0000000000..a18cbb3dfd --- /dev/null +++ b/src/panels/lovelace/hui-root.ts @@ -0,0 +1,584 @@ +import { + html, + LitElement, + PropertyDeclarations, + PropertyValues, +} from "@polymer/lit-element"; +import { TemplateResult } from "lit-html"; +import { classMap } from "lit-html/directives/classMap"; +import "@polymer/app-layout/app-header-layout/app-header-layout"; +import "@polymer/app-layout/app-header/app-header"; +import "@polymer/app-layout/app-scroll-effects/effects/waterfall"; +import "@polymer/app-layout/app-toolbar/app-toolbar"; +import "@polymer/app-route/app-route"; +import "@polymer/paper-icon-button/paper-icon-button"; +import "@polymer/paper-button/paper-button"; +import "@polymer/paper-item/paper-item"; +import "@polymer/paper-listbox/paper-listbox"; +import "@polymer/paper-menu-button/paper-menu-button"; +import "@polymer/paper-tabs/paper-tab"; +import "@polymer/paper-tabs/paper-tabs"; +import { HassEntities } from "home-assistant-js-websocket"; + +import scrollToTarget from "../../common/dom/scroll-to-target"; + +import "../../layouts/ha-app-layout"; +import "../../components/ha-start-voice-button"; +import "../../components/ha-icon"; +import { loadModule, loadCSS, loadJS } from "../../common/dom/load_resource"; +import { subscribeNotifications } from "../../data/ws-notifications"; +import debounce from "../../common/util/debounce"; +import { hassLocalizeLitMixin } from "../../mixins/lit-localize-mixin"; +import { HomeAssistant } from "../../types"; +import { LovelaceConfig } from "../../data/lovelace"; +import { navigate } from "../../common/navigate"; +import { fireEvent } from "../../common/dom/fire_event"; +import { computeNotifications } from "./common/compute-notifications"; +import "./components/notifications/hui-notification-drawer"; +import "./components/notifications/hui-notifications-button"; +import "./hui-unused-entities"; +import "./hui-view"; +import createCardElement from "./common/create-card-element"; +import { showEditViewDialog } from "./editor/view-editor/show-edit-view-dialog"; +import { Lovelace } from "./types"; + +// CSS and JS should only be imported once. Modules and HTML are safe. +const CSS_CACHE = {}; +const JS_CACHE = {}; + +class HUIRoot extends hassLocalizeLitMixin(LitElement) { + public narrow?: boolean; + public showMenu?: boolean; + public hass?: HomeAssistant; + public lovelace?: Lovelace; + public columns?: number; + public route?: { path: string; prefix: string }; + private _routeData?: { view: string }; + private _curView: number | "unused"; + private notificationsOpen?: boolean; + private _persistentNotifications?: Notification[]; + private _haStyle?: DocumentFragment; + + private _debouncedConfigChanged: () => void; + private _unsubNotifications?: () => void; + + static get properties(): PropertyDeclarations { + return { + narrow: {}, + showMenu: {}, + hass: {}, + lovelace: {}, + columns: {}, + route: {}, + _routeData: {}, + _curView: {}, + notificationsOpen: {}, + _persistentNotifications: {}, + }; + } + + constructor() { + super(); + this._curView = 0; + this._debouncedConfigChanged = debounce( + () => this._selectView(this._curView), + 100 + ); + } + + public connectedCallback(): void { + super.connectedCallback(); + this._unsubNotifications = subscribeNotifications( + this.hass!.connection, + (notifications) => { + this._persistentNotifications = notifications; + } + ); + } + + public disconnectedCallback(): void { + super.disconnectedCallback(); + if (this._unsubNotifications) { + this._unsubNotifications(); + } + } + + protected render(): TemplateResult { + return html` + ${this.renderStyle()} + + + + + ${ + this._editMode + ? html` + + +
+ ${this.localize("ui.panel.lovelace.editor.header")} +
+
+ ` + : html` + + +
${this.config.title || "Home Assistant"}
+ + + + + + ${ + this._yamlMode + ? html` + Refresh + ` + : "" + } + Unused entities + ${ + this.localize("ui.panel.lovelace.editor.configure_ui") + } + ${ + this._storageMode + ? html` + Raw config editor + ` + : "" + } + Help + + +
+ ` + } + + ${ + this.lovelace!.config.views.length > 1 || this._editMode + ? html` +
+ + ${ + this.lovelace!.config.views.map( + (view) => html` + + ${ + view.icon + ? html` + + ` + : view.title || "Unnamed view" + } + ${ + this._editMode + ? html` + + ` + : "" + } + + ` + ) + } + ${ + this._editMode + ? html` + + + + ` + : "" + } + +
+ ` + : "" + } +
+
+ + `; + } + + protected renderStyle(): TemplateResult { + if (!this._haStyle) { + this._haStyle = document.importNode( + (document.getElementById("ha-style")! + .children[0] as HTMLTemplateElement).content, + true + ); + } + + return html` + ${this._haStyle} + + `; + } + + protected updated(changedProperties: PropertyValues): void { + super.updated(changedProperties); + + const view = this._view; + const huiView = view.lastChild as any; + + if (changedProperties.has("columns") && huiView) { + (this._view.lastChild as any).columns = this.columns; + } + + if (changedProperties.has("hass") && huiView) { + huiView.hass = this.hass; + } + + if (changedProperties.has("route")) { + const views = this.config && this.config.views; + if ( + this.route!.path === "" && + this.route!.prefix === "/lovelace" && + views + ) { + navigate(this, `/lovelace/${views[0].path || 0}`, true); + } else if (this._routeData!.view) { + const selectedView = this._routeData!.view; + const selectedViewInt = parseInt(selectedView, 10); + let index = 0; + for (let i = 0; i < views.length; i++) { + if (views[i].path === selectedView || i === selectedViewInt) { + index = i; + break; + } + } + if (index !== this._curView) { + this._selectView(index); + } + } + } + + if (changedProperties.has("lovelace")) { + const oldLovelace = changedProperties.get("lovelace") as + | Lovelace + | undefined; + + if (!oldLovelace || oldLovelace.config !== this.lovelace!.config) { + this._loadResources(this.lovelace!.config.resources || []); + // On config change, recreate the view from scratch. + this._selectView(this._curView); + } + + if (!oldLovelace || oldLovelace.editMode !== this.lovelace!.editMode) { + this._editModeChanged(); + } + } + } + + private get _notifications() { + return this._updateNotifications( + this.hass!.states, + this._persistentNotifications! || [] + ); + } + + private get config(): LovelaceConfig { + return this.lovelace!.config; + } + + private get _yamlMode(): boolean { + return this.lovelace!.mode === "yaml"; + } + + private get _storageMode(): boolean { + return this.lovelace!.mode === "storage"; + } + + private get _editMode() { + return this.lovelace!.editMode; + } + + private get _layout(): any { + return this.shadowRoot!.getElementById("layout"); + } + + private get _view(): HTMLDivElement { + return this.shadowRoot!.getElementById("view") as HTMLDivElement; + } + + private _routeDataChanged(ev): void { + this._routeData = ev.detail.value; + } + + private _handleNotificationsOpenChanged(ev): void { + this.notificationsOpen = ev.detail.value; + } + + private _updateNotifications( + states: HassEntities, + persistent: Array + ): Array { + const configurator = computeNotifications(states); + return persistent.concat(configurator); + } + + private _handleRefresh(): void { + fireEvent(this, "config-refresh"); + } + + private _handleUnusedEntities(): void { + this._selectView("unused"); + } + + private _deselect(ev): void { + ev.target.selected = null; + } + + private _handleHelp(): void { + window.open("https://www.home-assistant.io/lovelace/", "_blank"); + } + + private _editModeEnable(): void { + if (this._yamlMode) { + window.alert("The edit UI is not available when in YAML mode."); + return; + } + this.lovelace!.setEditMode(true); + if (this.config.views.length < 2) { + fireEvent(this, "iron-resize"); + } + } + + private _editModeDisable(): void { + this.lovelace!.setEditMode(false); + if (this.config.views.length < 2) { + fireEvent(this, "iron-resize"); + } + } + + private _editModeChanged(): void { + this._selectView(this._curView); + } + + private _editView() { + showEditViewDialog(this, { + lovelace: this.lovelace!, + viewIndex: this._curView as number, + }); + } + + private _addView() { + showEditViewDialog(this, { + lovelace: this.lovelace!, + }); + } + + private _handleViewSelected(ev) { + const index = ev.detail.selected; + this._navigateView(index); + } + + private _navigateView(viewIndex: number): void { + if (viewIndex !== this._curView) { + const path = this.config.views[viewIndex].path || viewIndex; + navigate(this, `/lovelace/${path}`); + } + scrollToTarget(this, this._layout.header.scrollTarget); + } + + private _selectView(viewIndex: HUIRoot["_curView"]): void { + this._curView = viewIndex; + + // Recreate a new element to clear the applied themes. + const root = this._view; + if (root.lastChild) { + root.removeChild(root.lastChild); + } + + let view; + let background = this.config.background || ""; + + if (viewIndex === "unused") { + view = document.createElement("hui-unused-entities"); + view.setConfig(this.config); + } else { + const viewConfig = this.config.views[this._curView]; + if (!viewConfig) { + this._editModeEnable(); + return; + } + if (viewConfig.panel && viewConfig.cards && viewConfig.cards.length > 0) { + view = createCardElement(viewConfig.cards[0]); + view.isPanel = true; + } else { + view = document.createElement("hui-view"); + view.lovelace = this.lovelace; + view.config = viewConfig; + view.columns = this.columns; + view.index = viewIndex; + } + if (viewConfig.background) { + background = viewConfig.background; + } + } + + this._view.style.background = background; + + view.hass = this.hass; + root.appendChild(view); + } + + private _loadResources(resources) { + resources.forEach((resource) => { + switch (resource.type) { + case "css": + if (resource.url in CSS_CACHE) { + break; + } + CSS_CACHE[resource.url] = loadCSS(resource.url); + break; + + case "js": + if (resource.url in JS_CACHE) { + break; + } + JS_CACHE[resource.url] = loadJS(resource.url); + break; + + case "module": + loadModule(resource.url); + break; + + case "html": + import(/* webpackChunkName: "import-href-polyfill" */ "../../resources/html-import/import-href").then( + ({ importHref }) => importHref(resource.url) + ); + break; + + default: + // tslint:disable-next-line + console.warn(`Unknown resource type specified: ${resource.type}`); + } + }); + } +} + +declare global { + interface HTMLElementTagNameMap { + "hui-root": HUIRoot; + } +} + +customElements.define("hui-root", HUIRoot);