mirror of
https://github.com/home-assistant/frontend.git
synced 2026-11-07 21:45:16 +00:00
Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b6ec0cc537 | ||
|
|
e97745d7d5 | ||
|
|
790c957405 | ||
|
|
f740091579 | ||
|
|
f76c88dd9f | ||
|
|
a428d61cdb | ||
|
|
fcf7eeae46 | ||
|
|
6d2817b7cf | ||
|
|
640e4e2f94 | ||
|
|
6670139c02 | ||
|
|
93eb44b041 | ||
|
|
ba6067f048 | ||
|
|
cd866a497a | ||
|
|
98a8483426 | ||
|
|
33af67bafe | ||
|
|
027d9578ec | ||
|
|
5fef8f3a68 | ||
|
|
18e65c1940 |
No files matched your search
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
+6
-6
@@ -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",
|
||||
|
||||
Generated
+37
-37
@@ -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:
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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"),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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%;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from "../../../common/decorators/consume-context-entry";
|
||||
import { transform } from "../../../common/decorators/transform";
|
||||
import { computeDomain } from "../../../common/entity/compute_domain";
|
||||
import { getEntityAreaId } from "../../../common/entity/context/get_entity_context";
|
||||
import {
|
||||
computeGroupEntitiesState,
|
||||
toggleGroupEntities,
|
||||
@@ -236,8 +237,25 @@ class HuiAreaControlsCardFeature
|
||||
.map((n) => n.value)
|
||||
);
|
||||
|
||||
private _areaEntities = memoizeOne(
|
||||
(
|
||||
entities: HomeAssistant["entities"],
|
||||
devices: HomeAssistant["devices"],
|
||||
areaId: string
|
||||
): HomeAssistant["entities"] => {
|
||||
const areaEntities: HomeAssistant["entities"] = {};
|
||||
for (const [entityId, entry] of Object.entries(entities)) {
|
||||
if (getEntityAreaId(entityId, entities, devices) === areaId) {
|
||||
areaEntities[entityId] = entry;
|
||||
}
|
||||
}
|
||||
return areaEntities;
|
||||
}
|
||||
);
|
||||
|
||||
// States are part of the key: the filter reads device classes from them,
|
||||
// so an entity can only match once its state exists.
|
||||
// so an entity can only match once its state exists. Only the area's
|
||||
// entities are filtered, as this runs on every state change.
|
||||
private _controlEntities = memoizeOne(
|
||||
(
|
||||
controls: AreaControlDomain[],
|
||||
@@ -248,7 +266,7 @@ class HuiAreaControlsCardFeature
|
||||
) =>
|
||||
getAreaControlEntities(
|
||||
states,
|
||||
registries.entities,
|
||||
this._areaEntities(registries.entities, registries.devices, areaId),
|
||||
registries.devices,
|
||||
registries.areas,
|
||||
registries.floors,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -315,21 +318,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;
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"
|
||||
),
|
||||
}
|
||||
);
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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",
|
||||
@@ -7634,6 +7635,7 @@
|
||||
"state_icon": "State icon",
|
||||
"entity": "Entity",
|
||||
"device": "Device",
|
||||
"device_class": "Device class",
|
||||
"integration": "Integration",
|
||||
"disabled_by": "Disabled by",
|
||||
"status": "Status",
|
||||
@@ -7926,8 +7928,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 +12808,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 +13198,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",
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user