From 1d052fa5bb77bca0b02201600102dd6e886bcfcb Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 25 Feb 2020 13:06:25 -0800 Subject: [PATCH] Add support for multiple Lovelace dashboards (#4967) * Add support for multiple Lovelace dashboards * Fix navigation, add to cast, revert resource loading * Change resource logic * Lint + cast fix * Comments * Fixes * Console.bye * Lint" Co-authored-by: Bram Kragten --- cast/src/receiver/layout/hc-main.ts | 26 +++++--- src/cast/receiver_messages.ts | 5 +- src/cast/sender_messages.ts | 1 + src/components/ha-sidebar.ts | 13 +++- src/data/lovelace.ts | 59 +++++++++++++++---- src/entrypoints/core.ts | 13 +++- src/panels/lovelace/common/load-resources.ts | 4 +- .../lovelace/editor/add-entities-to-view.ts | 4 +- src/panels/lovelace/entity-rows/types.ts | 1 + src/panels/lovelace/ha-panel-lovelace.ts | 56 +++++++++++++----- src/panels/lovelace/hui-editor.ts | 9 ++- src/panels/lovelace/hui-root.ts | 23 ++------ .../lovelace/special-rows/hui-cast-row.ts | 9 ++- src/translations/en.json | 3 +- 14 files changed, 163 insertions(+), 63 deletions(-) diff --git a/cast/src/receiver/layout/hc-main.ts b/cast/src/receiver/layout/hc-main.ts index fa1badd560..1bb401c36a 100644 --- a/cast/src/receiver/layout/hc-main.ts +++ b/cast/src/receiver/layout/hc-main.ts @@ -15,6 +15,7 @@ import { import { LovelaceConfig, getLovelaceCollection, + fetchResources, } from "../../../../src/data/lovelace"; import "./hc-launch-screen"; import { castContext } from "../cast_context"; @@ -23,6 +24,8 @@ import { ReceiverStatusMessage } from "../../../../src/cast/sender_messages"; import { loadLovelaceResources } from "../../../../src/panels/lovelace/common/load-resources"; import { isNavigationClick } from "../../../../src/common/dom/is-navigation-click"; +let resourcesLoaded = false; + @customElement("hc-main") export class HcMain extends HassElement { @property() private _showDemo = false; @@ -34,6 +37,7 @@ export class HcMain extends HassElement { @property() private _error?: string; private _unsubLovelace?: UnsubscribeFunc; + private _urlPath?: string | null; public processIncomingMessage(msg: HassMessage) { if (msg.type === "connect") { @@ -108,6 +112,7 @@ export class HcMain extends HassElement { if (this.hass) { status.hassUrl = this.hass.auth.data.hassUrl; status.lovelacePath = this._lovelacePath!; + status.urlPath = this._urlPath; } if (senderId) { @@ -163,8 +168,19 @@ export class HcMain extends HassElement { this._error = "Cannot show Lovelace because we're not connected."; return; } - if (!this._unsubLovelace) { - const llColl = getLovelaceCollection(this.hass!.connection); + if (!resourcesLoaded) { + resourcesLoaded = true; + loadLovelaceResources( + await fetchResources(this.hass!.connection), + this.hass!.auth.data.hassUrl + ); + } + if (!this._unsubLovelace || this._urlPath !== msg.urlPath) { + this._urlPath = msg.urlPath; + if (this._unsubLovelace) { + this._unsubLovelace(); + } + const llColl = getLovelaceCollection(this.hass!.connection, msg.urlPath); // We first do a single refresh because we need to check if there is LL // configuration. try { @@ -194,12 +210,6 @@ export class HcMain extends HassElement { private _handleNewLovelaceConfig(lovelaceConfig: LovelaceConfig) { castContext.setApplicationState(lovelaceConfig.title!); this._lovelaceConfig = lovelaceConfig; - if (lovelaceConfig.resources) { - loadLovelaceResources( - lovelaceConfig.resources, - this.hass!.auth.data.hassUrl - ); - } } private _handleShowDemo(_msg: ShowDemoMessage) { diff --git a/src/cast/receiver_messages.ts b/src/cast/receiver_messages.ts index 5b2f82c5b2..50757ffbde 100644 --- a/src/cast/receiver_messages.ts +++ b/src/cast/receiver_messages.ts @@ -21,6 +21,7 @@ export interface ConnectMessage extends BaseCastMessage { export interface ShowLovelaceViewMessage extends BaseCastMessage { type: "show_lovelace_view"; viewPath: string | number | null; + urlPath: string | null; } export interface ShowDemoMessage extends BaseCastMessage { @@ -43,11 +44,13 @@ export const castSendAuth = (cast: CastManager, auth: Auth) => export const castSendShowLovelaceView = ( cast: CastManager, - viewPath: ShowLovelaceViewMessage["viewPath"] + viewPath: ShowLovelaceViewMessage["viewPath"], + urlPath?: string | null ) => cast.sendMessage({ type: "show_lovelace_view", viewPath, + urlPath: urlPath || null, }); export const castSendShowDemo = (cast: CastManager) => diff --git a/src/cast/sender_messages.ts b/src/cast/sender_messages.ts index 2dc7ab2438..e9a7f074a0 100644 --- a/src/cast/sender_messages.ts +++ b/src/cast/sender_messages.ts @@ -8,6 +8,7 @@ export interface ReceiverStatusMessage extends BaseCastMessage { showDemo: boolean; hassUrl?: string; lovelacePath?: string | number | null; + urlPath?: string | null; } export type SenderMessage = ReceiverStatusMessage; diff --git a/src/components/ha-sidebar.ts b/src/components/ha-sidebar.ts index 0e93596464..789e3f86bc 100644 --- a/src/components/ha-sidebar.ts +++ b/src/components/ha-sidebar.ts @@ -46,7 +46,18 @@ const SORT_VALUE_URL_PATHS = { config: 11, }; -const panelSorter = (a, b) => { +const panelSorter = (a: PanelInfo, b: PanelInfo) => { + // Put all the Lovelace at the top. + const aLovelace = a.component_name === "lovelace"; + const bLovelace = b.component_name === "lovelace"; + + if (aLovelace && !bLovelace) { + return -1; + } + if (bLovelace) { + return 1; + } + const aBuiltIn = a.url_path in SORT_VALUE_URL_PATHS; const bBuiltIn = b.url_path in SORT_VALUE_URL_PATHS; diff --git a/src/data/lovelace.ts b/src/data/lovelace.ts index 9dc35d9cef..618c146f5f 100644 --- a/src/data/lovelace.ts +++ b/src/data/lovelace.ts @@ -1,14 +1,22 @@ import { HomeAssistant } from "../types"; -import { Connection, getCollection } from "home-assistant-js-websocket"; +import { + Connection, + getCollection, + HassEventBase, +} from "home-assistant-js-websocket"; import { HASSDomEvent } from "../common/dom/fire_event"; export interface LovelaceConfig { title?: string; views: LovelaceViewConfig[]; background?: string; - resources?: Array<{ type: "css" | "js" | "module" | "html"; url: string }>; } +export type LovelaceResources = Array<{ + type: "css" | "js" | "module" | "html"; + url: string; +}>; + export interface LovelaceViewConfig { index?: number; title?: string; @@ -95,47 +103,78 @@ export type ActionConfig = | NoActionConfig | CustomActionConfig; +type LovelaceUpdatedEvent = HassEventBase & { + event_type: "lovelace_updated"; + data: { + url_path: string | null; + mode: "yaml" | "storage"; + }; +}; + +export const fetchResources = (conn: Connection): Promise => + conn.sendMessagePromise({ + type: "lovelace/resources", + }); export const fetchConfig = ( conn: Connection, + urlPath: string | null, force: boolean ): Promise => conn.sendMessagePromise({ type: "lovelace/config", + url_path: urlPath, force, }); - export const saveConfig = ( hass: HomeAssistant, + urlPath: string | null, config: LovelaceConfig ): Promise => hass.callWS({ type: "lovelace/config/save", + url_path: urlPath, config, }); -export const deleteConfig = (hass: HomeAssistant): Promise => +export const deleteConfig = ( + hass: HomeAssistant, + urlPath: string | null +): Promise => hass.callWS({ type: "lovelace/config/delete", + url_path: urlPath, }); export const subscribeLovelaceUpdates = ( conn: Connection, + urlPath: string | null, onChange: () => void -) => conn.subscribeEvents(onChange, "lovelace_updated"); +) => + conn.subscribeEvents((ev) => { + if (ev.data.url_path === urlPath) { + onChange(); + } + }, "lovelace_updated"); -export const getLovelaceCollection = (conn: Connection) => +export const getLovelaceCollection = ( + conn: Connection, + urlPath: string | null = null +) => getCollection( conn, - "_lovelace", - (conn2) => fetchConfig(conn2, false), + `_lovelace_${urlPath ?? ""}`, + (conn2) => fetchConfig(conn2, urlPath, false), (_conn, store) => - subscribeLovelaceUpdates(conn, () => - fetchConfig(conn, false).then((config) => store.setState(config, true)) + subscribeLovelaceUpdates(conn, urlPath, () => + fetchConfig(conn, urlPath, false).then((config) => + store.setState(config, true) + ) ) ); export interface WindowWithLovelaceProm extends Window { llConfProm?: Promise; + llResProm?: Promise; } export interface ActionHandlerOptions { diff --git a/src/entrypoints/core.ts b/src/entrypoints/core.ts index c44f03d892..897bbaacf6 100644 --- a/src/entrypoints/core.ts +++ b/src/entrypoints/core.ts @@ -15,7 +15,11 @@ import { subscribeThemes } from "../data/ws-themes"; import { subscribeUser } from "../data/ws-user"; import { HomeAssistant } from "../types"; import { hassUrl } from "../data/auth"; -import { fetchConfig, WindowWithLovelaceProm } from "../data/lovelace"; +import { + fetchConfig, + fetchResources, + WindowWithLovelaceProm, +} from "../data/lovelace"; declare global { interface Window { @@ -90,7 +94,12 @@ window.hassConnection.then(({ conn }) => { subscribeUser(conn, noop); if (location.pathname === "/" || location.pathname.startsWith("/lovelace/")) { - (window as WindowWithLovelaceProm).llConfProm = fetchConfig(conn, false); + (window as WindowWithLovelaceProm).llConfProm = fetchConfig( + conn, + null, + false + ); + (window as WindowWithLovelaceProm).llResProm = fetchResources(conn); } }); diff --git a/src/panels/lovelace/common/load-resources.ts b/src/panels/lovelace/common/load-resources.ts index 2a0fb199bf..b08736bbee 100644 --- a/src/panels/lovelace/common/load-resources.ts +++ b/src/panels/lovelace/common/load-resources.ts @@ -1,13 +1,13 @@ import { loadModule, loadCSS, loadJS } from "../../../common/dom/load_resource"; -import { LovelaceConfig } from "../../../data/lovelace"; +import { LovelaceResources } from "../../../data/lovelace"; // CSS and JS should only be imported once. Modules and HTML are safe. const CSS_CACHE = {}; const JS_CACHE = {}; export const loadLovelaceResources = ( - resources: NonNullable, + resources: NonNullable, hassUrl: string ) => resources.forEach((resource) => { diff --git a/src/panels/lovelace/editor/add-entities-to-view.ts b/src/panels/lovelace/editor/add-entities-to-view.ts index a152849254..b5f6240296 100644 --- a/src/panels/lovelace/editor/add-entities-to-view.ts +++ b/src/panels/lovelace/editor/add-entities-to-view.ts @@ -22,7 +22,7 @@ export const addEntitiesToLovelaceView = async ( } if (!lovelaceConfig) { try { - lovelaceConfig = await fetchConfig(hass.connection, false); + lovelaceConfig = await fetchConfig(hass.connection, null, false); } catch { alert( hass.localize( @@ -41,7 +41,7 @@ export const addEntitiesToLovelaceView = async ( if (!saveConfigFunc) { saveConfigFunc = async (newConfig: LovelaceConfig): Promise => { try { - await saveConfig(hass!, newConfig); + await saveConfig(hass!, null, newConfig); } catch { alert( hass.localize("ui.panel.config.devices.add_entities.saving_failed") diff --git a/src/panels/lovelace/entity-rows/types.ts b/src/panels/lovelace/entity-rows/types.ts index 6034c5b3b2..f620fb4d49 100644 --- a/src/panels/lovelace/entity-rows/types.ts +++ b/src/panels/lovelace/entity-rows/types.ts @@ -39,6 +39,7 @@ export interface CastConfig { icon: string; name: string; view: string | number; + dashboard?: string; // Hide the row if either unsupported browser or no API available. hide_if_unavailable: boolean; } diff --git a/src/panels/lovelace/ha-panel-lovelace.ts b/src/panels/lovelace/ha-panel-lovelace.ts index 3ff9faae8c..4243638f6a 100644 --- a/src/panels/lovelace/ha-panel-lovelace.ts +++ b/src/panels/lovelace/ha-panel-lovelace.ts @@ -8,6 +8,7 @@ import { subscribeLovelaceUpdates, WindowWithLovelaceProm, deleteConfig, + fetchResources, } from "../../data/lovelace"; import "../../layouts/hass-loading-screen"; import "../../layouts/hass-error-screen"; @@ -24,6 +25,7 @@ import { import { showSaveDialog } from "./editor/show-save-config-dialog"; import { generateLovelaceConfigFromHass } from "./common/generate-lovelace-config"; import { showToast } from "../../util/toast"; +import { loadLovelaceResources } from "./common/load-resources"; (window as any).loadCardHelpers = () => import("./custom-card-helpers"); @@ -32,6 +34,7 @@ interface LovelacePanelConfig { } let editorLoaded = false; +let resourcesLoaded = false; class LovelacePanel extends LitElement { @property() public panel?: PanelInfo; @@ -67,12 +70,12 @@ class LovelacePanel extends LitElement { if (state === "loaded") { return html` `; } @@ -130,10 +133,12 @@ class LovelacePanel extends LitElement { public firstUpdated() { this._fetchConfig(false); - // we don't want to unsub as we want to stay informed of updates - subscribeLovelaceUpdates(this.hass!.connection, () => - this._lovelaceChanged() - ); + if (this.urlPath === null) { + // we don't want to unsub as we want to stay informed of updates + subscribeLovelaceUpdates(this.hass!.connection, this.urlPath, () => + this._lovelaceChanged() + ); + } // reload lovelace on reconnect so we are sure we have the latest config window.addEventListener("connection-status", (ev) => { if (ev.detail === "connected") { @@ -214,6 +219,10 @@ class LovelacePanel extends LitElement { }); } + public get urlPath() { + return this.panel!.url_path === "lovelace" ? null : this.panel!.url_path; + } + private _forceFetchConfig() { this._fetchConfig(true); } @@ -221,26 +230,40 @@ class LovelacePanel extends LitElement { private async _fetchConfig(forceDiskRefresh: boolean) { let conf: LovelaceConfig; let confMode: Lovelace["mode"] = this.panel!.config.mode; - let confProm: Promise; + let confProm: Promise | undefined; const llWindow = window as WindowWithLovelaceProm; // On first load, we speed up loading page by having LL promise ready if (llWindow.llConfProm) { confProm = llWindow.llConfProm; llWindow.llConfProm = undefined; - } else { + } + if (!resourcesLoaded) { + resourcesLoaded = true; + ( + llWindow.llConfProm || fetchResources(this.hass!.connection) + ).then((resources) => + loadLovelaceResources(resources, this.hass!.auth.data.hassUrl) + ); + } + + if (this.urlPath !== null || !confProm) { // Refreshing a YAML config can trigger an update event. We will ignore - // all update events while fetching the config and for 2 seconds after the cnofig is back. + // all update events while fetching the config and for 2 seconds after the config is back. // We ignore because we already have the latest config. if (this.lovelace && this.lovelace.mode === "yaml") { this._ignoreNextUpdateEvent = true; } - confProm = fetchConfig(this.hass!.connection, forceDiskRefresh); + confProm = fetchConfig( + this.hass!.connection, + this.urlPath, + forceDiskRefresh + ); } try { - conf = await confProm; + conf = await confProm!; } catch (err) { if (err.code !== "config_not_found") { // tslint:disable-next-line @@ -282,6 +305,7 @@ class LovelacePanel extends LitElement { private _setLovelaceConfig(config: LovelaceConfig, mode: Lovelace["mode"]) { config = this._checkLovelaceConfig(config); + const urlPath = this.urlPath; this.lovelace = { config, mode, @@ -313,7 +337,7 @@ class LovelacePanel extends LitElement { mode: "storage", }); this._ignoreNextUpdateEvent = true; - await saveConfig(this.hass!, newConfig); + await saveConfig(this.hass!, urlPath, newConfig); } catch (err) { // tslint:disable-next-line console.error(err); @@ -335,7 +359,7 @@ class LovelacePanel extends LitElement { editMode: false, }); this._ignoreNextUpdateEvent = true; - await deleteConfig(this.hass!); + await deleteConfig(this.hass!, urlPath); } catch (err) { // tslint:disable-next-line console.error(err); diff --git a/src/panels/lovelace/hui-editor.ts b/src/panels/lovelace/hui-editor.ts index a226240285..502aae0af3 100644 --- a/src/panels/lovelace/hui-editor.ts +++ b/src/panels/lovelace/hui-editor.ts @@ -37,7 +37,6 @@ import { const lovelaceStruct = struct.interface({ title: "string?", views: ["object"], - resources: struct.optional(["object"]), }); @customElement("hui-editor") @@ -261,6 +260,14 @@ class LovelaceFullConfigEditor extends LitElement { }); return; } + // @ts-ignore + if (config.resources) { + showAlertDialog(this, { + text: this.hass.localize( + "ui.panel.lovelace.editor.raw_editor.resources_moved" + ), + }); + } try { await this.lovelace!.saveConfig(config); } catch (err) { diff --git a/src/panels/lovelace/hui-root.ts b/src/panels/lovelace/hui-root.ts index abedfe33f0..3e300badc3 100644 --- a/src/panels/lovelace/hui-root.ts +++ b/src/panels/lovelace/hui-root.ts @@ -47,7 +47,6 @@ import { Lovelace } from "./types"; import { afterNextRender } from "../../common/util/render-status"; import { haStyle } from "../../resources/styles"; import { computeRTLDirection } from "../../common/util/compute_rtl"; -import { loadLovelaceResources } from "./common/load-resources"; import { showVoiceCommandDialog } from "../../dialogs/voice-command-dialog/show-ha-voice-command-dialog"; import { isComponentLoaded } from "../../common/config/is_component_loaded"; import { showAlertDialog } from "../../dialogs/generic/show-dialog-box"; @@ -468,13 +467,9 @@ class HUIRoot extends LitElement { let force = false; 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); + const views = this.config.views; + if (this.route!.path === "" && views) { + navigate(this, `${this.route!.prefix}/${views[0].path || 0}`, true); newSelectView = 0; } else if (this._routeData!.view === "hass-unused-entities") { newSelectView = "hass-unused-entities"; @@ -498,12 +493,6 @@ class HUIRoot extends LitElement { | undefined; if (!oldLovelace || oldLovelace.config !== this.lovelace!.config) { - if (this.lovelace!.config.resources) { - loadLovelaceResources( - this.lovelace!.config.resources, - this.hass!.auth.data.hassUrl - ); - } // On config change, recreate the current view from scratch. force = true; // Recalculate to see if we need to adjust content area for tab bar @@ -517,7 +506,7 @@ class HUIRoot extends LitElement { this._routeData!.view === "hass-unused-entities" ) { const views = this.config && this.config.views; - navigate(this, `/lovelace/${views[0].path || 0}`); + navigate(this, `${this.route?.prefix}/${views[0].path || 0}`); newSelectView = 0; } // On edit mode change, recreate the current view from scratch @@ -565,7 +554,7 @@ class HUIRoot extends LitElement { } private _handleUnusedEntities(): void { - navigate(this, `/lovelace/hass-unused-entities`); + navigate(this, `${this.route?.prefix}/hass-unused-entities`); } private _deselect(ev): void { @@ -638,7 +627,7 @@ class HUIRoot extends LitElement { if (viewIndex !== this._curView) { const path = this.config.views[viewIndex].path || viewIndex; - navigate(this, `/lovelace/${path}`); + navigate(this, `${this.route?.prefix}/${path}`); } scrollToTarget(this, this._layout.header.scrollTarget); } diff --git a/src/panels/lovelace/special-rows/hui-cast-row.ts b/src/panels/lovelace/special-rows/hui-cast-row.ts index fa314d77f9..7abe7d6c23 100644 --- a/src/panels/lovelace/special-rows/hui-cast-row.ts +++ b/src/panels/lovelace/special-rows/hui-cast-row.ts @@ -49,7 +49,8 @@ class HuiCastRow extends LitElement implements LovelaceRow { const active = this._castManager && this._castManager.status && - this._config.view === this._castManager.status.lovelacePath; + this._config.view === this._castManager.status.lovelacePath && + this._config.dashboard === this._castManager.status.urlPath; return html` @@ -122,7 +123,11 @@ class HuiCastRow extends LitElement implements LovelaceRow { private async _sendLovelace() { await ensureConnectedCastSession(this._castManager!, this.hass.auth); - castSendShowLovelaceView(this._castManager!, this._config!.view); + castSendShowLovelaceView( + this._castManager!, + this._config!.view, + this._config!.dashboard + ); } static get styles(): CSSResult { diff --git a/src/translations/en.json b/src/translations/en.json index 797078805e..db349a08a8 100755 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -1759,7 +1759,8 @@ "error_parse_yaml": "Unable to parse YAML: {error}", "error_invalid_config": "Your configuration is not valid: {error}", "error_save_yaml": "Unable to save YAML: {error}", - "error_remove": "Unable to remove configuration: {error}" + "error_remove": "Unable to remove configuration: {error}", + "resources_moved": "Resources should no longer be added to the Lovelace configuration but can be added in the Lovelace config panel." }, "edit_lovelace": { "header": "Title of your Lovelace UI",