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 <mail@bramkragten.nl>
This commit is contained in:
Paulus Schoutsen 2020-02-25 13:06:25 -08:00 committed by GitHub
parent 25d6427aed
commit 1d052fa5bb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 163 additions and 63 deletions

View File

@ -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) {

View File

@ -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) =>

View File

@ -8,6 +8,7 @@ export interface ReceiverStatusMessage extends BaseCastMessage {
showDemo: boolean;
hassUrl?: string;
lovelacePath?: string | number | null;
urlPath?: string | null;
}
export type SenderMessage = ReceiverStatusMessage;

View File

@ -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;

View File

@ -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<LovelaceResources> =>
conn.sendMessagePromise({
type: "lovelace/resources",
});
export const fetchConfig = (
conn: Connection,
urlPath: string | null,
force: boolean
): Promise<LovelaceConfig> =>
conn.sendMessagePromise({
type: "lovelace/config",
url_path: urlPath,
force,
});
export const saveConfig = (
hass: HomeAssistant,
urlPath: string | null,
config: LovelaceConfig
): Promise<void> =>
hass.callWS({
type: "lovelace/config/save",
url_path: urlPath,
config,
});
export const deleteConfig = (hass: HomeAssistant): Promise<void> =>
export const deleteConfig = (
hass: HomeAssistant,
urlPath: string | null
): Promise<void> =>
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<LovelaceUpdatedEvent>((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<LovelaceConfig>;
llResProm?: Promise<LovelaceResources>;
}
export interface ActionHandlerOptions {

View File

@ -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);
}
});

View File

@ -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<LovelaceConfig["resources"]>,
resources: NonNullable<LovelaceResources>,
hassUrl: string
) =>
resources.forEach((resource) => {

View File

@ -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<void> => {
try {
await saveConfig(hass!, newConfig);
await saveConfig(hass!, null, newConfig);
} catch {
alert(
hass.localize("ui.panel.config.devices.add_entities.saving_failed")

View File

@ -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;
}

View File

@ -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<LovelacePanelConfig>;
@ -67,12 +70,12 @@ class LovelacePanel extends LitElement {
if (state === "loaded") {
return html`
<hui-root
.hass="${this.hass}"
.lovelace="${this.lovelace}"
.route="${this.route}"
.columns="${this._columns}"
.hass=${this.hass}
.lovelace=${this.lovelace}
.route=${this.route}
.columns=${this._columns}
.narrow=${this.narrow}
@config-refresh="${this._forceFetchConfig}"
@config-refresh=${this._forceFetchConfig}
></hui-root>
`;
}
@ -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<LovelaceConfig>;
let confProm: Promise<LovelaceConfig> | 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);

View File

@ -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) {

View File

@ -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);
}

View File

@ -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`
<ha-icon .icon="${this._config.icon}"></ha-icon>
@ -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 {

View File

@ -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",