Compare commits

...
Author SHA1 Message Date
Maarten Lakerveld 175636d20d Keep dialogs on the remaining default-route redirects
The energy panel, the energy config tabs and the media browser fill in
their default route with a replace, which closed a dialog restored from
the URL and dropped the query the restore needs. They now pass
keepDialogs and keep the query, like the router and dashboard redirects.

The more-info restore runs on every location change, so those redirects
re-showed the dialog that was already open. That hoisted it above any
dialog opened on top of it and reset its view. Skip the restore while
the more-info dialog is open.
2026-11-05 15:33:09 +01:00
TimothyandClaude Opus 5.5 da47ac9c16 Keep navigate async when keeping dialogs
Skipping the await committed the navigation synchronously, so a navigate()
called right after it saw the committed count change and was dropped as
stale.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 12:49:22 +00:00
TimothyandClaude Opus 5.5 3c49abb492 Merge remote-tracking branch 'origin/dev' into fix/dialog_not_open_when_using_navigate
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 12:49:07 +00:00
Javier Torres c42d048736 Wrap long text in cloud remote access settings list (#55660) 2026-11-05 12:34:55 +00:00
Stefan AgnerandClaude Opus 5.5 2f62ed5f73 Improve app panel ingress error messages (#55655)
Give the raw Supervisor client error some context when loading the app
info fails, show a dedicated message when a non-admin opens an
admin-only app, and include the underlying error when creating the
ingress session fails.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-11-05 14:19:46 +02:00
Aidan Timson 8b6caeecdd Render full dashboards with their tabs in the demo embed (#55640)
* Add an embedded mode to hui-root that leaves the page's URL alone

* Add a dashboard component to the demo embed

* Keep embedded dashboards' back navigation, height and sidebar tabs inside the embed

* Follow anchors, replacements and new configs in embedded dashboards

* Go back past a followed back_path in embedded dashboards

* Isolate embedded view containers so view backgrounds show

* Match decoded and aliased views and skip empty embedded dashboards

* Read the sidebar tab once sections views have their dashboard

* Declare the navigate events in HTMLElementEventMap

* Skip the Lovelace info fetch and its mock in embedded dashboards
2026-11-05 12:59:44 +02:00
Bram KragtenandClaude Opus 5.5 4800b4f7e9 Deploy production when the Demo and Cast workflows are run on master (#55657)
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-11-05 12:59:09 +02:00
Paul Bottein c7afc3391c Add history and statistics chart pages to the gallery (#55651) 2026-11-05 12:15:29 +02:00
Timothy 9494db6462 Apply suggestions 2026-11-05 11:14:43 +01:00
Aidan Timson 3e9490e547 Render views and dashboard strategies in the demo embed (#55639)
* Add a view component to the demo embed

* Show only the latest generated dashboard in the demo embed

* Stop stale and repeated strategy results in embedded views

* Draw view themes and backgrounds in embedded views
2026-11-05 09:50:00 +00:00
Aidan Timson 7a1cd6bbfe Render sections in the demo embed, with a device registry (#55638)
* Share embed provider lookup and theme in a base element

* Add devices to the demo embed provider

* Add a section component to the demo embed

* Draw section backgrounds and accept more section configs in the demo embed
2026-11-05 09:49:59 +00:00
Paul Bottein a2c3147d33 Fix area controls freezing large dashboards (#55650) 2026-11-05 10:44:35 +02:00
Petar Petrov 7039e3ad9f Open the new device or entry after adding an entry from an integration page (#55630)
Keep navigateToResult when adding an entry for a specific domain
2026-11-05 09:33:55 +01:00
JonathanandXyolyp e97745d7d5 Filter the device classes by the chosen unit (#55422)
* Filter the device classes by the chosen unit

* Keep unsupported device classes selectable

* Only preselect a required device class and keep the default rows

---------

Co-authored-by: Xyolyp <[email protected]>
2026-11-05 08:57:19 +02:00
Mr-Hell 790c957405 Add device class column, search and filter to entities table (#55366)
* Add device class column and filter to entities table

* Localize device classes and preserve redirect filters

* Fix import conflict after rebase

* Refactor device class filter based on review feedback

* Avoid rebuilding device class options while filter is collapsed

* Address final device class review feedback

* Use shared device class translation key for filter
2026-11-05 06:26:31 +00:00
JonathanandXyolyp f740091579 Prevent a double Finish click in the config flow success step (#55616)
* Prevent a double Finish click in the config flow success step

* Keep only the double-click test and stub ha-input

---------

Co-authored-by: Xyolyp <[email protected]>
2026-11-05 08:19:55 +02:00
Simon Lamon f76c88dd9f Clean up check list item based on mwc-list (#55643) 2026-11-05 08:19:18 +02:00
renovate[bot] a428d61cdb Update formatjs (#55644)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-11-05 01:13:34 +00:00
Bram KragtenandClaude Opus 5.5 fcf7eeae46 Use the preloaded Lovelace config again (#55626)
Since the "lovelace" dashboard got a real url_path (#28446), the panel threw
the config preloaded in core.ts away and fetched it a second time on every
page load at / or /lovelace/.

Use the preload for the "lovelace" dashboard and fetch again if it failed.
Only preload the config on /lovelace/, as / opens the default panel, which
is not known yet and is home for new installs.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-11-04 20:47:32 +01:00
6d2817b7cf Migrate the Cast launcher view picker (#55634)
* Migrate the Cast launcher view picker from ha-list

The view picker in the Cast launcher was the last use of the mwc-based
list outside of open pull requests. It is now a single-select
ha-list-selectable.

Views without an icon get the dashboard icon in the start slot, which
mwc never showed because it was put in a slot ha-list-item does not
have. As every row has an icon when any view has one, the hide-icons
attribute is no longer needed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Ln7qMagr2YDdssKkaBiqf5

* Enhance view selection in HcCast component with accessible header and click handling

* Type Cast view click event target

Co-authored-by: wendevlin <[email protected]>

---------

Co-authored-by: Claude <[email protected]>
Co-authored-by: copilot-swe-agent[bot] <[email protected]>
2026-11-04 19:46:53 +00:00
Maarten Lakerveld 640e4e2f94 Resolve security and maintenance entities through one helper per page (#55609)
The security page, the maintenance page, the Home summary badges and
the Home overview each rebuilt the same filter functions and ran
findEntities over every state. Add findFilteredEntities, which takes
the states and registries as one object, and a findSecurityEntities
and findMaintenanceEntities wrapper so every consumer resolves a
page's entity set the same way. No behaviour change.
2026-11-04 20:32:36 +01:00
renovate[bot] 6670139c02 Update dependency @bundle-stats/plugin-webpack-filter to v4.23.0 (#55642)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-11-04 20:13:39 +02:00
Erwin DounaandCopilot Autofix powered by AI 93eb44b041 Offer log download after disabling debug logging (#55621)
Co-authored-by: Copilot Autofix powered by AI <[email protected]>
2026-11-04 16:49:28 +01:00
Joost LekkerkerkerandClaude Opus 5.5 ba6067f048 Label marketplace open issue count as issues and pull requests (#55635)
* Label marketplace open issue count as issues and pull requests

The GitHub open issues count includes pull requests. Rename the
translation keys and update the English strings so the label matches
the number shown.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Update marketplace tests for the renamed open issues keys

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-11-04 17:22:53 +02:00
renovate[bot] cd866a497a Update dependency globals to v17.14.0 (#55633)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-11-04 17:20:30 +02:00
98a8483426 onboarding-location migrate list (#55467)
* onboarding-location migrate list

* Apply batched suggestions from code review

Co-authored-by: Aidan Timson <[email protected]>

* fix tests and review

---------

Co-authored-by: Aidan Timson <[email protected]>
Co-authored-by: Petar Petrov <[email protected]>
Co-authored-by: Wendelin <[email protected]>
2026-11-04 15:39:56 +02:00
33af67bafe Respect safe area insets on the onboarding page (#55623)
* Respect safe area insets on the onboarding page

* Update src/html/onboarding.html.template

Co-authored-by: Wendelin <[email protected]>

* Update src/html/onboarding.html.template

Co-authored-by: Maarten Lakerveld <[email protected]>

---------

Co-authored-by: Wendelin <[email protected]>
Co-authored-by: Maarten Lakerveld <[email protected]>
2026-11-04 12:58:51 +00:00
Bram KragtenandClaude Opus 5.5 027d9578ec Match the entity filter in the states tool to the entity column (#55622)
The entity filter matched the full name and the friendly name, so typing a
device name returned entities whose entity column showed no match. It now
matches the entity ID and the name the entity column shows. When the device
column is hidden, it still matches the full name.

Fixes #55451

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-11-04 14:54:05 +02:00
Bram KragtenandClaude Opus 5.5 5fef8f3a68 Refetch the cached lovelace dashboard after it changed elsewhere (#55624)
The "lovelace" panel is cached by the panel resolver, so it should stay
subscribed to config updates while the user is on another page. Since the
urlPath getter returns "lovelace" instead of null, the null check in
disconnectedCallback always unsubscribed it, and nothing resubscribed on
reconnect. Changes saved from another page, like adding a device section
from the device page, only showed after a browser refresh.

Also reset the refetch flag once used, so returning to the dashboard
without a change doesn't refetch the config again, and let a pending
refetch take precedence over the locale rebuild, which would otherwise
rebuild from the stale config.

Fixes #55440

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-11-04 14:53:35 +02:00
Timothy 06461704ad Keep dialogs while navigating 2026-11-04 12:23:49 +01:00
Wendelin 18e65c1940 Show a loading skeleton while the card editor loads (#55625) 2026-11-04 11:22:55 +00:00
81 changed files with 3012 additions and 606 deletions

No files matched your search

+2 -2
View File
@@ -18,7 +18,7 @@ jobs:
deploy_dev:
runs-on: ubuntu-latest
name: Deploy Development
if: github.repository == 'home-assistant/frontend' && github.event_name != 'push'
if: github.repository == 'home-assistant/frontend' && github.ref_name != 'master'
environment:
name: Cast Development
url: ${{ steps.deploy.outputs.netlify_url }}
@@ -50,7 +50,7 @@ jobs:
deploy_master:
runs-on: ubuntu-latest
name: Deploy Production
if: github.repository == 'home-assistant/frontend' && github.event_name == 'push'
if: github.repository == 'home-assistant/frontend' && github.ref_name == 'master'
environment:
name: Cast Production
url: ${{ steps.deploy.outputs.netlify_url }}
+2 -2
View File
@@ -19,7 +19,7 @@ jobs:
deploy_dev:
runs-on: ubuntu-latest
name: Demo Development
if: github.repository == 'home-assistant/frontend' && (github.event_name != 'push' || github.ref_name != 'master')
if: github.repository == 'home-assistant/frontend' && github.ref_name != 'master'
environment:
name: Demo Development
url: ${{ steps.deploy.outputs.unique_deploy_url }}
@@ -50,7 +50,7 @@ jobs:
deploy_master:
runs-on: ubuntu-latest
name: Demo Production
if: github.repository == 'home-assistant/frontend' && github.event_name == 'push' && github.ref_name == 'master'
if: github.repository == 'home-assistant/frontend' && github.ref_name == 'master'
environment:
name: Demo Production
url: ${{ steps.deploy.outputs.netlify_url }}
+42 -49
View File
@@ -1,7 +1,7 @@
import { mdiCast, mdiCastConnected, mdiViewDashboard } from "@mdi/js";
import type { Auth, Connection } from "home-assistant-js-websocket";
import type { TemplateResult, PropertyValues } from "lit";
import { LitElement, css, html } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import type { CastManager } from "../../../../src/cast/cast_manager";
import {
@@ -13,12 +13,14 @@ import {
enableWrite,
saveTokens,
} from "../../../../src/common/auth/token_storage";
import type { HASSDomCurrentTargetEvent } from "../../../../src/common/dom/fire_event";
import { atLeastVersion } from "../../../../src/common/config/version";
import "../../../../src/components/ha-button";
import "../../../../src/components/ha-icon";
import "../../../../src/components/ha-list";
import "../../../../src/components/ha-list-item";
import "../../../../src/components/ha-svg-icon";
import "../../../../src/components/item/ha-list-item-option";
import type { HaListItemOption } from "../../../../src/components/item/ha-list-item-option";
import "../../../../src/components/list/ha-list-selectable";
import {
getLegacyLovelaceCollection,
getLovelaceCollection,
@@ -52,6 +54,9 @@ class HcCast extends LitElement {
`
: undefined;
const views = this.lovelaceViews ?? [{ title: "Home" }];
const hasIcons = views.some((view) => view.icon);
return html`
<hc-layout .auth=${this.auth} .connection=${this.connection}>
${
@@ -92,45 +97,41 @@ class HcCast extends LitElement {
</p>
`
: html`
<div class="section-header">PICK A VIEW</div>
<ha-list @action=${this._handlePickView} activatable>
${(
this.lovelaceViews ?? [
{
title: "Home",
},
]
).map(
<div class="section-header" id="pick-view-header">
PICK A VIEW
</div>
<ha-list-selectable
controlled
aria-labelledby="pick-view-header"
>
${views.map(
(view, idx) => html`
<ha-list-item
graphic="avatar"
.activated=${
this.castManager.status?.lovelacePath ===
(view.path ?? idx)
}
<ha-list-item-option
data-index=${idx}
@click=${this._handlePickView}
.headline=${view.title || view.path || "Unnamed view"}
.selected=${
this.castManager.status?.lovelacePath ===
(view.path ?? idx)
}
>
${view.title || view.path || "Unnamed view"}
${
view.icon
? html`
<ha-icon
!hasIcons
? nothing
: view.icon
? html`<ha-icon
slot="start"
.icon=${view.icon}
slot="graphic"
></ha-icon>
`
: html`<ha-svg-icon
slot="item-icon"
.path=${mdiViewDashboard}
></ha-svg-icon>`
></ha-icon>`
: html`<ha-svg-icon
slot="start"
.path=${mdiViewDashboard}
></ha-svg-icon>`
}
</ha-list-item>
</ha-list-item-option>
`
)}</ha-list
>
)}
</ha-list-selectable>
`
}
@@ -193,14 +194,6 @@ class HcCast extends LitElement {
});
}
protected updated(changedProps: PropertyValues<this>) {
super.updated(changedProps);
this.toggleAttribute(
"hide-icons",
this.lovelaceViews ? !this.lovelaceViews.some((view) => view.icon) : true
);
}
private async _handleSkipSaveTokens() {
this.askWrite = false;
}
@@ -214,8 +207,13 @@ class HcCast extends LitElement {
this.castManager.requestSession();
}
private async _handlePickView(ev: CustomEvent<{ index: number }>) {
const path = this.lovelaceViews?.[ev.detail.index]?.path ?? ev.detail.index;
// Listens to clicks instead of `ha-list-item-selected`, which does not fire
// for the selected view, so the current view can still be cast again.
private async _handlePickView(
ev: HASSDomCurrentTargetEvent<HaListItemOption>
) {
const index = Number(ev.currentTarget.dataset.index);
const path = this.lovelaceViews?.[index]?.path ?? index;
await ensureConnectedCastSession(this.castManager!, this.auth!);
castSendShowLovelaceView(this.castManager, this.auth.data.hassUrl, path);
}
@@ -270,16 +268,11 @@ class HcCast extends LitElement {
color: var(--secondary-text-color);
}
ha-list-item ha-icon,
ha-list-item ha-svg-icon {
padding: 12px;
ha-list-item-option ha-icon,
ha-list-item-option ha-svg-icon {
color: var(--secondary-text-color);
}
:host([hide-icons]) ha-icon {
display: none;
}
.spacer {
flex: 1;
}
+173
View File
@@ -0,0 +1,173 @@
import { ContextEvent } from "@lit/context";
import type { TemplateResult } from "lit";
import { html, LitElement, nothing } from "lit";
import { property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { consume } from "../../../src/common/decorators/consume";
import { computeRTLDirection } from "../../../src/common/util/compute_rtl";
import { handleAction } from "../../../src/panels/lovelace/common/handle-action";
import type { HomeAssistant } from "../../../src/types";
import { embedHassContext } from "./context";
import type { ScriptObject } from "./script-data";
import { readScriptObject } from "./script-data";
const pageProvider = (): HTMLElement =>
document.querySelector("ha-embed-provider") ??
document.body.appendChild(document.createElement("ha-embed-provider"));
// Looks past shadow roots, so an element inside another component still
// finds the provider around that component.
const insideProvider = (element: Element): boolean => {
let current: Element | null = element;
while (current) {
if (current.closest("ha-embed-provider")) {
return true;
}
const root = current.getRootNode();
current = root instanceof ShadowRoot ? root.host : null;
}
return false;
};
/**
* The base of the embed elements that render dashboard content. The element
* uses the provider with the ID in `provider`, else the closest provider
* around it, else the first provider on the page. If the page has no
* provider, the element adds an empty one.
*/
export abstract class EmbedElement<
Config extends ScriptObject,
> extends LitElement {
/**
* The configuration of the content, the same as in a dashboard. Also read
* from a JSON or YAML script child, see `readScriptObject`.
*/
@property({ type: Object }) public config?: Config;
/** The ID of the `ha-embed-provider` to use. */
@property() public provider?: string;
@state()
@consume({ context: embedHassContext, subscribe: true })
protected _hass?: HomeAssistant;
// A provider that is not an ancestor, which gets the context requests of the
// element and its content. Context requests only reach ancestors by
// themselves.
private _linkedProvider?: HTMLElement;
private _scriptRead = false;
constructor() {
super();
this.addEventListener("context-request", (ev) => {
if (!this._linkedProvider) {
return;
}
ev.stopPropagation();
this._linkedProvider.dispatchEvent(
new ContextEvent(
ev.context,
ev.contextTarget,
ev.callback,
ev.subscribe
)
);
});
// Cards fire their tap, hold and double tap actions for the app to handle
this.addEventListener("hass-action", (ev) => {
if (this._hass) {
// The element that fired the action, so that fire-dom-event reaches
// listeners inside custom cards, as in action-mixin
const [origin] = ev.composedPath();
const node = origin instanceof HTMLElement ? origin : this;
handleAction(node, this._hass, ev.detail.config, ev.detail.action);
}
});
// The page's address belongs to the page, so only a hash on it, such as
// an anchor, navigates there. The browser does that itself, as the app's
// navigate() doesn't scroll to the anchor.
this.addEventListener("hass-navigate", (ev) => {
const { path, replace } = ev.detail;
ev.preventDefault();
if (!path.startsWith("#")) {
this.navigateTo(path, replace);
} else if (replace) {
window.location.replace(path);
} else {
window.location.hash = path;
}
});
}
public connectedCallback() {
// Before super.connectedCallback(), which sends the context requests.
this._linkedProvider = this.provider
? (document.getElementById(this.provider) ?? undefined)
: insideProvider(this)
? undefined
: pageProvider();
super.connectedCallback();
// Only once, so that moving the element keeps a config set since
if (this._scriptRead) {
return;
}
this._scriptRead = true;
const data = readScriptObject(this);
if (data !== undefined) {
this.setScriptData(data);
}
}
/**
* A navigate action of the content, other than to a hash on the page. It
* leaves the page where it is, like actions that open a dialog. `replace`
* is the action's `navigation_replace`.
*/
protected navigateTo(_path: string, _replace?: boolean) {
// Nothing to navigate to, outside a dashboard
}
/** Whether the data of a script child is a configuration of the content. */
protected abstract isConfig(data: ScriptObject): data is Config;
/** Sets the data of a JSON or YAML script child, see `readScriptObject`. */
protected setScriptData(data: ScriptObject) {
if (this.isConfig(data)) {
this.config = data;
}
}
/** The content, or `undefined` while there is nothing to render. */
protected abstract renderContent(
hass: HomeAssistant
): TemplateResult | undefined;
// The content in the theme of the provider
protected render() {
const hass = this._hass;
const content = hass && this.renderContent(hass);
if (!hass || !content) {
return nothing;
}
return html`<div
class=${classMap({ theme: true, dark: hass.themes.darkMode })}
dir=${computeRTLDirection(hass)}
>
${content}
</div>`;
}
}
+3
View File
@@ -4,6 +4,9 @@ import "./workers";
// they are defined, and cards need a provider to request their context from.
import "./ha-embed-provider";
import "./ha-embed-card";
import "./ha-embed-section";
import "./ha-embed-view";
import "./ha-embed-dashboard";
// For custom cards, which can't load them themselves. The built-in cards only
// load ha-list-item-button with the map card. The full app loads ha-select
// with the more info dialog.
+22 -141
View File
@@ -1,139 +1,44 @@
import { ContextEvent } from "@lit/context";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { css, html } from "lit";
import { customElement } from "lit/decorators";
import { createRef, ref } from "lit/directives/ref";
import { consume } from "../../../src/common/decorators/consume";
import { computeRTLDirection } from "../../../src/common/util/compute_rtl";
import type { LovelaceCardConfig } from "../../../src/data/lovelace/config/card";
import { handleAction } from "../../../src/panels/lovelace/common/handle-action";
import { computeCardGridSize } from "../../../src/panels/lovelace/common/compute-card-grid-size";
import "../../../src/panels/lovelace/cards/hui-card";
import type { HuiCard } from "../../../src/panels/lovelace/cards/hui-card";
import type { HomeAssistant } from "../../../src/types";
import { embedHassContext } from "./context";
import { readScriptData } from "./script-data";
import { embedThemeStyles } from "./theme-styles";
const pageProvider = (): HTMLElement =>
document.querySelector("ha-embed-provider") ??
document.body.appendChild(document.createElement("ha-embed-provider"));
// Looks past shadow roots, so a card inside another component still finds
// the provider around that component.
const insideProvider = (element: Element): boolean => {
let current: Element | null = element;
while (current) {
if (current.closest("ha-embed-provider")) {
return true;
}
const root = current.getRootNode();
current = root instanceof ShadowRoot ? root.host : null;
}
return false;
};
import { EmbedElement } from "./embed-element";
import type { ScriptObject } from "./script-data";
import { embedElementStyles } from "./theme-styles";
/**
* Renders a Lovelace card. The card uses the provider with the ID in
* `provider`, else the closest provider around it, else the first provider on
* the page. If the page has no provider, the card adds an empty one.
* Renders a Lovelace card with the backend of a provider, see `EmbedElement`.
*
* The card takes the columns and rows it would take in a section, so cards
* in an element with `display: grid` and 12 columns lay out like a section.
* It also sets `--column-size` and `--row-size` on itself.
*/
@customElement("ha-embed-card")
export class HaEmbedCard extends LitElement {
/**
* The card configuration. Also read from a JSON or YAML script child, see
* `readScriptData`.
*/
@property({ type: Object }) public config?: LovelaceCardConfig;
/** The ID of the `ha-embed-provider` to use. */
@property() public provider?: string;
@state()
@consume({ context: embedHassContext, subscribe: true })
private _hass?: HomeAssistant;
// A provider that is not an ancestor, which gets the context requests of the
// card and its content. Context requests only reach ancestors by themselves.
private _linkedProvider?: HTMLElement;
private _scriptRead = false;
export class HaEmbedCard extends EmbedElement<LovelaceCardConfig> {
private _card = createRef<HuiCard>();
constructor() {
super();
this.addEventListener("context-request", (ev) => {
if (!this._linkedProvider) {
return;
}
ev.stopPropagation();
this._linkedProvider.dispatchEvent(
new ContextEvent(
ev.context,
ev.contextTarget,
ev.callback,
ev.subscribe
)
);
});
// Cards fire their tap, hold and double tap actions for the app to handle
this.addEventListener("hass-action", (ev) => {
if (this._hass) {
// The element that fired the action, so that fire-dom-event reaches
// listeners inside custom cards, as in action-mixin
const node = (ev.composedPath()[0] as HTMLElement | undefined) ?? this;
handleAction(node, this._hass, ev.detail.config, ev.detail.action);
}
});
// A card names its type
protected isConfig(data: ScriptObject): data is LovelaceCardConfig {
return "type" in data && typeof data.type === "string";
}
public connectedCallback() {
// Before super.connectedCallback(), which sends the context requests.
this._linkedProvider = this.provider
? (document.getElementById(this.provider) ?? undefined)
: insideProvider(this)
? undefined
: pageProvider();
super.connectedCallback();
// Only once, so that moving the card keeps a config set since
if (this._scriptRead) {
return;
protected renderContent(hass: HomeAssistant) {
if (!this.config) {
return undefined;
}
this._scriptRead = true;
const config = readScriptData(this) as LovelaceCardConfig | undefined;
if (config) {
this.config = config;
}
}
protected render() {
if (!this._hass || !this.config) {
return nothing;
}
return html`<div
class=${classMap({ theme: true, dark: this._hass.themes.darkMode })}
dir=${computeRTLDirection(this._hass)}
>
<hui-card
${ref(this._card)}
.hass=${this._hass}
.config=${this.config}
.layout=${"grid"}
@card-updated=${this._updateSize}
@card-visibility-changed=${this._updateSize}
></hui-card>
</div>`;
return html`<hui-card
${ref(this._card)}
.hass=${hass}
.config=${this.config}
.layout=${"grid"}
@card-updated=${this._updateSize}
@card-visibility-changed=${this._updateSize}
></hui-card>`;
}
protected updated() {
@@ -163,7 +68,7 @@ export class HaEmbedCard extends LitElement {
};
static styles = [
embedThemeStyles,
embedElementStyles,
css`
/* The sizes of a card in a section, see hui-grid-section */
:host {
@@ -176,9 +81,6 @@ export class HaEmbedCard extends LitElement {
:host([full-width]) {
grid-column: 1 / -1;
}
:host([hidden]) {
display: none;
}
:host([fit-rows]) .theme {
height: calc(
(var(--row-size, 1) * (var(--row-height) + var(--row-gap))) - var(
@@ -186,27 +88,6 @@ export class HaEmbedCard extends LitElement {
)
);
}
/* The text styles the app sets on html and body, so that the card does
not inherit the text styles of the page that embeds it. */
.theme {
color: var(--primary-text-color);
font-family: var(--ha-font-family-body);
font-size: var(--ha-font-size-m);
font-weight: var(--ha-font-weight-body);
line-height: normal;
letter-spacing: normal;
text-align: start;
-moz-osx-font-smoothing: var(--ha-moz-osx-font-smoothing);
-webkit-font-smoothing: var(--ha-font-smoothing);
}
/* What setDirectionStyles sets on the app for a right-to-left language,
here only for the card */
.theme[dir="rtl"] {
--direction: rtl;
--float-start: right;
--float-end: left;
--scale-direction: -1;
}
`,
];
}
+173
View File
@@ -0,0 +1,173 @@
import type { PropertyValues } from "lit";
import { css, html } from "lit";
import { customElement } from "lit/decorators";
import { createRef, ref } from "lit/directives/ref";
import memoizeOne from "memoize-one";
import type { HASSDomEvent } from "../../../src/common/dom/fire_event";
import type {
LovelaceConfig,
LovelaceRawConfig,
} from "../../../src/data/lovelace/config/types";
import "../../../src/panels/lovelace/hui-root";
import type { HomeAssistant } from "../../../src/types";
import { EmbedLovelaceElement, isDashboardConfig } from "./lovelace";
import type { ScriptObject } from "./script-data";
/**
* Renders a dashboard, or a dashboard strategy, with its tabs, with the
* backend of a provider, see `EmbedElement`. Each dashboard keeps its own
* view in `view`, so switching tabs and following links to its views don't
* change the page's URL. The dashboard scrolls inside the element, under its
* toolbar.
*/
@customElement("ha-embed-dashboard")
export class HaEmbedDashboard extends EmbedLovelaceElement<LovelaceRawConfig> {
private _scroller = createRef<HTMLDivElement>();
// The route of hui-root, here without a page URL around it. A new
// configuration gives a new route, as hui-root only looks up the view of
// the path again when its route changes, and the views may have moved.
private _route = memoizeOne(
(view: string | undefined, _config: LovelaceConfig) => ({
prefix: "",
path: `/${view ?? ""}`,
})
);
protected get rawConfig() {
return this.config;
}
protected isConfig(data: ScriptObject): data is LovelaceRawConfig {
return isDashboardConfig(data);
}
// The view shown, and the views before it, which the back button of a
// subview without a `back_path` goes back through, as the browser's back
// would in the app
private _shownView?: string;
private _history: (string | undefined)[] = [];
// Going back, or a navigate action with `navigation_replace`, which
// replaces the view shown rather than adding to the views before it
private _skipHistory = false;
protected updated(changedProps: PropertyValues) {
super.updated(changedProps);
if (this.view === this._shownView) {
return;
}
if (this._shownView !== undefined) {
if (!this._skipHistory) {
this._history.push(this._shownView);
}
this._scroller.value?.scrollTo({ top: 0 });
}
this._skipHistory = false;
this._shownView = this.view;
}
protected navigateTo(path: string, replace?: boolean) {
const view = this.viewOfPath(path);
if (view === undefined || this.isSameView(view, this.view)) {
return;
}
this._skipHistory = Boolean(replace);
this.view = view;
}
// The embedded attribute is in the template, so that hui-root has it when
// it connects and skips the listeners on the page
protected renderContent(hass: HomeAssistant) {
if (!this._lovelace?.config.views.length) {
return undefined;
}
return html`<div class="scroller" ${ref(this._scroller)}>
<hui-root
embedded
no-edit
.hass=${hass}
.lovelace=${this._lovelace}
.route=${this._route(this.view, this._lovelace.config)}
.narrow=${this._narrow.value ?? false}
@view-navigate=${this._viewNavigate}
></hui-root>
</div>`;
}
// A tab, or the back button of a subview, which goes to its `back_path`
// when that is one of the dashboard's views, else to the view before it
private _viewNavigate(ev: HASSDomEvent<HASSDomEvents["view-navigate"]>) {
const view = this.viewOfPath(ev.detail.path);
if (!ev.detail.back) {
if (view !== undefined) {
this.view = view;
}
return;
}
const target = view === undefined ? this._previousView() : view;
// Following a `back_path` goes back too, so the view it goes to leaves
// the views before
if (
view !== undefined &&
this.isSameView(this._history[this._history.length - 1], view)
) {
this._history.pop();
}
if (!this.isSameView(target, this.view)) {
this._skipHistory = true;
this.view = target;
}
}
// The last view before that isn't the one shown, else the first view
private _previousView(): string | undefined {
while (this._history.length) {
const previous = this._history.pop();
if (!this.isSameView(previous, this.view)) {
return previous;
}
}
return undefined;
}
static styles = [
EmbedLovelaceElement.styles,
css`
/* Contained, so that the toolbar and background of hui-root, which are
fixed to the window in the app, are fixed to the element */
:host {
height: 600px;
contain: layout paint;
}
.theme,
.scroller {
height: 100%;
}
.scroller {
overflow: auto;
}
`,
];
}
declare global {
interface HTMLElementTagNameMap {
"ha-embed-dashboard": HaEmbedDashboard;
}
}
+35 -11
View File
@@ -8,6 +8,7 @@ import { listenMediaQuery } from "../../../src/common/dom/media_query";
import type { AreaRegistryEntry } from "../../../src/data/area/area_registry";
import type { CalendarEventApiData } from "../../../src/data/calendar";
import { reducedMotionContext } from "../../../src/data/context";
import type { DeviceRegistryEntry } from "../../../src/data/device/device_registry";
import type { EnergyInfo, EnergyPreferences } from "../../../src/data/energy";
import { EMPTY_PREFERENCES } from "../../../src/data/energy";
import type { EntityRegistryEntry } from "../../../src/data/entity/entity_registry";
@@ -22,6 +23,8 @@ import {
mockCalendarEvents,
WORK_EVENTS,
} from "../stubs/calendar";
import type { DemoDevice } from "../stubs/device_registry";
import { toDeviceRegistryEntry } from "../stubs/device_registry";
import { extEntry, newEntry } from "../stubs/entity_registry";
import { mockHistory } from "../stubs/history";
import { mockIcons } from "../stubs/icons";
@@ -40,6 +43,7 @@ import { readScriptData } from "./script-data";
interface EmbedProviderData {
entities?: EntityInput[];
areas?: DemoArea[];
devices?: DemoDevice[];
calendars?: Record<string, CalendarEventApiData[]>;
energy?: Partial<EnergyPreferences>;
}
@@ -54,8 +58,8 @@ const areaName = (areaId: string) => {
};
/**
* Holds a mocked Home Assistant backend for all `ha-embed-card` elements
* inside it. Cards in the same provider share their entities.
* Holds a mocked Home Assistant backend for all embed elements inside it.
* Elements in the same provider share their entities.
*/
@customElement("ha-embed-provider")
export class HaEmbedProvider extends LitElement {
@@ -67,13 +71,19 @@ export class HaEmbedProvider extends LitElement {
* All entities of the backend. Each change replaces all entities.
* Also read from a JSON or YAML script child, see `readScriptData`. The
* script holds either the list of entities or an object with `entities`,
* `areas`, `calendars` and `energy`.
* `areas`, `devices`, `calendars` and `energy`.
*/
@property({ type: Array }) public entities: EntityInput[] = [];
/** All areas. Without them, an area for each `area_id` of the entities. */
/**
* All areas. Without them, an area for each `area_id` of the entities and
* devices.
*/
@property({ attribute: false }) public areas?: DemoArea[];
/** All devices, which entities reference with their `device_id`. */
@property({ attribute: false }) public devices?: DemoDevice[];
/**
* Events of calendar entities by entity ID. Calendars without events here
* get sample events around today.
@@ -155,6 +165,9 @@ export class HaEmbedProvider extends LitElement {
hass.mockWS("config/area_registry/list", (_msg, currentHass) =>
Object.values(currentHass.areas)
);
hass.mockWS("config/device_registry/list", (_msg, currentHass) =>
Object.values(currentHass.devices)
);
// The registry of the entities, without settings such as calendar colors
const registryEntries = (currentHass: MockHomeAssistant) =>
Object.values(currentHass.entities).map(
@@ -222,6 +235,7 @@ export class HaEmbedProvider extends LitElement {
} else if (data) {
this.entities = data.entities ?? [];
this.areas = data.areas;
this.devices = data.devices;
this.calendars = data.calendars;
this.energy = data.energy;
}
@@ -238,14 +252,19 @@ export class HaEmbedProvider extends LitElement {
this._applyColorScheme();
}
if (changedProps.has("entities") || changedProps.has("areas")) {
if (
changedProps.has("entities") ||
changedProps.has("areas") ||
changedProps.has("devices")
) {
const devices = this.devices ?? [];
const areas: DemoArea[] =
this.areas ??
[
...new Set(
this.entities
.map((entity) => entity.area_id)
.filter((areaId) => areaId !== undefined)
[...this.entities, ...devices].flatMap((item) =>
item.area_id === undefined ? [] : [item.area_id]
)
),
].map((areaId) => ({ area_id: areaId, name: areaName(areaId) }));
@@ -253,11 +272,16 @@ export class HaEmbedProvider extends LitElement {
areas.forEach((area) => {
areasById[area.area_id] = toAreaRegistryEntry(area);
});
// Before the entities, so that their names can include the area
this.hass.updateHass({ areas: areasById });
const devicesById: Record<string, DeviceRegistryEntry> = {};
devices.forEach((device) => {
devicesById[device.id] = toDeviceRegistryEntry(device);
});
// Before the entities, so that their names can include the area and
// device
this.hass.updateHass({ areas: areasById, devices: devicesById });
// Entities that services changed keep their state when only the areas
// change
// or devices change
if (changedProps.has("entities")) {
this.hass.addEntities(this.entities, true);
} else {
+95
View File
@@ -0,0 +1,95 @@
import type { PropertyValues } from "lit";
import { css, html } from "lit";
import { customElement } from "lit/decorators";
import memoizeOne from "memoize-one";
import type { HASSDomEvent } from "../../../src/common/dom/fire_event";
import { normalizeSectionConfig } from "../../../src/data/lovelace/config/normalize";
import type { LovelaceSectionRawConfig } from "../../../src/data/lovelace/config/section";
import "../../../src/panels/lovelace/sections/hui-section";
import {
renderSectionContainer,
sectionContainerStyles,
} from "../../../src/panels/lovelace/sections/section-container";
import type { HomeAssistant } from "../../../src/types";
import { EmbedElement } from "./embed-element";
import type { ScriptObject } from "./script-data";
import { embedElementStyles } from "./theme-styles";
/**
* Renders a dashboard section, or a section strategy, with the backend of a
* provider, see `EmbedElement`. The section takes the width of the element.
*/
@customElement("ha-embed-section")
export class HaEmbedSection extends EmbedElement<LovelaceSectionRawConfig> {
// A title becomes a heading card, as on a dashboard
private _normalizedConfig = memoizeOne(normalizeSectionConfig);
// A section has cards, sections inside it, a strategy that makes them, a
// title, or the type of a custom section
protected isConfig(data: ScriptObject): data is LovelaceSectionRawConfig {
return (
"cards" in data ||
"sections" in data ||
"strategy" in data ||
"title" in data ||
("type" in data && typeof data.type === "string")
);
}
protected willUpdate(changedProps: PropertyValues) {
super.willUpdate(changedProps);
// The section that hid the element is gone, and the next one starts
// visible without telling it
if (changedProps.has("config") && !this.config) {
this.hidden = false;
}
}
// In the container that draws the background of the section, as in a
// sections view
protected renderContent(hass: HomeAssistant) {
if (!this.config) {
return undefined;
}
const config = this._normalizedConfig(this.config);
return renderSectionContainer(
hass,
config,
html`<hui-section
.hass=${hass}
.config=${config}
@section-visibility-changed=${this._visibilityChanged}
></hui-section>`
);
}
private _visibilityChanged(
ev: HASSDomEvent<HASSDomEvents["section-visibility-changed"]>
) {
// Sections inside the section fire the same event
if (ev.target !== ev.currentTarget) {
return;
}
this.hidden = !ev.detail.value;
}
static styles = [
embedElementStyles,
sectionContainerStyles,
css`
:host {
display: block;
}
`,
];
}
declare global {
interface HTMLElementTagNameMap {
"ha-embed-section": HaEmbedSection;
}
}
+134
View File
@@ -0,0 +1,134 @@
import { css, html } from "lit";
import { customElement, property } from "lit/decorators";
import { keyed } from "lit/directives/keyed";
import memoizeOne from "memoize-one";
import type {
LovelaceConfig,
LovelaceRawConfig,
} from "../../../src/data/lovelace/config/types";
import type { LovelaceViewRawConfig } from "../../../src/data/lovelace/config/view";
import "../../../src/panels/lovelace/views/hui-view";
import "../../../src/panels/lovelace/views/hui-view-background";
import "../../../src/panels/lovelace/views/hui-view-container";
import type { HomeAssistant } from "../../../src/types";
import {
EmbedLovelaceElement,
findViewIndex,
isDashboardConfig,
} from "./lovelace";
import type { ScriptObject } from "./script-data";
/**
* Renders a dashboard view, a view strategy, or one view of a dashboard or
* dashboard strategy, with the backend of a provider, see `EmbedElement`.
* `config` holds the view.
*/
@customElement("ha-embed-view")
export class HaEmbedView extends EmbedLovelaceElement<LovelaceViewRawConfig> {
/**
* A dashboard configuration or dashboard strategy, to show its `view`
* instead of `config`. Also read from the `dashboard` key of a script child,
* next to `view`.
*/
@property({ type: Object }) public dashboard?: LovelaceRawConfig;
private _viewDashboard = memoizeOne(
(view: LovelaceViewRawConfig): LovelaceConfig => ({ views: [view] })
);
protected get rawConfig() {
return this.dashboard ?? (this.config && this._viewDashboard(this.config));
}
// A view strategy and a dashboard strategy both have a `strategy` key, so
// a dashboard comes in its own key, which no view has
protected isConfig(data: ScriptObject): data is LovelaceViewRawConfig {
return !("dashboard" in data);
}
protected setScriptData(data: ScriptObject) {
if (!("dashboard" in data)) {
super.setScriptData(data);
return;
}
const { dashboard } = data;
if (
typeof dashboard === "object" &&
dashboard !== null &&
isDashboardConfig(dashboard)
) {
this.dashboard = dashboard;
}
if (
"view" in data &&
(typeof data.view === "string" || typeof data.view === "number")
) {
this.view ??= String(data.view);
}
}
// Nothing for a dashboard without views, such as an empty strategy result.
// The view's theme and background, or the dashboard's background, as in
// hui-root. Each view gets its own hui-view, as there, so that a view
// strategy still generating for the view before can't replace the cards of
// this one.
protected renderContent(hass: HomeAssistant) {
const views = this._lovelace?.config.views;
if (!this._lovelace || !views?.length) {
return undefined;
}
const index = findViewIndex(views, this.view);
const viewConfig = views[index];
const background =
viewConfig.background || this._lovelace.config.background;
return html`<hui-view-container .hass=${hass} .theme=${viewConfig.theme}>
<hui-view-background .hass=${hass} .background=${background}>
</hui-view-background>
${keyed(
viewConfig,
html`<hui-view
.hass=${hass}
.lovelace=${this._lovelace}
.index=${index}
.narrow=${this._narrow.value ?? false}
></hui-view>`
)}
</hui-view-container>`;
}
// A fixed background stays inside the element. With a height set on the
// element, a panel view fills it, and other views keep their own height.
static styles = [
EmbedLovelaceElement.styles,
css`
:host {
contain: layout paint;
}
.theme,
hui-view-container,
hui-view {
display: block;
height: 100%;
}
/* The background sits below the view, above the element's own */
hui-view-container {
position: relative;
isolation: isolate;
}
`,
];
}
declare global {
interface HTMLElementTagNameMap {
"ha-embed-view": HaEmbedView;
}
}
+217
View File
@@ -0,0 +1,217 @@
import { ResizeController } from "@lit-labs/observers/resize-controller";
import type { CSSResultGroup, PropertyValues } from "lit";
import { css } from "lit";
import { property, state } from "lit/decorators";
import { normalizeLovelaceConfig } from "../../../src/data/lovelace/config/normalize";
import type { LovelaceRawConfig } from "../../../src/data/lovelace/config/types";
import { isStrategyDashboard } from "../../../src/data/lovelace/config/types";
import type { LovelaceViewRawConfig } from "../../../src/data/lovelace/config/view";
import {
checkStrategyShouldRegenerate,
generateLovelaceDashboardStrategy,
} from "../../../src/panels/lovelace/strategies/get-strategy";
import type { Lovelace } from "../../../src/panels/lovelace/types";
import type { HomeAssistant } from "../../../src/types";
import { EmbedElement } from "./embed-element";
import type { ScriptObject } from "./script-data";
import { embedElementStyles } from "./theme-styles";
// The width below which the app is narrow, see home-assistant-main
const NARROW_WIDTH = 870;
/** The index of the view with this path or index, if there is one. */
const viewIndex = (
views: LovelaceViewRawConfig[],
view: string
): number | undefined => {
const byPath = views.findIndex((item) => item.path === view);
if (byPath !== -1) {
return byPath;
}
const index = Number(view);
return Number.isInteger(index) && index >= 0 && index < views.length
? index
: undefined;
};
/** Whether script data is a dashboard: its views, or a strategy for them. */
export const isDashboardConfig = (
data: ScriptObject
): data is LovelaceRawConfig => "views" in data || "strategy" in data;
/** The index of the view with this path or index, else the first view. */
export const findViewIndex = (
views: LovelaceViewRawConfig[],
view?: string
): number => (view === undefined ? 0 : (viewIndex(views, view) ?? 0));
/**
* The base of the embed elements that render a dashboard, or one of its
* views. It generates dashboard strategies, again when the registries they
* depend on change, and holds a dashboard that can't be edited or saved.
*/
export abstract class EmbedLovelaceElement<
Config extends ScriptObject,
> extends EmbedElement<Config> {
/** The path or index of the view to show. Without it, the first view. */
@property() public view?: string;
@state() protected _lovelace?: Lovelace;
// Narrow like the app at the same width, measured on the element because
// it rarely takes the width of the page
protected _narrow = new ResizeController(this, {
callback: (entries) =>
(entries[0]?.contentRect.width ?? NARROW_WIDTH) < NARROW_WIDTH,
});
// The configuration of the dashboard in `_lovelace`, or being generated
private _rawConfig?: LovelaceRawConfig;
// Counts the generations started, so that only the latest one is shown.
// Generating again for changed registries keeps the same configuration.
private _generation = 0;
// The backend at the last update, to tell what a strategy depends on changed
private _previousHass?: HomeAssistant;
/** The dashboard to render. The same object until it changes. */
protected abstract get rawConfig(): LovelaceRawConfig | undefined;
/**
* The view of the dashboard a path goes to, relative like `batteries` or
* with the dashboard like `/maintenance/batteries`, if it has that view.
*/
protected viewOfPath(path: string | undefined): string | undefined {
const views = this._lovelace?.config.views;
const view = path
? decodeURI(new URL(path, "http://embed/").pathname)
.split("/")
.filter(Boolean)
.pop()
: undefined;
return views && view !== undefined && viewIndex(views, view) !== undefined
? view
: undefined;
}
/** Whether two paths or indexes go to the same view of the dashboard. */
protected isSameView(a?: string, b?: string): boolean {
const views = this._lovelace?.config.views;
return views
? findViewIndex(views, a) === findViewIndex(views, b)
: a === b;
}
// A path to one of the dashboard's views shows that view. Other paths leave
// the dashboard where it is.
protected navigateTo(path: string) {
const view = this.viewOfPath(path);
if (view !== undefined) {
this.view = view;
}
}
protected willUpdate(changedProps: PropertyValues) {
super.willUpdate(changedProps);
const raw = this.rawConfig;
const oldHass = this._previousHass;
this._previousHass = this._hass;
// Without a configuration, nothing is shown, and a generation still
// running is dropped
if (!raw) {
if (this._rawConfig) {
this._rawConfig = undefined;
this._lovelace = undefined;
this._generation++;
}
return;
}
if (!this._hass) {
return;
}
// A strategy is only asked whether to generate again when the backend
// changed, as in ha-panel-lovelace, not when the generated dashboard
// arrives
if (
raw !== this._rawConfig ||
(oldHass &&
oldHass !== this._hass &&
isStrategyDashboard(raw) &&
this._hass.config.state === "RUNNING" &&
checkStrategyShouldRegenerate(
"dashboard",
raw.strategy,
oldHass,
this._hass
))
) {
this._rawConfig = raw;
this._generate(raw, this._hass, ++this._generation);
}
}
private async _generate(
raw: LovelaceRawConfig,
hass: HomeAssistant,
generation: number
) {
const generated = isStrategyDashboard(raw)
? await generateLovelaceDashboardStrategy(raw, hass)
: raw;
const config = normalizeLovelaceConfig(generated);
// A newer generation has started. A generated dashboard has its views,
// rather than a strategy.
if (generation !== this._generation || isStrategyDashboard(config)) {
return;
}
this._lovelace = {
config,
rawConfig: raw,
editMode: false,
urlPath: null,
mode: "generated",
locale: hass.locale,
enableFullEditMode: () => undefined,
saveConfig: async () => undefined,
deleteConfig: async () => undefined,
setEditMode: () => undefined,
showToast: () => undefined,
embedded: true,
};
}
// The background of a dashboard, and no insets for a notch or home bar
static styles: CSSResultGroup = [
embedElementStyles,
css`
:host {
display: block;
--app-safe-area-inset-top: 0px;
--app-safe-area-inset-bottom: 0px;
--app-safe-area-inset-left: 0px;
--app-safe-area-inset-right: 0px;
}
.theme {
position: relative;
background: var(--primary-background-color);
}
`,
];
}
+14
View File
@@ -15,3 +15,17 @@ export const readScriptData = (element: HTMLElement): unknown => {
? load(script.textContent!)
: JSON.parse(script.textContent!);
};
/** The data of a script child that holds an object, such as a config. */
export type ScriptObject = object;
/** The object in a script child, see `readScriptData`, if it holds one. */
export const readScriptObject = (
element: HTMLElement
): ScriptObject | undefined => {
const data = readScriptData(element);
return typeof data === "object" && data !== null && !Array.isArray(data)
? data
: undefined;
};
+35 -1
View File
@@ -1,4 +1,4 @@
import { unsafeCSS } from "lit";
import { css, unsafeCSS } from "lit";
import { hex2rgb } from "../../../src/common/color/convert-color";
import { darkColorVariables } from "../../../src/resources/theme/color";
import { darkSemanticVariables } from "../../../src/resources/theme/semantic.globals";
@@ -59,3 +59,37 @@ export const embedThemeStyles = unsafeCSS(
scopeThemeStyles(themeStyles, ".theme") +
`.theme.dark{${darkVariables.join("")}}`
);
/**
* The styles of every embed element: the theme, and the page styles of the
* app, for the `.theme` wrapper of `EmbedElement`.
*/
export const embedElementStyles = [
embedThemeStyles,
css`
:host([hidden]) {
display: none;
}
/* The text styles the app sets on html and body, so that the content does
not inherit the text styles of the page that embeds it. */
.theme {
color: var(--primary-text-color);
font-family: var(--ha-font-family-body);
font-size: var(--ha-font-size-m);
font-weight: var(--ha-font-weight-body);
line-height: normal;
letter-spacing: normal;
text-align: start;
-moz-osx-font-smoothing: var(--ha-moz-osx-font-smoothing);
-webkit-font-smoothing: var(--ha-font-smoothing);
}
/* What setDirectionStyles sets on the app for a right-to-left language,
here only for the content */
.theme[dir="rtl"] {
--direction: rtl;
--float-start: right;
--float-end: left;
--scale-direction: -1;
}
`,
];
+291 -9
View File
@@ -108,15 +108,24 @@
against a mocked backend in the browser. No Home Assistant server is
necessary.
</p>
<p>There are two components:</p>
<p>
<code>&lt;ha-embed-provider&gt;</code> holds the mocked backend and its
entities. These components render dashboard content with the backend of
a provider:
</p>
<ul>
<li><code>&lt;ha-embed-card&gt;</code> renders one card.</li>
<li>
<code>&lt;ha-embed-provider&gt;</code> holds the mocked backend and
its entities.
<code>&lt;ha-embed-section&gt;</code> renders one section, or a
section strategy.
</li>
<li>
<code>&lt;ha-embed-card&gt;</code> renders one card. It uses the
backend of a provider.
<code>&lt;ha-embed-view&gt;</code> renders one view, a view strategy,
or one view of a dashboard.
</li>
<li>
<code>&lt;ha-embed-dashboard&gt;</code> renders a dashboard, or a
dashboard strategy, with its tabs.
</li>
</ul>
<p>
@@ -324,6 +333,193 @@
</script><!-- htmlmin:ignore -->
</ha-embed-card>
</div>
<p>
The other components find their provider the same way, and take the
same <code>provider</code> attribute.
</p>
<h2>Sections</h2>
<p>
<code>&lt;ha-embed-section&gt;</code> renders a section the same way as
in a sections view. The configuration is the same as in a dashboard,
so headings span the top, and a <code>strategy</code> generates the
section. The section takes the width of the component.
</p>
<pre><code>&lt;ha-embed-section&gt;
&lt;script type="text/yaml"&gt;
cards:
- type: heading
heading: Kitchen
- type: tile
entity: light.kitchen
- type: tile
entity: switch.kettle
&lt;/script&gt;
&lt;/ha-embed-section&gt;</code></pre>
<ha-embed-provider>
<!-- htmlmin:ignore --><script type="text/yaml">
entities:
- entity_id: light.kitchen_ceiling
state: "on"
device_id: kitchen_ceiling
attributes:
friendly_name: Ceiling
supported_color_modes: [brightness]
color_mode: brightness
brightness: 200
- entity_id: switch.kettle
state: "off"
area_id: kitchen
attributes:
friendly_name: Kettle
device_class: outlet
- entity_id: sensor.kitchen_temperature
state: "21.4"
area_id: kitchen
attributes:
friendly_name: Kitchen temperature
device_class: temperature
state_class: measurement
unit_of_measurement: °C
devices:
- id: kitchen_ceiling
name: Kitchen ceiling light
manufacturer: Acme
model: Smart bulb
area_id: kitchen
</script><!-- htmlmin:ignore -->
<div class="single">
<ha-embed-section>
<!-- htmlmin:ignore --><script type="text/yaml">
cards:
- type: heading
heading: Kitchen
- type: tile
entity: light.kitchen_ceiling
tap_action:
action: toggle
- type: tile
entity: switch.kettle
tap_action:
action: toggle
- type: tile
entity: sensor.kitchen_temperature
grid_options:
columns: 12
</script><!-- htmlmin:ignore -->
</ha-embed-section>
</div>
</ha-embed-provider>
<h2>Views</h2>
<p>
<code>&lt;ha-embed-view&gt;</code> renders a view, with its badges and
layout. A <code>strategy</code> generates the view. Give the component
the width the view needs: masonry and sidebar views pick their columns
from the width of the page, not the component.
</p>
<pre><code>&lt;ha-embed-view&gt;
&lt;script type="text/yaml"&gt;
type: sections
badges:
- entity: sensor.outside_temperature
sections:
- cards: …
&lt;/script&gt;
&lt;/ha-embed-view&gt;</code></pre>
<p>
To show one view of a dashboard, or of a dashboard strategy, put the
dashboard in <code>dashboard</code> and the path or index of the view
in <code>view</code>. Without <code>view</code>, the first view shows.
</p>
<pre><code>&lt;ha-embed-view&gt;
&lt;script type="text/yaml"&gt;
dashboard:
strategy:
type: custom:my-dashboard
view: overview
&lt;/script&gt;
&lt;/ha-embed-view&gt;</code></pre>
<ha-embed-view provider="shared">
<!-- htmlmin:ignore --><script type="text/yaml">
type: sections
max_columns: 2
badges:
- entity: sensor.outside_temperature
sections:
- cards:
- type: heading
heading: Living room
- type: tile
entity: light.living_room
tap_action:
action: toggle
- type: tile
entity: switch.coffee_maker
tap_action:
action: toggle
- cards:
- type: heading
heading: Hallway
- type: thermostat
entity: climate.hallway
</script><!-- htmlmin:ignore -->
</ha-embed-view>
<h2>Dashboards</h2>
<p>
<code>&lt;ha-embed-dashboard&gt;</code> renders a dashboard with its
tabs. A <code>strategy</code> generates the dashboard. Each dashboard
keeps its own tab, so switching tabs does not change the address of
your page. The dashboard scrolls inside the component, under its
toolbar. The component is 600 pixels high; set its
<code>height</code> to change that. Set <code>view</code> to the path
or index of the first view to show.
</p>
<pre><code>&lt;ha-embed-dashboard style="height: 480px"&gt;
&lt;script type="text/yaml"&gt;
views:
- title: Home
path: home
cards: …
- title: Climate
path: climate
cards: …
&lt;/script&gt;
&lt;/ha-embed-dashboard&gt;</code></pre>
<ha-embed-dashboard provider="shared" style="height: 480px">
<!-- htmlmin:ignore --><script type="text/yaml">
views:
- title: Home
path: home
type: sections
max_columns: 2
sections:
- cards:
- type: heading
heading: Living room
- type: tile
entity: light.living_room
tap_action:
action: toggle
- type: tile
entity: switch.coffee_maker
tap_action:
action: toggle
- cards:
- type: heading
heading: Outside
- type: tile
entity: sensor.outside_temperature
- title: Climate
path: climate
type: sections
sections:
- cards:
- type: thermostat
entity: climate.hallway
</script><!-- htmlmin:ignore -->
</ha-embed-dashboard>
<h2>Light and dark mode</h2>
<p>
@@ -375,7 +571,7 @@ provider.entities = Object.values(provider.hass.states).map((entity) =&gt;
<code>provider.hass.states</code>.
</p>
<h2>Areas, calendars and energy</h2>
<h2>Areas, devices, calendars and energy</h2>
<p>
Some cards need data that is not an entity. To set it, put an object in
the script tag of the provider instead of the list. The object has the
@@ -385,7 +581,15 @@ provider.entities = Object.values(provider.hass.states).map((entity) =&gt;
<li>
<code>areas</code>: a list of areas, each with an
<code>area_id</code> and a <code>name</code>. Without it, each
<code>area_id</code> of the entities becomes an area.
<code>area_id</code> of the entities and devices becomes an area.
</li>
<li>
<code>devices</code>: a list of devices, each with an
<code>id</code> and a <code>name</code>, and optionally a
<code>manufacturer</code>, <code>model</code> and
<code>area_id</code>. Entities belong to a device with their
<code>device_id</code>, and cards and strategies then find the
device and its name.
</li>
<li>
<code>calendars</code>: the events of calendar entities, by entity
@@ -679,14 +883,92 @@ customElements.define("embed-demo-custom-card", EmbedDemoCustomCard);</code></pr
<td>Attribute and property</td>
<td>
The ID of the provider to use. Without it, the card uses the
provider around it, else the first provider on the page.
provider around it, else the first provider on the page. The other
components take it too.
</td>
</tr>
</table>
<h3><code>&lt;ha-embed-section&gt;</code></h3>
<table>
<tr>
<th>Name</th>
<th>Type</th>
<th>Description</th>
</tr>
<tr>
<td><code>config</code></td>
<td>Attribute and property</td>
<td>
The section configuration or section strategy, the same as in a
dashboard. A JSON or YAML script child sets the first
configuration.
</td>
</tr>
</table>
<h3><code>&lt;ha-embed-view&gt;</code></h3>
<table>
<tr>
<th>Name</th>
<th>Type</th>
<th>Description</th>
</tr>
<tr>
<td><code>config</code></td>
<td>Attribute and property</td>
<td>
The view configuration or view strategy, the same as in a
dashboard. A JSON or YAML script child sets the first
configuration.
</td>
</tr>
<tr>
<td><code>dashboard</code></td>
<td>Attribute and property</td>
<td>
A dashboard configuration or dashboard strategy, to show one of its
views instead of <code>config</code>. A script child with a
<code>dashboard</code> key, and optionally <code>view</code>, sets
it.
</td>
</tr>
<tr>
<td><code>view</code></td>
<td>Attribute and property</td>
<td>
The path or index of the view of <code>dashboard</code> to show.
Without it, the first view.
</td>
</tr>
</table>
<h3><code>&lt;ha-embed-dashboard&gt;</code></h3>
<table>
<tr>
<th>Name</th>
<th>Type</th>
<th>Description</th>
</tr>
<tr>
<td><code>config</code></td>
<td>Attribute and property</td>
<td>
The dashboard configuration or dashboard strategy. A JSON or YAML
script child sets the first configuration.
</td>
</tr>
<tr>
<td><code>view</code></td>
<td>Attribute and property</td>
<td>
The path or index of the first view to show. Without it, the first
view.
</td>
</tr>
</table>
<p>
The mocked backend knows the actions of lights, switches, climate,
covers, fans, locks, media players, and more. Actions that open dialogs,
such as <code>more-info</code>, do nothing.
such as <code>more-info</code>, do nothing. Dashboards can't be edited,
and their toolbar only shows their tabs.
</p>
</main>
<script>
+54
View File
@@ -1,6 +1,60 @@
import type { DeviceRegistryEntry } from "../../../src/data/device/device_registry";
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
/** The fields of a device that the demo devices leave at their default. */
export const baseDevice: Omit<
DeviceRegistryEntry,
| "id"
| "name"
| "manufacturer"
| "model"
| "config_entries"
| "primary_config_entry"
| "entry_type"
> = {
config_entries_subentries: {},
connections: [],
identifiers: [],
model_id: null,
labels: [],
sw_version: null,
hw_version: null,
serial_number: null,
via_device_id: null,
area_id: null,
name_by_user: null,
disabled_by: null,
configuration_url: null,
parent_device_id: null,
created_at: 0,
modified_at: 0,
};
export interface DemoDevice {
id: string;
name: string;
manufacturer?: string;
model?: string;
area_id?: string;
via_device_id?: string;
entry_type?: "service";
}
export const toDeviceRegistryEntry = (
device: DemoDevice
): DeviceRegistryEntry => ({
...baseDevice,
id: device.id,
name: device.name,
manufacturer: device.manufacturer ?? null,
model: device.model ?? null,
area_id: device.area_id ?? null,
via_device_id: device.via_device_id ?? null,
entry_type: device.entry_type ?? null,
config_entries: [],
primary_config_entry: null,
});
export const mockDeviceRegistry = (
hass: MockHomeAssistant,
data: DeviceRegistryEntry[] = []
+1 -19
View File
@@ -1,24 +1,6 @@
import type { DeviceRegistryEntry } from "../../../src/data/device/device_registry";
import { connectivityDevices } from "./connectivity/fixtures";
const baseDevice = {
config_entries_subentries: {},
connections: [] as [string, string][],
identifiers: [] as [string, string][],
model_id: null,
labels: [] as string[],
sw_version: null,
hw_version: null,
serial_number: null,
via_device_id: null,
area_id: null,
name_by_user: null,
disabled_by: null,
configuration_url: null,
parent_device_id: null,
created_at: 0,
modified_at: 0,
};
import { baseDevice } from "./device_registry";
export const demoDevices: DeviceRegistryEntry[] = [
{
+4
View File
@@ -81,6 +81,10 @@ export default [
header: "Feedback and status",
pages: ["ha-alert", "ha-spinner", "ha-tip", "ha-bar", "ha-gauge"],
},
{
header: "Charts",
pages: ["state-history-charts", "statistics-chart"],
},
{
header: "Labels and text",
pages: ["ha-badge", "ha-label-badge", "ha-chips", "ha-marquee-text"],
+7 -2
View File
@@ -683,8 +683,13 @@ const SCHEMAS: {
name: "Unit of measurement",
input: {
sensor_device_class: {
name: "Device class",
selector: { device_class: { domain: "sensor" } },
name: "Device class of the unit below",
selector: {
device_class: {
domain: "sensor",
context: { filter_unit_of_measurement: "unit_linked" },
},
},
},
sensor_state_class: {
name: "State class",
@@ -0,0 +1,6 @@
---
title: History charts
subtitle: State history as timelines and line charts.
---
`<state-history-charts>` renders the state history of entities: a timeline for entities with text states, and a line chart per unit for numeric ones. It is used by the history panel, the history graph card and the more-info history. The data below is generated, so the charts work without a backend.
@@ -0,0 +1,211 @@
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import "../../../../src/components/chart/state-history-charts";
import "../../../../src/components/ha-card";
import type {
HistoryResult,
LineChartState,
TimelineState,
} from "../../../../src/data/history";
import { provideHass } from "../../../../src/fake_data/provide_hass";
import type { HomeAssistant } from "../../../../src/types";
const HOUR = 60 * 60 * 1000;
const HOURS = 48;
const END = new Date();
END.setMinutes(0, 0, 0);
const START = new Date(END.getTime() - HOURS * HOUR);
const ENTITIES = [
{
entity_id: "sensor.outdoor_temperature",
state: "12.4",
attributes: {
friendly_name: "Outdoor temperature",
device_class: "temperature",
state_class: "measurement",
unit_of_measurement: "°C",
},
},
{
entity_id: "light.living_room",
state: "on",
attributes: { friendly_name: "Living room light" },
},
{
entity_id: "binary_sensor.front_door",
state: "off",
attributes: { friendly_name: "Front door", device_class: "door" },
},
];
const temperatureStates = (): LineChartState[] =>
Array.from({ length: HOURS * 2 }, (_, index) => {
const time = START.getTime() + (index * HOUR) / 2;
const hourOfDay = new Date(time).getHours();
const value =
10 +
4 * Math.sin(((hourOfDay - 9) / 24) * 2 * Math.PI) +
0.5 * Math.sin(time / HOUR);
return {
state: value.toFixed(1),
last_changed: time,
};
});
const toggleStates = (
onHours: [number, number][],
onLabel: string,
offLabel: string
): TimelineState[] => {
const on = { state: "on", state_localize: onLabel };
const off = { state: "off", state_localize: offLabel };
const states: TimelineState[] = [{ ...off, last_changed: START.getTime() }];
onHours.forEach(([from, to]) => {
states.push(
{ ...on, last_changed: START.getTime() + from * HOUR },
{ ...off, last_changed: START.getTime() + to * HOUR }
);
});
return states;
};
const HISTORY: HistoryResult = {
line: [
{
unit: "°C",
device_class: "temperature",
identifier: "temperature",
data: [
{
domain: "sensor",
name: "Outdoor temperature",
entity_id: "sensor.outdoor_temperature",
states: temperatureStates(),
},
],
},
],
timeline: [
{
name: "Living room light",
entity_id: "light.living_room",
data: toggleStates(
[
[6, 8],
[18, 23],
[30, 32],
[42, 47],
],
"On",
"Off"
),
},
{
name: "Front door",
entity_id: "binary_sensor.front_door",
data: toggleStates(
[
[7.5, 7.6],
[17, 17.2],
[31.5, 31.6],
[41, 41.1],
],
"Open",
"Closed"
),
},
],
};
const LIGHT_ONLY: HistoryResult = {
line: [],
timeline: [HISTORY.timeline[0]],
};
interface Example {
title: string;
description: string;
history: HistoryResult;
showNames?: boolean;
insideLabels?: boolean;
syncCharts?: boolean;
}
const EXAMPLES: Example[] = [
{
title: "Names in a column",
description:
"The default layout, like the history graph card: timeline names on the left and a line chart for each unit.",
history: HISTORY,
},
{
title: "Names inside the timeline, synced zoom",
description:
"Like the history panel: names sit under each timeline bar, and zooming one chart zooms all of them.",
history: HISTORY,
insideLabels: true,
syncCharts: true,
},
{
title: "Single entity without names",
description:
"Like the more-info dialog, where the entity is already known.",
history: LIGHT_ONLY,
showNames: false,
},
];
@customElement("demo-components-state-history-charts")
export class DemoStateHistoryCharts extends LitElement {
@property({ attribute: false }) public hass?: HomeAssistant;
protected firstUpdated(changedProps: PropertyValues<this>) {
super.firstUpdated(changedProps);
const hass = provideHass(this);
hass.updateTranslations(null, "en");
hass.addEntities(ENTITIES);
}
protected render() {
if (!this.hass) {
return nothing;
}
return EXAMPLES.map(
(example) => html`
<ha-card header=${example.title}>
<div class="card-content">
<p>${example.description}</p>
<state-history-charts
.historyData=${example.history}
.startTime=${START}
.endTime=${END}
.showNames=${example.showNames ?? true}
.insideLabels=${example.insideLabels ?? false}
.syncCharts=${example.syncCharts ?? false}
.clickForMoreInfo=${false}
></state-history-charts>
</div>
</ha-card>
`
);
}
static styles = css`
ha-card {
max-width: 800px;
margin: 24px auto;
}
p {
margin-top: 0;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"demo-components-state-history-charts": DemoStateHistoryCharts;
}
}
@@ -0,0 +1,6 @@
---
title: Statistics chart
subtitle: Long-term statistics as a bar or line chart.
---
`<statistics-chart>` renders long-term statistics from the recorder. It is used by the statistics graph card, the more-info history and the statistics developer tools. The data below is generated, so the charts work without a backend.
@@ -0,0 +1,212 @@
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import "../../../../src/components/chart/statistics-chart";
import "../../../../src/components/ha-card";
import type {
Statistics,
StatisticsMetaData,
StatisticType,
StatisticValue,
} from "../../../../src/data/recorder";
import { StatisticMeanType } from "../../../../src/data/recorder";
import { provideHass } from "../../../../src/fake_data/provide_hass";
import type { HomeAssistant } from "../../../../src/types";
const HOUR = 60 * 60 * 1000;
const HOURS = 48;
const END = new Date();
END.setMinutes(0, 0, 0);
const START = new Date(END.getTime() - HOURS * HOUR);
const round = (value: number, digits: number) =>
Math.round(value * 10 ** digits) / 10 ** digits;
const hourlyValues = (
compute: (start: number) => Partial<StatisticValue>
): StatisticValue[] =>
Array.from({ length: HOURS }, (_, hour) => {
const start = START.getTime() + hour * HOUR;
return { start, end: start + HOUR, ...compute(start) };
});
const energyValues = (dayUse: number, eveningUse: number) => {
let sum = 0;
return hourlyValues((start) => {
const hourOfDay = new Date(start).getHours();
const base = hourOfDay < 7 ? 0.1 : hourOfDay < 18 ? dayUse : eveningUse;
const change = round(base + 0.2 * Math.abs(Math.sin(start / HOUR)), 2);
sum += change;
return { change, sum: round(sum, 2) };
});
};
const temperatureValues = (average: number, swing: number) =>
hourlyValues((start) => {
const hourOfDay = new Date(start).getHours();
const mean =
average +
swing * Math.sin(((hourOfDay - 9) / 24) * 2 * Math.PI) +
0.3 * Math.sin(start / HOUR);
return {
mean: round(mean, 1),
min: round(mean - 0.4, 1),
max: round(mean + 0.4, 1),
};
});
const HEAT_PUMP_ID = "sensor.heat_pump_energy";
const LIGHTING_ID = "sensor.lighting_energy";
const LIVING_ROOM_ID = "sensor.living_room_temperature";
const BEDROOM_ID = "sensor.bedroom_temperature";
const HEAT_PUMP = energyValues(0.4, 1.2);
const LIGHTING = energyValues(0.05, 0.3);
const LIVING_ROOM = temperatureValues(21, 1.5);
const BEDROOM = temperatureValues(18.5, 1);
const energyMetadata = (id: string, name: string): StatisticsMetaData => ({
statistic_id: id,
statistics_unit_of_measurement: "kWh",
source: "recorder",
name,
has_sum: true,
mean_type: StatisticMeanType.NONE,
unit_class: "energy",
});
const temperatureMetadata = (id: string, name: string): StatisticsMetaData => ({
statistic_id: id,
statistics_unit_of_measurement: "°C",
source: "recorder",
name,
has_sum: false,
mean_type: StatisticMeanType.ARITHMETIC,
unit_class: "temperature",
});
const METADATA: Record<string, StatisticsMetaData> = {
[HEAT_PUMP_ID]: energyMetadata(HEAT_PUMP_ID, "Heat pump"),
[LIGHTING_ID]: energyMetadata(LIGHTING_ID, "Lighting"),
[LIVING_ROOM_ID]: temperatureMetadata(LIVING_ROOM_ID, "Living room"),
[BEDROOM_ID]: temperatureMetadata(BEDROOM_ID, "Bedroom"),
};
const NAMES = Object.fromEntries(
Object.values(METADATA).map((meta) => [meta.statistic_id, meta.name!])
);
interface Example {
title: string;
description: string;
statistics: Statistics;
statTypes: StatisticType[];
chartType: "line" | "line-stack" | "bar" | "bar-stack";
unit: string;
hideLegend?: boolean;
height?: string;
}
const EXAMPLES: Example[] = [
{
title: "Sum as bars",
description:
"The change of a sum for each hour, like an energy statistics graph card.",
statistics: { [HEAT_PUMP_ID]: HEAT_PUMP },
statTypes: ["change"],
chartType: "bar",
unit: "kWh",
},
{
title: "Stacked bars",
description: "Several sums stacked, to compare what each one adds.",
statistics: { [HEAT_PUMP_ID]: HEAT_PUMP, [LIGHTING_ID]: LIGHTING },
statTypes: ["change"],
chartType: "bar-stack",
unit: "kWh",
},
{
title: "Mean, min and max",
description: "A measurement with its hourly mean, min and max.",
statistics: { [LIVING_ROOM_ID]: LIVING_ROOM },
statTypes: ["mean", "min", "max"],
chartType: "line",
unit: "°C",
},
{
title: "Several statistics",
description: "The mean of several measurements with the same unit.",
statistics: { [LIVING_ROOM_ID]: LIVING_ROOM, [BEDROOM_ID]: BEDROOM },
statTypes: ["mean"],
chartType: "line",
unit: "°C",
},
{
title: "Without legend",
description:
"A single statistic with a fixed height, like in the more-info dialog.",
statistics: { [LIVING_ROOM_ID]: LIVING_ROOM },
statTypes: ["mean", "min", "max"],
chartType: "line",
unit: "°C",
hideLegend: true,
height: "240px",
},
];
@customElement("demo-components-statistics-chart")
export class DemoStatisticsChart extends LitElement {
@property({ attribute: false }) public hass?: HomeAssistant;
protected firstUpdated(changedProps: PropertyValues<this>) {
super.firstUpdated(changedProps);
const hass = provideHass(this);
hass.updateTranslations(null, "en");
}
protected render() {
if (!this.hass) {
return nothing;
}
return EXAMPLES.map(
(example) => html`
<ha-card header=${example.title}>
<div class="card-content">
<p>${example.description}</p>
<statistics-chart
.statisticsData=${example.statistics}
.metadata=${METADATA}
.names=${NAMES}
.statTypes=${example.statTypes}
.chartType=${example.chartType}
period="hour"
.unit=${example.unit}
.startTime=${START}
.endTime=${END}
.hideLegend=${example.hideLegend ?? false}
.height=${example.height}
.clickForMoreInfo=${false}
></statistics-chart>
</div>
</ha-card>
`
);
}
static styles = css`
ha-card {
max-width: 800px;
margin: 24px auto;
}
p {
margin-top: 0;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"demo-components-statistics-chart": DemoStatisticsChart;
}
}
+6 -6
View File
@@ -54,14 +54,14 @@
"@codemirror/view": "6.43.15",
"@date-fns/tz": "1.5.0",
"@egjs/hammerjs": "2.0.17",
"@formatjs/intl-datetimeformat": "8.0.1",
"@formatjs/intl-datetimeformat": "8.0.2",
"@formatjs/intl-displaynames": "8.0.1",
"@formatjs/intl-durationformat": "0.12.2",
"@formatjs/intl-durationformat": "0.12.3",
"@formatjs/intl-getcanonicallocales": "3.3.0",
"@formatjs/intl-listformat": "9.0.1",
"@formatjs/intl-locale": "5.4.2",
"@formatjs/intl-numberformat": "10.0.1",
"@formatjs/intl-pluralrules": "7.0.1",
"@formatjs/intl-numberformat": "10.0.2",
"@formatjs/intl-pluralrules": "7.0.2",
"@formatjs/intl-relativetimeformat": "13.0.1",
"@home-assistant/webawesome": "3.14.0-ha.2",
"@lezer/common": "1.5.5",
@@ -144,7 +144,7 @@
"@babel/plugin-transform-private-methods": "8.0.1",
"@babel/plugin-transform-runtime": "8.0.6",
"@babel/preset-env": "8.0.7",
"@bundle-stats/plugin-webpack-filter": "4.22.3",
"@bundle-stats/plugin-webpack-filter": "4.23.0",
"@eslint/js": "10.0.1",
"@gfx/zopfli": "1.0.15",
"@html-eslint/eslint-plugin": "0.67.0",
@@ -189,7 +189,7 @@
"fs-extra": "11.4.1",
"generate-license-file": "4.2.5",
"glob": "14.0.3",
"globals": "17.13.0",
"globals": "17.14.0",
"gulp": "5.0.1",
"gulp-json-transform": "0.5.0",
"gulp-rename": "2.1.0",
+37 -37
View File
@@ -223,14 +223,14 @@ importers:
specifier: 2.0.17
version: 2.0.17
'@formatjs/intl-datetimeformat':
specifier: 8.0.1
version: 8.0.1
specifier: 8.0.2
version: 8.0.2
'@formatjs/intl-displaynames':
specifier: 8.0.1
version: 8.0.1
'@formatjs/intl-durationformat':
specifier: 0.12.2
version: 0.12.2
specifier: 0.12.3
version: 0.12.3
'@formatjs/intl-getcanonicallocales':
specifier: 3.3.0
version: 3.3.0
@@ -241,11 +241,11 @@ importers:
specifier: 5.4.2
version: 5.4.2
'@formatjs/intl-numberformat':
specifier: 10.0.1
version: 10.0.1
specifier: 10.0.2
version: 10.0.2
'@formatjs/intl-pluralrules':
specifier: 7.0.1
version: 7.0.1
specifier: 7.0.2
version: 7.0.2
'@formatjs/intl-relativetimeformat':
specifier: 13.0.1
version: 13.0.1
@@ -488,8 +488,8 @@ importers:
specifier: 8.0.7
version: 8.0.7(@babel/[email protected])
'@bundle-stats/plugin-webpack-filter':
specifier: 4.22.3
version: 4.22.3([email protected])
specifier: 4.23.0
version: 4.23.0([email protected])
'@eslint/js':
specifier: 10.0.1
version: 10.0.1([email protected]([email protected]))
@@ -623,8 +623,8 @@ importers:
specifier: 14.0.3
version: 14.0.3
globals:
specifier: 17.13.0
version: 17.13.0
specifier: 17.14.0
version: 17.14.0
gulp:
specifier: 5.0.1
version: 5.0.1
@@ -1740,8 +1740,8 @@ packages:
resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==}
hasBin: true
'@bundle-stats/[email protected]2.3':
resolution: {integrity: sha512-tDYUsXFf1LLwaYF2v7ntWjDx1weh4PCEgNtySzNJgf+AwcC8WPydjad1TiGclLG3XuqUi/ZgTW+7tn/lhAZqCg==}
'@bundle-stats/[email protected]3.0':
resolution: {integrity: sha512-tqPJqUmyev0Kef8qPWcaAwvJ0c/CAClj0EKwJeSFnED4zYprjb7j5Tk8e6i4gJBNFp4KC+5gVgxqhZYoIx08ww==}
engines: {node: '>= 14.0'}
peerDependencies:
core-js: ^3.0.0
@@ -1929,8 +1929,8 @@ packages:
'@floating-ui/[email protected]':
resolution: {integrity: sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==}
'@formatjs/[email protected].9':
resolution: {integrity: sha512-rV5U95Koz9LADq080OdPOLy629Z1HNaid3842EORxm+0Q7QhvUalH7rgVAK3Zsa9pbmNvac/mNmkP7+xf8wYwg==}
'@formatjs/[email protected].10':
resolution: {integrity: sha512-npIs6UARNOg5amJY5UOVsd6HUQQfzYjet0sa0SWIqzsnVhxLSpaIx2DjoIVrr/vqmjURjvroeW6dItjfy3Uf8A==}
'@formatjs/[email protected]':
resolution: {integrity: sha512-qrycXDeaORzIqNhBOx0btnhpD1c+/qFIHAN9znofuMJX6QBwtbrmlpWfD4oiUUD2vJUOIYFA/gYtg2KAMGG7sA==}
@@ -1953,14 +1953,14 @@ packages:
'@formatjs/[email protected]':
resolution: {integrity: sha512-ca7tZMLpfHX3sUD7hP6gE/qr6Iq89EDd3ZLeRhbsB7r0UD55DGJq+tmFLzrGrnx5StahUevQmQQVtbDRHsfDHA==}
'@formatjs/[email protected].1':
resolution: {integrity: sha512-NSgDp17ZaXNkSuKpvSfzh5bzV0x6i4KlQKgv8fEfLobkL/spFRBrY/WzWJAeangH18bcmAVoHXgz1Uyz9l7KRw==}
'@formatjs/[email protected].2':
resolution: {integrity: sha512-isKDo8jeQ8VoYvrweku92ajF/Z9fJ76DDnw2eCmKBTJTwZa5GeGl4u40RJiFYPbR6ROluvN0CsbNtGtnd2NyIQ==}
'@formatjs/[email protected]':
resolution: {integrity: sha512-8RTQvKIGx+SAqI+bvVrrupwzeEZGGzdzJwVbZaK9U5VKuVo34LOFOkn7GX9KfwHuVIKxM7pKv8yYTs6UIkeyTA==}
'@formatjs/[email protected].2':
resolution: {integrity: sha512-qQzwm47mlPFRGv0vl7xBzzy6xdUQSo+1bIJqy05vxRCT4J0CXu7U0LUHYtW/LMY7o2bCyWi9ZHhMtF7roOLXkg==}
'@formatjs/[email protected].3':
resolution: {integrity: sha512-cpyMSdAZxRXVmA0TjZZn38ERjfxV8Kc1nDcp1RBAXyU6pB7Mgox+o3dVUnPLsNfblW5OAr1o281IyPRrod/T6g==}
'@formatjs/[email protected]':
resolution: {integrity: sha512-ndj9jw/LHAftSe34D/A6+lj3Y7GHVfPEZ6FZP2wjd8/beYeh3d1sjlSpZSBlzegSKS3U3mUEIzLCpI0+jKNT6w==}
@@ -1977,11 +1977,11 @@ packages:
'@formatjs/[email protected]':
resolution: {integrity: sha512-ePEgLgVCqi2BBFnTMWPfIghu6FkbZnnBVhO2sSxvLfrdFw7wCHAHiDoM2h4NRgjbaY7+B7HgOLZGkK187pZTZg==}
'@formatjs/[email protected].1':
resolution: {integrity: sha512-P0PUpnUZz9Gy70EYYdUVVgDqd4H/dBRiO0k82hoYWS0mlxcjAjQica63sVJIO8+892bd80ybjZC4QZHVXHh+Rg==}
'@formatjs/[email protected].2':
resolution: {integrity: sha512-iLS3ly1lGZ/a/IVpcSUgC37RjQUZ2wN6zR9+Zbr2uXsxATfMLD+fBqggiXuFudLmZXpbPueCs4Mz3vDsaOVE9A==}
'@formatjs/[email protected].1':
resolution: {integrity: sha512-Ca0gf/5lplUfDXl0OATgcQIogrGJzXTZPBz8xx1tnkALuibvS5fA71h+V0ghnUJsp1cA4PyPVj5LkbrlyhVvGw==}
'@formatjs/[email protected].2':
resolution: {integrity: sha512-PJ4+ldlqXlo3csmP02u2HqyXw09TDB/fatYpDCyWryfEQeVWl2EbMzij9SBksPaW2U//+q69lXR3IMnCRQAmrw==}
'@formatjs/[email protected]':
resolution: {integrity: sha512-Gl5dCHb7elEC19tTzxIyllWLHaHnN+HpQYEaA9xxAt9rKgRLwC3vGty1w8bVGG7p2YcGSPIg5c4qn4KdkGcTqg==}
@@ -5126,8 +5126,8 @@ packages:
resolution: {integrity: sha512-5lsx1NUDHtSjfg0eHlmYvZKv8/nVqX4ckFbM+FrGcQ+04KWcWFo9P5MxPZYSzUvyzmdTbI7Eix8Q4IbELDqzKg==}
engines: {node: '>=0.10.0'}
[email protected]3.0:
resolution: {integrity: sha512-RwMTC61u7hrG3ZJMkZQOK4JLJrKS8QtoTii63EmWvFXHqycY9FObBJQCbsLxSO8kjKhWE5kV1GC+Vb1g3RI0Zg==}
[email protected]4.0:
resolution: {integrity: sha512-UtdkTVyAJePQv0J2M9Xe4L0ruNSDV314XiIJsADI2Zf40bcdAonE/D/OhAU8AT99oij9mhLByb+NvA3QWZYgCg==}
engines: {node: '>=18'}
[email protected]:
@@ -9135,7 +9135,7 @@ snapshots:
dependencies:
css-tree: 3.2.1
'@bundle-stats/[email protected]2.3([email protected])':
'@bundle-stats/[email protected]3.0([email protected])':
dependencies:
core-js: 3.51.0
tslib: 2.9.1
@@ -9384,7 +9384,7 @@ snapshots:
'@floating-ui/[email protected]': {}
'@formatjs/[email protected].9': {}
'@formatjs/[email protected].10': {}
'@formatjs/[email protected]':
dependencies:
@@ -9416,18 +9416,18 @@ snapshots:
'@formatjs/[email protected]': {}
'@formatjs/[email protected].1':
'@formatjs/[email protected].2':
dependencies:
'@formatjs/bigdecimal': 0.2.9
'@formatjs/bigdecimal': 0.2.10
'@formatjs/intl-localematcher': 0.10.1
'@formatjs/[email protected]':
dependencies:
'@formatjs/intl-localematcher': 0.10.1
'@formatjs/[email protected].2':
'@formatjs/[email protected].3':
dependencies:
'@formatjs/bigdecimal': 0.2.9
'@formatjs/bigdecimal': 0.2.10
'@formatjs/intl-localematcher': 0.10.1
'@formatjs/[email protected]': {}
@@ -9449,14 +9449,14 @@ snapshots:
dependencies:
tslib: 2.9.1
'@formatjs/[email protected].1':
'@formatjs/[email protected].2':
dependencies:
'@formatjs/bigdecimal': 0.2.9
'@formatjs/bigdecimal': 0.2.10
'@formatjs/intl-localematcher': 0.10.1
'@formatjs/[email protected].1':
'@formatjs/[email protected].2':
dependencies:
'@formatjs/bigdecimal': 0.2.9
'@formatjs/bigdecimal': 0.2.10
'@formatjs/intl-localematcher': 0.10.1
'@formatjs/[email protected]':
@@ -12889,7 +12889,7 @@ snapshots:
is-windows: 1.0.2
which: 1.3.1
[email protected]3.0: {}
[email protected]4.0: {}
[email protected]:
dependencies:
+22
View File
@@ -20,6 +20,11 @@ export interface EntityFilter {
export type EntityFilterFunc = (entityId: string) => boolean;
export type EntityFilterSources = Pick<
HomeAssistant,
"states" | "entities" | "devices" | "areas" | "floors"
>;
const normalizeFilterArray = <T>(
value: T | null | T[] | (T | null)[] | undefined
): Set<T | null> | undefined => {
@@ -157,3 +162,20 @@ export const findEntities = (
return results;
};
export const findFilteredEntities = (
sources: EntityFilterSources,
filters: EntityFilter[]
): string[] => {
const filterFuncs = filters.map((filter) =>
generateEntityFilter(
sources.states,
sources.entities,
sources.devices,
sources.areas,
sources.floors,
filter
)
);
return findEntities(Object.keys(sources.states), filterFuncs);
};
+13 -2
View File
@@ -16,6 +16,13 @@ declare global {
export interface NavigateOptions {
replace?: boolean;
data?: Record<string, unknown>;
/**
* Leave open dialogs open. For a replace that only canonicalizes the current
* URL, like a router filling in its default page: a dialog restored from the
* URL, such as a more-info deep link, may already be open at that point, and
* the entry keeps its state so back still closes it.
*/
keepDialogs?: boolean;
}
// max time to wait for dialogs to close before navigating
@@ -196,7 +203,8 @@ const buildHistoryState = (
};
const performNavigation = async (path: string, options?: NavigateOptions) => {
const canProceed = await ensureDialogsClosed(Date.now());
const keepDialogs = options?.keepDialogs || false;
const canProceed = await (keepDialogs || ensureDialogsClosed(Date.now()));
if (!canProceed) {
return false;
}
@@ -215,7 +223,10 @@ const performNavigation = async (path: string, options?: NavigateOptions) => {
const { history } = mainWindow;
if (replace) {
if (replace && keepDialogs) {
// Same entry under its canonical URL: its state, dialog included, stays.
history.replaceState(history.state, "", path);
} else if (replace) {
// A replaced entry keeps its predecessor, so it keeps `from`.
const { root, from } = history.state ?? {};
const data = root ? { root: true } : options?.data;
+3
View File
@@ -20,6 +20,7 @@ export const entitiesFilterQueryParamConfig = {
"floor",
"area",
"device",
"device_class",
"domain",
"entity_domain",
"label",
@@ -50,6 +51,7 @@ export const entitiesFilterQueryParams = (
floor: floorAreas(filters["ha-filter-floor-areas"])?.floors,
area: floorAreas(filters["ha-filter-floor-areas"])?.areas,
device: list(filters["ha-filter-devices"]),
device_class: list(filters["ha-filter-device-classes"]),
domain: list(filters["ha-filter-integrations"]),
entity_domain: list(filters["ha-filter-domains"]),
label: list(filters["ha-filter-labels"]),
@@ -114,6 +116,7 @@ export const entitiesLinkFromUrl = (
"ha-filter-floor-areas":
floors.length || areas.length ? { floors, areas } : undefined,
"ha-filter-devices": values("device"),
"ha-filter-device-classes": values("device_class"),
"ha-filter-integrations": values("domain"),
"ha-filter-domains": values("entity_domain"),
"ha-filter-labels": values("label"),
+9 -3
View File
@@ -13,6 +13,7 @@ import {
import {
AREA_CONTROLS_BUTTONS,
getAreaControlEntities,
getAreaEntityIds,
type AreaControlDomain,
} from "../data/area/area_controls";
import {
@@ -148,15 +149,20 @@ export class HaAreaControlsPicker extends LitElement {
currentValue === id ||
(excludeValues !== undefined && excludeValues.includes(id));
const areaEntities = getAreaEntityIds(
registries.entities,
registries.devices,
areaId,
excludeEntities
);
const controlEntities = getAreaControlEntities(
areaEntities,
states,
registries.entities,
registries.devices,
registries.areas,
registries.floors,
AREA_CONTROL_DOMAINS,
areaId,
excludeEntities
AREA_CONTROL_DOMAINS
);
const items: (AreaControlPickerItem | string)[] = [];
-93
View File
@@ -1,93 +0,0 @@
import { CheckListItemBase } from "@material/mwc-list/mwc-check-list-item-base";
import { styles as controlStyles } from "@material/mwc-list/mwc-control-list-item.css";
import { styles } from "@material/mwc-list/mwc-list-item.css";
import { css, html, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { fireEvent } from "../common/dom/fire_event";
import { preventDefault } from "../common/dom/prevent_default";
import { stopPropagation } from "../common/dom/stop_propagation";
import "./ha-checkbox";
@customElement("ha-check-list-item")
export class HaCheckListItem extends CheckListItemBase {
@property({ type: Boolean, attribute: "checkbox-disabled" })
checkboxDisabled = false;
@property({ type: Boolean })
indeterminate = false;
@property({ type: Boolean, attribute: "separate-checkbox-click" })
separateCheckboxClick = false;
async onChange(event) {
event.stopPropagation();
super.onChange(event);
fireEvent(this, event.type);
}
override render() {
const text = this.renderText();
const graphic =
this.graphic && this.graphic !== "control" && !this.left
? this.renderGraphic()
: nothing;
const meta = this.hasMeta && this.left ? this.renderMeta() : nothing;
const ripple = this.renderRipple();
return html` ${ripple} ${graphic} ${this.left ? "" : text}
<ha-checkbox
tabindex=${this.separateCheckboxClick ? this.tabindex : -1}
.checked=${this.selected}
.indeterminate=${this.indeterminate}
?disabled=${this.disabled || this.checkboxDisabled}
@change=${this.onChange}
@click=${this.separateCheckboxClick ? stopPropagation : preventDefault}
class=${this.left ? "left" : ""}
>
</ha-checkbox>
${this.left ? text : ""} ${meta}`;
}
static override styles = [
styles,
controlStyles,
css`
:host {
--mdc-theme-secondary: var(--primary-color);
}
:host([graphic="avatar"]) .mdc-deprecated-list-item__graphic,
:host([graphic="medium"]) .mdc-deprecated-list-item__graphic,
:host([graphic="large"]) .mdc-deprecated-list-item__graphic,
:host([graphic="control"]) .mdc-deprecated-list-item__graphic {
margin-inline-end: var(--mdc-list-item-graphic-margin, 16px);
margin-inline-start: 0px;
direction: var(--direction);
}
ha-checkbox {
flex-shrink: 0;
direction: var(--direction);
margin-inline-start: auto;
margin-inline-end: 0;
height: 100%;
justify-content: center;
}
ha-checkbox.left {
margin-inline-start: 0;
}
.mdc-deprecated-list-item__graphic {
margin-top: var(--check-list-item-graphic-margin-top);
}
:host([graphic="icon"]) .mdc-deprecated-list-item__graphic {
margin-inline-start: 0;
margin-inline-end: var(--mdc-list-item-graphic-margin, 32px);
}
`,
];
}
declare global {
interface HTMLElementTagNameMap {
"ha-check-list-item": HaCheckListItem;
}
}
+65 -4
View File
@@ -5,10 +5,13 @@ import memoizeOne from "memoize-one";
import { consume } from "../common/decorators/consume";
import { ensureArray } from "../common/array/ensure-array";
import { fireEvent } from "../common/dom/fire_event";
import { caseInsensitiveStringCompare } from "../common/string/compare";
import type { LocalizeFunc } from "../common/translations/localize";
import { internationalizationContext } from "../data/context";
import { DOMAIN_DEVICE_CLASSES } from "../data/device_classes";
import { computeDeviceClassName } from "../data/entity/device_class";
import { computeUnitDeviceClasses } from "../data/sensor/unit_of_measurement";
import type { FuseWeightedKey } from "../resources/fuseMultiTerm";
import type {
HomeAssistantInternationalization,
ValueChangedEvent,
@@ -27,6 +30,13 @@ export const getDeviceClassOptions = (
return { id: deviceClass, primary, sorting_label: primary };
});
// The reason line of a device class that does not support the unit names the
// unit, so searching it would match every unsupported device class
const UNIT_FILTER_SEARCH_KEYS: FuseWeightedKey[] = [
{ name: "primary", weight: 10 },
{ name: "id", weight: 3 },
];
@customElement("ha-device-class-picker")
export class HaDeviceClassPicker extends LitElement {
@property() public domain?: string;
@@ -43,6 +53,9 @@ export class HaDeviceClassPicker extends LitElement {
@property({ type: Boolean }) public required = false;
/** Unit of measurement: device classes that do not support it are listed last */
@property() public unit?: string;
@state()
@consume({ context: internationalizationContext, subscribe: true })
private _i18n?: HomeAssistantInternationalization;
@@ -70,18 +83,60 @@ export class HaDeviceClassPicker extends LitElement {
private _options = memoizeOne(
(
domain: string | undefined,
localize: LocalizeFunc | undefined
): PickerComboBoxItem[] =>
domain && localize ? getDeviceClassOptions(domain, localize) : []
localize: LocalizeFunc | undefined,
unit: string | undefined,
language: string | undefined
): PickerComboBoxItem[] => {
if (!domain || !localize) {
return [];
}
const options = getDeviceClassOptions(domain, localize);
const fitting = computeUnitDeviceClasses(domain, unit);
if (!fitting) {
return options;
}
// Device classes of the unit first, then those that accept any unit
const rank = (deviceClass: string) =>
fitting.matching.includes(deviceClass)
? 0
: fitting.unrestricted.includes(deviceClass)
? 1
: 2;
const notSupported = localize(
"ui.components.device-class-picker.unit_not_supported",
{ unit }
);
return options
.map((option) =>
rank(option.id) === 2
? { ...option, secondary: notSupported }
: option
)
.sort(
(a, b) =>
rank(a.id) - rank(b.id) ||
caseInsensitiveStringCompare(a.primary, b.primary, language)
);
}
);
// The options are sorted here when the unit filters them
private get _filtered(): boolean {
return computeUnitDeviceClasses(this.domain, this.unit) !== undefined;
}
private _availableOptions = memoizeOne(
(options: PickerComboBoxItem[], selected: string[]) =>
options.filter((option) => !selected.includes(option.id))
);
private _getItems = () => {
const options = this._options(this.domain, this._i18n?.localize);
const options = this._options(
this.domain,
this._i18n?.localize,
this.unit,
this._i18n?.language
);
return this.multiple
? this._availableOptions(options, this._value)
: options;
@@ -104,6 +159,8 @@ export class HaDeviceClassPicker extends LitElement {
const emptyLabel = localize?.(
"ui.components.device-class-picker.no_device_classes"
);
const filtered = this._filtered;
const searchKeys = filtered ? UNIT_FILTER_SEARCH_KEYS : undefined;
if (this.multiple) {
const value = this._value;
@@ -145,6 +202,8 @@ export class HaDeviceClassPicker extends LitElement {
.getItems=${this._getItems}
.notFoundLabel=${this._notFoundLabel}
.emptyLabel=${emptyLabel}
.noSort=${filtered}
.searchKeys=${searchKeys}
@value-changed=${this._itemAdded}
></ha-generic-picker>
`;
@@ -164,6 +223,8 @@ export class HaDeviceClassPicker extends LitElement {
.valueRenderer=${this._valueRenderer}
.notFoundLabel=${this._notFoundLabel}
.emptyLabel=${emptyLabel}
.noSort=${filtered}
.searchKeys=${searchKeys}
@value-changed=${this._valueChanged}
></ha-generic-picker>
`;
@@ -0,0 +1,78 @@
import type { ContextType } from "@lit/context";
import { customElement, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { consume } from "../common/decorators/consume";
import { consumeLocalize } from "../common/decorators/consume-context-entry";
import { computeDomain } from "../common/entity/compute_domain";
import type { LocalizeFunc } from "../common/translations/localize";
import { stringCompare } from "../common/string/compare";
import { internationalizationContext, statesContext } from "../data/context";
import { computeDeviceClassName } from "../data/entity/device_class";
import { HaFilterStates } from "./ha-filter-states";
@customElement("ha-filter-device-classes")
export class HaFilterDeviceClasses extends HaFilterStates {
@state()
@consume({ context: statesContext, subscribe: true })
private _entityStates?: ContextType<typeof statesContext>;
@state()
@consume({ context: internationalizationContext, subscribe: true })
private _i18n?: ContextType<typeof internationalizationContext>;
@state()
@consumeLocalize()
private _localize?: LocalizeFunc;
private _deviceClasses = memoizeOne(
(
states: ContextType<typeof statesContext>,
localize: LocalizeFunc,
language: string
) => {
const classes = new Map<string, string>();
for (const [entityId, stateObj] of Object.entries(states)) {
const deviceClass = stateObj.attributes.device_class;
if (typeof deviceClass !== "string" || classes.has(deviceClass)) {
continue;
}
classes.set(
deviceClass,
computeDeviceClassName(localize, computeDomain(entityId), deviceClass)
);
}
return [...classes]
.map(([value, label]) => ({ value, label }))
.sort((a, b) => stringCompare(a.label, b.label, language));
}
);
protected firstUpdated() {
this._i18n?.loadBackendTranslation("entity_component");
}
protected willUpdate() {
if (
this._entityStates &&
this._localize &&
this._i18n &&
(this.expanded || !this.states)
) {
this.states = this._deviceClasses(
this._entityStates,
this._localize,
this._i18n.locale.language
);
}
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-filter-device-classes": HaFilterDeviceClasses;
}
}
@@ -1,6 +1,9 @@
import type { PropertyValues } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import { fireEvent } from "../../common/dom/fire_event";
import type { DeviceClassSelector } from "../../data/selector";
import { computeDeviceClassForUnit } from "../../data/sensor/unit_of_measurement";
import "../ha-device-class-picker";
@customElement("ha-selector-device_class")
@@ -17,12 +20,17 @@ export class HaDeviceClassSelector extends LitElement {
@property({ type: Boolean }) public required = true;
@property({ attribute: false }) public context?: {
filter_unit_of_measurement?: string;
};
protected render() {
return html`
<ha-device-class-picker
.domain=${this.selector.device_class?.domain}
.value=${this.value}
.multiple=${this.selector.device_class?.multiple ?? false}
.unit=${this.context?.filter_unit_of_measurement}
.label=${this.label}
.helper=${this.helper}
.disabled=${this.disabled}
@@ -31,6 +39,39 @@ export class HaDeviceClassSelector extends LitElement {
`;
}
protected updated(changedProps: PropertyValues<this>): void {
super.updated(changedProps);
// A unit does not imply a device class, so an optional field stays empty
if (
!this.required ||
!changedProps.has("context") ||
this.value ||
this.selector.device_class?.multiple
) {
return;
}
// Preselect only when the unit changes, so a form that opens with a unit
// and a device class the user cleared stay without a device class
const oldContext = changedProps.get("context");
const unit = this.context?.filter_unit_of_measurement;
if (
oldContext === undefined ||
oldContext.filter_unit_of_measurement === unit
) {
return;
}
// Preselect the only device class of the unit
const deviceClass = computeDeviceClassForUnit(
this.selector.device_class?.domain,
unit
);
if (deviceClass) {
fireEvent(this, "value-changed", { value: deviceClass });
}
}
static styles = css`
ha-device-class-picker {
width: 100%;
+16 -7
View File
@@ -1,3 +1,4 @@
import { getEntityAreaId } from "../../common/entity/context/get_entity_context";
import { generateEntityFilter } from "../../common/entity/entity_filter";
import type { HomeAssistant } from "../../types";
@@ -61,15 +62,26 @@ export const AREA_CONTROLS_BUTTONS: Record<
"cover-window": coverButton("window"),
};
export const getAreaEntityIds = (
entities: HomeAssistant["entities"],
devices: HomeAssistant["devices"],
areaId: string,
excludeEntities?: string[]
): string[] =>
Object.keys(entities).filter(
(entityId) =>
getEntityAreaId(entityId, entities, devices) === areaId &&
!excludeEntities?.includes(entityId)
);
export const getAreaControlEntities = (
areaEntities: string[],
states: HomeAssistant["states"],
entities: HomeAssistant["entities"],
devices: HomeAssistant["devices"],
areas: HomeAssistant["areas"],
floors: HomeAssistant["floors"],
controls: readonly AreaControlDomain[],
areaId: string,
excludeEntities: string[] | undefined
controls: readonly AreaControlDomain[]
): Record<AreaControlDomain, string[]> =>
controls.reduce(
(acc, control) => {
@@ -81,15 +93,12 @@ export const getAreaControlEntities = (
areas,
floors,
{
area: areaId,
entity_category: "none",
...controlButton.filter,
}
);
acc[control] = Object.keys(entities).filter(
(entityId) => filter(entityId) && !excludeEntities?.includes(entityId)
);
acc[control] = areaEntities.filter(filter);
return acc;
},
{} as Record<AreaControlDomain, string[]>
+6
View File
@@ -527,10 +527,16 @@ export interface SelectSelector {
} | null;
}
// Maps a context key to the name of another field or blueprint input
export interface DeviceClassSelectorContext {
filter_unit_of_measurement?: string;
}
export interface DeviceClassSelector {
device_class: {
domain: string;
multiple?: boolean;
context?: DeviceClassSelectorContext;
} | null;
}
+50
View File
@@ -1,4 +1,5 @@
import { ensureArray } from "../../common/array/ensure-array";
import { DOMAIN_DEVICE_CLASSES } from "../device_classes";
import {
SENSOR_DEVICE_CLASS_UNITS,
SENSOR_NUMERIC_DEVICE_CLASSES,
@@ -97,3 +98,52 @@ export const computeSelectorUnits = (
}
return fromConfig ?? fromContext;
};
// Number entities share the unit tables of sensors
const UNIT_DOMAINS = ["sensor", "number"];
export interface UnitDeviceClasses {
/** Device classes whose unit table contains the unit */
matching: string[];
/** Numeric device classes without a unit table (e.g. `monetary`), which accept any unit */
unrestricted: string[];
}
/**
* Compute which device classes of a domain accept a unit. A custom unit is in
* no unit table, so only the device classes without a unit table accept it.
*
* @returns `undefined` when nothing is filtered: no unit, or a domain without units
*/
export const computeUnitDeviceClasses = (
domain: string | undefined,
unit: string | null | undefined
): UnitDeviceClasses | undefined => {
if (!unit || !domain || !UNIT_DOMAINS.includes(domain)) {
return undefined;
}
const result: UnitDeviceClasses = { matching: [], unrestricted: [] };
for (const deviceClass of DOMAIN_DEVICE_CLASSES[domain] ?? []) {
const units = SENSOR_DEVICE_CLASS_UNITS[deviceClass];
if (units) {
if (units.includes(unit)) {
result.matching.push(deviceClass);
}
} else if (SENSOR_NUMERIC_DEVICE_CLASSES.includes(deviceClass)) {
result.unrestricted.push(deviceClass);
}
}
return result;
};
/**
* The device class to preselect for a unit: the only one whose unit table
* contains it.
*/
export const computeDeviceClassForUnit = (
domain: string | undefined,
unit: string | null | undefined
): string | undefined => {
const matching = computeUnitDeviceClasses(domain, unit)?.matching;
return matching?.length === 1 ? matching[0] : undefined;
};
@@ -595,6 +595,7 @@ class DataEntryFlowDialog extends DirtyStateProviderMixin<
<ha-dialog-footer slot="footer">
<ha-button
slot="primaryAction"
.loading=${this._formStepLoading}
@click=${this._finishCreateEntryStep}
>
${this.hass.localize(
@@ -55,6 +55,8 @@ class StepFlowCreateEntry extends LitElement {
{ name?: string; area?: string }
> = {};
@state() private _loading = false;
private _deviceEntities = memoizeOne(
(
deviceId: string,
@@ -217,6 +219,11 @@ class StepFlowCreateEntry extends LitElement {
hasPendingUpdates: Object.keys(this._deviceUpdate).length > 0,
});
}
if (changedProps.has("_loading")) {
fireEvent(this, "flow-step-footer-state-changed", {
loading: this._loading,
});
}
}
private async _loadDomains() {
@@ -295,8 +302,19 @@ class StepFlowCreateEntry extends LitElement {
}
}
public finish(): Promise<void> {
return this._flowDone();
public async finish(): Promise<void> {
if (this._loading) {
return;
}
// Stay loading until the dialog closes, so a second click cannot save
// the updates again
this._loading = true;
try {
await this._flowDone();
} catch (err) {
this._loading = false;
throw err;
}
}
private async _areaPicked(ev: ValueChangedEvent<string>) {
+3
View File
@@ -85,6 +85,9 @@ const _removeOpenDialogTag = (dialogTag: string) => {
export const getTopOpenDialogTag = (): string | undefined =>
OPEN_DIALOG_TAGS[OPEN_DIALOG_TAGS.length - 1];
export const isDialogOpen = (dialogTag: string): boolean =>
OPEN_DIALOG_STACK.some((dialog) => dialog.dialogTag === dialogTag);
/** The URL of the last dialog's entry, when the dialog went back to close. */
let closedDialogUrl: { pathname: string; search: string } | undefined;
+6 -2
View File
@@ -140,11 +140,15 @@ window.hassConnection.then(({ conn }) => {
const preloadWindow = window as WindowWithPreloads;
preloadWindow.recorderInfoProm = getRecorderInfo(conn);
if (location.pathname === "/" || location.pathname.startsWith("/lovelace/")) {
preloadWindow.llConfProm = fetchConfig(conn, null, false);
// "/" opens the default panel, which is not known yet, so only preload the
// config when the "lovelace" dashboard is opened directly.
if (location.pathname.startsWith("/lovelace/")) {
preloadWindow.llConfProm = fetchConfig(conn, "lovelace", false);
preloadWindow.llConfProm.catch(() => {
// Ignore it, it is handled by Lovelace panel.
});
}
if (location.pathname === "/" || location.pathname.startsWith("/lovelace/")) {
preloadWindow.llResProm = fetchResources(conn);
}
});
+4 -2
View File
@@ -21,9 +21,11 @@
}
.content {
max-width: min(560px, calc(100vw - var(--safe-area-inset-right, 0px) - var(--safe-area-inset-left, 0px)));
max-width: 560px;
margin: 0 auto;
padding: 0 16px;
padding: 0 calc(var(--ha-space-4, 16px) + var(--safe-area-inset-right, 0px))
var(--safe-area-inset-bottom, 0px)
calc(var(--ha-space-4, 16px) + var(--safe-area-inset-left, 0px));
box-sizing: content-box;
}
+2
View File
@@ -104,6 +104,7 @@ export class HassRouterPage extends ReactiveElement {
const queryParams = window.location.search;
navigate(`${route.prefix}/${defaultPage}${queryParams}`, {
replace: true,
keepDialogs: true,
});
}
@@ -134,6 +135,7 @@ export class HassRouterPage extends ReactiveElement {
if (route) {
navigate(`${route.prefix}/${result}${location.search}`, {
replace: true,
keepDialogs: true,
});
}
}
+4
View File
@@ -15,6 +15,7 @@ import type { WindowWithPreloads } from "../data/preloads";
import type { RecorderInfo } from "../data/recorder";
import { getRecorderInfo } from "../data/recorder";
import { showHttpPendingConfigDialog } from "../dialogs/http-pending-config/show-dialog-http-pending-config";
import { isDialogOpen } from "../dialogs/make-dialog-manager";
import { showMoreInfoDialog } from "../dialogs/more-info/show-ha-more-info-dialog";
import "../resources/custom-card-support";
import { HassElement } from "../state/hass-element";
@@ -327,6 +328,9 @@ export class HomeAssistantAppEl extends QuickBarMixin(HassElement) {
// Wait for the next render to ensure the view is fully loaded
// because the more info dialog is closed when the url changes
afterNextRender(() => {
if (isDialogOpen("ha-more-info-dialog")) {
return;
}
showMoreInfoDialog(this, {
entityId,
view,
+1 -1
View File
@@ -189,7 +189,7 @@ class PartialPanelResolver extends HassRouterPage {
const { pathname, search, hash } = mainWindow.location;
navigate(
`${pathname.replace(/^\/hacs/, "/marketplace")}${search}${hash}`,
{ replace: true }
{ replace: true, keepDialogs: true }
);
return undefined;
}
+3 -3
View File
@@ -524,9 +524,9 @@ class HaOnboarding extends litLocalizeLiteMixin(HassElement) {
--ha-progress-bar-border-radius: 0;
--ha-progress-bar-track-height: 4px;
position: fixed;
top: 0;
left: 0;
width: 100%;
top: var(--safe-area-inset-top, 0px);
left: var(--safe-area-inset-left, 0px);
right: var(--safe-area-inset-right, 0px);
z-index: 10;
}
.footer {
+32 -20
View File
@@ -8,6 +8,7 @@ import {
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import memoizeOne from "memoize-one";
import { consumeLocalize } from "../common/decorators/consume-context-entry";
import { fireEvent } from "../common/dom/fire_event";
@@ -18,11 +19,12 @@ import {
import type { LocalizeFunc } from "../common/translations/localize";
import "../components/ha-alert";
import "../components/ha-button";
import "../components/ha-list";
import "../components/ha-list-item";
import "../components/ha-spinner";
import "../components/input/ha-input";
import type { HaInput } from "../components/input/ha-input";
import "../components/item/ha-list-item-base";
import "../components/item/ha-list-item-button";
import "../components/list/ha-list-base";
import "../components/map/ha-locations-editor";
import type {
HaLocationsEditor,
@@ -141,7 +143,11 @@ class OnboardingLocation extends LitElement {
${
this._places !== undefined
? html`
<ha-list activatable>
<ha-list-base
aria-label=${this._localize(
"ui.panel.page-onboarding.core-config.search_results"
)}
>
${
this._places?.length
? this._places.map((place) => {
@@ -164,26 +170,28 @@ class OnboardingLocation extends LitElement {
]
.filter(Boolean)
.join(", ");
return html`<ha-list-item
return html`<ha-list-item-button
@click=${this._itemClicked}
.placeId=${place.place_id}
.selected=${this._highlightedMarker === place.place_id}
.activated=${this._highlightedMarker === place.place_id}
.twoline=${primary && secondary}
aria-current=${this._highlightedMarker === place.place_id ? "true" : nothing}
class=${classMap({
selected:
this._highlightedMarker === place.place_id,
})}
>
${primary || secondary}
<span slot="secondary"
<span slot="headline">${primary || secondary}</span>
<span slot="supporting-text"
>${primary ? secondary : ""}</span
>
</ha-list-item>`;
</ha-list-item-button>`;
})
: html`<ha-list-item noninteractive
>${
this._places === null ? "" : "No results"
}</ha-list-item
>`
: html`<ha-list-item-base>
<span slot="headline"
>${this._places === null ? "" : "No results"}</span
>
</ha-list-item-base>`
}
</ha-list>
</ha-list-base>
`
: nothing
}
@@ -514,17 +522,21 @@ class OnboardingLocation extends LitElement {
border-radius: var(--mdc-shape-large, 16px);
overflow: hidden;
}
ha-list {
ha-list-base {
width: 100%;
border: 1px solid var(--divider-color);
box-sizing: border-box;
border-top-width: 0;
border-bottom-left-radius: var(--mdc-shape-small, 4px);
border-bottom-right-radius: var(--mdc-shape-small, 4px);
--mdc-list-vertical-padding: 0;
overflow: hidden;
--ha-row-item-min-height: 72px;
}
ha-list-item {
height: 72px;
ha-list-item-button.selected::part(base) {
background-color: var(--ha-color-fill-primary-quiet-resting);
}
ha-list-item-button.selected::part(headline) {
color: var(--primary-color);
}
.attribution {
/* input helper style */
+9 -3
View File
@@ -237,7 +237,11 @@ class HaPanelApp extends LitElement {
} catch (err: any) {
await this._showErrorAndNavigateHome(
addonSlug,
extractApiErrorMessage(err)
err?.code === "unauthorized"
? this.hass.localize("ui.panel.app.error_app_unauthorized")
: this.hass.localize("ui.panel.app.error_loading_app", {
error: extractApiErrorMessage(err),
})
);
return;
}
@@ -317,13 +321,15 @@ class HaPanelApp extends LitElement {
try {
session = await createSessionPromise;
} catch (_err: any) {
} catch (err: any) {
if (this._sessionKeepAlive) {
clearInterval(this._sessionKeepAlive);
}
await this._showErrorAndNavigateHome(
addon.name,
this.hass.localize("ui.panel.app.error_creating_session")
this.hass.localize("ui.panel.app.error_creating_session", {
error: extractApiErrorMessage(err),
})
);
return;
}
@@ -311,6 +311,9 @@ export class CloudRemotePref extends LitElement {
ha-list-base {
--ha-row-item-padding-inline: 0;
}
ha-list-item-base::part(supporting-text) {
white-space: normal;
}
ha-tip {
max-width: 600px;
margin: 0 auto;
+4 -1
View File
@@ -97,7 +97,10 @@ class HaConfigEnergy extends LitElement {
if (changedProperties.has("route")) {
const tab = this.route.path.substring(1);
if (!tab || !TABS.some((t) => t.path.endsWith(`/${tab}`))) {
navigate(`${this.route.prefix}/electricity`, { replace: true });
navigate(`${this.route.prefix}/electricity${location.search}`, {
replace: true,
keepDialogs: true,
});
}
}
}
@@ -67,6 +67,7 @@ import "../../../components/ha-dropdown";
import type { HaDropdownSelectEvent } from "../../../components/ha-dropdown";
import "../../../components/ha-dropdown-item";
import "../../../components/ha-entity-id-icon";
import "../../../components/ha-filter-device-classes";
import "../../../components/ha-filter-devices";
import "../../../components/ha-filter-domains";
import "../../../components/ha-filter-floor-areas";
@@ -87,6 +88,7 @@ import type {
DataTableFiltersItems,
DataTableFiltersValues,
} from "../../../data/data_table_filters";
import { computeDeviceClassName } from "../../../data/entity/device_class";
import { UNAVAILABLE } from "../../../data/entity/entity";
import type {
EntityRegistryEntry,
@@ -160,6 +162,7 @@ export interface EntityRow extends StateEntity {
status: string | undefined;
area?: string;
device?: string;
device_class: string | null;
device_full?: string;
localized_platform: string;
domain: string;
@@ -298,6 +301,22 @@ export class HaConfigEntities extends LitElement {
);
}
private _getDeviceClass(entry: StateEntity): string | null {
return this.hass.states[entry.entity_id]?.attributes.device_class ?? null;
}
private _getDeviceClassName(
localize: LocalizeFunc,
entityId: string,
deviceClass: string
): string {
return computeDeviceClassName(
localize,
computeDomain(entityId),
deviceClass
);
}
private _states = memoize((localize: LocalizeFunc) => [
{
value: "available",
@@ -392,6 +411,13 @@ export class HaConfigEntities extends LitElement {
filterable: true,
},
domain: getDomainTableColumn(localize),
device_class: {
title: localize("ui.panel.config.entities.picker.headers.device_class"),
sortable: true,
filterable: true,
defaultHidden: true,
template: (entry) => entry.device_class || "—",
},
disabled_by: {
title: localize("ui.panel.config.entities.picker.headers.disabled_by"),
hidden: true,
@@ -629,6 +655,15 @@ export class HaConfigEntities extends LitElement {
entryIds.includes(entity.config_entry_id))
);
filter!.forEach((domain) => filteredDomains.add(domain));
} else if (
key === "ha-filter-device-classes" &&
Array.isArray(filter) &&
filter.length
) {
filteredEntities = filteredEntities.filter((entry) => {
const deviceClass = this._getDeviceClass(entry);
return deviceClass != null && filter.includes(deviceClass);
});
} else if (
key === "ha-filter-domains" &&
Array.isArray(filter) &&
@@ -713,9 +748,13 @@ export class HaConfigEntities extends LitElement {
exposedEntities,
entry.entity_id
);
const deviceClass = this._getDeviceClass(entry);
result.push({
...entry,
device_class: deviceClass
? this._getDeviceClassName(localize, entry.entity_id, deviceClass)
: null,
name:
entityName ||
deviceName ||
@@ -1064,6 +1103,17 @@ export class HaConfigEntities extends LitElement {
.narrow=${this.narrow}
@expanded-changed=${this._filterExpanded}
></ha-filter-devices>
<ha-filter-device-classes
.label=${this.hass.localize(
"ui.panel.config.entities.picker.headers.device_class"
)}
.value=${this._filters["ha-filter-device-classes"]}
@data-table-filter-changed=${this._filterChanged}
slot="filter-pane"
.expanded=${this._expandedFilter === "ha-filter-device-classes"}
.narrow=${this.narrow}
@expanded-changed=${this._filterExpanded}
></ha-filter-device-classes>
<ha-filter-domains
.value=${this._filters["ha-filter-domains"]}
@data-table-filter-changed=${this._filterChanged}
@@ -1163,6 +1213,7 @@ export class HaConfigEntities extends LitElement {
}
protected firstUpdated() {
this.hass.loadBackendTranslation("entity_component");
this._fetchExposedEntities();
fetchEntitySourcesWithCache(this.hass.callWS, this.hass.states).then(
(sources) => {
@@ -124,6 +124,7 @@ class AddIntegrationDialog extends LitElement {
private _loadId = 0;
public async showDialog(params?: AddIntegrationDialogParams): Promise<void> {
this._navigateToResult = params?.navigateToResult ?? false;
const loadPromise = this._load(params?.integrationFilter);
if (params?.domain) {
@@ -159,7 +160,6 @@ class AddIntegrationDialog extends LitElement {
this._openedDirectly = !!(params?.brand || params?.domain);
this._initialFilter = params?.initialFilter;
this._integrationFilter = params?.integrationFilter;
this._navigateToResult = params?.navigateToResult ?? false;
this._narrow = matchMedia(
"all and (max-width: 450px), all and (max-height: 500px)"
).matches;
@@ -3,6 +3,7 @@ import {
mdiBugPlay,
mdiBugStop,
mdiDotsVertical,
mdiDownload,
mdiFileCodeOutline,
mdiHelpCircleOutline,
mdiOpenInNew,
@@ -172,6 +173,10 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
@state() private _logInfo?: IntegrationLogInfo;
@state() private _debugLogReady = false;
private _debugLogRequest = 0;
@state() private _searchParms = new URLSearchParams(
__DEMO__ ? window.location.search : window.location.hash.substring(1)
);
@@ -228,6 +233,7 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
public disconnectedCallback(): void {
super.disconnectedCallback();
window.removeEventListener("hashchange", this._handleHashChange);
this._resetDebugLogReady();
}
public hassSubscribe(): UnsubscribeFunc[] {
@@ -238,6 +244,12 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
subscribeLogInfo(this.hass.connection, (log_infos) => {
for (const log_info of log_infos) {
if (log_info.domain === this.domain) {
if (
log_info.level === LogSeverity.DEBUG &&
this._logInfo?.level !== LogSeverity.DEBUG
) {
this._resetDebugLogReady();
}
this._logInfo = log_info;
}
}
@@ -251,6 +263,7 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
this.hass.loadBackendTranslation("config", [this.domain]);
this.hass.loadBackendTranslation("config_subentries", [this.domain]);
this._extraConfigEntries = undefined;
this._resetDebugLogReady();
this._filter = "";
this._subEntries = {};
this._fetchManifest();
@@ -840,7 +853,34 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
</div>
</ha-alert>
</div>`
: nothing
: this._debugLogReady
? html`<div class="section">
<ha-alert
alert-type="info"
dismissable
.title=${this.hass.localize(
"ui.panel.config.integrations.config_entry.debug_log_ready"
)}
@alert-dismissed-clicked=${this._resetDebugLogReady}
>
${this.hass.localize(
"ui.panel.config.integrations.config_entry.debug_log_ready_description"
)}
<br /><br />
<ha-button
size="s"
appearance="filled"
@click=${this._downloadDebugLog}
>
<ha-svg-icon
slot="start"
.path=${mdiDownload}
></ha-svg-icon>
${this.hass.localize("ui.common.download")}
</ha-button>
</ha-alert>
</div>`
: nothing
}
${
filteredDiscoveryData.length
@@ -1381,16 +1421,19 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
}
private async _handleDisableDebugLogging(ev: Event) {
// Stop propagation since otherwise we end up here twice while we await the log level change
// and trigger two identical debug log downloads.
// Stop propagation since otherwise we end up here twice while we await the log level change.
ev.stopPropagation();
const integration = this.domain;
const request = this._debugLogRequest;
await setIntegrationLogLevel(
this.hass,
integration,
LogSeverity[LogSeverity.NOTSET],
"once"
);
if (request !== this._debugLogRequest) {
return;
}
const logFileDownloadUnavailableReason =
getCoreLogFileDownloadUnavailableReason(this.hass);
if (logFileDownloadUnavailableReason) {
@@ -1404,7 +1447,17 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
});
return;
}
this._debugLogReady = true;
}
// Also invalidates pending disable requests so they can't restore the alert.
private _resetDebugLogReady() {
this._debugLogReady = false;
this._debugLogRequest++;
}
private async _downloadDebugLog() {
const integration = this.domain;
const timeString = new Date().toISOString().replace(/:/g, "-");
const logFileName = `home-assistant_${integration}_${timeString}.log`;
const signedUrl = await getSignedPath(
+10 -4
View File
@@ -15,7 +15,6 @@ import { formatDateTimeWithSeconds } from "../../../../common/datetime/format_da
import { storage } from "../../../../common/decorators/storage";
import type { HASSDomTargetEvent } from "../../../../common/dom/fire_event";
import { escapeRegExp } from "../../../../common/string/escape_regexp";
import { computeEntitySearchLabels } from "../../../../common/entity/compute_entity_name_display";
import { computeAreaName } from "../../../../common/entity/compute_area_name";
import { computeDeviceName } from "../../../../common/entity/compute_device_name";
import { copyToClipboard } from "../../../../common/util/copy-clipboard";
@@ -132,6 +131,7 @@ class HaPanelDevState extends LitElement {
attributeFilter: string,
deviceFilter: string,
areaFilter: string,
deviceColumnShown: boolean,
states: HassEntities,
entities: HomeAssistantRegistries["entities"],
devices: HomeAssistantRegistries["devices"],
@@ -143,6 +143,7 @@ class HaPanelDevState extends LitElement {
attributeFilter,
deviceFilter,
areaFilter,
deviceColumnShown,
states,
entities,
devices,
@@ -157,6 +158,7 @@ class HaPanelDevState extends LitElement {
this._attributeFilter,
this._deviceFilter,
this._areaFilter,
!this.narrow && this._showDevice,
this._states,
this._registries.entities,
this._registries.devices,
@@ -493,6 +495,7 @@ class HaPanelDevState extends LitElement {
attributeFilter: string,
deviceFilter: string,
areaFilter: string,
deviceColumnShown: boolean,
states: HassEntities,
entities: HomeAssistantRegistries["entities"],
devices: HomeAssistantRegistries["devices"],
@@ -540,13 +543,16 @@ class HaPanelDevState extends LitElement {
return Object.values(states)
.filter((value) => {
// Match the entity column's name; without the device column, match
// the full name.
if (
entityFilterRegExp &&
!entityFilterRegExp.test(value.entity_id) &&
!entityFilterRegExp.test(this.hass.formatEntityName(value)) &&
!entityFilterRegExp.test(
computeEntitySearchLabels(value, this._registries).friendlyName ??
""
this.hass.formatEntityName(
value,
deviceColumnShown ? { type: "entity" } : undefined
)
)
) {
return false;
+4 -1
View File
@@ -93,7 +93,10 @@ class PanelEnergy extends LitElement {
const validPaths = views.map((view) => view.path);
const viewPath: string | undefined = this.route!.path.split("/")[1];
if (!viewPath || !validPaths.includes(viewPath)) {
navigate(`${this.route!.prefix}/${validPaths[0]}`, { replace: true });
navigate(`${this.route!.prefix}/${validPaths[0]}${location.search}`, {
replace: true,
keepDialogs: true,
});
} else {
// Force hui-root to re-process the route by creating a new route object
this.route = { ...this.route! };
@@ -27,6 +27,7 @@ import "../../../components/ha-svg-icon";
import {
AREA_CONTROLS_BUTTONS,
getAreaControlEntities,
getAreaEntityIds,
MAX_DEFAULT_AREA_CONTROLS,
} from "../../../data/area/area_controls";
import type { AreaRegistryEntry } from "../../../data/area/area_registry";
@@ -236,6 +237,8 @@ class HuiAreaControlsCardFeature
.map((n) => n.value)
);
private _areaEntityIds = memoizeOne(getAreaEntityIds);
// States are part of the key: the filter reads device classes from them,
// so an entity can only match once its state exists.
private _controlEntities = memoizeOne(
@@ -245,17 +248,23 @@ class HuiAreaControlsCardFeature
excludeEntities: string[] | undefined,
registries: HomeAssistantRegistries,
states: HassEntities
) =>
getAreaControlEntities(
) => {
const areaEntities = this._areaEntityIds(
registries.entities,
registries.devices,
areaId,
excludeEntities
);
return getAreaControlEntities(
areaEntities,
states,
registries.entities,
registries.devices,
registries.areas,
registries.floors,
controls,
areaId,
excludeEntities
)
controls
);
}
);
protected render() {
@@ -51,11 +51,13 @@ import {
filterLowBatteryEntities,
filterProblemEntities,
filterUnavailableBatteryEntities,
findMaintenanceEntities,
} from "../../maintenance/strategies/maintenance-view-strategy";
import {
isSecurityAlertActive,
resolveSecurityAlertSeverity,
} from "../../security/strategies/security-alerts";
import { findSecurityEntities } from "../../security/strategies/security-view-strategy";
import type { LovelaceCard, LovelaceGridOptions } from "../types";
import { tileCardStyle } from "./tile/tile-card-style";
import type { HomeSummaryCard } from "./types";
@@ -231,11 +233,13 @@ export class HuiHomeSummaryCard extends LitElement implements LovelaceCard {
}
case "security": {
// Alarm and lock status
const securityFilters = HOME_SUMMARIES_FILTERS.security.map((filter) =>
generateEntityFilter(states, entities, devices, areas, floors, filter)
);
const securityEntities = findEntities(allEntities, securityFilters);
const securityEntities = findSecurityEntities({
states,
entities,
devices,
areas,
floors,
});
const locks = securityEntities.filter((entityId) => {
const domain = computeDomain(entityId);
@@ -318,22 +322,13 @@ export class HuiHomeSummaryCard extends LitElement implements LovelaceCard {
: localize("ui.card.home-summary.no_media_playing");
}
case "maintenance": {
const maintenanceFilters = HOME_SUMMARIES_FILTERS.maintenance.map(
(filter) =>
generateEntityFilter(
states,
entities,
devices,
areas,
floors,
filter
)
);
const maintenanceEntities = findEntities(
allEntities,
maintenanceFilters
);
const maintenanceEntities = findMaintenanceEntities({
states,
entities,
devices,
areas,
floors,
});
const lowBatteryEntities = filterLowBatteryEntities(
states,
+23 -3
View File
@@ -1,4 +1,5 @@
import { sanitizeUrl } from "@braintree/sanitize-url";
import type { HASSDomEvent } from "../../../common/dom/fire_event";
import { fireEvent } from "../../../common/dom/fire_event";
import { navigate } from "../../../common/navigate";
import { forwardHaptic } from "../../../data/haptics";
@@ -13,6 +14,11 @@ import { toggleEntity } from "./entity/toggle-entity";
declare global {
interface HASSDomEvents {
"ll-custom": ActionConfig;
"hass-navigate": { path: string; replace?: boolean };
}
interface HTMLElementEventMap {
"hass-navigate": HASSDomEvent<HASSDomEvents["hass-navigate"]>;
}
}
@@ -120,9 +126,23 @@ export const handleAction = async (
}
case "navigate":
if (actionConfig.navigation_path) {
navigate(actionConfig.navigation_path, {
replace: actionConfig.navigation_replace,
});
// A page that embeds the dashboard, such as the demo embed, can
// navigate within it instead, and cancel the event
const navigateEvent = fireEvent(
node,
"hass-navigate",
{
path: actionConfig.navigation_path,
replace: actionConfig.navigation_replace,
},
{ cancelable: true }
);
if (!navigateEvent.defaultPrevented) {
navigate(actionConfig.navigation_path, {
replace: actionConfig.navigation_replace,
});
}
} else {
showToast(node, {
message: hass.localize(
@@ -121,7 +121,13 @@ export class HuiCardElementEditor extends HuiTypedElementEditor<LovelaceCardConf
}
static override styles = [
HuiTypedElementEditor.styles,
css`
.gui-editor,
.yaml-editor {
padding: 0;
}
ha-tab-group {
margin-bottom: 16px;
}
@@ -16,6 +16,7 @@ import "../../../../components/ha-svg-icon";
import type { HomeAssistant } from "../../../../types";
import {
getAreaControlEntities,
getAreaEntityIds,
MAX_DEFAULT_AREA_CONTROLS,
} from "../../../../data/area/area_controls";
import {
@@ -64,15 +65,20 @@ export class HuiAreaControlsCardFeatureEditor
areas: HomeAssistant["areas"],
floors: HomeAssistant["floors"]
) => {
const areaEntities = getAreaEntityIds(
entities,
devices,
areaId,
excludeEntities
);
const controlEntities = getAreaControlEntities(
areaEntities,
this.hass!.states,
entities,
devices,
areas,
floors,
AREA_CONTROL_DOMAINS,
areaId,
excludeEntities
AREA_CONTROL_DOMAINS
);
return (
Object.keys(controlEntities) as (keyof typeof controlEntities)[]
@@ -6,8 +6,10 @@ import type { HASSDomEvent } from "../../../common/dom/fire_event";
import { fireEvent } from "../../../common/dom/fire_event";
import { handleStructError } from "../../../common/structs/handle-errors";
import { deepEqual } from "../../../common/util/deep-equal";
import "../../../components/animation/ha-fade-in";
import "../../../components/ha-alert";
import "../../../components/ha-spinner";
import "../../../components/skeleton/ha-skeleton";
import "../../../components/skeleton/ha-skeleton-text";
import "../../../components/ha-yaml-editor";
import type { HaYamlEditor } from "../../../components/ha-yaml-editor";
import type { LovelaceConfig } from "../../../data/lovelace/config/types";
@@ -230,6 +232,31 @@ export abstract class HuiElementEditor<
};
}
private _renderLoading() {
return html`
<ha-fade-in .duration=${300} easing="ease-in">
<div
class="loading"
role="progressbar"
aria-label=${this.hass.localize("ui.common.loading")}
>
<div class="loading-field">
<ha-skeleton-text></ha-skeleton-text>
<ha-skeleton class="input"></ha-skeleton>
</div>
<div class="loading-field">
<ha-skeleton-text></ha-skeleton-text>
<div class="boxes">
<ha-skeleton class="box"></ha-skeleton>
<ha-skeleton class="box"></ha-skeleton>
<ha-skeleton class="box"></ha-skeleton>
</div>
</div>
</div>
</ha-fade-in>
`;
}
protected render(): TemplateResult {
return html`
<div class="wrapper">
@@ -242,9 +269,7 @@ export abstract class HuiElementEditor<
>
${
this._loading
? html`
<ha-spinner class="center margin-bot"></ha-spinner>
`
? this._renderLoading()
: cache(
this._subElementEditorConfig
? this._renderSubElement()
@@ -368,7 +393,11 @@ export abstract class HuiElementEditor<
}
protected async loadConfigElement(): Promise<void> {
if (this._configElement) return;
if (this._configElement) {
return;
}
this._loading = true;
let configElement = await this.getConfigElement();
@@ -447,7 +476,9 @@ export abstract class HuiElementEditor<
}
this.GUImode = false;
} finally {
this._loading = false;
if (loadNum === this._loadCount) {
this._loading = false;
}
}
}
@@ -469,9 +500,33 @@ export abstract class HuiElementEditor<
ha-code-editor {
--code-mirror-max-height: calc(100vh - 245px);
}
ha-spinner {
display: block;
margin: auto;
.loading {
display: flex;
flex-direction: column;
gap: var(--ha-space-6);
}
.loading-field {
display: flex;
flex-direction: column;
gap: var(--ha-space-2);
}
.loading ha-skeleton-text {
--ha-skeleton-text-width: 120px;
}
.loading .input {
height: 56px;
}
.loading .boxes {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: var(--ha-space-3);
}
.loading .box {
height: 96px;
--ha-skeleton-border-radius: var(
--ha-card-border-radius,
var(--ha-border-radius-lg)
);
}
` as CSSResultGroup;
}
+31 -21
View File
@@ -26,6 +26,7 @@ import {
saveConfig,
} from "../../data/lovelace/config/types";
import { fetchResources } from "../../data/lovelace/resource";
import { LOVELACE_PANEL } from "../../data/panel";
import type { WindowWithPreloads } from "../../data/preloads";
import "../../layouts/hass-error-screen";
import "../../layouts/hass-loading-screen";
@@ -83,7 +84,11 @@ export class LovelacePanel extends LitElement {
public connectedCallback(): void {
super.connectedCallback();
if (
if (this._fetchConfigOnConnect) {
// Config was changed when we were not at the lovelace panel
this._fetchConfigOnConnect = false;
this._fetchConfig(false);
} else if (
this.lovelace &&
this.hass &&
this.lovelace.locale !== this.hass.locale
@@ -94,17 +99,15 @@ export class LovelacePanel extends LitElement {
this.lovelace.rawConfig,
this.lovelace.mode
);
} else if (this._fetchConfigOnConnect) {
// Config was changed when we were not at the lovelace panel
this._fetchConfig(false);
}
window.addEventListener("connection-status", this._handleConnectionStatus);
}
public disconnectedCallback(): void {
super.disconnectedCallback();
// On the main dashboard we want to stay subscribed as that one is cached.
if (this.urlPath !== null && this._unsubUpdates) {
// The "lovelace" panel is cached by the panel resolver, stay subscribed so
// we refetch its config when it is shown again.
if (this.urlPath !== LOVELACE_PANEL && this._unsubUpdates) {
this._unsubUpdates.then((unsub) => unsub());
this._unsubUpdates = undefined;
}
@@ -288,6 +291,12 @@ export class LovelacePanel extends LitElement {
this._fetchConfig(true);
}
private _redirectToHome() {
// Keeps the query and any open dialog: a more-info deep link may be
// restored from the URL around this redirect.
navigate(`/home${location.search}`, { replace: true, keepDialogs: true });
}
private async _fetchConfig(forceDiskRefresh: boolean) {
this._loading = true;
@@ -297,7 +306,7 @@ export class LovelacePanel extends LitElement {
// If no mode, redirect to /home as there is no "lovelace" dashboard
if (!confMode) {
navigate("/home", { replace: true });
this._redirectToHome();
return;
}
@@ -315,21 +324,22 @@ export class LovelacePanel extends LitElement {
(resources) => loadLovelaceResources(resources, this.hass!)
);
}
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 config is back.
// We ignore because we already have the latest config.
if (confMode === "yaml") {
this._ignoreNextUpdateEvent = true;
}
confProm = fetchConfig(
this.hass!.connection,
this.urlPath,
forceDiskRefresh
);
// 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 config is back.
// We ignore because we already have the latest config.
if (confMode === "yaml") {
this._ignoreNextUpdateEvent = true;
}
const fetchDashboardConfig = () =>
fetchConfig(this.hass!.connection, this.urlPath, forceDiskRefresh);
// The preload is the config of the "lovelace" dashboard. Fetch it again if
// it failed, for example because the connection dropped before it came back.
confProm =
confProm && this.urlPath === "lovelace"
? confProm.catch(fetchDashboardConfig)
: fetchDashboardConfig();
try {
rawConf = await confProm;
@@ -357,7 +367,7 @@ export class LovelacePanel extends LitElement {
const dashboards = await fetchDashboards(this.hass!);
const dashboard = dashboards.find((d) => d.url_path === "lovelace");
if (!dashboard) {
navigate("/home", { replace: true });
this._redirectToHome();
return;
}
}
+118 -30
View File
@@ -32,6 +32,7 @@ import { UndoRedoController } from "../../common/controllers/undo-redo-controlle
import { fireEvent } from "../../common/dom/fire_event";
import type { HASSDomEvent } from "../../common/dom/fire_event";
import { ctrlOrCmdLabel } from "../../common/keyboard/ctrl-or-cmd";
import type { NavigateOptions } from "../../common/navigate";
import { goBack, navigate, replaceCurrentUrl } from "../../common/navigate";
import type { LocalizeKeys } from "../../common/translations/localize";
import { constructUrlCurrentPath } from "../../common/url/construct-url";
@@ -157,6 +158,13 @@ class HUIRoot extends KeyboardShortcutMixin(LitElement) {
@property({ attribute: false }) public backPath?: string;
/**
* Inside another page, such as the demo embed. Views change through
* `view-navigate` instead of the page's URL, the page's scroll position and
* history stay untouched, and only the tabs and titles show in the toolbar.
*/
@property({ type: Boolean, reflect: true }) public embedded = false;
@state() private _curView?: number | "hass-unused-entities";
@state() private _resourceMode: "yaml" | "storage" = "storage";
@@ -583,6 +591,7 @@ class HUIRoot extends KeyboardShortcutMixin(LitElement) {
class=${classMap({
"edit-mode": this._editMode,
narrow: this.narrow,
embedded: this.embedded,
})}
>
<div class="header">
@@ -612,19 +621,23 @@ class HUIRoot extends KeyboardShortcutMixin(LitElement) {
`
: html`
${
isSubview || this.backButton
? html`
<ha-icon-button-arrow-prev
slot="navigationIcon"
.href=${this._backPath}
@click=${this._handleBackClick}
></ha-icon-button-arrow-prev>
`
: html`
<ha-menu-button
slot="navigationIcon"
></ha-menu-button>
`
this.embedded && !isSubview
? nothing
: isSubview || this.backButton
? html`
<ha-icon-button-arrow-prev
slot="navigationIcon"
.href=${
this.embedded ? undefined : this._backPath
}
@click=${this._handleBackClick}
></ha-icon-button-arrow-prev>
`
: html`
<ha-menu-button
slot="navigationIcon"
></ha-menu-button>
`
}
${
isSubview
@@ -641,9 +654,13 @@ class HUIRoot extends KeyboardShortcutMixin(LitElement) {
</div>
`
}
<div class="action-items">
${this._renderActionItems()}
</div>
${
this.embedded
? nothing
: html`<div class="action-items">
${this._renderActionItems()}
</div>`
}
`
}
</div>
@@ -708,17 +725,30 @@ class HUIRoot extends KeyboardShortcutMixin(LitElement) {
protected firstUpdated(changedProps: PropertyValues<this>) {
super.firstUpdated(changedProps);
// The page's URL and scroll position belong to the page around it, and
// the action items that need the resource mode aren't shown
if (this.embedded) {
return;
}
fetchLovelaceInfo(this.hass).then((info) => {
this._resourceMode = info.resource_mode;
});
window.addEventListener("scroll", this._handleWindowScroll, {
passive: true,
});
this._handleUrlChanged();
fetchLovelaceInfo(this.hass).then((info) => {
this._resourceMode = info.resource_mode;
});
}
public connectedCallback(): void {
super.connectedCallback();
if (this.embedded) {
return;
}
window.addEventListener("scroll", this._handleWindowScroll, {
passive: true,
});
@@ -730,6 +760,11 @@ class HUIRoot extends KeyboardShortcutMixin(LitElement) {
public disconnectedCallback(): void {
super.disconnectedCallback();
if (this.embedded) {
return;
}
window.removeEventListener("scroll", this._handleWindowScroll);
window.removeEventListener("popstate", this._handlePopState);
window.removeEventListener("location-changed", this._locationChanged);
@@ -797,7 +832,12 @@ class HUIRoot extends KeyboardShortcutMixin(LitElement) {
if (!viewPath && views.length) {
newSelectView = views.findIndex(this._isVisible);
this._navigateToView(views[newSelectView].path || newSelectView, true);
// Filling in the default view keeps the entry as is: a dialog restored
// from the URL, like a more-info deep link, can already be open.
this._navigateToView(views[newSelectView].path || newSelectView, {
replace: true,
keepDialogs: true,
});
} else if (viewPath === "hass-unused-entities") {
newSelectView = "hass-unused-entities";
} else if (viewPath) {
@@ -831,14 +871,15 @@ class HUIRoot extends KeyboardShortcutMixin(LitElement) {
viewPath === "hass-unused-entities"
) {
newSelectView = views.findIndex(this._isVisible);
this._navigateToView(
views[newSelectView].path || newSelectView,
true
);
this._navigateToView(views[newSelectView].path || newSelectView, {
replace: true,
});
}
}
if (huiView) {
// A view the new configuration no longer has keeps its old one until
// another view is selected, instead of failing to find its config
if (huiView && !(huiView.index >= this.config.views.length)) {
huiView.lovelace = this.lovelace!;
}
}
@@ -846,7 +887,7 @@ class HUIRoot extends KeyboardShortcutMixin(LitElement) {
if (newSelectView !== undefined) {
// Will allow for ripples to start rendering
afterNextRender(() => {
if (changedProperties.has("route")) {
if (changedProperties.has("route") && !this.embedded) {
const position =
(this._restoreScroll && this._viewScrollPositions[newSelectView]) ||
0;
@@ -913,6 +954,23 @@ class HUIRoot extends KeyboardShortcutMixin(LitElement) {
}
private _handleBackClick(ev: MouseEvent): void {
if (this.embedded) {
// The view the subview goes back to, without the page's history
const views = this.lovelace?.config.views ?? [];
const curViewConfig =
this._curView === "hass-unused-entities" || this._curView === undefined
? undefined
: views[this._curView];
fireEvent(this, "view-navigate", {
path: curViewConfig?.back_path ?? "",
back: true,
});
return;
}
handleBackClick(ev, this._backPath, () => this._goBack());
}
@@ -1144,12 +1202,18 @@ class HUIRoot extends KeyboardShortcutMixin(LitElement) {
});
}
private _navigateToView(path: string | number, replace?: boolean) {
private _navigateToView(path: string | number, options?: NavigateOptions) {
if (this.embedded) {
fireEvent(this, "view-navigate", { path: String(path) });
return;
}
const url = this._viewUrl(path);
const currentUrl = `${location.pathname}${location.search}`;
if (currentUrl !== url) {
navigate(url, { replace });
navigate(url, options);
}
}
@@ -1162,6 +1226,7 @@ class HUIRoot extends KeyboardShortcutMixin(LitElement) {
private _handleViewTabNewTabClick(ev: MouseEvent): void {
if (
this._editMode ||
this.embedded ||
(ev.button !== 1 && !ev.metaKey && !ev.ctrlKey && !ev.shiftKey)
) {
return;
@@ -1218,7 +1283,7 @@ class HUIRoot extends KeyboardShortcutMixin(LitElement) {
newCurView = indexAfterMove(curView, oldIndex, newIndex);
// Views without a path are addressed by their index in the URL
if (newCurView !== curView && !this.config.views[curView].path) {
this._navigateToView(newCurView, true);
this._navigateToView(newCurView, { replace: true });
}
}
lovelace.saveConfig(moveView(lovelace.config, oldIndex, newIndex));
@@ -1282,7 +1347,7 @@ class HUIRoot extends KeyboardShortcutMixin(LitElement) {
}
// Save scroll position of current view
if (this._curView != null) {
if (this._curView != null && !this.embedded) {
this._viewScrollPositions[this._curView] = window.scrollY;
}
@@ -1623,6 +1688,19 @@ class HUIRoot extends KeyboardShortcutMixin(LitElement) {
var(--view-container-padding-bottom, 0px)
);
}
/* At least as high as the element it is embedded in, rather than the
window, so that panel views fill it */
:host([embedded]),
.embedded {
display: block;
height: 100%;
}
/* The view background sits below the view, above the opaque
background of the element it is embedded in */
:host([embedded]) hui-view-container {
min-height: 100%;
isolation: isolate;
}
.narrow hui-view-container {
--view-container-inset-left: var(--safe-area-inset-left);
padding-left: var(--view-container-inset-left);
@@ -1662,4 +1740,14 @@ declare global {
interface HTMLElementTagNameMap {
"hui-root": HUIRoot;
}
interface HASSDomEvents {
// A view to show, fired instead of navigating when embedded. `back` is
// set for the back button of a subview.
"view-navigate": { path: string; back?: boolean };
}
interface HTMLElementEventMap {
"view-navigate": HASSDomEvent<HASSDomEvents["view-navigate"]>;
}
}
@@ -1,7 +1,10 @@
import { ReactiveElement } from "lit";
import { customElement } from "lit/decorators";
import { floorDefaultIcon } from "../../../../components/ha-floor-icon";
import { getAreaControlEntities } from "../../../../data/area/area_controls";
import {
getAreaControlEntities,
getAreaEntityIds,
} from "../../../../data/area/area_controls";
import type { LovelaceSectionConfig } from "../../../../data/lovelace/config/section";
import type { LovelaceViewConfig } from "../../../../data/lovelace/config/view";
import type { HomeAssistant } from "../../../../types";
@@ -90,15 +93,20 @@ export class AreasOverviewViewStrategy extends ReactiveElement {
const controls: AreaControlDomain[] = AREA_CONTROL_DOMAINS.filter(
(a) => a !== "switch" // Exclude switches control for areas as we don't know what the switches control
);
const areaEntities = getAreaEntityIds(
hass.entities,
hass.devices,
area.area_id,
hiddenEntities
);
const controlEntities = getAreaControlEntities(
areaEntities,
hass.states,
hass.entities,
hass.devices,
hass.areas,
hass.floors,
controls,
area.area_id,
hiddenEntities
controls
);
const filteredControls = controls.filter(
@@ -37,11 +37,13 @@ import type {
UpdatesCardConfig,
} from "../../cards/types";
import { computeFavoriteCardConfig } from "../helpers/favorite-cards";
import { findMaintenanceEntities } from "../../../maintenance/strategies/maintenance-view-strategy";
import {
computeDefaultSecurityAlertVisibility,
filterSecurityAlertEntities,
resolveSecurityAlertSeverity,
} from "../../../security/strategies/security-alerts";
import { findSecurityEntities } from "../../../security/strategies/security-view-strategy";
import {
LARGE_SCREEN_CONDITION,
SMALL_SCREEN_CONDITION,
@@ -354,40 +356,15 @@ export class HomeOverviewViewStrategy extends ReactiveElement {
)
);
const securityFilters = HOME_SUMMARIES_FILTERS.security.map((filter) =>
generateEntityFilter(
hass.states,
hass.entities,
hass.devices,
hass.areas,
hass.floors,
filter
)
);
const maintenanceFilters = HOME_SUMMARIES_FILTERS.maintenance.map(
(filter) =>
generateEntityFilter(
hass.states,
hass.entities,
hass.devices,
hass.areas,
hass.floors,
filter
)
);
const hasLights =
hass.panels.light && findEntities(allEntities, lightsFilters).length > 0;
const hasMediaPlayers =
findEntities(allEntities, mediaPlayerFilter).length > 0;
const hasClimate = hass.panels.climate && hasClimateEntities(hass);
const hasSecurity =
hass.panels.security &&
findEntities(allEntities, securityFilters).length > 0;
hass.panels.security && findSecurityEntities(hass).length > 0;
const hasMaintenance =
hass.panels.maintenance &&
findEntities(allEntities, maintenanceFilters).length > 0;
hass.panels.maintenance && findMaintenanceEntities(hass).length > 0;
const alertEntities = config.alert_entities ?? [];
const alertSeverityEntities = filterSecurityAlertEntities(
+5
View File
@@ -43,6 +43,11 @@ export interface Lovelace {
saveConfig: (newConfig: LovelaceRawConfig) => Promise<void>;
deleteConfig: () => Promise<void>;
showToast: (params: ShowToastParams) => void;
/**
* Shown inside another page, such as the demo embed, which owns the
* window's history and scroll position.
*/
embedded?: boolean;
}
export interface LovelaceBadge extends HTMLElement {
+20 -1
View File
@@ -79,6 +79,10 @@ export class SectionsView extends LitElement implements LovelaceViewElement {
@state() private _sidebarVisible = true;
// Set on connecting, so that the next update reads the sidebar tab from the
// page's history state, once `lovelace` has been set
private _readSidebarTab = false;
private _contentScrollTop = 0;
private _sidebarScrollTop = 0;
@@ -142,7 +146,8 @@ export class SectionsView extends LitElement implements LovelaceViewElement {
"section-visibility-changed",
this._sectionVisibilityChanged
);
this._sidebarTabActive = Boolean(getHistoryState()?.sidebar);
this._readSidebarTab = true;
this.requestUpdate();
}
disconnectedCallback(): void {
@@ -154,6 +159,12 @@ export class SectionsView extends LitElement implements LovelaceViewElement {
}
willUpdate(changedProperties: PropertyValues<this>): void {
// An embedded dashboard leaves the page's history state alone
if (this._readSidebarTab) {
this._readSidebarTab = false;
this._sidebarTabActive =
!this.lovelace?.embedded && Boolean(getHistoryState()?.sidebar);
}
if (changedProperties.has("sections")) {
this._computeSectionsCount();
}
@@ -491,6 +502,14 @@ export class SectionsView extends LitElement implements LovelaceViewElement {
}
private _toggleView() {
// The page around an embedded dashboard owns the window's history and
// scroll position
if (this.lovelace?.embedded) {
this._sidebarTabActive = !this._sidebarTabActive;
return;
}
// Save current scroll position
if (this._sidebarTabActive) {
this._sidebarScrollTop = window.scrollY;
@@ -3,9 +3,10 @@ import { customElement } from "lit/decorators";
import memoizeOne from "memoize-one";
import { getAreasFloorHierarchy } from "../../../common/areas/areas-floor-hierarchy";
import {
findEntities,
findFilteredEntities,
generateEntityFilter,
type EntityFilter,
type EntityFilterSources,
} from "../../../common/entity/entity_filter";
import { floorDefaultIcon } from "../../../components/ha-floor-icon";
import type { LovelaceCardConfig } from "../../../data/lovelace/config/card";
@@ -33,6 +34,10 @@ export const maintenanceEntityFilters: EntityFilter[] = [
},
];
export const findMaintenanceEntities = (
sources: EntityFilterSources
): string[] => findFilteredEntities(sources, maintenanceEntityFilters);
const LOW_BATTERY_THRESHOLD = 20;
const _deviceEntityLookup = memoizeOne((entities: HomeAssistant["entities"]) =>
@@ -198,20 +203,7 @@ export class MaintenanceViewStrategy extends ReactiveElement {
const sections: LovelaceSectionRawConfig[] = [];
const allEntities = Object.keys(hass.states);
const batteryFilters = maintenanceEntityFilters.map((filter) =>
generateEntityFilter(
hass.states,
hass.entities,
hass.devices,
hass.areas,
hass.floors,
filter
)
);
const entities = findEntities(allEntities, batteryFilters);
const entities = findMaintenanceEntities(hass);
const floorCount =
hierarchy.floors.length + (hierarchy.areas.length ? 1 : 0);
@@ -968,7 +968,7 @@ type: module</pre>`,
? {
icon: mdiAlertCircleOutline,
text: localize(
"ui.panel.marketplace.repository.community.open_issues",
"ui.panel.marketplace.repository.community.open_issues_and_prs",
{
number: repository.issues,
count: html`<strong
@@ -980,7 +980,7 @@ type: module</pre>`,
: {
icon: mdiCheckCircleOutline,
text: localize(
"ui.panel.marketplace.repository.community.no_open_issues"
"ui.panel.marketplace.repository.community.no_open_issues_and_prs"
),
}
);
@@ -191,7 +191,10 @@ class PanelMediaBrowser extends LitElement {
}
if (this.route.path === "") {
navigate(`/media-browser/${this._entityId}`, { replace: true });
navigate(`/media-browser/${this._entityId}${location.search}`, {
replace: true,
keepDialogs: true,
});
return;
}
+1
View File
@@ -172,6 +172,7 @@ const entitiesTableParams = {
floor: "string?",
area: "string?",
device: "string?",
device_class: "string?",
domain: "string?",
entity_domain: "string?",
label: "string?",
@@ -6,9 +6,10 @@ import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import { ALERT_DOMAINS } from "../../../common/entity/alert_active";
import { computeDomain } from "../../../common/entity/compute_domain";
import {
findEntities,
findFilteredEntities,
generateEntityFilter,
type EntityFilter,
type EntityFilterSources,
} from "../../../common/entity/entity_filter";
import { computeStateDomain } from "../../../common/entity/compute_state_domain";
import { floorDefaultIcon } from "../../../components/ha-floor-icon";
@@ -78,11 +79,11 @@ export const securityEntityFilters: EntityFilter[] = [
},
];
export const findSecurityEntities = (sources: EntityFilterSources): string[] =>
findFilteredEntities(sources, securityEntityFilters);
export const isSecurityPanelEntity = (
hass: Pick<
HomeAssistant,
"states" | "entities" | "devices" | "areas" | "floors"
>,
hass: EntityFilterSources,
stateObj: HassEntity
): boolean =>
securityEntityFilters.some((filter) =>
@@ -182,20 +183,7 @@ export class SecurityViewStrategy extends ReactiveElement {
const sections: LovelaceSectionRawConfig[] = [];
const allEntities = Object.keys(hass.states);
const securityFilters = securityEntityFilters.map((filter) =>
generateEntityFilter(
hass.states,
hass.entities,
hass.devices,
hass.areas,
hass.floors,
filter
)
);
const entities = findEntities(allEntities, securityFilters);
const entities = findSecurityEntities(hass);
const favoriteEntities = (config.favorite_entities ?? []).filter(
(entityId) =>
+12 -5
View File
@@ -1050,7 +1050,8 @@
"device_class": "Device class",
"add": "Add device class",
"no_device_classes": "No device classes available",
"no_match": "No device classes found for {term}"
"no_match": "No device classes found for {term}",
"unit_not_supported": "Does not support {unit}"
},
"category-picker": {
"clear": "Clear",
@@ -2860,13 +2861,15 @@
},
"panel": {
"app": {
"error_loading_app": "Unable to load the app information from the Supervisor: {error}",
"error_app_unauthorized": "You do not have permission to open this app.",
"error_app_not_installed": "The app is not installed. Please install it first.",
"error_app_no_ingress": "This app does not support ingress.",
"error_app_not_running": "The app is not running. Do you want to start it now?",
"start_app": "Start app",
"app_starting": "The app is starting, this can take some time...",
"error_starting_app": "Error starting the app",
"error_creating_session": "Unable to create an ingress session",
"error_creating_session": "Unable to create an ingress session: {error}",
"error_app_not_ready": "The app seems to not be ready, it might still be starting. Do you want to try again?",
"retry": "Retry"
},
@@ -7634,6 +7637,7 @@
"state_icon": "State icon",
"entity": "Entity",
"device": "Device",
"device_class": "Device class",
"integration": "Integration",
"disabled_by": "Disabled by",
"status": "Status",
@@ -7926,8 +7930,10 @@
"no_config_flow": "This integration was not set up from the UI",
"disabled_polling": "Automatic polling for updated data disabled",
"debug_logging_enabled": "Debug logging enabled",
"debug_logging_enabled_description": "Extra detail from this integration is being written to the logs. Reproduce the problem, then disable it. Home Assistant will download the log for your issue report if log file downloads are available. Leaving it on can slow Home Assistant down and fill up the logs.",
"debug_logging_enabled_description": "Extra detail from this integration is being written to the logs. Reproduce the problem, then disable it to download the log for your issue report. Leaving it on can slow Home Assistant down and fill up the logs.",
"show_logs": "Show logs",
"debug_log_ready": "Debug log ready",
"debug_log_ready_description": "Debug logging is disabled. Download the log to attach it to your issue report.",
"live_log": {
"title": "Live log",
"waiting": "Waiting for log messages from {integration}…",
@@ -12804,6 +12810,7 @@
"location_name": "Name of your Home Assistant installation",
"location_name_default": "Home",
"address_label": "Search address",
"search_results": "Search results",
"button_detect": "Detect",
"finish": "Next"
},
@@ -13193,8 +13200,8 @@
"last_change": "Last change on GitHub {time}",
"downloads": "{number, plural,\n one {Downloaded {count} time}\n other {Downloaded {count} times}\n}",
"stars": "{number, plural,\n one {{count} star on GitHub}\n other {{count} stars on GitHub}\n}",
"open_issues": "{number, plural,\n one {{count} open issue on GitHub}\n other {{count} open issues on GitHub}\n}",
"no_open_issues": "No open issues on GitHub"
"open_issues_and_prs": "{number, plural,\n one {{count} open issue or pull request on GitHub}\n other {{count} open issues and pull requests on GitHub}\n}",
"no_open_issues_and_prs": "No open issues or pull requests on GitHub"
},
"details": {
"requires": "Requires Home Assistant {version} or newer",
+18 -1
View File
@@ -1,5 +1,8 @@
import { describe, expect, it } from "vitest";
import { generateEntityFilter } from "../../../src/common/entity/entity_filter";
import {
findFilteredEntities,
generateEntityFilter,
} from "../../../src/common/entity/entity_filter";
import type { HomeAssistant } from "../../../src/types";
// Mock HomeAssistant with comprehensive data
@@ -705,3 +708,17 @@ describe("generateEntityFilter", () => {
});
});
});
describe("findFilteredEntities", () => {
it("should match entities against any of the filters", () => {
const entities = findFilteredEntities(mockHass, [
{ domain: "light" },
{ domain: "switch" },
]);
expect(entities).toContain("light.living_room");
expect(entities).toContain("light.bedroom");
expect(entities).toContain("switch.kitchen");
expect(entities).not.toContain("sensor.temperature");
});
});
+43 -2
View File
@@ -12,10 +12,15 @@ import {
registerUnsavedChangesGuard,
unregisterUnsavedChangesGuard,
} from "../../src/common/navigate";
import { closeAllDialogs } from "../../src/dialogs/make-dialog-manager";
// navigate() closes open dialogs before touching history.
// navigate() closes open dialogs before touching history. A closed dialog
// drops its state from the entry, which is what navigate() waits for.
vi.mock("../../src/dialogs/make-dialog-manager", () => ({
closeAllDialogs: vi.fn(async () => true),
closeAllDialogs: vi.fn(async () => {
window.history.replaceState(null, "");
return true;
}),
}));
// Fabricates a raw entry like a document load, which navigate() never produces.
@@ -56,6 +61,7 @@ const trackDeferredGuard = (isDirty: () => boolean = () => true) => {
afterEach(() => {
registeredGuards.splice(0).forEach(unregisterUnsavedChangesGuard);
vi.mocked(closeAllDialogs).mockClear();
});
describe("navigate", () => {
@@ -97,6 +103,41 @@ describe("navigate", () => {
expect(window.history.state).toBeNull();
expect(canGoBack()).toBe(false);
});
it("closes an open dialog before replacing the entry", async () => {
setEntry("/home", { dialog: "ha-more-info-dialog" });
await navigate("/home/overview", { replace: true });
expect(closeAllDialogs).toHaveBeenCalledOnce();
expect(window.location.pathname).toEqual("/home/overview");
expect(window.history.state).toBeNull();
});
it("keeps an open dialog and the entry state when asked to", async () => {
setEntry("/home", { dialog: "ha-more-info-dialog", from: "/" });
await navigate("/home/overview", { replace: true, keepDialogs: true });
expect(closeAllDialogs).not.toHaveBeenCalled();
expect(window.location.pathname).toEqual("/home/overview");
expect(window.history.state).toEqual({
dialog: "ha-more-info-dialog",
from: "/",
});
});
it("does not drop a navigation right after one that keeps dialogs", async () => {
setEntry("/home");
const results = await Promise.all([
navigate("/home/overview", { replace: true, keepDialogs: true }),
navigate("/config"),
]);
expect(results).toEqual([true, true]);
expect(window.location.pathname).toEqual("/config");
});
});
describe("unsaved changes guard", () => {
@@ -22,6 +22,7 @@ describe("entities query params", () => {
"ha-filter-states": [],
"ha-filter-floor-areas": undefined,
"ha-filter-devices": ["abc"],
"ha-filter-device-classes": [],
"ha-filter-integrations": [],
"ha-filter-domains": [],
"ha-filter-labels": ["a", "b"],
@@ -63,6 +64,7 @@ describe("entities query params", () => {
"ha-filter-states": ["enabled", "hidden"],
"ha-filter-floor-areas": { floors: ["ground"], areas: ["kitchen"] },
"ha-filter-devices": ["abc"],
"ha-filter-device-classes": ["problem", "temperature"],
"ha-filter-integrations": ["hue"],
"ha-filter-domains": ["light"],
"ha-filter-labels": ["a"],
@@ -1,8 +1,12 @@
import { describe, expect, it } from "vitest";
import { DOMAIN_DEVICE_CLASSES } from "../../src/data/device_classes";
import {
computeAllowedUnits,
computeDeviceClassForUnit,
computeSelectorUnits,
computeUnitDeviceClasses,
} from "../../src/data/sensor/unit_of_measurement";
import { SENSOR_DEVICE_CLASS_UNITS } from "../../src/data/sensor_entity_constants";
const sorted = (units?: (string | null)[]) =>
units === undefined ? undefined : [...units].sort();
@@ -164,3 +168,90 @@ describe("computeSelectorUnits", () => {
).toEqual([]);
});
});
describe("computeUnitDeviceClasses", () => {
it("does not filter without a unit", () => {
expect(computeUnitDeviceClasses("sensor", undefined)).toBeUndefined();
expect(computeUnitDeviceClasses("sensor", null)).toBeUndefined();
expect(computeUnitDeviceClasses("sensor", "")).toBeUndefined();
});
it("does not filter domains without units", () => {
expect(computeUnitDeviceClasses("binary_sensor", "lx")).toBeUndefined();
expect(computeUnitDeviceClasses(undefined, "lx")).toBeUndefined();
});
it("finds the only device class of a unit", () => {
expect(computeUnitDeviceClasses("sensor", "lx")).toEqual({
matching: ["illuminance"],
unrestricted: ["monetary"],
});
});
it("finds all device classes of a unit", () => {
expect(computeUnitDeviceClasses("sensor", "°C")?.matching.sort()).toEqual([
"temperature",
"temperature_delta",
]);
});
it("only accepts device classes without a unit table for a custom unit", () => {
expect(computeUnitDeviceClasses("sensor", "beans")).toEqual({
matching: [],
unrestricted: ["monetary"],
});
});
it("never accepts a unit for unitless and non-numeric device classes", () => {
const result = computeUnitDeviceClasses("sensor", "%")!;
for (const deviceClass of ["aqi", "ph", "enum", "date", "timestamp"]) {
expect(result.matching).not.toContain(deviceClass);
expect(result.unrestricted).not.toContain(deviceClass);
}
});
it("uses the sensor unit tables for numbers", () => {
expect(computeUnitDeviceClasses("number", "lx")).toEqual({
matching: ["illuminance"],
unrestricted: ["monetary"],
});
});
it("matches the units each device class allows", () => {
const units = new Set(
Object.values(SENSOR_DEVICE_CLASS_UNITS)
.flat()
.filter((unit): unit is string => unit !== null)
);
for (const unit of units) {
const { matching } = computeUnitDeviceClasses("sensor", unit)!;
for (const deviceClass of DOMAIN_DEVICE_CLASSES.sensor) {
const allowed = computeAllowedUnits([deviceClass]);
expect(matching.includes(deviceClass), `${unit} ${deviceClass}`).toBe(
allowed?.includes(unit) ?? false
);
}
}
});
});
describe("computeDeviceClassForUnit", () => {
it("preselects the only device class of a unit", () => {
expect(computeDeviceClassForUnit("sensor", "lx")).toBe("illuminance");
expect(computeDeviceClassForUnit("number", "lx")).toBe("illuminance");
});
it("does not preselect when several device classes fit", () => {
expect(computeDeviceClassForUnit("sensor", "°C")).toBeUndefined();
expect(computeDeviceClassForUnit("sensor", "%")).toBeUndefined();
});
it("does not preselect for a custom unit", () => {
expect(computeDeviceClassForUnit("sensor", "beans")).toBeUndefined();
});
it("does not preselect without a unit or for domains without units", () => {
expect(computeDeviceClassForUnit("sensor", undefined)).toBeUndefined();
expect(computeDeviceClassForUnit("binary_sensor", "lx")).toBeUndefined();
});
});
@@ -1,13 +1,16 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import type { HaDialog } from "../../../src/components/ha-dialog";
import type { ConfigEntry } from "../../../src/data/config_entries";
import type {
DataEntryFlowProgressedEvent,
DataEntryFlowStep,
DataEntryFlowStepCreateEntry,
DataEntryFlowStepExternal,
DataEntryFlowStepForm,
DataEntryFlowStepMenu,
DataEntryFlowStepProgress,
} from "../../../src/data/data_entry_flow";
import type { DeviceRegistryEntry } from "../../../src/data/device/device_registry";
import "../../../src/dialogs/config-flow/dialog-data-entry-flow";
import type { FlowConfig } from "../../../src/dialogs/config-flow/show-dialog-data-entry-flow";
import type { HomeAssistant } from "../../../src/types";
@@ -53,6 +56,16 @@ vi.mock("../../../src/components/ha-list-item", () => {
return {};
});
vi.mock("../../../src/components/ha-area-picker", () => {
customElements.define("ha-area-picker", class extends HTMLElement {});
return {};
});
vi.mock("../../../src/components/input/ha-input", () => {
customElements.define("ha-input", class extends HTMLElement {});
return {};
});
const formStep: DataEntryFlowStepForm = {
type: "form",
flow_id: "test-flow",
@@ -131,6 +144,7 @@ const eventListeners: Record<string, (ev: unknown) => void> = {};
const hass = {
localize: (key: string) => key,
devices: {},
callWS: vi.fn(),
connection: {
subscribeEvents: vi.fn(
async (callback: (ev: unknown) => void, eventType: string) => {
@@ -398,3 +412,83 @@ describe("dialog-data-entry-flow progress", () => {
expect(dialog["_step"]).toBeUndefined();
});
});
describe("dialog-data-entry-flow finish", () => {
const createEntryStep: DataEntryFlowStepCreateEntry = {
type: "create_entry",
flow_id: "test-flow",
handler: "test",
title: "Test",
result: {
entry_id: "test-entry",
domain: "test",
title: "Test",
state: "loaded",
} as ConfigEntry,
description: null,
};
const device = {
id: "device-1",
name: "Lamp",
name_by_user: null,
manufacturer: null,
model: null,
area_id: null,
primary_config_entry: null,
config_entries: ["test-entry"],
} as DeviceRegistryEntry;
afterEach(() => {
document.body.replaceChildren();
vi.clearAllMocks();
});
it("saves the device updates once when Finish is clicked twice", async () => {
const pendingSaves: (() => void)[] = [];
vi.mocked(hass.callWS).mockImplementation((async (msg: {
type: string;
}) => {
if (msg.type === "config/device_registry/update") {
return new Promise<unknown>((resolve) => {
pendingSaves.push(() => resolve({}));
});
}
if (msg.type === "config/entity_registry/get_automatic_entity_ids") {
return {};
}
return [];
}) as HomeAssistant["callWS"]);
const step = document.createElement("step-flow-create-entry");
step.hass = { ...hass, entities: {} };
step.flowConfig = flowConfig;
step.step = createEntryStep;
step.devices = [device];
document.body.append(step);
await step.updateComplete;
step
.shadowRoot!.querySelector("ha-area-picker")!
.dispatchEvent(
new CustomEvent("value-changed", { detail: { value: "kitchen" } })
);
await step.updateComplete;
const flowUpdates: unknown[] = [];
step.addEventListener("flow-update", (ev) => {
flowUpdates.push(ev.detail);
});
const first = step.finish();
const second = step.finish();
pendingSaves.forEach((save) => save());
await Promise.all([first, second]);
expect(
vi
.mocked(hass.callWS)
.mock.calls.filter(
([msg]) => msg.type === "config/device_registry/update"
)
).toHaveLength(1);
expect(flowUpdates).toHaveLength(1);
});
});
+3 -1
View File
@@ -371,7 +371,9 @@ export async function searchAddress(page: Page) {
const input = location.locator("ha-input input");
await input.fill(place.name);
await input.press("Enter");
await expect(location.locator("ha-list")).toContainText(place.name);
await expect(
location.getByRole("list", { name: "Search results" })
).toContainText(place.name);
}
export async function confirmLocation(page: Page) {
+1 -1
View File
@@ -45,7 +45,7 @@ describe("partial-panel-resolver", () => {
// The Marketplace loads its translations for the path it is at
expect(navigate).toHaveBeenCalledWith(
"/marketplace/repository/1?category=theme#top",
{ replace: true }
{ replace: true, keepDialogs: true }
);
});
@@ -0,0 +1,102 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { subscribeLovelaceUpdates } from "../../../src/data/lovelace";
import { fetchConfig } from "../../../src/data/lovelace/config/types";
import "../../../src/panels/lovelace/ha-panel-lovelace";
type LovelacePanel = HTMLElement & {
hass: unknown;
panel: unknown;
updateComplete: Promise<boolean>;
};
vi.mock("../../../src/panels/lovelace/hui-root", () => ({}));
vi.mock("../../../src/layouts/hass-loading-screen", () => ({}));
vi.mock("../../../src/layouts/hass-error-screen", () => ({}));
vi.mock("../../../src/data/lovelace", () => ({
subscribeLovelaceUpdates: vi.fn(),
}));
vi.mock("../../../src/data/lovelace/config/types", async (importOriginal) => ({
...(await importOriginal<object>()),
fetchConfig: vi.fn(),
}));
vi.mock("../../../src/data/lovelace/resource", () => ({
fetchResources: vi.fn(async () => []),
}));
vi.mock("../../../src/panels/lovelace/common/load-resources", () => ({
loadLovelaceResources: vi.fn(),
}));
const flush = () =>
new Promise<void>((resolve) => {
setTimeout(resolve);
});
const openPanel = async (urlPath: string) => {
let notifyUpdated: () => void = () => undefined;
const unsub = vi.fn(async () => {
notifyUpdated = () => undefined;
});
vi.mocked(subscribeLovelaceUpdates).mockImplementation(
(_conn, _urlPath, onChange) => {
notifyUpdated = onChange;
return Promise.resolve(unsub);
}
);
vi.mocked(fetchConfig).mockResolvedValue({ views: [{ cards: [] }] });
const panel = document.createElement(
"ha-panel-lovelace"
) as unknown as LovelacePanel;
panel.hass = {
connection: {},
locale: { language: "en" },
localize: (key: string) => key,
};
panel.panel = {
url_path: urlPath,
component_name: "lovelace",
config: { mode: "storage" },
};
document.body.append(panel);
await panel.updateComplete;
await flush();
return { panel, unsub, notifyUpdated: () => notifyUpdated() };
};
afterEach(() => {
document.body.replaceChildren();
vi.clearAllMocks();
});
describe("ha-panel-lovelace config updates", () => {
it("refetches the cached lovelace dashboard when it was updated while away", async () => {
const { panel, unsub, notifyUpdated } = await openPanel("lovelace");
expect(fetchConfig).toHaveBeenCalledTimes(1);
// Navigate away: the panel resolver keeps this element cached.
panel.remove();
await flush();
expect(unsub).not.toHaveBeenCalled();
// Config saved from elsewhere, e.g. "Add to dashboard" on a device page.
notifyUpdated();
expect(fetchConfig).toHaveBeenCalledTimes(1);
document.body.append(panel);
await flush();
expect(fetchConfig).toHaveBeenCalledTimes(2);
// Coming back again without a change uses the cached config.
panel.remove();
document.body.append(panel);
await flush();
expect(fetchConfig).toHaveBeenCalledTimes(2);
});
it("unsubscribes other dashboards when they are removed", async () => {
const { panel, unsub } = await openPanel("dashboard-test");
panel.remove();
await flush();
expect(unsub).toHaveBeenCalledTimes(1);
});
});
@@ -673,7 +673,7 @@ describe("ha-marketplace-repository-dashboard", () => {
)
).toEqual([
"ui.panel.marketplace.repository.community.stars",
"ui.panel.marketplace.repository.community.open_issues",
"ui.panel.marketplace.repository.community.open_issues_and_prs",
]);
});
+2 -2
View File
@@ -42,9 +42,9 @@ describe("Marketplace strings", () => {
"1 star on GitHub",
],
[
"repository.community.open_issues",
"repository.community.open_issues_and_prs",
{ number: 1, count: "1" },
"1 open issue on GitHub",
"1 open issue or pull request on GitHub",
],
])("formats %s with %o", (key, values, expected) => {
expect(format(key, values)).toBe(expected);