Compare commits

...
Author SHA1 Message Date
Paul Bottein 0c3929f8a6 Omit owners in another area from entity context 2026-09-15 15:45:09 +03:00
Paul Bottein 546315ca5b Share entity display and search label helpers across pickers (#54192) 2026-09-15 15:45:04 +03:00
renovate[bot] 7c98912b73 Update formatjs (#54162)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-09-15 15:42:31 +03:00
Bram KragtenandClaude Opus 5 8c72e9c22d Remove unused Material Web wrappers (#54198)
`ha-outlined-button` and `ha-outlined-field` had no references anywhere
in the repository, so their custom elements were never registered at
runtime. `ha-sub-menu` was imported by five config panels, but no
template renders the tag.

Removes three `@material/web/**/internal/**` import sites; Material Web
is now only reached from chips, list, ripple and iconbutton.

Co-authored-by: Claude Opus 5 <[email protected]>
2026-09-15 15:30:00 +03:00
Lucas Mindêllo de Andrade 892514ed21 Show toggle for fan entity only if TURN_ON or TURN_OFF is supported (#54188)
* fix(fan): keep toggle only if on off is supported

* feat(lovelace): add fan entity row

* code review adjusts
2026-09-15 15:26:14 +03:00
Bruno Pantaleão GonçalvesandClaude 06be6f433d Add more info opened and closed external bus messages (#54187)
Co-authored-by: Claude <[email protected]>
2026-09-15 09:29:34 +02:00
renovate[bot] e9a6e2936d Update dependency nanoid to v6 (#54189) 2026-09-15 07:13:27 +00:00
d3b7d787b4 Add trigger ID auto-generation and selection (#54130)
* Add trigger ID auto-generation and selection

* Fix trigger list index and descriptions

* Potential fix for blueprint crash

Co-authored-by: Copilot Autofix powered by AI <[email protected]>

* Split cleanup

* Process Wendelin code review comments

* Process Marcin code review comments

* Remove unused controller config option

* Swap

* Fix minor review comments

* Update src/panels/config/automation/condition/types/ha-automation-condition-trigger.ts

* Update src/panels/config/automation/condition/types/ha-automation-condition-trigger.ts

---------

Co-authored-by: Copilot Autofix powered by AI <[email protected]>
Co-authored-by: Wendelin <[email protected]>
2026-09-15 09:07:03 +02:00
renovate[bot] 0662f8238f Update dependency hls.js to v1.7.3 (#54181)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-09-15 06:17:17 +02:00
Jonas PeterssonandJonas Petersson 8a59516d8f Support glass break binary sensors (#53989)
* feat(security): support glass break binary sensors

* fix: add glass_break translation for device class override dropdown

* fix: add glass_break to generated device classes list

* fix: add glass_break icon to entity component icons for demo/fake data

---------

Co-authored-by: Jonas Petersson <[email protected]>
2026-09-14 17:44:15 +02:00
Petar Petrov 0afe6bb136 Cap unsupported config warning height so YAML fallback stays visible (#54177) 2026-09-14 16:52:31 +02:00
Bram KragtenandClaude Opus 5 61b487f995 Rename "For" to "For at least" and let duration inputs fill the row (#54175)
Co-authored-by: Claude Opus 5 <[email protected]>
2026-09-14 15:35:18 +02:00
91abff2d0c Hide "for" on state condition when matching an attribute (#52710)
* Hide "for" on state condition when matching an attribute

The legacy state condition measures the `for` duration against
`last_changed`, which only updates on state changes, not attribute
changes. So `for` together with `attribute` is unreliable and core is
moving to reject it. Omit the `for` field when an attribute is selected,
drop any lingering duration value, and explain why via a helper.

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

* Keep "for" visible but disabled when matching an attribute

Removing the field left the explanation pointing at a control that was no
longer on screen. Keep "for" in the form, disable it while an attribute is
selected, and move the helper text under it.

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

---------

Co-authored-by: Claude Opus 4.8 <[email protected]>
Co-authored-by: copilot-swe-agent[bot] <[email protected]>
2026-09-14 16:32:15 +03:00
Bram KragtenandClaude Opus 5 53d16c97fe Keep helper text readable on disabled fields (#54174)
Disabled fields dimmed their helper text to the disabled ink color, which
drops it to 2.6:1 contrast in light mode (5.7:1 when enabled). Helper text
is guidance about the control rather than part of its value, and it is often
the only thing explaining why a field is disabled, so keep it at its regular
contrast. The disabled state is still carried by the label, the value, and
the missing clear button.

Co-authored-by: Claude Opus 5 <[email protected]>
2026-09-14 16:17:43 +03:00
Bram KragtenandClaude Opus 5 7493bfc86c Honor configured name in bootstrap formatEntityName (#54172)
Co-authored-by: Claude Opus 5 <[email protected]>
2026-09-14 14:39:26 +02:00
infernalK ca1eb4ed7d Fix TypeError when hass.panels changes while rebuild() is in flight (#54171)
_updateRoutes() awaits this.rebuild() and then reads
this.hass.panels[this._currentPage].component_name without checking that
the entry still exists. hass.panels can be replaced again while the
rebuild() await is pending (e.g. multiple integrations or HACS-managed
Lovelace resources updating panels around startup), leaving
this._currentPage pointing at a panel that is no longer present. The
resulting TypeError breaks rendering of the current dashboard.

Guard the access after the await and bail out when the panel is gone:
willUpdate already re-triggers _updateRoutes for the newer hass.panels
value in that race, so returning early is safe and avoids the crash.

Fixes #54006
2026-09-14 15:38:16 +03:00
Petar PetrovandCopilot Autofix powered by AI c1dd17c4f9 Show Energy Now view for water and gas flow without power sensors (#54100)
Co-authored-by: Copilot Autofix powered by AI <[email protected]>
2026-09-14 14:34:38 +02:00
Stu Kabakoff 5c74a84d01 show up to 5 discrete buttons on fan cards (#54137)
show up to 5 discrete buttons to fan card
2026-09-14 14:14:41 +02:00
shbatmandPetar Petrov a51d255ec3 Show disabled entities when filtering entities by device or area (#53444)
* Show disabled entities when filtering entities by device or area

The device and area filters on the entities config page resolve their
selection through search/related, which omits disabled entities. Opt in
to the new include_disabled_entities option there, so that filtering by
a device or area no longer hides that device's disabled entities.

The option is only set on the entities page, so every other consumer of
findRelated keeps its current behaviour.

* Update src/panels/config/entities/ha-config-entities.ts

Co-authored-by: Petar Petrov <[email protected]>

* Update src/panels/config/entities/ha-config-entities.ts

Co-authored-by: Petar Petrov <[email protected]>

---------

Co-authored-by: Petar Petrov <[email protected]>
2026-09-14 13:25:35 +02:00
Petar Petrov 8c0dec0d9a Align stacked statistics lines across recorder gaps (#54166)
ECharts stacks a time axis by data index, so a statistic with a gap or a
different range shifted every stacked line after it. Merge the stacked line
datasets onto one timeline, let ECharts sample them after stacking, and hide
the padded null rows in the tooltip.
2026-09-14 13:49:48 +03:00
renovate[bot] 2c4b3c9109 Update dependency @rsdoctor/rspack-plugin to v1.6.4 (#54170)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-09-14 13:34:56 +03:00
Petar Petrov c75bd2b5f0 Fix inconsistent grid labels in energy usage graph (#54168)
Make energy usage graph grid consumption label name-first
2026-09-14 12:07:18 +02:00
Wendelin 9b1665f5bf Refactor more-info update backup row (#54119)
* Extract and refactor update-backup

* run requests in parallel

* Separate fetches

* fix
2026-09-14 10:53:46 +02:00
Wendelin 603528bf47 Landing page: provide and use i18n context (#54128)
Migrate landing page to internationalization context
2026-09-14 10:45:08 +02:00
Wendelin 030b962e26 ha-auth use i18n context (#54127)
Use i18n context in ha-auth
2026-09-14 10:39:07 +02:00
Wendelin 85a34aeeee Onboarding: use localize context (#54126)
* Migrate onboarding to use localize context

* Load onboarding fragment translations after hass initialization.
2026-09-14 10:29:02 +02:00
149 changed files with 4464 additions and 1944 deletions
+2 -4
View File
@@ -9,6 +9,7 @@ import { ifDefined } from "lit/directives/if-defined";
import { applyThemesOnElement } from "../../src/common/dom/apply_themes_on_element";
import { dynamicElement } from "../../src/common/dom/dynamic-element-directive";
import type { HASSDomEvent } from "../../src/common/dom/fire_event";
import { computeEntityNameDisplayWithoutContext } from "../../src/common/entity/compute_entity_name_display";
import { setDirectionStyles } from "../../src/common/util/compute_rtl";
import "../../src/components/ha-button";
import "../../src/components/ha-drawer";
@@ -682,10 +683,7 @@ class HaGallery extends LitElement {
formatEntityAttributeName: (_stateObj, attribute) => attribute,
formatEntityAttributeValue: (stateObj, attribute, value) =>
value != null ? value : (stateObj.attributes[attribute] ?? ""),
formatEntityName: (stateObj, type) =>
typeof type === "string"
? type
: (stateObj.attributes.friendly_name ?? stateObj.entity_id),
formatEntityName: computeEntityNameDisplayWithoutContext,
} as unknown as HomeAssistant;
}
+82 -2
View File
@@ -12,7 +12,10 @@ import "../../components/demo-cards";
import { mockIcons } from "../../../../demo/src/stubs/icons";
import { ClimateEntityFeature } from "../../../../src/data/climate";
import { FanEntityFeature } from "../../../../src/data/fan";
import type { TileCardConfig } from "../../../../src/panels/lovelace/cards/types";
import type {
GridCardConfig,
TileCardConfig,
} from "../../../../src/panels/lovelace/cards/types";
const ENTITIES = [
{
@@ -163,6 +166,39 @@ const ENTITIES = [
FanEntityFeature.OSCILLATE,
},
},
{
entity_id: "fan.three_speed_fan",
state: "on",
attributes: {
friendly_name: "Desk fan",
device_class: "fan",
percentage: 67,
percentage_step: 100 / 3,
supported_features: FanEntityFeature.SET_SPEED,
},
},
{
entity_id: "fan.four_speed_fan",
state: "on",
attributes: {
friendly_name: "Bedroom fan",
device_class: "fan",
percentage: 50,
percentage_step: 25,
supported_features: FanEntityFeature.SET_SPEED,
},
},
{
entity_id: "fan.five_speed_fan",
state: "on",
attributes: {
friendly_name: "Attic fan",
device_class: "fan",
percentage: 80,
percentage_step: 20,
supported_features: FanEntityFeature.SET_SPEED,
},
},
];
const CONFIGS = [
@@ -332,6 +368,50 @@ const CONFIGS = [
features: [{ type: "fan-speed" }],
},
},
{
heading: "Fan speed feature (3 speeds)",
config: {
type: "tile",
entity: "fan.three_speed_fan",
features: [{ type: "fan-speed" }],
},
},
{
heading: "Fan speed feature (4 speeds)",
config: {
type: "tile",
entity: "fan.four_speed_fan",
features: [{ type: "fan-speed" }],
},
},
{
heading: "Fan speed feature (5 speeds)",
config: {
type: "tile",
entity: "fan.five_speed_fan",
features: [{ type: "fan-speed" }],
},
},
{
heading: "Fan speed feature (half width)",
config: {
type: "grid",
columns: 2,
square: false,
cards: [
{
type: "tile",
entity: "fan.four_speed_fan",
features: [{ type: "fan-speed" }],
},
{
type: "tile",
entity: "fan.five_speed_fan",
features: [{ type: "fan-speed" }],
},
],
},
},
{
heading: "Fan oscillate feature",
config: {
@@ -418,7 +498,7 @@ const CONFIGS = [
],
},
},
] satisfies DemoCardConfig<TileCardConfig>[];
] satisfies DemoCardConfig<TileCardConfig | GridCardConfig>[];
@customElement("demo-lovelace-tile-card")
class DemoTile extends LitElement {
+55
View File
@@ -21,6 +21,61 @@ const ENTITIES = [
FanEntityFeature.SET_SPEED,
},
},
{
entity_id: "fan.one_speed_fan",
state: "on",
attributes: {
friendly_name: "One speed fan",
device_class: "fan",
percentage: 100,
percentage_step: 100,
supported_features: FanEntityFeature.SET_SPEED,
},
},
{
entity_id: "fan.two_speed_fan",
state: "on",
attributes: {
friendly_name: "Two speed fan",
device_class: "fan",
percentage: 50,
percentage_step: 50,
supported_features: FanEntityFeature.SET_SPEED,
},
},
{
entity_id: "fan.three_speed_fan",
state: "on",
attributes: {
friendly_name: "Three speed fan",
device_class: "fan",
percentage: 67,
percentage_step: 100 / 3,
supported_features: FanEntityFeature.SET_SPEED,
},
},
{
entity_id: "fan.four_speed_fan",
state: "on",
attributes: {
friendly_name: "Four speed fan",
device_class: "fan",
percentage: 50,
percentage_step: 25,
supported_features: FanEntityFeature.SET_SPEED,
},
},
{
entity_id: "fan.five_speed_fan",
state: "on",
attributes: {
friendly_name: "Five speed fan",
device_class: "fan",
percentage: 80,
percentage_step: 20,
supported_features: FanEntityFeature.SET_SPEED,
},
},
];
@customElement("demo-more-info-fan")
@@ -1,8 +1,9 @@
import { IntersectionController } from "@lit-labs/observers/intersection-controller.js";
import { mdiArrowCollapseDown, mdiDownload } from "@mdi/js";
import { LitElement, type PropertyValues, css, html, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { customElement, query, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { consumeLocalize } from "../../../src/common/decorators/consume-context-entry";
import { fireEvent } from "../../../src/common/dom/fire_event";
import type {
LandingPageKeys,
@@ -34,9 +35,6 @@ const SCHEDULE_FETCH_OBSERVER_LOGS = 5;
@customElement("landing-page-logs")
class LandingPageLogs extends LitElement {
@property({ attribute: false })
public localize!: LocalizeFunc<LandingPageKeys>;
@query("ha-ansi-to-html") private _ansiToHtmlElement?: HaAnsiToHtml;
@query(".logs") private _logElement?: HTMLElement;
@@ -44,6 +42,10 @@ class LandingPageLogs extends LitElement {
@query("#scroll-bottom-marker")
private _scrollBottomMarkerElement?: HTMLElement;
@state()
@consumeLocalize()
private _localize!: LocalizeFunc<LandingPageKeys>;
@state() private _show = false;
@state() private _scrolledToBottomController =
@@ -63,12 +65,12 @@ class LandingPageLogs extends LitElement {
return html`
<div class="actions">
<ha-button appearance="plain" @click=${this._toggleLogDetails}>
${this.localize(this._show ? "hide_details" : "show_details")}
${this._localize(this._show ? "hide_details" : "show_details")}
</ha-button>
${
this._show
? html`<ha-icon-button
.label=${this.localize("logs.download_logs")}
.label=${this._localize("logs.download_logs")}
.path=${mdiDownload}
@click=${this._downloadLogs}
></ha-icon-button>`
@@ -80,14 +82,14 @@ class LandingPageLogs extends LitElement {
? html`
<ha-alert
alert-type="error"
.title=${this.localize("logs.fetch_error")}
.title=${this._localize("logs.fetch_error")}
>
<ha-button
size="small"
variant="danger"
@click=${this._startLogStream}
>
${this.localize("logs.retry")}
${this._localize("logs.retry")}
</ha-button>
</ha-alert>
`
@@ -115,7 +117,7 @@ class LandingPageLogs extends LitElement {
@click=${this._scrollToBottom}
>
<ha-svg-icon .path=${mdiArrowCollapseDown} slot="start"></ha-svg-icon>
${this.localize("logs.scroll_down_button")}
${this._localize("logs.scroll_down_button")}
<ha-svg-icon .path=${mdiArrowCollapseDown} slot="end"></ha-svg-icon>
</ha-button>
`;
@@ -1,35 +1,37 @@
import { LitElement, css, html, nothing, type CSSResultGroup } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { type CSSResultGroup, LitElement, css, html, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { consumeLocalize } from "../../../src/common/decorators/consume-context-entry";
import { fireEvent } from "../../../src/common/dom/fire_event";
import type {
LandingPageKeys,
LocalizeFunc,
} from "../../../src/common/translations/localize";
import "../../../src/components/ha-button";
import "../../../src/components/ha-alert";
import "../../../src/components/ha-button";
import type { NetworkInterface } from "../../../src/data/hassio/network";
import { showAlertDialog } from "../../../src/dialogs/generic/show-dialog-box";
import {
ALTERNATIVE_DNS_SERVERS,
setSupervisorNetworkDns,
type NetworkInfo,
} from "../data/supervisor";
import { showAlertDialog } from "../../../src/dialogs/generic/show-dialog-box";
import type { NetworkInterface } from "../../../src/data/hassio/network";
import { fireEvent } from "../../../src/common/dom/fire_event";
@customElement("landing-page-network")
class LandingPageNetwork extends LitElement {
@property({ attribute: false })
public localize!: LocalizeFunc<LandingPageKeys>;
@property({ attribute: false }) public networkInfo?: NetworkInfo;
@property({ type: Boolean }) public error = false;
@state()
@consumeLocalize()
private _localize!: LocalizeFunc<LandingPageKeys>;
protected render() {
if (this.error) {
return html`
<ha-alert alert-type="error">
<p>${this.localize("network_issue.error_get_network_info")}</p>
<p>${this._localize("network_issue.error_get_network_info")}</p>
</ha-alert>
`;
}
@@ -47,19 +49,21 @@ class LandingPageNetwork extends LitElement {
return html`
<ha-alert
alert-type="warning"
.title=${this.localize("network_issue.title")}
.title=${this._localize("network_issue.title")}
>
<p>
${this.localize("network_issue.description", {
${this._localize("network_issue.description", {
dns: dnsPrimaryInterfaceNameservers || "?",
})}
</p>
<p>${this.localize("network_issue.resolve_different")}</p>
<p>${this._localize("network_issue.resolve_different")}</p>
${
!dnsPrimaryInterfaceNameservers
? html`
<p>
<b>${this.localize("network_issue.no_primary_interface")} </b>
<b
>${this._localize("network_issue.no_primary_interface")}
</b>
</p>
`
: nothing
@@ -72,7 +76,7 @@ class LandingPageNetwork extends LitElement {
.index=${key}
.disabled=${!dnsPrimaryInterfaceNameservers}
@click=${this._setDns}
>${this.localize(translationKey)}</ha-button
>${this._localize(translationKey)}</ha-button
>`
)}
</div>
@@ -118,12 +122,12 @@ class LandingPageNetwork extends LitElement {
// eslint-disable-next-line no-console
console.error(err);
showAlertDialog(this, {
title: this.localize("network_issue.failed"),
title: this._localize("network_issue.failed"),
warning: true,
text: `${this.localize(
text: `${this._localize(
"network_issue.set_dns_failed"
)}${err?.message ? ` ${this.localize("network_issue.error")}: ${err.message}` : ""}`,
confirmText: this.localize("network_issue.close"),
)}${err?.message ? ` ${this._localize("network_issue.error")}: ${err.message}` : ""}`,
confirmText: this._localize("network_issue.close"),
});
}
}
+2 -4
View File
@@ -9,6 +9,7 @@ import "../../src/components/ha-spinner";
import "../../src/components/ha-svg-icon";
import "../../src/components/progress/ha-progress-bar";
import { makeDialogManager } from "../../src/dialogs/make-dialog-manager";
import { provideLiteI18nMixin } from "../../src/mixins/provide-lite-i18n-mixin";
import "../../src/onboarding/onboarding-welcome-links";
import { onBoardingStyles } from "../../src/onboarding/styles";
import { haStyle } from "../../src/resources/styles";
@@ -28,7 +29,7 @@ const SCHEDULE_FETCH_NETWORK_INFO_SECONDS = 5;
const SCHEDULE_FETCH_JOBS_INFO_SECONDS = 2;
@customElement("ha-landing-page")
class HaLandingPage extends LandingPageBaseElement {
class HaLandingPage extends provideLiteI18nMixin(LandingPageBaseElement) {
@property({ attribute: false }) public translationFragment = "landing-page";
@state() private _supervisorError = false;
@@ -82,7 +83,6 @@ class HaLandingPage extends LandingPageBaseElement {
networkIssue || this._networkInfoError
? html`
<landing-page-network
.localize=${this.localize}
.networkInfo=${this._networkInfo}
.error=${this._networkInfoError}
@dns-set=${this._fetchSupervisorInfo}
@@ -103,13 +103,11 @@ class HaLandingPage extends LandingPageBaseElement {
: nothing
}
<landing-page-logs
.localize=${this.localize}
@landing-page-error=${this._showError}
></landing-page-logs>
</div>
</ha-card>
<onboarding-welcome-links
.localize=${this.localize}
.mobileApp=${this._mobileApp}
></onboarding-welcome-links>
<div class="footer">
+12 -11
View File
@@ -52,15 +52,15 @@
"@codemirror/view": "6.43.11",
"@date-fns/tz": "1.5.0",
"@egjs/hammerjs": "2.0.17",
"@formatjs/intl-datetimeformat": "7.6.1",
"@formatjs/intl-displaynames": "7.3.13",
"@formatjs/intl-durationformat": "0.10.18",
"@formatjs/intl-getcanonicallocales": "3.2.11",
"@formatjs/intl-listformat": "8.3.13",
"@formatjs/intl-locale": "5.3.10",
"@formatjs/intl-numberformat": "9.4.0",
"@formatjs/intl-pluralrules": "6.3.13",
"@formatjs/intl-relativetimeformat": "12.3.13",
"@formatjs/intl-datetimeformat": "7.6.2",
"@formatjs/intl-displaynames": "7.3.14",
"@formatjs/intl-durationformat": "0.11.0",
"@formatjs/intl-getcanonicallocales": "3.2.12",
"@formatjs/intl-listformat": "8.3.14",
"@formatjs/intl-locale": "5.3.11",
"@formatjs/intl-numberformat": "9.4.1",
"@formatjs/intl-pluralrules": "6.3.14",
"@formatjs/intl-relativetimeformat": "12.3.14",
"@fullcalendar/core": "6.1.21",
"@fullcalendar/daygrid": "6.1.21",
"@fullcalendar/interaction": "6.1.21",
@@ -103,7 +103,7 @@
"echarts-extension-chart2music": "0.1.1",
"element-internals-polyfill": "3.0.2",
"fuse.js": "7.5.0",
"hls.js": "1.7.2",
"hls.js": "1.7.3",
"home-assistant-js-websocket": "9.6.0",
"idb-keyval": "6.3.0",
"intl-messageformat": "11.2.14",
@@ -116,6 +116,7 @@
"maplibre-gl": "5.24.0",
"marked": "18.0.12",
"memoize-one": "6.0.0",
"nanoid": "6.0.1",
"node-vibrant": "4.0.4",
"object-hash": "3.0.0",
"punycode": "2.3.1",
@@ -151,7 +152,7 @@
"@octokit/plugin-retry": "8.1.1",
"@octokit/rest": "22.0.1",
"@playwright/test": "1.62.1",
"@rsdoctor/rspack-plugin": "1.6.3",
"@rsdoctor/rspack-plugin": "1.6.4",
"@rspack/core": "2.2.3",
"@rspack/dev-server": "2.2.1",
"@types/babel__plugin-transform-runtime": "7.9.5",
+20 -18
View File
@@ -23,6 +23,7 @@ import type {
} from "../data/data_entry_flow";
import "./ha-auth-form";
import type { HaAuthForm } from "./ha-auth-form";
import { consumeLocalize } from "../common/decorators/consume-context-entry";
type State = "loading" | "error" | "step";
@@ -36,12 +37,14 @@ export class HaAuthFlow extends LitElement {
@property({ attribute: false }) public oauth2State?: string;
@property({ attribute: false }) public localize!: LocalizeFunc;
@property({ attribute: false }) public step?: DataEntryFlowStep;
@property({ attribute: false }) public initStoreToken = false;
@state()
@consumeLocalize()
private _localize!: LocalizeFunc;
@state() private _storeToken = false;
@state() private _state: State = "loading";
@@ -184,8 +187,8 @@ export class HaAuthFlow extends LitElement {
>
${
this.step.type === "form"
? this.localize("ui.panel.page-authorize.form.next")
: this.localize("ui.panel.page-authorize.form.start_over")
? this._localize("ui.panel.page-authorize.form.next")
: this._localize("ui.panel.page-authorize.form.start_over")
}
</ha-button>
</div>
@@ -193,20 +196,20 @@ export class HaAuthFlow extends LitElement {
case "error":
return html`
<ha-alert alert-type="error">
${this.localize("ui.panel.page-authorize.form.error", {
${this._localize("ui.panel.page-authorize.form.error", {
error: this._errorMessage,
})}
</ha-alert>
<div class="action">
<ha-button @click=${this._startOver}>
${this.localize("ui.panel.page-authorize.form.start_over")}
${this._localize("ui.panel.page-authorize.form.start_over")}
</ha-button>
</div>
`;
case "loading":
return html`
<ha-alert alert-type="info">
${this.localize("ui.panel.page-authorize.form.working")}
${this._localize("ui.panel.page-authorize.form.working")}
</ha-alert>
`;
default:
@@ -218,8 +221,8 @@ export class HaAuthFlow extends LitElement {
switch (step.type) {
case "abort":
return html`
${this.localize("ui.panel.page-authorize.abort_intro")}:
${this.localize(
${this._localize("ui.panel.page-authorize.abort_intro")}:
${this._localize(
`ui.panel.page-authorize.form.providers.${step.handler[0]}.abort.${step.reason}`
)}
`;
@@ -228,15 +231,14 @@ export class HaAuthFlow extends LitElement {
<h1>
${
!["select_mfa_module", "mfa"].includes(step.step_id)
? this.localize("ui.panel.page-authorize.welcome_home")
: this.localize("ui.panel.page-authorize.just_checking")
? this._localize("ui.panel.page-authorize.welcome_home")
: this._localize("ui.panel.page-authorize.just_checking")
}
</h1>
${this._computeStepDescription(step)}
${keyed(
step.step_id,
html`<ha-auth-form
.localize=${this.localize}
.data=${this._stepData!}
.schema=${autocompleteLoginFields(step.data_schema)}
.error=${step.errors}
@@ -256,7 +258,7 @@ export class HaAuthFlow extends LitElement {
.checked=${this._storeToken}
@change=${this._storeTokenChanged}
>
${this.localize("ui.panel.page-authorize.store_token")}
${this._localize("ui.panel.page-authorize.store_token")}
</ha-checkbox>
`
: ""
@@ -266,7 +268,7 @@ export class HaAuthFlow extends LitElement {
href="https://www.home-assistant.io/docs/locked_out/#forgot-password"
target="_blank"
rel="noreferrer noopener"
>${this.localize("ui.panel.page-authorize.forgot_password")}</a
>${this._localize("ui.panel.page-authorize.forgot_password")}</a
>
</div>
`;
@@ -336,13 +338,13 @@ export class HaAuthFlow extends LitElement {
private _computeStepDescription(step: DataEntryFlowStepForm) {
const resourceKey =
`ui.panel.page-authorize.form.providers.${step.handler[0]}.step.${step.step_id}.description` as const;
return this.localize(resourceKey, step.description_placeholders);
return this._localize(resourceKey, step.description_placeholders);
}
private _computeLabelCallback(step: DataEntryFlowStepForm) {
// Returns a callback for ha-form to calculate labels per schema object
return (schema) =>
this.localize(
this._localize(
`ui.panel.page-authorize.form.providers.${step.handler[0]}.step.${step.step_id}.data.${schema.name}`
);
}
@@ -350,13 +352,13 @@ export class HaAuthFlow extends LitElement {
private _computeErrorCallback(step: DataEntryFlowStepForm) {
// Returns a callback for ha-form to calculate error messages
return (error) =>
this.localize(
this._localize(
`ui.panel.page-authorize.form.providers.${step.handler[0]}.error.${error}`
);
}
private _unknownError() {
return this.localize("ui.panel.page-authorize.form.unknown_error");
return this._localize("ui.panel.page-authorize.form.unknown_error");
}
private _startOver() {
+6 -3
View File
@@ -1,6 +1,7 @@
/* eslint-disable lit/prefer-static-styles */
import { html } from "lit";
import { customElement, property } from "lit/decorators";
import { customElement, state } from "lit/decorators";
import { consumeLocalize } from "../common/decorators/consume-context-entry";
import type { LocalizeFunc } from "../common/translations/localize";
import { HaForm } from "../components/ha-form/ha-form";
import "./ha-auth-form-string";
@@ -9,11 +10,13 @@ const localizeBaseKey = "ui.panel.page-authorize.form";
@customElement("ha-auth-form")
export class HaAuthForm extends HaForm {
@property({ attribute: false }) public localize?: LocalizeFunc;
@state()
@consumeLocalize()
private _localize!: LocalizeFunc;
protected getFormProperties(): Record<string, any> {
return {
localize: this.localize,
localize: this._localize,
localizeBaseKey,
};
}
+4 -3
View File
@@ -12,6 +12,7 @@ import "../components/ha-svg-icon";
import type { AuthProvider, AuthUrlSearchParams } from "../data/auth";
import { fetchAuthProviders } from "../data/auth";
import { litLocalizeLiteMixin } from "../mixins/lit-localize-lite-mixin";
import { provideLiteI18nMixin } from "../mixins/provide-lite-i18n-mixin";
import { registerServiceWorker } from "../util/register-service-worker";
import "./ha-auth-flow";
@@ -23,7 +24,9 @@ const appNames = {
};
@customElement("ha-authorize")
export class HaAuthorize extends litLocalizeLiteMixin(LitElement) {
export class HaAuthorize extends provideLiteI18nMixin(
litLocalizeLiteMixin(LitElement)
) {
@property({ attribute: false }) public clientId?: string;
@property({ attribute: false }) public redirectUri?: string;
@@ -183,14 +186,12 @@ export class HaAuthorize extends litLocalizeLiteMixin(LitElement) {
.redirectUri=${this.redirectUri}
.oauth2State=${this.oauth2State}
.authProvider=${this._authProvider}
.localize=${this.localize}
.initStoreToken=${this._preselectStoreToken}
></ha-auth-flow>
${
inactiveProviders!.length > 0
? html`
<ha-pick-auth-provider
.localize=${this.localize}
.clientId=${this.clientId}
.authProviders=${inactiveProviders!}
@pick-auth-provider=${this._handleAuthProviderPick}
+6 -3
View File
@@ -1,5 +1,6 @@
import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import { customElement, property, state } from "lit/decorators";
import { consumeLocalize } from "../common/decorators/consume-context-entry";
import { fireEvent } from "../common/dom/fire_event";
import type { LocalizeFunc } from "../common/translations/localize";
import "../components/ha-icon-next";
@@ -20,13 +21,15 @@ declare global {
export class HaPickAuthProvider extends LitElement {
@property({ attribute: false }) public authProviders: AuthProvider[] = [];
@property({ attribute: false }) public localize!: LocalizeFunc;
@state()
@consumeLocalize()
private _localize!: LocalizeFunc;
protected render() {
return html`
<h3>
<span
>${this.localize("ui.panel.page-authorize.pick_auth_provider")}</span
>${this._localize("ui.panel.page-authorize.pick_auth_provider")}</span
>
</h3>
<ha-list>
+142 -13
View File
@@ -1,13 +1,20 @@
import type { HassEntity } from "home-assistant-js-websocket";
import type {
EntityRegistryDisplayEntry,
EntityRegistryEntry,
} from "../../data/entity/entity_registry";
import type { HomeAssistant } from "../../types";
import { ensureArray } from "../array/ensure-array";
import { computeRTL } from "../util/compute_rtl";
import { computeAreaName } from "./compute_area_name";
import { computeDeviceName } from "./compute_device_name";
import { computeEntityName, entityUseDeviceName } from "./compute_entity_name";
import {
computeEntityEntryName,
entityUseDeviceName,
} from "./compute_entity_name";
import { computeFloorName } from "./compute_floor_name";
import { computeStateName } from "./compute_state_name";
import { getEntityContext } from "./context/get_entity_context";
import { getEntityEntryContext } from "./context/get_entity_context";
const DEFAULT_SEPARATOR = " ";
@@ -40,6 +47,42 @@ export interface EntityNameOptions {
separator?: string;
}
// Joins items that need no entity context. Returns undefined as soon as one
// item references the registries (device, area, ...).
const computeTextOnlyName = (
items: EntityNameItem[],
options?: EntityNameOptions
): string | undefined =>
items.every((n) => n.type === "text")
? items
.map((item) => item.text)
.join(options?.separator ?? DEFAULT_SEPARATOR)
: undefined;
/**
* Name formatter used before the registry-aware one is installed
* (see state/connection-mixin and state/state-display-mixin). It honours a
* configured string or text-only name and falls back to the friendly name for
* anything that needs entity context, so a configured name is never dropped
* while the real formatter is still loading.
*/
export const computeEntityNameDisplayWithoutContext = (
stateObj: HassEntity,
name: string | EntityNameItem | EntityNameItem[] | undefined,
options?: EntityNameOptions
): string => {
if (typeof name === "string") {
return name;
}
if (!name) {
return computeStateName(stateObj);
}
return (
computeTextOnlyName(ensureArray(name), options) ??
computeStateName(stateObj)
);
};
export const computeEntityNameDisplay = (
stateObj: HassEntity,
name: string | EntityNameItem | EntityNameItem[] | undefined,
@@ -63,8 +106,9 @@ export const computeEntityNameDisplay = (
const separator = options?.separator ?? DEFAULT_SEPARATOR;
// If all items are text, just join them
if (items.every((n) => n.type === "text")) {
return items.map((item) => item.text).join(separator);
const textOnlyName = computeTextOnlyName(items, options);
if (textOnlyName !== undefined) {
return textOnlyName;
}
const useDeviceName = entityUseDeviceName(stateObj, entities, devices);
@@ -83,7 +127,8 @@ export const computeEntityNameDisplay = (
entities,
devices,
areas,
floors
floors,
{ followContext: false }
);
// If after processing there is only one name, return that
@@ -94,30 +139,85 @@ export const computeEntityNameDisplay = (
return names.filter((n) => n).join(separator);
};
export interface EntityNameListOptions {
followContext?: boolean;
}
export const computeEntityNameList = (
stateObj: HassEntity,
name: EntityNameItem[],
entities: HomeAssistant["entities"],
devices: HomeAssistant["devices"],
areas: HomeAssistant["areas"],
floors: HomeAssistant["floors"]
floors: HomeAssistant["floors"],
options?: EntityNameListOptions
): (string | undefined)[] => {
const { device, parentDevice, area, floor } = getEntityContext(
stateObj,
const entry = entities[stateObj.entity_id] as
EntityRegistryDisplayEntry | undefined;
if (!entry) {
return name.map((item) =>
item.type === "entity"
? computeStateName(stateObj)
: item.type === "text"
? item.text
: undefined
);
}
return computeEntityEntryNameList(
entry,
name,
entities,
devices,
areas,
floors,
options
);
};
export const computeEntityEntryNameList = (
entry: EntityRegistryDisplayEntry | EntityRegistryEntry,
name: EntityNameItem[],
entities: HomeAssistant["entities"],
devices: HomeAssistant["devices"],
areas: HomeAssistant["areas"],
floors: HomeAssistant["floors"],
options?: EntityNameListOptions
): (string | undefined)[] => {
const { device, parentDevice, area, floor } = getEntityEntryContext(
entry,
entities,
devices,
areas,
floors
);
const entityName = computeEntityEntryName(entry, devices);
const followContext = options?.followContext ?? true;
const names = name.map((item) => {
// Same rule as core's follow_context: owners above the first node with its
// own area are left out. An entity without a name of its own is still named
// after its device.
const nameDevice =
!followContext || entry.context_source !== "area" || !entityName
? device
: null;
const nameParentDevice =
!followContext ||
(entry.context_source !== "area" && device?.context_source !== "area")
? parentDevice
: null;
return name.map((item) => {
switch (item.type) {
case "entity":
return computeEntityName(stateObj, entities, devices);
return entityName;
case "device":
return device ? computeDeviceName(device) : undefined;
return nameDevice ? computeDeviceName(nameDevice) : undefined;
case "parent_device":
return parentDevice ? computeDeviceName(parentDevice) : undefined;
return nameParentDevice
? computeDeviceName(nameParentDevice)
: undefined;
case "area":
return area ? computeAreaName(area) : undefined;
case "floor":
@@ -128,8 +228,37 @@ export const computeEntityNameList = (
return "";
}
});
};
return names;
export const computeEntitySearchLabels = (
stateObj: HassEntity,
entities: HomeAssistant["entities"],
devices: HomeAssistant["devices"],
areas: HomeAssistant["areas"],
floors: HomeAssistant["floors"]
) => {
const [entityName, deviceName, parentDeviceName, areaName] =
computeEntityNameList(
stateObj,
[
{ type: "entity" },
{ type: "device" },
{ type: "parent_device" },
{ type: "area" },
],
entities,
devices,
areas,
floors,
{ followContext: false }
);
return {
entityName: entityName || null,
friendlyName: computeStateName(stateObj) || null,
deviceName: deviceName || null,
parentDeviceName: parentDeviceName || null,
areaName: areaName || null,
};
};
export interface EntityPickerDisplay {
+1
View File
@@ -95,6 +95,7 @@ const FIXED_DOMAIN_ATTRIBUTE_STATES = {
"door",
"garage_door",
"gas",
"glass_break",
"heat",
"light",
"lock",
+1 -1
View File
@@ -33,7 +33,7 @@ export type FormatEntityAttributeNameFunc = (
export type FormatEntityNameFunc = (
stateObj: HassEntity,
name: EntityNameItem | EntityNameItem[],
name: string | EntityNameItem | EntityNameItem[] | undefined,
options?: EntityNameOptions
) => string;
@@ -90,6 +90,7 @@ export class HaAutomationConditionSummary extends LitElement {
></ha-automation-row-options>`
: nothing
}
<slot name="references"></slot>
${
note
? html`
+47 -1
View File
@@ -53,6 +53,41 @@ export interface StatisticsChartData {
yAxisFractionDigits: number;
}
// ECharts stacks a time axis by data index. Merge the sorted point lists so
// every stacked line has the same x at every index, padding with null.
function alignStackedLines(datasets: LineSeriesOption[]) {
const sources = datasets.map((d) => d.data as [number, number | null][]);
const cursors = sources.map(() => 0);
const counts = sources.map(() => 0);
const aligned = sources.map((): [number, number | null][] => []);
while (cursors.some((cursor, i) => cursor < sources[i].length)) {
let x = Infinity;
for (let i = 0; i < sources.length; i++) {
const point = sources[i][cursors[i]];
if (point && point[0] < x) x = point[0];
}
let slots = 1;
for (let i = 0; i < sources.length; i++) {
let count = 0;
while (sources[i][cursors[i] + count]?.[0] === x) count++;
counts[i] = count;
if (count > slots) slots = count;
}
for (let i = 0; i < sources.length; i++) {
const count = counts[i];
for (let slot = 0; slot < slots; slot++) {
aligned[i].push(
count ? sources[i][cursors[i] + Math.min(slot, count - 1)] : [x, null]
);
}
cursors[i] += count;
}
}
datasets.forEach((d, i) => {
d.data = aligned[i];
});
}
/**
* Transforms raw statistics into ECharts series for `statistics-chart`.
* Pure data processing: all environment inputs (current time, theme style,
@@ -263,8 +298,10 @@ export function generateStatisticsChartData(
),
symbol: "none",
// minmax sampling operates independently per series, breaking stacking alignment
// echarts stacks before it samples, so its lttb keeps stacks aligned
// https://github.com/apache/echarts/issues/11879
sampling: band && drawBands ? "lttb" : "minmax",
sampling:
band || (chartStacked && chartType === "line") ? "lttb" : "minmax",
animationDurationUpdate: 0,
lineStyle: {
width: 1.5,
@@ -439,6 +476,15 @@ export function generateStatisticsChartData(
Array.prototype.push.apply(legendData, statLegendData);
});
if (chartType === "line" && chartStacked) {
const stacked = (totalDataSets as LineSeriesOption[]).filter(
(d) => d.data?.length
);
if (stacked.length > 1) {
alignStackedLines(stacked);
}
}
if (chartType === "bar") {
fillDataGapsAndRoundCaps(totalDataSets as BarSeriesOption[], chartStacked);
}
+2
View File
@@ -253,6 +253,8 @@ export class StatisticsChart extends LitElement {
}[] = [];
for (const param of params) {
if (rendered[param.seriesIndex]) continue;
// stacked lines are padded with null where a statistic has no data
if (!chartIsBar && param.value[1] === null) continue;
rendered[param.seriesIndex] = true;
const statisticId = this._statisticIds[param.seriesIndex];
@@ -136,9 +136,7 @@ export class HaEntityNamePicker extends LitElement {
${
this.helper
? html`
<ha-input-helper-text .disabled=${this.disabled}>
${this.helper}
</ha-input-helper-text>
<ha-input-helper-text> ${this.helper} </ha-input-helper-text>
`
: nothing
}
@@ -329,11 +329,7 @@ export class HaStateContentPicker extends LitElement {
private _renderHelper() {
return this.helper
? html`
<ha-input-helper-text .disabled=${this.disabled}>
${this.helper}
</ha-input-helper-text>
`
? html` <ha-input-helper-text> ${this.helper} </ha-input-helper-text> `
: nothing;
}
+37 -78
View File
@@ -11,9 +11,10 @@ import { customElement, property, query } from "lit/decorators";
import memoizeOne from "memoize-one";
import { ensureArray } from "../../common/array/ensure-array";
import { type HASSDomEvent, fireEvent } from "../../common/dom/fire_event";
import { computeEntityNameList } from "../../common/entity/compute_entity_name_display";
import { computeStateName } from "../../common/entity/compute_state_name";
import { computeRTL } from "../../common/util/compute_rtl";
import {
computeEntityPickerDisplay,
computeEntitySearchLabels,
} from "../../common/entity/compute_entity_name_display";
import { domainToName } from "../../data/integration";
import {
getStatisticLabel,
@@ -233,11 +234,6 @@ export class HaStatisticPicker extends LitElement {
});
}
const isRTL = computeRTL(
hass.language,
hass.translationMetadata.translations
);
const output: StatisticComboBoxItem[] = [];
statisticIds.forEach((meta) => {
@@ -291,31 +287,17 @@ export class HaStatisticPicker extends LitElement {
}
const id = meta.statistic_id;
const friendlyName = computeStateName(stateObj); // Keep this for search
const [entityName, deviceName, parentDeviceName, areaName] =
computeEntityNameList(
stateObj,
[
{ type: "entity" },
{ type: "device" },
{ type: "parent_device" },
{ type: "area" },
],
hass.entities,
hass.devices,
hass.areas,
hass.floors
);
const primary = entityName || deviceName || id;
const secondary = [
areaName,
parentDeviceName,
entityName ? deviceName : undefined,
]
.filter(Boolean)
.join(isRTL ? " ◂ " : " ▸ ");
const { primary, secondary } = computeEntityPickerDisplay(
hass,
stateObj
);
const searchLabels = computeEntitySearchLabels(
stateObj,
hass.entities,
hass.devices,
hass.areas,
hass.floors
);
const sortingPrefix = `${TYPE_ORDER.indexOf("entity")}`;
output.push({
@@ -325,14 +307,12 @@ export class HaStatisticPicker extends LitElement {
secondary,
stateObj: stateObj,
type: "entity",
sorting_label: [sortingPrefix, deviceName, entityName].join("_"),
search_labels: {
entityName: entityName || null,
deviceName: deviceName || null,
parentDeviceName: parentDeviceName || null,
areaName: areaName || null,
friendlyName,
},
sorting_label: [
sortingPrefix,
searchLabels.deviceName,
searchLabels.entityName,
].join("_"),
search_labels: searchLabels,
});
});
@@ -407,35 +387,17 @@ export class HaStatisticPicker extends LitElement {
const stateObj = this.hass.states[statisticId];
if (stateObj) {
const [entityName, deviceName, parentDeviceName, areaName] =
computeEntityNameList(
stateObj,
[
{ type: "entity" },
{ type: "device" },
{ type: "parent_device" },
{ type: "area" },
],
this.hass.entities,
this.hass.devices,
this.hass.areas,
this.hass.floors
);
const isRTL = computeRTL(
this.hass.language,
this.hass.translationMetadata.translations
const { primary, secondary } = computeEntityPickerDisplay(
this.hass,
stateObj
);
const searchLabels = computeEntitySearchLabels(
stateObj,
this.hass.entities,
this.hass.devices,
this.hass.areas,
this.hass.floors
);
const primary = entityName || deviceName || statisticId;
const secondary = [
areaName,
parentDeviceName,
entityName ? deviceName : undefined,
]
.filter(Boolean)
.join(isRTL ? " ◂ " : " ▸ ");
const friendlyName = computeStateName(stateObj); // Keep this for search
const sortingPrefix = `${TYPE_ORDER.indexOf("entity")}`;
return {
@@ -445,15 +407,12 @@ export class HaStatisticPicker extends LitElement {
secondary,
stateObj: stateObj,
type: "entity",
sorting_label: [sortingPrefix, deviceName, entityName].join("_"),
search_labels: {
entityName: entityName || null,
deviceName: deviceName || null,
parentDeviceName: parentDeviceName || null,
areaName: areaName || null,
friendlyName,
statisticId,
},
sorting_label: [
sortingPrefix,
searchLabels.deviceName,
searchLabels.entityName,
].join("_"),
search_labels: { ...searchLabels, statisticId },
};
}
+16 -13
View File
@@ -1,6 +1,7 @@
import type { CSSResultGroup, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { customElement, property, state } from "lit/decorators";
import { consumeLocalize } from "../common/decorators/consume-context-entry";
import { fireEvent } from "../common/dom/fire_event";
import type { LocalizeFunc } from "../common/translations/localize";
import type { Analytics, AnalyticsPreferences } from "../data/analytics";
@@ -20,13 +21,15 @@ declare global {
@customElement("ha-analytics")
export class HaAnalytics extends LitElement {
@property({ attribute: false }) public localize!: LocalizeFunc;
@property({ attribute: false }) public analytics?: Analytics;
@property({ attribute: "translation_key_panel" }) public translationKeyPanel:
"page-onboarding" | "config" = "config";
@state()
@consumeLocalize()
private _localize!: LocalizeFunc;
protected render(): TemplateResult {
const loading = this.analytics === undefined;
const baseEnabled = !loading && this.analytics!.preferences.base;
@@ -34,12 +37,12 @@ export class HaAnalytics extends LitElement {
return html`
<ha-row-item>
<span slot="headline"
>${this.localize(
>${this._localize(
`ui.panel.${this.translationKeyPanel}.analytics.preferences.base.title`
)}</span
>
<span slot="supporting-text"
>${this.localize(
>${this._localize(
`ui.panel.${this.translationKeyPanel}.analytics.preferences.base.description`
)}</span
>
@@ -51,7 +54,7 @@ export class HaAnalytics extends LitElement {
.disabled=${loading}
name="base"
>
${this.localize(
${this._localize(
`ui.panel.${this.translationKeyPanel}.analytics.preferences.base.title`
)}
</ha-switch>
@@ -60,12 +63,12 @@ export class HaAnalytics extends LitElement {
(preference) => html`
<ha-row-item>
<span slot="headline"
>${this.localize(
>${this._localize(
`ui.panel.${this.translationKeyPanel}.analytics.preferences.${preference}.title`
)}</span
>
<span slot="supporting-text"
>${this.localize(
>${this._localize(
`ui.panel.${this.translationKeyPanel}.analytics.preferences.${preference}.description`
)}</span
>
@@ -77,7 +80,7 @@ export class HaAnalytics extends LitElement {
.preference=${preference}
name=${preference}
>
${this.localize(
${this._localize(
`ui.panel.${this.translationKeyPanel}.analytics.preferences.${preference}.title`
)}
</ha-switch>
@@ -88,7 +91,7 @@ export class HaAnalytics extends LitElement {
.for="switch-${preference}"
placement="right"
>
${this.localize(
${this._localize(
`ui.panel.${this.translationKeyPanel}.analytics.need_base_enabled`
)}
</ha-tooltip>`
@@ -98,12 +101,12 @@ export class HaAnalytics extends LitElement {
)}
<ha-row-item>
<span slot="headline"
>${this.localize(
>${this._localize(
`ui.panel.${this.translationKeyPanel}.analytics.preferences.diagnostics.title`
)}</span
>
<span slot="supporting-text"
>${this.localize(
>${this._localize(
`ui.panel.${this.translationKeyPanel}.analytics.preferences.diagnostics.description`
)}</span
>
@@ -115,7 +118,7 @@ export class HaAnalytics extends LitElement {
.disabled=${loading}
name="diagnostics"
>
${this.localize(
${this._localize(
`ui.panel.${this.translationKeyPanel}.analytics.preferences.diagnostics.title`
)}
</ha-switch>
+5 -30
View File
@@ -3,8 +3,7 @@ import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import Fuse from "fuse.js";
import memoizeOne from "memoize-one";
import { computeEntityNameList } from "../common/entity/compute_entity_name_display";
import { computeRTL } from "../common/util/compute_rtl";
import { computeEntityPickerDisplay } from "../common/entity/compute_entity_name_display";
import type { LocalizeFunc } from "../common/translations/localize";
import {
multiTermSortedSearch,
@@ -183,11 +182,6 @@ export class HaAreaControlsPicker extends LitElement {
const allEntityIds = Object.values(controlEntities).flat();
const uniqueEntityIds = Array.from(new Set(allEntityIds));
const isRTL = computeRTL(
this.hass.language,
this.hass.translationMetadata.translations
);
uniqueEntityIds.forEach((entityId) => {
if (isSelected(entityId)) {
return;
@@ -197,29 +191,10 @@ export class HaAreaControlsPicker extends LitElement {
return;
}
const [entityName, deviceName, parentDeviceName, areaName] =
computeEntityNameList(
stateObj,
[
{ type: "entity" },
{ type: "device" },
{ type: "parent_device" },
{ type: "area" },
],
this.hass!.entities,
this.hass!.devices,
this.hass!.areas,
this.hass!.floors
);
const primary = entityName || deviceName || entityId;
const secondary = [
areaName,
parentDeviceName,
entityName ? deviceName : undefined,
]
.filter(Boolean)
.join(isRTL ? " ◂ " : " ▸ ");
const { primary, secondary } = computeEntityPickerDisplay(
this.hass!,
stateObj
);
entityItems.push({
type: "entity",
+1 -3
View File
@@ -312,9 +312,7 @@ export class HaBaseTimeInput extends LitElement {
</div>
${
this.helper
? html`<ha-input-helper-text .disabled=${this.disabled}
>${this.helper}</ha-input-helper-text
>`
? html`<ha-input-helper-text>${this.helper}</ha-input-helper-text>`
: nothing
}
`;
@@ -198,11 +198,7 @@ export class HaClockDateFormatPicker extends LitElement {
private _renderHelper() {
return this.helper
? html`
<ha-input-helper-text .disabled=${this.disabled}>
${this.helper}
</ha-input-helper-text>
`
? html` <ha-input-helper-text> ${this.helper} </ha-input-helper-text> `
: nothing;
}
+5
View File
@@ -272,6 +272,11 @@ export class HaDurationInput extends LitElement {
display: flex;
align-items: center;
}
/* Full width: bigger touch targets, no ragged right edge in a form. */
ha-base-time-input {
flex: 1;
--time-input-flex: 1;
}
ha-button-toggle-group {
margin: var(--ha-space-2);
}
+15 -12
View File
@@ -27,9 +27,9 @@ declare global {
@customElement("ha-file-upload")
export class HaFileUpload extends LitElement {
@property({ attribute: false }) public localize?: LocalizeFunc;
@state() @consumeLocalize() private _localize?: LocalizeFunc;
@state()
@consumeLocalize()
private _localize!: LocalizeFunc;
@state()
@consume({ context: internationalizationContext, subscribe: true })
@@ -92,7 +92,6 @@ export class HaFileUpload extends LitElement {
}
public render(): TemplateResult {
const localize = this.localize || this._localize!;
return html`
${
this.uploading
@@ -102,10 +101,13 @@ export class HaFileUpload extends LitElement {
>${
this.uploadingLabel ||
(this.value
? localize("ui.components.file-upload.uploading_name", {
name: this._name,
})
: localize("ui.components.file-upload.uploading"))
? this._localize(
"ui.components.file-upload.uploading_name",
{
name: this._name,
}
)
: this._localize("ui.components.file-upload.uploading"))
}</span
>
${
@@ -147,12 +149,12 @@ export class HaFileUpload extends LitElement {
slot="start"
.path=${this.icon || mdiFileUpload}
></ha-svg-icon>
${this.label || localize("ui.components.file-upload.label")}
${this.label || this._localize("ui.components.file-upload.label")}
</ha-button>
<span class="secondary"
>${
this.secondary ||
localize("ui.components.file-upload.secondary")
this._localize("ui.components.file-upload.secondary")
}</span
>
<span class="supports">${this.supports}</span>`
@@ -166,7 +168,7 @@ export class HaFileUpload extends LitElement {
</div>
<ha-icon-button
@click=${this._clearValue}
.label=${this.deleteLabel || localize("ui.common.delete")}
.label=${this.deleteLabel || this._localize("ui.common.delete")}
.path=${mdiDelete}
></ha-icon-button>
</div>`
@@ -185,7 +187,8 @@ export class HaFileUpload extends LitElement {
<ha-icon-button
@click=${this._clearValue}
.label=${
this.deleteLabel || localize("ui.common.delete")
this.deleteLabel ||
this._localize("ui.common.delete")
}
.path=${mdiDelete}
></ha-icon-button>
+11 -1
View File
@@ -60,6 +60,9 @@ export class HaFilterDevices extends LitElement {
@property() public type?: keyof RelatedResult;
@property({ type: Boolean, attribute: "include-disabled-entities" })
public includeDisabledEntities = false;
@property({ type: Boolean, reflect: true }) public expanded = false;
@property({ type: Boolean }) public narrow = false;
@@ -227,7 +230,14 @@ export class HaFilterDevices extends LitElement {
for (const deviceId of this.value) {
value.push(deviceId);
if (this.type) {
relatedPromises.push(findRelated(this._api, "device", deviceId));
relatedPromises.push(
findRelated(
this._api,
"device",
deviceId,
this.includeDisabledEntities
)
);
}
}
const results = await Promise.all(relatedPromises);
+14 -2
View File
@@ -65,6 +65,9 @@ export class HaFilterFloorAreas extends LitElement {
@property() public type?: keyof RelatedResult;
@property({ type: Boolean, attribute: "include-disabled-entities" })
public includeDisabledEntities = false;
@property({ type: Boolean }) public narrow = false;
@property({ type: Boolean, reflect: true }) public expanded = false;
@@ -295,7 +298,9 @@ export class HaFilterFloorAreas extends LitElement {
if (this.value.areas) {
for (const areaId of this.value.areas) {
if (this.type) {
relatedPromises.push(findRelated(this._api, "area", areaId));
relatedPromises.push(
findRelated(this._api, "area", areaId, this.includeDisabledEntities)
);
}
}
}
@@ -303,7 +308,14 @@ export class HaFilterFloorAreas extends LitElement {
if (this.value.floors) {
for (const floorId of this.value.floors) {
if (this.type) {
relatedPromises.push(findRelated(this._api, "floor", floorId));
relatedPromises.push(
findRelated(
this._api,
"floor",
floorId,
this.includeDisabledEntities
)
);
}
}
}
+1 -1
View File
@@ -91,7 +91,7 @@ export class HaFormInteger extends LitElement implements HaFormElement {
</div>
${
this.helper
? html`<ha-input-helper-text .disabled=${this.disabled}
? html`<ha-input-helper-text
>${this.helper}</ha-input-helper-text
>`
: nothing
+1 -1
View File
@@ -329,7 +329,7 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
return nothing;
}
return html`<ha-input-helper-text .disabled=${this.disabled}>
return html`<ha-input-helper-text>
${
showError
? html`<span class="error">${this.errorMessage}</span> ${
+1 -6
View File
@@ -1,11 +1,9 @@
import type { TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import { customElement } from "lit/decorators";
@customElement("ha-input-helper-text")
class InputHelperText extends LitElement {
@property({ type: Boolean, reflect: true }) disabled = false;
protected render(): TemplateResult {
return html`<slot></slot>`;
}
@@ -25,9 +23,6 @@ class InputHelperText extends LitElement {
);
line-height: normal;
}
:host([disabled]) {
color: var(--mdc-text-field-disabled-ink-color, rgba(0, 0, 0, 0.6));
}
`;
}
+1 -3
View File
@@ -50,9 +50,7 @@ class HaLabeledSlider extends LitElement {
</div>
${
this.helper
? html`<ha-input-helper-text .disabled=${this.disabled}>
${this.helper}
</ha-input-helper-text>`
? html`<ha-input-helper-text> ${this.helper} </ha-input-helper-text>`
: nothing
}
`;
+1 -3
View File
@@ -239,9 +239,7 @@ export class HaMediaItemPicker extends LitElement {
}
${
hideEntityPicker && this.helper
? html`<ha-input-helper-text .disabled=${this.disabled}
>${this.helper}</ha-input-helper-text
>`
? html`<ha-input-helper-text>${this.helper}</ha-input-helper-text>`
: nothing
}
`;
-26
View File
@@ -1,26 +0,0 @@
import { OutlinedButton } from "@material/web/button/internal/outlined-button";
import { styles as sharedStyles } from "@material/web/button/internal/shared-styles.cssresult.js";
import { styles } from "@material/web/button/internal/outlined-styles.cssresult.js";
import { css } from "lit";
import { customElement } from "lit/decorators";
@customElement("ha-outlined-button")
export class HaOutlinedButton extends OutlinedButton {
static override styles = [
sharedStyles,
styles,
css`
:host {
--ha-icon-display: block;
--md-sys-color-primary: var(--primary-text-color);
--md-sys-color-outline: var(--outline-color);
}
`,
];
}
declare global {
interface HTMLElementTagNameMap {
"ha-outlined-button": HaOutlinedButton;
}
}
-36
View File
@@ -1,36 +0,0 @@
import { OutlinedField } from "@material/web/field/internal/outlined-field";
import { styles } from "@material/web/field/internal/outlined-styles.cssresult.js";
import { styles as sharedStyles } from "@material/web/field/internal/shared-styles.cssresult.js";
import { css } from "lit";
import { customElement } from "lit/decorators";
import { literal } from "lit/static-html";
@customElement("ha-outlined-field")
export class HaOutlinedField extends OutlinedField {
protected readonly fieldTag = literal`ha-outlined-field`;
static override styles = [
sharedStyles,
styles,
css`
.container::before {
display: block;
content: "";
position: absolute;
inset: 0;
background-color: var(--ha-outlined-field-container-color, transparent);
opacity: var(--ha-outlined-field-container-opacity, 1);
border-start-start-radius: var(--_container-shape-start-start);
border-start-end-radius: var(--_container-shape-start-end);
border-end-start-radius: var(--_container-shape-end-start);
border-end-end-radius: var(--_container-shape-end-end);
}
`,
];
}
declare global {
interface HTMLElementTagNameMap {
"ha-outlined-field": HaOutlinedField;
}
}
+1 -3
View File
@@ -164,9 +164,7 @@ export class HaSelect extends LitElement {
private _renderHelper() {
return this.helper
? html`<ha-input-helper-text .disabled=${this.disabled}
>${this.helper}</ha-input-helper-text
>`
? html`<ha-input-helper-text>${this.helper}</ha-input-helper-text>`
: nothing;
}
@@ -72,9 +72,7 @@ export class HaSelectorAutomationBehavior extends LitElement {
></ha-select-box>
${
this.helper
? html`<ha-input-helper-text .disabled=${this.disabled}
>${this.helper}</ha-input-helper-text
>`
? html`<ha-input-helper-text>${this.helper}</ha-input-helper-text>`
: nothing
}
`;
@@ -68,9 +68,7 @@ export class HaDateTimeSelector extends LitElement {
</div>
${
this.helper
? html`<ha-input-helper-text .disabled=${this.disabled}
>${this.helper}</ha-input-helper-text
>`
? html`<ha-input-helper-text>${this.helper}</ha-input-helper-text>`
: ""
}
`;
@@ -143,9 +143,7 @@ export class HaNumberSelector extends LitElement {
</div>
${
!isBox && this.helper
? html`<ha-input-helper-text .disabled=${this.disabled}
>${this.helper}</ha-input-helper-text
>`
? html`<ha-input-helper-text>${this.helper}</ha-input-helper-text>`
: nothing
}
`;
@@ -204,9 +204,7 @@ export class HaObjectSelector extends LitElement {
></ha-yaml-editor>
${
this.helper
? html`<ha-input-helper-text .disabled=${this.disabled}
>${this.helper}</ha-input-helper-text
>`
? html`<ha-input-helper-text>${this.helper}</ha-input-helper-text>`
: ""
} `;
}
@@ -268,9 +268,7 @@ export class HaSelectSelector extends LitElement {
private _renderHelper() {
return this.helper
? html`<ha-input-helper-text .disabled=${this.disabled}
>${this.helper}</ha-input-helper-text
>`
? html`<ha-input-helper-text>${this.helper}</ha-input-helper-text>`
: "";
}
@@ -132,9 +132,7 @@ ${
}
${
this.helper
? html`<ha-input-helper-text .disabled=${this.disabled}
>${this.helper}</ha-input-helper-text
>`
? html`<ha-input-helper-text>${this.helper}</ha-input-helper-text>`
: nothing
}
`;
-42
View File
@@ -1,42 +0,0 @@
import { SubMenu } from "@material/web/menu/internal/submenu/sub-menu";
import { styles } from "@material/web/menu/internal/submenu/sub-menu-styles.cssresult.js";
import { css } from "lit";
import { customElement } from "lit/decorators";
@customElement("ha-sub-menu")
export class HaSubMenu extends SubMenu {
async show() {
super.show();
this.menu.hasOverflow = false;
}
static override styles = [
styles,
css`
:host {
--ha-icon-display: block;
--md-sys-color-primary: var(--primary-text-color);
--md-sys-color-on-primary: var(--primary-text-color);
--md-sys-color-secondary: var(--secondary-text-color);
--md-sys-color-surface: var(--card-background-color);
--md-sys-color-on-surface: var(--primary-text-color);
--md-sys-color-on-surface-variant: var(--secondary-text-color);
--md-sys-color-secondary-container: rgba(
var(--rgb-primary-color),
0.15
);
--md-sys-color-on-secondary-container: var(--text-primary-color);
--mdc-icon-size: 16px;
--md-sys-color-on-primary-container: var(--primary-text-color);
--md-sys-color-on-secondary-container: var(--primary-text-color);
}
`,
];
}
declare global {
interface HTMLElementTagNameMap {
"ha-sub-menu": HaSubMenu;
}
}
+1 -3
View File
@@ -208,9 +208,7 @@ class HaInputMulti extends LitElement {
</div>
${
this.helper
? html`<ha-input-helper-text .disabled=${this.disabled}
>${this.helper}</ha-input-helper-text
>`
? html`<ha-input-helper-text>${this.helper}</ha-input-helper-text>`
: nothing
}
`;
@@ -7,9 +7,11 @@ import { customElement, property, query } from "lit/decorators";
import { styleMap } from "lit/directives/style-map";
import { fireEvent, type HASSDomEvent } from "../../common/dom/fire_event";
import { computeDomain } from "../../common/entity/compute_domain";
import { computeEntityNameList } from "../../common/entity/compute_entity_name_display";
import {
computeEntityPickerDisplay,
computeEntitySearchLabels,
} from "../../common/entity/compute_entity_name_display";
import { computeStateDomain } from "../../common/entity/compute_state_domain";
import { computeStateName } from "../../common/entity/compute_state_name";
import { supportsFeature } from "../../common/entity/supports-feature";
import {
areasContext,
@@ -107,9 +109,6 @@ export class HaMediaPlayerPicker extends LitElement {
const webBrowserLabel = this._i18n.localize(
"ui.components.media-browser.web-browser"
);
const lang = this._i18n.language || "en";
const isRTL =
this._i18n.translationMetadata.translations[lang]?.isRTL || false;
return [
{
@@ -128,34 +127,22 @@ export class HaMediaPlayerPicker extends LitElement {
...Object.values(this._states)
.filter(this._filterPlayerEntities)
.map<MediaPlayerComboBoxItem>((stateObj) => {
const friendlyName = computeStateName(stateObj);
const [entityName, deviceName, parentDeviceName, areaName] =
computeEntityNameList(
stateObj,
[
{ type: "entity" },
{ type: "device" },
{ type: "parent_device" },
{ type: "area" },
],
this._entities,
this._devices,
this._areas,
this._floors
);
const entityId = stateObj.entity_id;
const domainName = domainToName(
this._i18n.localize,
computeDomain(entityId)
);
const primary = entityName || deviceName || entityId;
const secondary = [
areaName,
parentDeviceName,
entityName ? deviceName : undefined,
]
.filter(Boolean)
.join(isRTL ? " ◂ " : " ▸ ");
const { primary, secondary } = computeEntityPickerDisplay(
{
entities: this._entities,
devices: this._devices,
areas: this._areas,
floors: this._floors,
language: this._i18n.language,
translationMetadata: this._i18n.translationMetadata,
},
stateObj
);
return {
id: entityId,
@@ -165,12 +152,14 @@ export class HaMediaPlayerPicker extends LitElement {
domain_name: domainName,
sorting_label: [primary, secondary].filter(Boolean).join("_"),
search_labels: {
entityName: entityName || null,
deviceName: deviceName || null,
parentDeviceName: parentDeviceName || null,
areaName: areaName || null,
...computeEntitySearchLabels(
stateObj,
this._entities,
this._devices,
this._areas,
this._floors
),
domainName: domainName || null,
friendlyName: friendlyName || null,
entityId,
},
stateObj,
@@ -7,8 +7,7 @@ import memoizeOne from "memoize-one";
import { consumeEntityState } from "../../common/decorators/consume-context-entry";
import { fireEvent } from "../../common/dom/fire_event";
import { computeEntityNameList } from "../../common/entity/compute_entity_name_display";
import { computeRTL } from "../../common/util/compute_rtl";
import { computeEntityPickerDisplay } from "../../common/entity/compute_entity_name_display";
import {
areasContext,
devicesContext,
@@ -54,40 +53,24 @@ class HaMediaPlayerToggle extends LitElement {
private _computeDisplayData = memoizeOne(
(
entityId: string,
entities: ContextType<typeof entitiesContext>,
devices: ContextType<typeof devicesContext>,
areas: ContextType<typeof areasContext>,
floors: ContextType<typeof floorsContext>,
isRTL: boolean,
i18n: ContextType<typeof internationalizationContext>,
stateObj: HassEntity
) => {
const [entityName, deviceName, parentDeviceName, areaName] =
computeEntityNameList(
stateObj,
[
{ type: "entity" },
{ type: "device" },
{ type: "parent_device" },
{ type: "area" },
],
) =>
computeEntityPickerDisplay(
{
entities,
devices,
areas,
floors
);
const primary = entityName || deviceName || entityId;
const secondary = [
areaName,
parentDeviceName,
entityName ? deviceName : undefined,
]
.filter(Boolean)
.join(isRTL ? " ◂ " : " ▸ ");
return { primary, secondary };
}
floors,
language: i18n.language,
translationMetadata: i18n.translationMetadata,
},
stateObj
)
);
protected render() {
@@ -104,18 +87,12 @@ class HaMediaPlayerToggle extends LitElement {
icon = mdiSpeakerPause;
}
const isRTL = computeRTL(
this._i18n.language,
this._i18n.translationMetadata.translations
);
const { primary, secondary } = this._computeDisplayData(
this.entityId,
this._entities,
this._devices,
this._areas,
this._floors,
isRTL,
this._i18n,
stateObj
);
@@ -26,12 +26,11 @@ import {
computeDeviceNameDisplay,
} from "../../common/entity/compute_device_name";
import { computeDomain } from "../../common/entity/compute_domain";
import { computeEntityName } from "../../common/entity/compute_entity_name";
import { computeEntityPickerDisplay } from "../../common/entity/compute_entity_name_display";
import {
getDeviceArea,
getDeviceAreaId,
} from "../../common/entity/context/get_device_context";
import { getEntityContext } from "../../common/entity/context/get_entity_context";
import { computeRTL } from "../../common/util/compute_rtl";
import type { AreaRegistryEntry } from "../../data/area/area_registry";
import { getConfigEntry } from "../../data/config_entries";
@@ -640,9 +639,10 @@ export class HaTargetPickerItemRow extends LitElement {
const area = device
? getDeviceArea(device, this.hass.areas, this.hass.devices)
: undefined;
const parentDevice = device?.parent_device_id
? this.hass.devices[device.parent_device_id]
: undefined;
const parentDevice =
device?.parent_device_id && device.context_source !== "area"
? this.hass.devices[device.parent_device_id]
: undefined;
const context = [
area ? computeAreaName(area) : undefined,
parentDevice ? computeDeviceName(parentDevice) : undefined,
@@ -672,40 +672,12 @@ export class HaTargetPickerItemRow extends LitElement {
}
if (type === "entity") {
const stateObject: HassEntity | undefined = this.hass.states[item];
const entityName = stateObject
? computeEntityName(stateObject, this.hass.entities, this.hass.devices)
: item;
const { area, device, parentDevice } = stateObject
? getEntityContext(
stateObject,
this.hass.entities,
this.hass.devices,
this.hass.areas,
this.hass.floors
)
: { area: undefined, device: undefined, parentDevice: undefined };
const deviceName = device ? computeDeviceName(device) : undefined;
const parentDeviceName = parentDevice
? computeDeviceName(parentDevice)
: undefined;
const areaName = area ? computeAreaName(area) : undefined;
const context = [
areaName,
parentDeviceName,
entityName ? deviceName : undefined,
]
.filter(Boolean)
.join(
computeRTL(
this.hass.language,
this.hass.translationMetadata.translations
)
? " ◂ "
: " ▸ "
);
const { primary, secondary } = stateObject
? computeEntityPickerDisplay(this.hass, stateObject)
: { primary: item, secondary: undefined };
return {
name: entityName || deviceName || item,
context,
name: primary,
context: secondary,
stateObject,
notFound: !stateObject && item !== "all" && item !== "none",
};
@@ -326,7 +326,7 @@ export class HaTracePathDetails extends LitElement {
this._entityReg,
currentDetail,
undefined,
false,
undefined,
this._manifests
)
: selectedType === "chooseOption"
+1 -1
View File
@@ -301,7 +301,7 @@ export interface TemplateCondition extends BaseCondition {
export interface TriggerCondition extends BaseCondition {
condition: "trigger";
id: string;
id: string | string[];
}
type ShorthandBaseCondition = Omit<BaseCondition, "condition">;
+8
View File
@@ -149,6 +149,8 @@ const formatNumericLimitValue = (
export interface DescribeOptions {
// Skip the user defined alias and describe the underlying config.
ignoreAlias?: boolean;
// Rows with trigger-reference chips render the IDs separately.
hideTriggerIds?: boolean;
}
export const describeTrigger = (
@@ -923,6 +925,12 @@ const tryDescribeCondition = (
return condition.alias;
}
if (condition.condition === "trigger" && options?.hideTriggerIds) {
return hass.localize(
`${conditionsTranslationBaseKey}.trigger.description.summary`
);
}
if (!condition.condition) {
const shorthands: ("and" | "or" | "not")[] = ["and", "or", "not"];
for (const key of shorthands) {
+3 -1
View File
@@ -8,7 +8,7 @@ import type {
EntityRegistryEntry,
} from "../entity/entity_registry";
import type { EntitySources } from "../entity/entity_sources";
import type { RegistryEntry } from "../registry";
import type { ContextSource, RegistryEntry } from "../registry";
export {
fetchDeviceRegistry,
@@ -46,6 +46,7 @@ export interface DeviceRegistryEntry extends RegistryEntry {
// Set when this device is a child (logical part) of another device.
// null for regular top-level devices.
parent_device_id: string | null;
context_source?: ContextSource | null;
}
/**
@@ -68,6 +69,7 @@ export interface ChildDeviceRegistryEntry extends RegistryEntry {
area_id: string | null;
disabled_by: DeviceDisabler | null;
parent_device_id: string;
context_source?: ContextSource | null;
}
/**
+1
View File
@@ -12,6 +12,7 @@ export const DOMAIN_DEVICE_CLASSES: Record<string, string[]> = {
"door",
"garage_door",
"gas",
"glass_break",
"heat",
"light",
"lock",
+12 -36
View File
@@ -1,11 +1,12 @@
import type { HassEntity } from "home-assistant-js-websocket";
import { getEntityAreaId } from "../../common/entity/context/get_entity_context";
import { computeDomain } from "../../common/entity/compute_domain";
import { computeEntityNameList } from "../../common/entity/compute_entity_name_display";
import { computeStateName } from "../../common/entity/compute_state_name";
import {
computeEntityPickerDisplay,
computeEntitySearchLabels,
} from "../../common/entity/compute_entity_name_display";
import type { RelatedIdSets } from "../../common/search/related-context";
import { caseInsensitiveStringCompare } from "../../common/string/compare";
import { computeRTL } from "../../common/util/compute_rtl";
import type { PickerComboBoxItem } from "../../components/ha-picker-combo-box";
import type { FuseWeightedKey } from "../../resources/fuseMultiTerm";
import type { HomeAssistant } from "../../types";
@@ -123,31 +124,11 @@ export const getEntities = (
// These values are the same for every entity, so compute them once instead
// of inside the map over (potentially thousands of) entities.
const isRTL = computeRTL(
hass.language,
hass.translationMetadata.translations
);
const domainNames = new Map<string, string>();
items = entityIds.map<EntityComboBoxItem>((entityId) => {
const stateObj = hass.states[entityId];
const friendlyName = computeStateName(stateObj); // Keep this for search
const [entityName, deviceName, parentDeviceName, areaName] =
computeEntityNameList(
stateObj,
[
{ type: "entity" },
{ type: "device" },
{ type: "parent_device" },
{ type: "area" },
],
hass.entities,
hass.devices,
hass.areas,
hass.floors
);
const domain = computeDomain(entityId);
let domainName = domainNames.get(domain);
if (domainName === undefined) {
@@ -155,14 +136,7 @@ export const getEntities = (
domainNames.set(domain, domainName);
}
const primary = entityName || deviceName || entityId;
const secondary = [
areaName,
parentDeviceName,
entityName ? deviceName : undefined,
]
.filter(Boolean)
.join(isRTL ? " ◂ " : " ▸ ");
const { primary, secondary } = computeEntityPickerDisplay(hass, stateObj);
return {
id: `${idPrefix}${entityId}`,
@@ -171,12 +145,14 @@ export const getEntities = (
domain_name: domainName,
sorting_label: [primary, secondary].filter(Boolean).join("_"),
search_labels: {
entityName: entityName || null,
deviceName: deviceName || null,
parentDeviceName: parentDeviceName || null,
areaName: areaName || null,
...computeEntitySearchLabels(
stateObj,
hass.entities,
hass.devices,
hass.areas,
hass.floors
),
domainName: domainName || null,
friendlyName: friendlyName || null,
entityId: entityId,
},
stateObj: stateObj,
+4 -1
View File
@@ -8,7 +8,7 @@ import { caseInsensitiveStringCompare } from "../../common/string/compare";
import { debounce } from "../../common/util/debounce";
import type { HomeAssistant } from "../../types";
import type { LightColor } from "../light";
import type { RegistryEntry } from "../registry";
import type { ContextSource, RegistryEntry } from "../registry";
import type { Segment } from "../vacuum";
type EntityCategory = "config" | "diagnostic";
@@ -19,6 +19,7 @@ export interface EntityRegistryDisplayEntry {
icon?: string;
device_id?: string;
area_id?: string;
context_source?: ContextSource;
labels: string[];
hidden?: boolean;
entity_category?: EntityCategory;
@@ -33,6 +34,7 @@ export interface EntityRegistryDisplayEntryResponse {
ei: string;
di?: string;
ai?: string;
cs?: ContextSource;
lb: string[];
ec?: number;
en?: string;
@@ -56,6 +58,7 @@ export interface EntityRegistryEntry extends RegistryEntry {
config_subentry_id: string | null;
device_id: string | null;
area_id: string | null;
context_source?: ContextSource | null;
labels: string[];
disabled_by: "user" | "device" | "integration" | "config_entry" | null;
hidden_by: Exclude<EntityRegistryEntry["disabled_by"], "config_entry">;
+45 -23
View File
@@ -36,23 +36,51 @@ export interface FanEntity extends HassEntityBase {
export type FanDirection = "forward" | "reverse";
export type FanSpeed = "off" | "low" | "medium" | "high" | "on";
// Named speeds are used when there are icons for every step. Fans with more
// steps use numbered speeds ("1", "2", ...) instead.
export type FanSpeed = "off" | "low" | "medium" | "high" | "on" | `${number}`;
export const FAN_SPEEDS: Partial<Record<number, FanSpeed[]>> = {
const FAN_SPEEDS_NAMED: Partial<Record<number, FanSpeed[]>> = {
2: ["off", "on"],
3: ["off", "low", "high"],
4: ["off", "low", "medium", "high"],
};
export const FAN_SPEED_COUNT_MAX_FOR_BUTTONS = 6;
export function computeFanSpeedCount(stateObj: FanEntity): number {
const step = stateObj.attributes.percentage_step ?? 1;
const speedCount = Math.round(100 / step) + 1;
return speedCount;
}
export function computeFanSpeeds(stateObj: FanEntity): FanSpeed[] | undefined {
const speedCount = computeFanSpeedCount(stateObj);
if (speedCount > FAN_SPEED_COUNT_MAX_FOR_BUTTONS) {
return undefined;
}
return (
FAN_SPEEDS_NAMED[speedCount] ?? [
"off",
...Array.from(
{ length: speedCount - 1 },
(_, index) => `${index + 1}` as const
),
]
);
}
export const isNumberedFanSpeed = (speed: FanSpeed): speed is `${number}` =>
!isNaN(Number(speed));
export function fanPercentageToSpeed(
stateObj: FanEntity,
value: number
): FanSpeed {
const step = stateObj.attributes.percentage_step ?? 1;
const speedValue = Math.round(value / step);
const speedCount = Math.round(100 / step) + 1;
const speeds = FAN_SPEEDS[speedCount];
const speeds = computeFanSpeeds(stateObj);
return speeds?.[speedValue] ?? "off";
}
@@ -61,9 +89,8 @@ export function fanSpeedToPercentage(
speed: FanSpeed
): number {
const step = stateObj.attributes.percentage_step ?? 1;
const speedCount = Math.round(100 / step) + 1;
const speeds = FAN_SPEEDS[speedCount];
const speeds = computeFanSpeeds(stateObj);
if (!speeds) {
return 0;
@@ -76,27 +103,22 @@ export function fanSpeedToPercentage(
return Math.floor(speedValue * step);
}
export function computeFanSpeedCount(stateObj: FanEntity): number {
const step = stateObj.attributes.percentage_step ?? 1;
const speedCount = Math.round(100 / step) + 1;
return speedCount;
}
export function computeFanSpeedIcon(
stateObj: FanEntity,
speed: FanSpeed
): string {
const speedCount = computeFanSpeedCount(stateObj);
const speeds = FAN_SPEEDS[speedCount];
const index = speeds?.indexOf(speed) ?? 1;
return speed === "on"
? mdiFan
: speed === "off"
? mdiFanOff
: [mdiFanSpeed1, mdiFanSpeed2, mdiFanSpeed3][index - 1];
): string | undefined {
if (speed === "on") {
return mdiFan;
}
if (speed === "off") {
return mdiFanOff;
}
if (isNumberedFanSpeed(speed)) {
return undefined;
}
const index = computeFanSpeeds(stateObj)?.indexOf(speed) ?? 1;
return [mdiFanSpeed1, mdiFanSpeed2, mdiFanSpeed3][index - 1];
}
export const FAN_SPEED_COUNT_MAX_FOR_BUTTONS = 4;
export function computeFanSpeedStateDisplay(
stateObj: FanEntity,
+2
View File
@@ -1,3 +1,5 @@
export type ContextSource = "area" | "device" | "parent_device";
export interface RegistryEntry {
created_at: number;
modified_at: number;
+11 -6
View File
@@ -6,7 +6,7 @@ import { formatListWithAnds } from "../common/string/format-list";
import { isTemplate } from "../common/string/has-template";
import type { HomeAssistant } from "../types";
import type { Condition } from "./automation";
import { describeCondition } from "./automation_i18n";
import { describeCondition, type DescribeOptions } from "./automation_i18n";
import { localizeDeviceAutomationAction } from "./device/device_automation";
import type { EntityRegistryEntry } from "./entity/entity_registry";
import { domainToName } from "./integration";
@@ -47,7 +47,7 @@ export const describeAction = <T extends ActionType>(
entityRegistry: EntityRegistryEntry[],
action: ActionTypes[T],
actionType?: T,
ignoreAlias = false,
options?: DescribeOptions,
manifests?: DomainManifestLookup
): string => {
try {
@@ -56,7 +56,7 @@ export const describeAction = <T extends ActionType>(
entityRegistry,
action,
actionType,
ignoreAlias,
options,
manifests
);
if (typeof description !== "string") {
@@ -79,10 +79,10 @@ const tryDescribeAction = <T extends ActionType>(
entityRegistry: EntityRegistryEntry[],
action: ActionTypes[T],
actionType?: T,
ignoreAlias = false,
options?: DescribeOptions,
manifests?: DomainManifestLookup
): string => {
if (action.alias && !ignoreAlias) {
if (action.alias && !options?.ignoreAlias) {
return action.alias;
}
if (!actionType) {
@@ -322,7 +322,12 @@ const tryDescribeAction = <T extends ActionType>(
return hass.localize(
`${actionTranslationBaseKey}.check_condition.description.full`,
{
condition: describeCondition(action as Condition, hass, entityRegistry),
condition: describeCondition(
action as Condition,
hass,
entityRegistry,
options
),
}
);
}
+3 -1
View File
@@ -38,10 +38,12 @@ export type ItemType =
export const findRelated = (
hass: Pick<HomeAssistant, "callWS">,
itemType: ItemType,
itemId: string
itemId: string,
includeDisabledEntities = false
): Promise<RelatedResult> =>
hass.callWS<RelatedResult>({
type: "search/related",
item_type: itemType,
item_id: itemId,
...(includeDisabledEntities ? { include_disabled_entities: true } : {}),
});
@@ -0,0 +1,318 @@
import "@home-assistant/webawesome/dist/components/skeleton/skeleton";
import { consume } from "@lit/context";
import type { HassConfig } from "home-assistant-js-websocket";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { isComponentLoaded } from "../../../../common/config/is_component_loaded";
import { relativeTime } from "../../../../common/datetime/relative_time";
import { consumeLocalize } from "../../../../common/decorators/consume-context-entry";
import { transform } from "../../../../common/decorators/transform";
import { supportsFeature } from "../../../../common/entity/supports-feature";
import type { LocalizeFunc } from "../../../../common/translations/localize";
import "../../../../components/animation/ha-fade-in";
import "../../../../components/ha-switch";
import "../../../../components/item/ha-row-item";
import type { BackupConfig } from "../../../../data/backup";
import { fetchBackupConfig } from "../../../../data/backup";
import {
apiContext,
configContext,
internationalizationContext,
statesContext,
} from "../../../../data/context";
import type { EntitySources } from "../../../../data/entity/entity_sources";
import { fetchEntitySourcesWithCache } from "../../../../data/entity/entity_sources";
import { getSupervisorUpdateConfig } from "../../../../data/supervisor/update";
import type { FrontendLocaleData } from "../../../../data/translation";
import type { UpdateEntity, UpdateType } from "../../../../data/update";
import {
getUpdateType,
UpdateEntityFeature,
updateIsInstalling,
} from "../../../../data/update";
import type {
HomeAssistant,
HomeAssistantApi,
HomeAssistantConfig,
HomeAssistantInternationalization,
} from "../../../../types";
@customElement("ha-more-info-update-backup")
export class HaMoreInfoUpdateBackup extends LitElement {
@property({ attribute: false }) public stateObj?: UpdateEntity;
@state()
@consumeLocalize()
private _localize!: LocalizeFunc;
@state()
@consume({ context: internationalizationContext, subscribe: true })
@transform<HomeAssistantInternationalization, FrontendLocaleData>({
transformer: ({ locale }) => locale,
})
private _locale!: FrontendLocaleData;
@state() private _backupConfigLoading = true;
@state() private _backupConfig?: BackupConfig;
@state() private _createBackupLoading = true;
@state() private _createBackup = false;
@state() private _entitySources?: EntitySources;
@consume({ context: apiContext, subscribe: true })
private _api!: HomeAssistantApi;
@consume({ context: statesContext, subscribe: true })
private _states!: HomeAssistant["states"];
@consume({ context: configContext, subscribe: true })
@transform<HomeAssistantConfig, HassConfig>({
transformer: ({ config }) => config,
})
private _config!: HassConfig;
public get createBackup(): boolean {
if (
!this.stateObj ||
!supportsFeature(this.stateObj, UpdateEntityFeature.BACKUP)
) {
return false;
}
return this._createBackup;
}
protected firstUpdated(): void {
this._setupBackup();
}
protected render() {
if (
!this.stateObj ||
!supportsFeature(this.stateObj, UpdateEntityFeature.BACKUP)
) {
return nothing;
}
const createBackupTexts = this._computeCreateBackupTexts();
if (!createBackupTexts && !this._backupConfigLoading) {
return nothing;
}
return html`
<ha-row-item
.headline=${createBackupTexts ? createBackupTexts.title : undefined}
.supportingText=${
createBackupTexts ? createBackupTexts.description : undefined
}
>
${
!createBackupTexts
? html`<ha-fade-in slot="headline" .delay=${500}
><wa-skeleton effect="sheen"></wa-skeleton
></ha-fade-in>`
: nothing
}
${
this._createBackupLoading
? html`<ha-fade-in class="skeleton-end" slot="end" .delay=${500}
><wa-skeleton effect="sheen"></wa-skeleton
></ha-fade-in>`
: html`<ha-switch
slot="end"
.checked=${this._createBackup}
@change=${this._createBackupChanged}
.disabled=${updateIsInstalling(this.stateObj)}
></ha-switch>`
}
</ha-row-item>
`;
}
private async _setupBackup(): Promise<void> {
if (!supportsFeature(this.stateObj!, UpdateEntityFeature.BACKUP)) {
this._createBackupLoading = false;
this._backupConfigLoading = false;
return;
}
let type: UpdateType | undefined = undefined;
try {
this._entitySources = await fetchEntitySourcesWithCache({
callWS: this._api.callWS,
states: this._states,
});
type = getUpdateType(this.stateObj!, this._entitySources!);
} catch (err) {
// ignore error, because the generic backup option remains available
// eslint-disable-next-line no-console
console.error(err);
}
if (type === undefined) {
this._createBackupLoading = false;
this._backupConfigLoading = false;
return;
}
this._fetchUpdateBackupConfig(type);
this._fetchBackupConfig(type);
}
private async _fetchUpdateBackupConfig(type: UpdateType) {
try {
if (
isComponentLoaded(this._config, "hassio") &&
["addon", "home_assistant", "home_assistant_os"].includes(type)
) {
const config = await getSupervisorUpdateConfig(this._api);
// for home assistant and OS updates
if (this._isHaOrOsUpdate(type)) {
this._createBackup = config.core_backup_before_update;
return;
}
if (type === "addon") {
this._createBackup = config.add_on_backup_before_update;
}
}
} catch (err) {
// ignore error, because user can still set the config
// eslint-disable-next-line no-console
console.error(err);
this._createBackup = false;
} finally {
this._createBackupLoading = false;
}
}
private async _fetchBackupConfig(type: UpdateType): Promise<void> {
try {
if (this._isHaOrOsUpdate(type)) {
const { config } = await fetchBackupConfig(this._api);
this._backupConfig = config;
}
} catch (err) {
// ignore error, because user can still set the config
// eslint-disable-next-line no-console
console.error(err);
} finally {
this._backupConfigLoading = false;
}
}
private _isHaOrOsUpdate(type: UpdateType): boolean {
return ["home_assistant", "home_assistant_os"].includes(type);
}
private _computeCreateBackupTexts():
{ title: string; description?: string } | undefined {
const updateType = this._entitySources
? getUpdateType(this.stateObj!, this._entitySources)
: "generic";
if (this._isHaOrOsUpdate(updateType)) {
if (this._backupConfigLoading) {
return undefined;
}
const isBackupConfigValid =
!!this._backupConfig &&
!!this._backupConfig.automatic_backups_configured &&
!!this._backupConfig.create_backup.password &&
this._backupConfig.create_backup.agent_ids.length > 0;
if (!isBackupConfigValid) {
return {
title: this._localize(
"ui.dialogs.more_info_control.update.create_backup.manual"
),
description: this._localize(
"ui.dialogs.more_info_control.update.create_backup.manual_description"
),
};
}
const lastAutomaticBackupDate = this._backupConfig
?.last_completed_automatic_backup
? new Date(this._backupConfig?.last_completed_automatic_backup)
: null;
const now = new Date();
return {
title: this._localize(
"ui.dialogs.more_info_control.update.create_backup.automatic"
),
description: lastAutomaticBackupDate
? this._localize(
"ui.dialogs.more_info_control.update.create_backup.automatic_description_last",
{
relative_time: relativeTime(
lastAutomaticBackupDate,
this._locale,
now,
true
),
}
)
: this._localize(
"ui.dialogs.more_info_control.update.create_backup.automatic_description_none"
),
};
}
// App backup
if (updateType === "addon") {
const version = this.stateObj!.attributes.installed_version;
return {
title: this._localize(
"ui.dialogs.more_info_control.update.create_backup.app"
),
description: version
? this._localize(
"ui.dialogs.more_info_control.update.create_backup.app_description",
{ version: version }
)
: undefined,
};
}
// Fallback to generic UI
return {
title: this._localize(
"ui.dialogs.more_info_control.update.create_backup.generic"
),
};
}
private _createBackupChanged(ev) {
this._createBackup = ev.target.checked;
}
static styles = css`
:host {
display: block;
width: 100%;
}
ha-row-item {
width: 100%;
--ha-row-item-padding-inline: var(--ha-space-6);
}
.skeleton-end {
width: 48px;
height: 24px;
display: block;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-more-info-update-backup": HaMoreInfoUpdateBackup;
}
}
+11 -13
View File
@@ -131,6 +131,10 @@ class MoreInfoFan extends LitElement {
return nothing;
}
const supportsOnOff =
supportsFeature(this.stateObj, FanEntityFeature.TURN_ON) ||
supportsFeature(this.stateObj, FanEntityFeature.TURN_OFF);
const supportsSpeed = supportsFeature(
this.stateObj,
FanEntityFeature.SET_SPEED
@@ -174,21 +178,15 @@ class MoreInfoFan extends LitElement {
`
}
${
supportSpeedPercentage
supportSpeedPercentage && supportsOnOff
? html`
<div class="buttons">
${
supportSpeedPercentage
? html`
<ha-outlined-icon-button
.disabled=${this.stateObj.state === UNAVAILABLE}
@click=${this._toggle}
>
<ha-svg-icon .path=${mdiPower}></ha-svg-icon>
</ha-outlined-icon-button>
`
: nothing
}
<ha-outlined-icon-button
.disabled=${this.stateObj.state === UNAVAILABLE}
@click=${this._toggle}
>
<ha-svg-icon .path=${mdiPower}></ha-svg-icon>
</ha-outlined-icon-button>
</div>
`
: nothing
@@ -1,57 +1,32 @@
import "@home-assistant/webawesome/dist/components/skeleton/skeleton";
import { consume } from "@lit/context";
import type { HassConfig } from "home-assistant-js-websocket";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import { customElement, property, query, state } from "lit/decorators";
import { BINARY_STATE_OFF } from "../../../common/const";
import { relativeTime } from "../../../common/datetime/relative_time";
import { consumeLocalize } from "../../../common/decorators/consume-context-entry";
import { transform } from "../../../common/decorators/transform";
import { supportsFeature } from "../../../common/entity/supports-feature";
import type { LocalizeFunc } from "../../../common/translations/localize";
import { sanitizeHttpUrl } from "../../../common/url/sanitize-http-url";
import "../../../components/animation/ha-fade-in";
import "../../../components/buttons/ha-progress-button";
import "../../../components/ha-alert";
import "../../../components/ha-button";
import "../../../components/ha-faded";
import "../../../components/ha-markdown";
import "../../../components/ha-spinner";
import "../../../components/ha-switch";
import "../../../components/item/ha-row-item";
import "../../../components/progress/ha-progress-bar";
import type { BackupConfig } from "../../../data/backup";
import { fetchBackupConfig } from "../../../data/backup";
import {
apiContext,
configContext,
formattersContext,
internationalizationContext,
statesContext,
} from "../../../data/context";
import { apiContext, formattersContext } from "../../../data/context";
import { UNAVAILABLE, UNKNOWN } from "../../../data/entity/entity";
import type { EntitySources } from "../../../data/entity/entity_sources";
import { fetchEntitySourcesWithCache } from "../../../data/entity/entity_sources";
import { getSupervisorUpdateConfig } from "../../../data/supervisor/update";
import type { FrontendLocaleData } from "../../../data/translation";
import type { UpdateEntity, UpdateType } from "../../../data/update";
import type { UpdateEntity } from "../../../data/update";
import {
getUpdateType,
latestVersionIsSkipped,
updateButtonIsDisabled,
UpdateEntityFeature,
updateIsInstalling,
updateReleaseNotes,
} from "../../../data/update";
import type {
HomeAssistant,
HomeAssistantApi,
HomeAssistantConfig,
HomeAssistantFormatters,
HomeAssistantInternationalization,
} from "../../../types";
import type { HomeAssistantApi, HomeAssistantFormatters } from "../../../types";
import { showAlertDialog } from "../../generic/show-dialog-box";
import "../components/update/ha-more-info-update-backup";
import type { HaMoreInfoUpdateBackup } from "../components/update/ha-more-info-update-backup";
@customElement("more-info-update")
class MoreInfoUpdate extends LitElement {
@@ -61,182 +36,21 @@ class MoreInfoUpdate extends LitElement {
@consumeLocalize()
private _localize!: LocalizeFunc;
@state()
@consume({ context: internationalizationContext, subscribe: true })
@transform<HomeAssistantInternationalization, FrontendLocaleData>({
transformer: ({ locale }) => locale,
})
private _locale!: FrontendLocaleData;
@state()
@consume({ context: formattersContext, subscribe: true })
private _formatters!: HomeAssistantFormatters;
@state()
@consume({ context: apiContext, subscribe: true })
private _api!: HomeAssistantApi;
@state()
@consume({ context: statesContext, subscribe: true })
private _states!: HomeAssistant["states"];
@state()
@consume({ context: configContext, subscribe: true })
@transform<HomeAssistantConfig, HassConfig>({
transformer: ({ config }) => config,
})
private _config!: HassConfig;
@state() private _releaseNotes?: string | null;
@state() private _error?: string;
@state() private _markdownLoading = true;
@state() private _backupConfigLoading = true;
@state() private _backupConfig?: BackupConfig;
@state() private _createBackupLoading = true;
@state() private _createBackup = false;
@state() private _entitySources?: EntitySources;
private async _fetchBackupConfig() {
try {
const { config } = await fetchBackupConfig(this._api);
this._backupConfig = config;
} catch (err) {
// ignore error, because user will get a manual backup option
// eslint-disable-next-line no-console
console.error(err);
} finally {
this._backupConfigLoading = false;
}
}
private async _fetchUpdateBackupConfig(type: UpdateType) {
try {
const config = await getSupervisorUpdateConfig(this._api);
// for home assistant and OS updates
if (this._isHaOrOsUpdate(type)) {
this._createBackup = config.core_backup_before_update;
this._createBackupLoading = false;
return;
}
if (type === "addon") {
this._createBackup = config.add_on_backup_before_update;
}
} catch (err) {
// ignore error, because user can still set the config
// eslint-disable-next-line no-console
console.error(err);
this._createBackup = false;
} finally {
this._createBackupLoading = false;
}
}
private async _fetchEntitySources() {
this._entitySources = await fetchEntitySourcesWithCache({
callWS: this._api.callWS,
states: this._states,
});
}
private _isHaOrOsUpdate(type: UpdateType): boolean {
return ["home_assistant", "home_assistant_os"].includes(type);
}
private _computeCreateBackupTexts():
{ title: string; description?: string } | undefined {
if (
!this.stateObj ||
!supportsFeature(this.stateObj, UpdateEntityFeature.BACKUP)
) {
return undefined;
}
const updateType = this._entitySources
? getUpdateType(this.stateObj, this._entitySources)
: "generic";
if (this._isHaOrOsUpdate(updateType)) {
if (this._backupConfigLoading) {
return undefined;
}
const isBackupConfigValid =
!!this._backupConfig &&
!!this._backupConfig.automatic_backups_configured &&
!!this._backupConfig.create_backup.password &&
this._backupConfig.create_backup.agent_ids.length > 0;
if (!isBackupConfigValid) {
return {
title: this._localize(
"ui.dialogs.more_info_control.update.create_backup.manual"
),
description: this._localize(
"ui.dialogs.more_info_control.update.create_backup.manual_description"
),
};
}
const lastAutomaticBackupDate = this._backupConfig
?.last_completed_automatic_backup
? new Date(this._backupConfig?.last_completed_automatic_backup)
: null;
const now = new Date();
return {
title: this._localize(
"ui.dialogs.more_info_control.update.create_backup.automatic"
),
description: lastAutomaticBackupDate
? this._localize(
"ui.dialogs.more_info_control.update.create_backup.automatic_description_last",
{
relative_time: relativeTime(
lastAutomaticBackupDate,
this._locale,
now,
true
),
}
)
: this._localize(
"ui.dialogs.more_info_control.update.create_backup.automatic_description_none"
),
};
}
// App backup
if (updateType === "addon") {
const version = this.stateObj.attributes.installed_version;
return {
title: this._localize(
"ui.dialogs.more_info_control.update.create_backup.app"
),
description: version
? this._localize(
"ui.dialogs.more_info_control.update.create_backup.app_description",
{ version: version }
)
: undefined,
};
}
// Fallback to generic UI
return {
title: this._localize(
"ui.dialogs.more_info_control.update.create_backup.generic"
),
};
}
@query("ha-more-info-update-backup")
private _backupElement?: HaMoreInfoUpdateBackup;
protected render() {
if (
@@ -248,7 +62,6 @@ class MoreInfoUpdate extends LitElement {
return nothing;
}
const createBackupTexts = this._computeCreateBackupTexts();
const releaseUrl = sanitizeHttpUrl(this.stateObj.attributes.release_url);
return html`
@@ -347,39 +160,9 @@ class MoreInfoUpdate extends LitElement {
}
</div>
<div class="footer">
${
createBackupTexts || this._backupConfigLoading
? html`
<ha-row-item
.headline=${createBackupTexts ? createBackupTexts.title : undefined}
.supportingText=${createBackupTexts ? createBackupTexts.description : undefined}
>
${
!createBackupTexts
? html`<ha-fade-in slot="headline" .delay=${500}
><wa-skeleton effect="sheen"></wa-skeleton
></ha-fade-in>`
: nothing
}
${
this._createBackupLoading
? html`<ha-fade-in
class="skeleton-end"
slot="end"
.delay=${500}
><wa-skeleton effect="sheen"></wa-skeleton
></ha-fade-in>`
: html`<ha-switch
slot="end"
.checked=${this._createBackup}
@change=${this._createBackupChanged}
.disabled=${updateIsInstalling(this.stateObj)}
></ha-switch>`
}
</ha-row-item>
`
: nothing
}
<ha-more-info-update-backup
.stateObj=${this.stateObj}
></ha-more-info-update-backup>
<div class="actions">
${
this.stateObj.state === BINARY_STATE_OFF &&
@@ -440,36 +223,6 @@ class MoreInfoUpdate extends LitElement {
if (supportsFeature(this.stateObj!, UpdateEntityFeature.RELEASE_NOTES)) {
this._fetchReleaseNotes();
}
if (supportsFeature(this.stateObj!, UpdateEntityFeature.BACKUP)) {
this._fetchEntitySources()
.then(() => {
const type = getUpdateType(this.stateObj!, this._entitySources!);
if (
isComponentLoaded(this._config, "hassio") &&
["addon", "home_assistant", "home_assistant_os"].includes(type)
) {
this._fetchUpdateBackupConfig(type);
} else {
this._createBackupLoading = false;
}
if (this._isHaOrOsUpdate(type)) {
this._fetchBackupConfig();
} else {
this._backupConfigLoading = false;
}
})
.catch((err) => {
// ignore error, because the generic backup option remains available
// eslint-disable-next-line no-console
console.error(err);
this._createBackupLoading = false;
this._backupConfigLoading = false;
});
} else {
this._createBackupLoading = false;
this._backupConfigLoading = false;
}
}
private async _markdownLoaded() {
@@ -489,19 +242,12 @@ class MoreInfoUpdate extends LitElement {
}
}
get _shouldCreateBackup(): boolean {
if (!supportsFeature(this.stateObj!, UpdateEntityFeature.BACKUP)) {
return false;
}
return this._createBackup;
}
private _handleInstall(): void {
const installData: Record<string, any> = {
entity_id: this.stateObj!.entity_id,
};
if (this._shouldCreateBackup) {
if (this._backupElement?.createBackup ?? false) {
installData.backup = true;
}
@@ -515,10 +261,6 @@ class MoreInfoUpdate extends LitElement {
this._api.callService("update", "install", installData);
}
private _createBackupChanged(ev) {
this._createBackup = ev.target.checked;
}
private _handleSkip(): void {
if (this.stateObj!.attributes.auto_update) {
showAlertDialog(this, {
@@ -589,11 +331,6 @@ class MoreInfoUpdate extends LitElement {
z-index: 10;
}
ha-row-item {
width: 100%;
--ha-row-item-padding-inline: var(--ha-space-6);
}
.actions {
width: 100%;
display: flex;
@@ -627,11 +364,6 @@ class MoreInfoUpdate extends LitElement {
box-sizing: border-box;
padding-bottom: var(--ha-space-4);
}
.skeleton-end {
width: 48px;
height: 24px;
display: block;
}
`;
}
+53 -42
View File
@@ -29,17 +29,12 @@ import type { HASSDomEvent } from "../../common/dom/fire_event";
import { fireEvent } from "../../common/dom/fire_event";
import { mainWindow } from "../../common/dom/get_main_window";
import { stopPropagation } from "../../common/dom/stop_propagation";
import { computeAreaName } from "../../common/entity/compute_area_name";
import { computeDeviceName } from "../../common/entity/compute_device_name";
import { computeDomain } from "../../common/entity/compute_domain";
import {
computeEntityEntryName,
computeEntityName,
} from "../../common/entity/compute_entity_name";
import {
getEntityContext,
getEntityEntryContext,
} from "../../common/entity/context/get_entity_context";
computeEntityEntryNameList,
computeEntityNameList,
type EntityNameItem,
} from "../../common/entity/compute_entity_name_display";
import { shouldHandleRequestSelectedEvent } from "../../common/mwc/handle-request-selected-event";
import {
getHistoryState,
@@ -130,6 +125,13 @@ declare global {
const DEFAULT_VIEW: MoreInfoView = "info";
const BREADCRUMB_NAME: EntityNameItem[] = [
{ type: "area" },
{ type: "parent_device" },
{ type: "device" },
{ type: "entity" },
];
@customElement("ha-more-info-dialog")
export class MoreInfoDialog extends DirtyStateProviderMixin<
EntitySettingsState | Helper | Record<string, string[]> | null,
@@ -564,44 +566,27 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
const showCloseIcon =
isDefaultView && this._parentEntityIds.length === 0 && !this._childView;
const context = stateObj
? getEntityContext(
stateObj,
this.hass.entities,
this.hass.devices,
this.hass.areas,
this.hass.floors
)
: this._entry
? getEntityEntryContext(
this._entry,
const breadcrumb = (
stateObj
? computeEntityNameList(
stateObj,
BREADCRUMB_NAME,
this.hass.entities,
this.hass.devices,
this.hass.areas,
this.hass.floors
)
: undefined;
const entityName = stateObj
? computeEntityName(stateObj, this.hass.entities, this.hass.devices)
: this._entry
? computeEntityEntryName(this._entry, this.hass.devices)
: entityId;
const deviceName = context?.device
? computeDeviceName(context.device)
: undefined;
const parentDeviceName = context?.parentDevice
? computeDeviceName(context.parentDevice)
: undefined;
const areaName = context?.area ? computeAreaName(context.area) : undefined;
const breadcrumb = [
areaName,
parentDeviceName,
deviceName,
entityName,
].filter((v): v is string => Boolean(v));
: this._entry
? computeEntityEntryNameList(
this._entry,
BREADCRUMB_NAME,
this.hass.entities,
this.hass.devices,
this.hass.areas,
this.hass.floors
)
: [entityId]
).filter((v): v is string => Boolean(v));
const addToMenuItem = this.hass.localize(
"ui.dialogs.more_info_control.add_to.item"
);
@@ -1031,6 +1016,32 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
this._infoEditMode = false;
this._detailsYamlMode = false;
}
if (changedProps.has("_entityId")) {
this._reportShownEntityToExternalApp(
changedProps.get("_entityId") as string | null | undefined
);
}
}
private _reportShownEntityToExternalApp(
previousEntityId: string | null | undefined
) {
const external = this.hass.auth.external;
if (!external) {
return;
}
if (this._entityId) {
external.fireMessage({
type: "more_info/opened",
payload: { entity_id: this._entityId },
});
} else if (previousEntityId) {
external.fireMessage({
type: "more_info/closed",
payload: { entity_id: previousEntityId },
});
}
}
private _entryUpdated(ev: CustomEvent<ExtEntityRegistryEntry>) {
@@ -24,7 +24,6 @@ export class CloudStepSignin extends LitElement {
check-connection
.hass=${this.hass}
.email=${this.email}
.localize=${this.hass.localize}
@cloud-forgot-password=${this._forgotPassword}
@cloud-logged-in=${this._loggedIn}
></cloud-login>
+16
View File
@@ -184,6 +184,20 @@ interface EMOutgoingMessageAddEntityTo extends EMMessage {
};
}
interface EMOutgoingMessageMoreInfoOpened extends EMMessage {
type: "more_info/opened";
payload: {
entity_id: string;
};
}
interface EMOutgoingMessageMoreInfoClosed extends EMMessage {
type: "more_info/closed";
payload: {
entity_id: string;
};
}
interface EMOutgoingMessageFocusElement extends EMMessage {
type: "focus_element";
payload: {
@@ -216,6 +230,8 @@ type EMOutgoingMessageWithoutAnswer =
| EMOutgoingMessageHaptic
| EMOutgoingMessageImportThreadCredentials
| EMOutgoingMessageMatterCommission
| EMOutgoingMessageMoreInfoOpened
| EMOutgoingMessageMoreInfoClosed
| EMOutgoingMessageSidebarShow
| EMOutgoingMessageTagWrite
| EMOutgoingMessageThemeUpdate
+3
View File
@@ -465,6 +465,9 @@ export const ENTITY_COMPONENT_ICONS: Record<string, ComponentIcons> = {
on: "mdi:alert-circle",
},
},
glass_break: {
default: "mdi:glass-fragile",
},
heat: {
default: "mdi:thermometer",
state: {
+2 -4
View File
@@ -5,6 +5,7 @@ import {
invalidateThemeCache,
} from "../common/dom/apply_themes_on_element";
import { fireEvent } from "../common/dom/fire_event";
import { computeEntityNameDisplayWithoutContext } from "../common/entity/compute_entity_name_display";
import { computeFormatFunctions } from "../common/translations/entity-state";
import { computeLocalize } from "../common/translations/localize";
import {
@@ -604,10 +605,7 @@ export const provideHass = (
value: value !== null ? value : (stateObj.attributes[attribute] ?? ""),
},
],
formatEntityName: (stateObj, type) =>
typeof type === "string"
? type
: (stateObj.attributes.friendly_name ?? stateObj.entity_id),
formatEntityName: computeEntityNameDisplayWithoutContext,
...overrideData,
};
+7
View File
@@ -257,6 +257,13 @@ class PartialPanelResolver extends HassRouterPage {
)
) {
await this.rebuild();
// hass.panels can change again while rebuild() is in flight (e.g.
// multiple integrations/resources updating panels around startup), so
// the panel we were about to show may no longer exist. willUpdate will
// re-run _updateRoutes for the newer panels, so just bail out here.
if (!this.hass.panels[this._currentPage]) {
return;
}
const component =
COMPONENTS[this.hass.panels[this._currentPage].component_name];
await promiseTimeout(
+56
View File
@@ -0,0 +1,56 @@
import { ContextProvider } from "@lit/context";
import type { LitElement, PropertyValues } from "lit";
import { buildLiteInternationalization } from "../common/translations/lite-internationalization";
import type { LocalizeFunc } from "../common/translations/localize";
import {
internationalizationContext,
localeContext,
localizeContext,
} from "../data/context";
import type { Constructor } from "../types";
interface LiteLocalizeElement {
localize?: LocalizeFunc<any>;
language?: string;
}
/**
* Provides the i18n contexts from the "lite" localize state, so
* context-consuming components work on pre-login roots that have no `hass`.
*/
export const provideLiteI18nMixin = <
T extends Constructor<LitElement & LiteLocalizeElement>,
>(
superClass: T
) =>
class extends superClass {
private __i18nProvider = new ContextProvider(this, {
context: internationalizationContext,
});
private __localizeProvider = new ContextProvider(this, {
context: localizeContext,
});
private __localeProvider = new ContextProvider(this, {
context: localeContext,
});
protected willUpdate(changedProperties: PropertyValues): void {
super.willUpdate(changedProperties);
if (
this.localize &&
this.language &&
(changedProperties.has("localize") || changedProperties.has("language"))
) {
const value = buildLiteInternationalization(
this.language,
this.localize
);
this.__i18nProvider.setValue(value);
this.__localizeProvider.setValue(value.localize);
this.__localeProvider.setValue(value.locale);
}
}
};
+14 -19
View File
@@ -1,21 +1,20 @@
import { LitElement, css, html, nothing } from "lit";
import { customElement } from "lit/decorators";
import { LitElement, css, html } from "lit";
import { customElement, state } from "lit/decorators";
import { consumeLocalize } from "../../common/decorators/consume-context-entry";
import type { LocalizeFunc } from "../../common/translations/localize";
import "../../components/ha-dialog";
import { DialogMixin } from "../../dialogs/dialog-mixin";
import type { AppDialogParams } from "./show-app-dialog";
@customElement("app-dialog")
class DialogApp extends DialogMixin<AppDialogParams>(LitElement) {
class DialogApp extends DialogMixin(LitElement) {
@state()
@consumeLocalize()
private _localize!: LocalizeFunc;
protected render() {
if (!this.params?.localize) {
return nothing;
}
return html`<ha-dialog
open
header-title=${
this.params.localize("ui.panel.page-onboarding.welcome.download_app") ||
"Click here to download the app"
}
header-title=${this._localize("ui.panel.page-onboarding.welcome.download_app") || "Click here to download the app"}
>
<div>
<div class="app-qr">
@@ -27,17 +26,13 @@ class DialogApp extends DialogMixin<AppDialogParams>(LitElement) {
<img
loading="lazy"
src="/static/images/appstore.svg"
alt=${this.params.localize(
"ui.panel.page-onboarding.welcome.appstore"
)}
alt=${this._localize("ui.panel.page-onboarding.welcome.appstore")}
class="icon"
/>
<img
loading="lazy"
src="/static/images/qr-appstore.svg"
alt=${this.params.localize(
"ui.panel.page-onboarding.welcome.appstore"
)}
alt=${this._localize("ui.panel.page-onboarding.welcome.appstore")}
/>
</a>
<a
@@ -48,7 +43,7 @@ class DialogApp extends DialogMixin<AppDialogParams>(LitElement) {
<img
loading="lazy"
src="/static/images/playstore.svg"
alt=${this.params.localize(
alt=${this._localize(
"ui.panel.page-onboarding.welcome.playstore"
)}
class="icon"
@@ -56,7 +51,7 @@ class DialogApp extends DialogMixin<AppDialogParams>(LitElement) {
<img
loading="lazy"
src="/static/images/qr-playstore.svg"
alt=${this.params.localize(
alt=${this._localize(
"ui.panel.page-onboarding.welcome.playstore"
)}
/>
+14 -14
View File
@@ -1,22 +1,24 @@
import { mdiAccountGroup, mdiOpenInNew } from "@mdi/js";
import { LitElement, css, html, nothing } from "lit";
import { customElement } from "lit/decorators";
import { LitElement, css, html } from "lit";
import { customElement, state } from "lit/decorators";
import { consumeLocalize } from "../../common/decorators/consume-context-entry";
import type { LocalizeFunc } from "../../common/translations/localize";
import "../../components/ha-dialog";
import "../../components/ha-svg-icon";
import "../../components/item/ha-list-item-button";
import "../../components/list/ha-list-nav";
import { DialogMixin } from "../../dialogs/dialog-mixin";
import type { CommunityDialogParams } from "./show-community-dialog";
@customElement("community-dialog")
class DialogCommunity extends DialogMixin<CommunityDialogParams>(LitElement) {
class DialogCommunity extends DialogMixin(LitElement) {
@state()
@consumeLocalize()
private _localize!: LocalizeFunc;
protected render() {
if (!this.params?.localize) {
return nothing;
}
return html`<ha-dialog
open
header-title=${this.params.localize(
header-title=${this._localize(
"ui.panel.page-onboarding.welcome.community"
)}
>
@@ -32,7 +34,7 @@ class DialogCommunity extends DialogMixin<CommunityDialogParams>(LitElement) {
alt="Home Assistant Logo"
/>
<span slot="headline">
${this.params.localize("ui.panel.page-onboarding.welcome.forums")}
${this._localize("ui.panel.page-onboarding.welcome.forums")}
</span>
<ha-svg-icon slot="end" .path=${mdiOpenInNew}></ha-svg-icon>
</ha-list-item-button>
@@ -47,7 +49,7 @@ class DialogCommunity extends DialogMixin<CommunityDialogParams>(LitElement) {
alt="Open Home Foundation Logo"
/>
<span slot="headline">
${this.params.localize(
${this._localize(
"ui.panel.page-onboarding.welcome.open_home_newsletter"
)}
</span>
@@ -64,7 +66,7 @@ class DialogCommunity extends DialogMixin<CommunityDialogParams>(LitElement) {
alt="Discord Logo"
/>
<span slot="headline">
${this.params.localize("ui.panel.page-onboarding.welcome.discord")}
${this._localize("ui.panel.page-onboarding.welcome.discord")}
</span>
<ha-svg-icon slot="end" .path=${mdiOpenInNew}></ha-svg-icon>
</ha-list-item-button>
@@ -75,9 +77,7 @@ class DialogCommunity extends DialogMixin<CommunityDialogParams>(LitElement) {
>
<ha-svg-icon .path=${mdiAccountGroup} slot="start"></ha-svg-icon>
<span slot="headline">
${this.params.localize(
"ui.panel.page-onboarding.welcome.social_media"
)}
${this._localize("ui.panel.page-onboarding.welcome.social_media")}
</span>
<ha-svg-icon slot="end" .path=${mdiOpenInNew}></ha-svg-icon>
</ha-list-item-button>
+1 -10
View File
@@ -1,20 +1,11 @@
import { fireEvent } from "../../common/dom/fire_event";
import type { LocalizeFunc } from "../../common/translations/localize";
export const loadAppDialog = () => import("./app-dialog");
export interface AppDialogParams {
localize: LocalizeFunc;
}
export const showAppDialog = (
element: HTMLElement,
params: AppDialogParams
): void => {
export const showAppDialog = (element: HTMLElement): void => {
fireEvent(element, "show-dialog", {
dialogTag: "app-dialog",
dialogImport: loadAppDialog,
dialogParams: params,
addHistory: false,
});
};
@@ -1,20 +1,11 @@
import { fireEvent } from "../../common/dom/fire_event";
import type { LocalizeFunc } from "../../common/translations/localize";
export const loadCommunityDialog = () => import("./community-dialog");
export interface CommunityDialogParams {
localize: LocalizeFunc;
}
export const showCommunityDialog = (
element: HTMLElement,
params: CommunityDialogParams
): void => {
export const showCommunityDialog = (element: HTMLElement): void => {
fireEvent(element, "show-dialog", {
dialogTag: "community-dialog",
dialogImport: loadCommunityDialog,
dialogParams: params,
addHistory: false,
});
};
+7 -20
View File
@@ -134,7 +134,6 @@ class HaOnboarding extends litLocalizeLiteMixin(HassElement) {
${
this._init && !this._restoring
? html`<onboarding-welcome-links
.localize=${this.localize}
.mobileApp=${this._mobileApp}
></onboarding-welcome-links>`
: nothing
@@ -158,7 +157,6 @@ class HaOnboarding extends litLocalizeLiteMixin(HassElement) {
private _renderStep() {
if (this._restoring) {
return html`<onboarding-restore-backup
.localize=${this.localize}
.supervisor=${this._supervisor ?? false}
.mode=${this._restoring}
>
@@ -175,34 +173,21 @@ class HaOnboarding extends litLocalizeLiteMixin(HassElement) {
return html`<onboarding-loading></onboarding-loading>`;
}
if (step.step === "user") {
return html`<onboarding-create-user
.localize=${this.localize}
.language=${this.language}
>
</onboarding-create-user>`;
return html`<onboarding-create-user></onboarding-create-user>`;
}
if (step.step === "core_config") {
return html`
<onboarding-core-config
.hass=${this.hass}
.onboardingLocalize=${this.localize}
></onboarding-core-config>
<onboarding-core-config .hass=${this.hass}></onboarding-core-config>
`;
}
if (step.step === "analytics") {
return html`
<onboarding-analytics
.hass=${this.hass}
.localize=${this.localize}
></onboarding-analytics>
<onboarding-analytics .hass=${this.hass}></onboarding-analytics>
`;
}
if (step.step === "integration") {
return html`
<onboarding-integrations
.hass=${this.hass}
.onboardingLocalize=${this.localize}
></onboarding-integrations>
<onboarding-integrations .hass=${this.hass}></onboarding-integrations>
`;
}
return nothing;
@@ -489,7 +474,9 @@ class HaOnboarding extends litLocalizeLiteMixin(HassElement) {
storeState(this.hass!);
}
// Load config strings for integrations
(this as any)._loadFragmentTranslations(this.hass!.language, "config");
this.hass!.loadFragmentTranslation("config");
// Load onboarding strings so hass can resolve them for the remaining steps.
await this.hass!.loadFragmentTranslation("page-onboarding");
// Make sure hass is initialized + the config/user callbacks have called.
await new Promise((resolve) => {
setTimeout(resolve, 0);
+10 -8
View File
@@ -1,7 +1,8 @@
import { mdiOpenInNew } from "@mdi/js";
import type { CSSResultGroup, TemplateResult, PropertyValues } from "lit";
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import { consumeLocalize } from "../common/decorators/consume-context-entry";
import { fireEvent, type HASSDomEvent } from "../common/dom/fire_event";
import type { LocalizeFunc } from "../common/translations/localize";
import "../components/ha-analytics";
@@ -22,7 +23,9 @@ import { onBoardingStyles } from "./styles";
class OnboardingAnalytics extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public localize!: LocalizeFunc;
@state()
@consumeLocalize()
private _localize!: LocalizeFunc;
@state() private _error?: string;
@@ -34,15 +37,15 @@ class OnboardingAnalytics extends LitElement {
protected render(): TemplateResult {
return html`
<h1>${this.localize("ui.panel.page-onboarding.analytics.header")}</h1>
<p>${this.localize("ui.panel.page-onboarding.analytics.intro")}</p>
<h1>${this._localize("ui.panel.page-onboarding.analytics.header")}</h1>
<p>${this._localize("ui.panel.page-onboarding.analytics.intro")}</p>
<p>
<a
href=${documentationUrl(this.hass, "/integrations/analytics/")}
target="_blank"
rel="noreferrer"
>
${this.localize("ui.panel.page-onboarding.analytics.learn_more")}
${this._localize("ui.panel.page-onboarding.analytics.learn_more")}
<ha-svg-icon .path=${mdiOpenInNew}></ha-svg-icon>
</a>
</p>
@@ -52,7 +55,6 @@ class OnboardingAnalytics extends LitElement {
<ha-analytics
translation_key_panel="page-onboarding"
@analytics-preferences-changed=${this._preferencesChanged}
.localize=${this.localize}
.analytics=${this._analyticsDetails}
>
</ha-analytics>
@@ -61,7 +63,7 @@ class OnboardingAnalytics extends LitElement {
<div class="loading">
<ha-spinner></ha-spinner>
<p>
${this.localize("ui.panel.page-onboarding.analytics.waiting")}
${this._localize("ui.panel.page-onboarding.analytics.waiting")}
</p>
</div>
`
@@ -69,7 +71,7 @@ class OnboardingAnalytics extends LitElement {
${this._error ? html`<div class="error">${this._error}</div>` : ""}
<div class="footer">
<ha-button @click=${this._save} .disabled=${!this._analyticsDetails}>
${this.localize("ui.panel.page-onboarding.analytics.finish")}
${this._localize("ui.panel.page-onboarding.analytics.finish")}
</ha-button>
</div>
`;
+7 -9
View File
@@ -6,6 +6,7 @@ import {
HAS_RESOLVED_IANA_TIME_ZONE,
LOCAL_TIME_ZONE,
} from "../common/datetime/resolve-time-zone";
import { consumeLocalize } from "../common/decorators/consume-context-entry";
import { fireEvent } from "../common/dom/fire_event";
import type { LocalizeFunc } from "../common/translations/localize";
import "../components/ha-alert";
@@ -27,7 +28,9 @@ import "./onboarding-location";
class OnboardingCoreConfig extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public onboardingLocalize!: LocalizeFunc;
@state()
@consumeLocalize()
private _localize!: LocalizeFunc;
@state() private _working = false;
@@ -79,7 +82,6 @@ class OnboardingCoreConfig extends LitElement {
if (!this._location) {
return html`<onboarding-location
.hass=${this.hass}
.onboardingLocalize=${this.onboardingLocalize}
@value-changed=${this._locationChanged}
></onboarding-location>`;
}
@@ -96,9 +98,7 @@ class OnboardingCoreConfig extends LitElement {
}
<p>
${this.onboardingLocalize(
"ui.panel.page-onboarding.core-config.country_intro"
)}
${this._localize("ui.panel.page-onboarding.core-config.country_intro")}
</p>
<ha-form
@@ -115,9 +115,7 @@ class OnboardingCoreConfig extends LitElement {
<div class="footer">
<ha-button @click=${this._save} .disabled=${this._working}>
${this.onboardingLocalize(
"ui.panel.page-onboarding.core-config.finish"
)}
${this._localize("ui.panel.page-onboarding.core-config.finish")}
</ha-button>
</div>
`;
@@ -192,7 +190,7 @@ class OnboardingCoreConfig extends LitElement {
this._working = true;
try {
await saveCoreConfig(this.hass, {
location_name: this.onboardingLocalize(
location_name: this._localize(
"ui.panel.page-onboarding.core-config.location_name_default"
),
latitude: this._location[0],
+14 -13
View File
@@ -1,9 +1,9 @@
import { consume, type ContextType } from "@lit/context";
import { genClientId } from "home-assistant-js-websocket";
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { html, LitElement } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { customElement, query, state } from "lit/decorators";
import { fireEvent } from "../common/dom/fire_event";
import type { LocalizeFunc } from "../common/translations/localize";
import { debounce } from "../common/util/debounce";
import "../components/ha-button";
import "../components/ha-form/ha-form";
@@ -12,6 +12,7 @@ import type {
HaFormDataContainer,
HaFormSchema,
} from "../components/ha-form/types";
import { internationalizationContext } from "../data/context";
import { onboardUserStep } from "../data/onboarding";
import type { ValueChangedEvent } from "../types";
import { onBoardingStyles } from "./styles";
@@ -43,9 +44,9 @@ const CREATE_USER_SCHEMA: HaFormSchema[] = [
@customElement("onboarding-create-user")
class OnboardingCreateUser extends LitElement {
@property({ attribute: false }) public localize!: LocalizeFunc;
@property() public language!: string;
@state()
@consume({ context: internationalizationContext, subscribe: true })
private _i18n!: ContextType<typeof internationalizationContext>;
@state() private _loading = false;
@@ -59,8 +60,8 @@ class OnboardingCreateUser extends LitElement {
protected render(): TemplateResult {
return html`
<h1>${this.localize("ui.panel.page-onboarding.user.header")}</h1>
<p>${this.localize("ui.panel.page-onboarding.user.intro")}</p>
<h1>${this._i18n.localize("ui.panel.page-onboarding.user.header")}</h1>
<p>${this._i18n.localize("ui.panel.page-onboarding.user.intro")}</p>
${
this._errorMsg
@@ -69,8 +70,8 @@ class OnboardingCreateUser extends LitElement {
}
<ha-form
.computeLabel=${this._computeLabel(this.localize)}
.computeHelper=${this._computeHelper(this.localize)}
.computeLabel=${this._computeLabel(this._i18n.localize)}
.computeHelper=${this._computeHelper(this._i18n.localize)}
.data=${this._newUser}
.disabled=${this._loading}
.error=${this._formError}
@@ -89,7 +90,7 @@ class OnboardingCreateUser extends LitElement {
this._newUser.password !== this._newUser.password_confirm
}
>
${this.localize("ui.panel.page-onboarding.user.create_account")}
${this._i18n.localize("ui.panel.page-onboarding.user.create_account")}
</ha-button>
</div>
`;
@@ -156,7 +157,7 @@ class OnboardingCreateUser extends LitElement {
this._formError.password_confirm =
this._newUser.password_confirm &&
this._newUser.password !== this._newUser.password_confirm
? this.localize(
? this._i18n.localize(
"ui.panel.page-onboarding.user.error.password_not_match"
)
: "";
@@ -180,7 +181,7 @@ class OnboardingCreateUser extends LitElement {
private _checkUsername(): void {
const old = this._formError.username;
if (CHECK_USERNAME_REGEX.test(this._newUser.username as string)) {
this._formError.username = this.localize(
this._formError.username = this._i18n.localize(
"ui.panel.page-onboarding.user.error.username_not_normalized"
);
} else {
@@ -204,7 +205,7 @@ class OnboardingCreateUser extends LitElement {
name: String(this._newUser.name),
username: String(this._newUser.username),
password: String(this._newUser.password),
language: this.language,
language: this._i18n.language,
});
fireEvent(this, "onboarding-step", {
+11 -22
View File
@@ -3,6 +3,7 @@ import type { CSSResultGroup, PropertyValues } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { isComponentLoaded } from "../common/config/is_component_loaded";
import { consumeLocalize } from "../common/decorators/consume-context-entry";
import { fireEvent } from "../common/dom/fire_event";
import { stringCompare } from "../common/string/compare";
import type { LocalizeFunc } from "../common/translations/localize";
@@ -32,7 +33,9 @@ const HIDDEN_DOMAINS = new Set([
class OnboardingIntegrations extends SubscribeMixin(LitElement) {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public onboardingLocalize!: LocalizeFunc;
@state()
@consumeLocalize()
private _localize!: LocalizeFunc;
@state() private _entries: ConfigEntry[] = [];
@@ -125,20 +128,14 @@ class OnboardingIntegrations extends SubscribeMixin(LitElement) {
return html`
<div class="all-set-icon">🎉</div>
<h1>
${this.onboardingLocalize(
"ui.panel.page-onboarding.integration.all_set"
)}
${this._localize("ui.panel.page-onboarding.integration.all_set")}
</h1>
<p>
${this.onboardingLocalize(
"ui.panel.page-onboarding.integration.lets_start"
)}
${this._localize("ui.panel.page-onboarding.integration.lets_start")}
</p>
<div class="footer">
<ha-button @click=${this._finish}>
${this.onboardingLocalize(
"ui.panel.page-onboarding.integration.finish"
)}
${this._localize("ui.panel.page-onboarding.integration.finish")}
</ha-button>
</div>
`;
@@ -149,14 +146,8 @@ class OnboardingIntegrations extends SubscribeMixin(LitElement) {
}
return html`
<h1>
${this.onboardingLocalize(
"ui.panel.page-onboarding.integration.header"
)}
</h1>
<p>
${this.onboardingLocalize("ui.panel.page-onboarding.integration.intro")}
</p>
<h1>${this._localize("ui.panel.page-onboarding.integration.header")}</h1>
<p>${this._localize("ui.panel.page-onboarding.integration.intro")}</p>
<div class="badges">
${domains.map(
([domain, title]) =>
@@ -169,7 +160,7 @@ class OnboardingIntegrations extends SubscribeMixin(LitElement) {
${
foundIntegrations > domains.length
? html`<div class="more">
${this.onboardingLocalize(
${this._localize(
"ui.panel.page-onboarding.integration.more_integrations",
{ count: foundIntegrations - domains.length }
)}
@@ -179,9 +170,7 @@ class OnboardingIntegrations extends SubscribeMixin(LitElement) {
</div>
<div class="footer">
<ha-button @click=${this._finish}>
${this.onboardingLocalize(
"ui.panel.page-onboarding.integration.finish"
)}
${this._localize("ui.panel.page-onboarding.integration.finish")}
</ha-button>
</div>
`;
+14 -15
View File
@@ -4,10 +4,11 @@ import {
mdiMapMarker,
mdiMapSearchOutline,
} from "@mdi/js";
import type { CSSResultGroup, TemplateResult, PropertyValues } from "lit";
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { consumeLocalize } from "../common/decorators/consume-context-entry";
import { fireEvent } from "../common/dom/fire_event";
import type { LocalizeFunc } from "../common/translations/localize";
import "../components/ha-alert";
@@ -38,7 +39,9 @@ const LOCATION_MARKER_ID = "location";
class OnboardingLocation extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public onboardingLocalize!: LocalizeFunc;
@state()
@consumeLocalize()
private _localize!: LocalizeFunc;
@state() private _working = false;
@@ -67,7 +70,7 @@ class OnboardingLocation extends LitElement {
@query("ha-input") private _input?: HTMLElement;
protected render(): TemplateResult {
const addressAttribution = this.onboardingLocalize(
const addressAttribution = this._localize(
"ui.panel.page-onboarding.core-config.location_address",
{
openstreetmap: html`<a
@@ -80,7 +83,7 @@ class OnboardingLocation extends LitElement {
href="https://wiki.osmfoundation.org/wiki/Privacy_Policy"
target="_blank"
rel="noopener noreferrer"
>${this.onboardingLocalize(
>${this._localize(
"ui.panel.page-onboarding.core-config.osm_privacy_policy"
)}</a
>`,
@@ -89,7 +92,7 @@ class OnboardingLocation extends LitElement {
return html`
<h1>
${this.onboardingLocalize(
${this._localize(
"ui.panel.page-onboarding.core-config.location_header"
)}
</h1>
@@ -100,14 +103,12 @@ class OnboardingLocation extends LitElement {
}
<p>
${this.onboardingLocalize(
"ui.panel.page-onboarding.core-config.intro_location"
)}
${this._localize("ui.panel.page-onboarding.core-config.intro_location")}
</p>
<div class="location-search">
<ha-input
label=${this.onboardingLocalize(
label=${this._localize(
"ui.panel.page-onboarding.core-config.address_label"
)}
.disabled=${this._working}
@@ -122,7 +123,7 @@ class OnboardingLocation extends LitElement {
@click=${this._handleButtonClick}
slot="end"
.disabled=${this._working}
.label=${this.onboardingLocalize(
.label=${this._localize(
this._search
? "ui.common.search"
: "ui.panel.page-onboarding.core-config.button_detect"
@@ -200,9 +201,7 @@ class OnboardingLocation extends LitElement {
<div class="footer">
<ha-button @click=${this._save} .disabled=${this._working}>
${this.onboardingLocalize(
"ui.panel.page-onboarding.core-config.finish"
)}
${this._localize("ui.panel.page-onboarding.core-config.finish")}
</ha-button>
</div>
`;
@@ -408,10 +407,10 @@ class OnboardingLocation extends LitElement {
private async _whoAmI() {
const confirm = await showConfirmationDialog(this, {
title: this.onboardingLocalize(
title: this._localize(
"ui.panel.page-onboarding.core-config.title_location_detect"
),
text: this.onboardingLocalize(
text: this._localize(
"ui.panel.page-onboarding.core-config.intro_location_detect"
),
});
+10 -12
View File
@@ -1,6 +1,7 @@
import type { TemplateResult, PropertyValues } from "lit";
import type { PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { consumeLocalize } from "../common/decorators/consume-context-entry";
import { storage } from "../common/decorators/storage";
import type { HASSDomEvent } from "../common/dom/fire_event";
import { navigate } from "../common/navigate";
@@ -28,12 +29,14 @@ const STATUS_INTERVAL_IN_MS = 5000;
@customElement("onboarding-restore-backup")
class OnboardingRestoreBackup extends LitElement {
@property({ attribute: false }) public localize!: LocalizeFunc;
@property({ type: Boolean }) public supervisor = false;
@property() public mode!: "upload" | "cloud";
@state()
@consumeLocalize()
private _localize!: LocalizeFunc;
@state() private _view:
| "loading"
| "upload"
@@ -78,33 +81,29 @@ class OnboardingRestoreBackup extends LitElement {
? html`
<onboarding-restore-backup-upload
.supervisor=${this.supervisor}
.localize=${this.localize}
@backup-uploaded=${this._backupUploaded}
></onboarding-restore-backup-upload>
`
: this._view === "cloud_login"
? html`
<onboarding-restore-backup-cloud-login
.localize=${this.localize}
@ha-refresh-cloud-status=${this._showCloudBackup}
></onboarding-restore-backup-cloud-login>
`
: this._view === "empty_cloud"
? html`
<onboarding-restore-backup-no-cloud-backup
.localize=${this.localize}
@sign-out=${this._signOut}
></onboarding-restore-backup-no-cloud-backup>
`
: this._view === "restore"
? html`<onboarding-restore-backup-restore
.mode=${this.mode}
.localize=${this.localize}
.backup=${this._backup!}
.supervisor=${this.supervisor}
.error=${
this._failed
? this.localize(
? this._localize(
`ui.panel.page-onboarding.restore.${this._backupInfo?.last_action_event?.reason === "password_incorrect" ? "failed_wrong_password_description" : "failed_description"}`
)
: this._error
@@ -118,7 +117,6 @@ class OnboardingRestoreBackup extends LitElement {
${
this._view === "status" && this._backupInfo
? html`<onboarding-restore-backup-status
.localize=${this.localize}
.backupInfo=${this._backupInfo}
@restore-backup-back=${this._back}
></onboarding-restore-backup-status>`
@@ -302,7 +300,7 @@ class OnboardingRestoreBackup extends LitElement {
showToast(this, {
id: "sign-out-ha-cloud",
message: this.localize(
message: this._localize(
"ui.panel.page-onboarding.restore.ha-cloud.sign_out_progress"
),
});
@@ -312,7 +310,7 @@ class OnboardingRestoreBackup extends LitElement {
await signOutHaCloud();
showToast(this, {
id: "sign-out-ha-cloud",
message: this.localize(
message: this._localize(
"ui.panel.page-onboarding.restore.ha-cloud.sign_out_success"
),
});
@@ -321,7 +319,7 @@ class OnboardingRestoreBackup extends LitElement {
console.error(err);
showToast(this, {
id: "sign-out-ha-cloud",
message: this.localize(
message: this._localize(
"ui.panel.page-onboarding.restore.ha-cloud.sign_out_error"
),
});
+10 -7
View File
@@ -1,7 +1,8 @@
import { mdiAccountGroup, mdiFileDocument, mdiTabletCellphone } from "@mdi/js";
import type { TemplateResult } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { customElement, property, state } from "lit/decorators";
import { consumeLocalize } from "../common/decorators/consume-context-entry";
import type { LocalizeFunc } from "../common/translations/localize";
import "../components/ha-card";
import { showAppDialog } from "./dialogs/show-app-dialog";
@@ -10,7 +11,9 @@ import "./onboarding-welcome-link";
@customElement("onboarding-welcome-links")
class OnboardingWelcomeLinks extends LitElement {
@property({ attribute: false }) public localize!: LocalizeFunc<any>;
@state()
@consumeLocalize()
private _localize!: LocalizeFunc;
@property({ attribute: "mobile-app", type: Boolean })
public mobileApp = false;
@@ -24,7 +27,7 @@ class OnboardingWelcomeLinks extends LitElement {
<onboarding-welcome-link
noninteractive
.iconPath=${mdiFileDocument}
.label=${this.localize("ui.panel.page-onboarding.welcome.vision")}
.label=${this._localize("ui.panel.page-onboarding.welcome.vision")}
>
</onboarding-welcome-link>
</a>
@@ -32,7 +35,7 @@ class OnboardingWelcomeLinks extends LitElement {
class="community"
@click=${this._openCommunity}
.iconPath=${mdiAccountGroup}
.label=${this.localize("ui.panel.page-onboarding.welcome.community")}
.label=${this._localize("ui.panel.page-onboarding.welcome.community")}
>
</onboarding-welcome-link>
${
@@ -42,7 +45,7 @@ class OnboardingWelcomeLinks extends LitElement {
class="app"
@click=${this._openApp}
.iconPath=${mdiTabletCellphone}
.label=${this.localize(
.label=${this._localize(
"ui.panel.page-onboarding.welcome.download_app"
)}
>
@@ -51,11 +54,11 @@ class OnboardingWelcomeLinks extends LitElement {
}
private _openCommunity(): void {
showCommunityDialog(this, { localize: this.localize });
showCommunityDialog(this);
}
private _openApp(): void {
showAppDialog(this, { localize: this.localize });
showAppDialog(this);
}
static styles = css`
@@ -1,22 +1,25 @@
import { css, html, LitElement, nothing, type CSSResultGroup } from "lit";
import { customElement, property, state, query } from "lit/decorators";
import "../../panels/config/cloud/login/cloud-login";
import { customElement, property, query, state } from "lit/decorators";
import { consumeLocalize } from "../../common/decorators/consume-context-entry";
import { navigate } from "../../common/navigate";
import type { LocalizeFunc } from "../../common/translations/localize";
import { removeSearchParam } from "../../common/url/search-params";
import "../../components/ha-icon-button-arrow-prev";
import "../../components/ha-spinner";
import type { LocalizeFunc } from "../../common/translations/localize";
import type { BackupContentExtended } from "../../data/backup";
import { navigate } from "../../common/navigate";
import { removeSearchParam } from "../../common/url/search-params";
import { onBoardingStyles } from "../styles";
import type { CloudLogin } from "../../panels/config/cloud/login/cloud-login";
import type { CloudForgotPasswordCard } from "../../panels/config/cloud/forgot-password/cloud-forgot-password-card";
import "../../panels/config/cloud/login/cloud-login";
import type { CloudLogin } from "../../panels/config/cloud/login/cloud-login";
import { onBoardingStyles } from "../styles";
@customElement("onboarding-restore-backup-cloud-login")
class OnboardingRestoreBackupCloudLogin extends LitElement {
@property({ attribute: false }) public localize!: LocalizeFunc;
@property({ attribute: false }) public backup!: BackupContentExtended;
@state()
@consumeLocalize()
private _localize!: LocalizeFunc;
@state() private _email?: string;
@state() private _view: "login" | "forgot-password" | "loading" = "login";
@@ -31,12 +34,12 @@ class OnboardingRestoreBackupCloudLogin extends LitElement {
render() {
return html`
<ha-icon-button-arrow-prev
.label=${this.localize("ui.panel.page-onboarding.restore.back")}
.label=${this._localize("ui.panel.page-onboarding.restore.back")}
@click=${this._back}
></ha-icon-button-arrow-prev>
<h1>Home Assistant Cloud</h1>
<p>
${this.localize(
${this._localize(
"ui.panel.page-onboarding.restore.ha-cloud.sign_in_description"
)}
</p>
@@ -46,7 +49,6 @@ class OnboardingRestoreBackupCloudLogin extends LitElement {
? html`<cloud-login
card-less
.email=${this._email}
.localize=${this.localize}
translation-key-panel="page-onboarding.restore.ha-cloud"
@cloud-forgot-password=${this._showForgotPassword}
></cloud-login>`
@@ -57,7 +59,6 @@ class OnboardingRestoreBackupCloudLogin extends LitElement {
: html`<cloud-forgot-password-card
card-less
.email=${this._email}
.localize=${this.localize}
translation-key-panel="page-onboarding.restore.ha-cloud.forgot_password"
@cloud-email-changed=${this._emailChanged}
@cloud-done=${this._showPasswordResetDone}
@@ -80,7 +81,7 @@ class OnboardingRestoreBackupCloudLogin extends LitElement {
dismissable
@alert-dismissed-clicked=${this._dismissResetPasswordDoneInfo}
>
${this.localize(
${this._localize(
"ui.panel.page-onboarding.restore.ha-cloud.forgot_password.check_your_email"
)}
</ha-alert>`;
@@ -1,36 +1,39 @@
import { LitElement, html, css, type CSSResultGroup } from "lit";
import { customElement, property } from "lit/decorators";
import type { LocalizeFunc } from "../../common/translations/localize";
import "../../components/ha-button";
import "../../components/ha-icon-button-arrow-prev";
import { LitElement, css, html, type CSSResultGroup } from "lit";
import { customElement, state } from "lit/decorators";
import { consumeLocalize } from "../../common/decorators/consume-context-entry";
import { fireEvent } from "../../common/dom/fire_event";
import { navigate } from "../../common/navigate";
import type { LocalizeFunc } from "../../common/translations/localize";
import { removeSearchParam } from "../../common/url/search-params";
import "../../components/ha-button";
import "../../components/ha-icon-button-arrow-prev";
import { onBoardingStyles } from "../styles";
@customElement("onboarding-restore-backup-no-cloud-backup")
class OnboardingRestoreBackupNoCloudBackup extends LitElement {
@property({ attribute: false }) public localize!: LocalizeFunc;
@state()
@consumeLocalize()
private _localize!: LocalizeFunc;
render() {
return html`
<ha-icon-button-arrow-prev
.label=${this.localize("ui.panel.page-onboarding.restore.back")}
.label=${this._localize("ui.panel.page-onboarding.restore.back")}
@click=${this._back}
></ha-icon-button-arrow-prev>
<h1>
${this.localize(
${this._localize(
"ui.panel.page-onboarding.restore.ha-cloud.no_cloud_backup"
)}
</h1>
<div class="description">
${this.localize(
${this._localize(
"ui.panel.page-onboarding.restore.ha-cloud.no_cloud_backup_description"
)}
</div>
<div class="actions">
<ha-button @click=${this._signOut}>
${this.localize("ui.panel.page-onboarding.restore.ha-cloud.sign_out")}
${this._localize("ui.panel.page-onboarding.restore.ha-cloud.sign_out")}
</ha-button>
<ha-button
href="https://www.nabucasa.com/config/backups/"
@@ -38,7 +41,7 @@ class OnboardingRestoreBackupNoCloudBackup extends LitElement {
rel="noreferrer noopener"
appearance="plain"
>
${this.localize(
${this._localize(
"ui.panel.page-onboarding.restore.ha-cloud.learn_more"
)}
</ha-button>
@@ -25,11 +25,10 @@ import "../../panels/config/backup/components/ha-backup-data-picker";
import "../../panels/config/backup/components/ha-backup-formfield-label";
import { onBoardingStyles } from "../styles";
import type { ValueChangedEvent } from "../../types";
import { consumeLocalize } from "../../common/decorators/consume-context-entry";
@customElement("onboarding-restore-backup-restore")
class OnboardingRestoreBackupRestore extends LitElement {
@property({ attribute: false }) public localize!: LocalizeFunc;
@property({ attribute: false }) public backup!: BackupContentExtended;
@property({ type: Boolean }) public supervisor = false;
@@ -38,6 +37,10 @@ class OnboardingRestoreBackupRestore extends LitElement {
@property() public mode!: "upload" | "cloud";
@state()
@consumeLocalize()
private _localize!: LocalizeFunc;
@state() private _encryptionKey = "";
@state() private _encryptionKeyWrong = false;
@@ -66,11 +69,11 @@ class OnboardingRestoreBackupRestore extends LitElement {
return html`
<ha-icon-button-arrow-prev
.label=${this.localize("ui.panel.page-onboarding.restore.back")}
.label=${this._localize("ui.panel.page-onboarding.restore.back")}
@click=${this._back}
></ha-icon-button-arrow-prev>
<h1>
${this.localize(
${this._localize(
"ui.panel.page-onboarding.restore.details.restore.title"
)}
</h1>
@@ -78,13 +81,13 @@ class OnboardingRestoreBackupRestore extends LitElement {
${
this.backup.homeassistant_included
? html`<div class="description">
${this.localize(
${this._localize(
"ui.panel.page-onboarding.restore.confirm_restore_full_backup_text"
)}
</div>`
: html`
<ha-alert alert-type="error">
${this.localize(
${this._localize(
"ui.panel.page-onboarding.restore.details.home_assistant_missing"
)}
</ha-alert>
@@ -94,7 +97,7 @@ class OnboardingRestoreBackupRestore extends LitElement {
this.error
? html`<ha-alert
alert-type="error"
.title=${this.localize("ui.panel.page-onboarding.restore.failed")}
.title=${this._localize("ui.panel.page-onboarding.restore.failed")}
>
${this.error}
</ha-alert>`
@@ -103,7 +106,7 @@ class OnboardingRestoreBackupRestore extends LitElement {
<ha-row-item>
<span slot="headline">
${this.localize(
${this._localize(
"ui.panel.page-onboarding.restore.details.summary.created"
)}
</span>
@@ -113,14 +116,14 @@ class OnboardingRestoreBackupRestore extends LitElement {
onlyHomeAssistantBackup
? html`<ha-row-item>
<span slot="headline">
${this.localize(
${this._localize(
"ui.panel.page-onboarding.restore.details.summary.content"
)}
</span>
<ha-backup-formfield-label
slot="supporting-text"
.version=${this.backup.homeassistant_version}
.label=${this.localize(
.label=${this._localize(
`ui.panel.page-onboarding.restore.data_picker.${this.backup.database_included ? "settings_and_history" : "settings"}`
)}
></ha-backup-formfield-label>
@@ -130,7 +133,7 @@ class OnboardingRestoreBackupRestore extends LitElement {
${
!onlyHomeAssistantBackup
? html`<h2>
${this.localize("ui.panel.page-onboarding.restore.select_type")}
${this._localize("ui.panel.page-onboarding.restore.select_type")}
</h2>`
: nothing
}
@@ -139,7 +142,7 @@ class OnboardingRestoreBackupRestore extends LitElement {
!this.supervisor &&
this.backup.addons.length > 0
? html`<ha-alert class="supervisor-warning">
${this.localize(
${this._localize(
"ui.panel.page-onboarding.restore.details.apps_unsupported"
)}
<ha-button
@@ -149,7 +152,7 @@ class OnboardingRestoreBackupRestore extends LitElement {
rel="noreferrer noopener"
size="s"
>
${this.localize(
${this._localize(
"ui.panel.page-onboarding.restore.ha-cloud.learn_more"
)}</ha-button
>
@@ -160,7 +163,6 @@ class OnboardingRestoreBackupRestore extends LitElement {
!onlyHomeAssistantBackup
? html`<ha-backup-data-picker
translation-key-panel="page-onboarding.restore"
.localize=${this.localize}
.data=${this.backup}
.value=${this._selectedData}
@value-changed=${this._selectedBackupChanged}
@@ -178,24 +180,24 @@ class OnboardingRestoreBackupRestore extends LitElement {
backupProtected
? html`<div class="encryption">
<h2>
${this.localize(
${this._localize(
"ui.panel.page-onboarding.restore.details.restore.encryption.label"
)}
</h2>
<span>
${this.localize(
${this._localize(
`ui.panel.page-onboarding.restore.details.restore.encryption.description${this.mode === "cloud" ? "_cloud" : ""}`
)}
</span>
<ha-input
.disabled=${this._loading}
@input=${this._encryptionKeyChanged}
.label=${this.localize(
.label=${this._localize(
"ui.panel.page-onboarding.restore.details.restore.encryption.input_label"
)}
.value=${this._encryptionKey}
@keydown=${this._keyDown}
.validationMessage=${this.localize(
.validationMessage=${this._localize(
"ui.panel.page-onboarding.restore.details.restore.encryption.incorrect_key"
)}
.invalid=${this._encryptionKeyWrong}
@@ -208,7 +210,7 @@ class OnboardingRestoreBackupRestore extends LitElement {
${
this.mode === "cloud"
? html`<ha-button appearance="plain" @click=${this._signOut}>
${this.localize(
${this._localize(
"ui.panel.page-onboarding.restore.ha-cloud.sign_out"
)}
</ha-button>`
@@ -223,7 +225,7 @@ class OnboardingRestoreBackupRestore extends LitElement {
}
@click=${this._startRestore}
>
${this.localize(
${this._localize(
"ui.panel.page-onboarding.restore.details.restore.action"
)}
</ha-progress-button>
@@ -1,5 +1,6 @@
import { css, html, LitElement, nothing, type CSSResultGroup } from "lit";
import { customElement, property } from "lit/decorators";
import { customElement, property, state } from "lit/decorators";
import { consumeLocalize } from "../../common/decorators/consume-context-entry";
import { fireEvent } from "../../common/dom/fire_event";
import type { LocalizeFunc } from "../../common/translations/localize";
import "../../components/ha-alert";
@@ -10,7 +11,9 @@ import { onBoardingStyles } from "../styles";
@customElement("onboarding-restore-backup-status")
class OnboardingRestoreBackupStatus extends LitElement {
@property({ attribute: false }) public localize!: LocalizeFunc;
@state()
@consumeLocalize()
private _localize!: LocalizeFunc;
@property({ attribute: false })
public backupInfo!: BackupOnboardingInfo;
@@ -18,14 +21,14 @@ class OnboardingRestoreBackupStatus extends LitElement {
render() {
return html`
<h1>
${this.localize(
${this._localize(
`ui.panel.page-onboarding.restore.${this.backupInfo.state === "restore_backup" ? "in_progress" : "failed"}`
)}
</h1>
${
this.backupInfo.state === "restore_backup"
? html` <p>
${this.localize(
? html`<p>
${this._localize(
`ui.panel.page-onboarding.restore.in_progress_description`
)}
</p>`
@@ -41,7 +44,7 @@ class OnboardingRestoreBackupStatus extends LitElement {
`
: html`
<ha-alert alert-type="error">
${this.localize(
${this._localize(
"ui.panel.page-onboarding.restore.failed_status_description"
)}
</ha-alert>
@@ -62,7 +65,7 @@ class OnboardingRestoreBackupStatus extends LitElement {
this.backupInfo.state !== "restore_backup"
? html`<div class="actions">
<ha-button @click=${this._back}>
${this.localize("ui.panel.page-onboarding.restore.back")}
${this._localize("ui.panel.page-onboarding.restore.back")}
</ha-button>
</div>`
: nothing
@@ -1,22 +1,23 @@
import { mdiFolderUpload } from "@mdi/js";
import { css, html, LitElement, nothing, type CSSResultGroup } from "lit";
import { customElement, property, state } from "lit/decorators";
import "../../components/ha-file-upload";
import "../../components/ha-alert";
import "../../components/ha-icon-button-arrow-prev";
import { consumeLocalize } from "../../common/decorators/consume-context-entry";
import { fireEvent, type HASSDomEvent } from "../../common/dom/fire_event";
import { showAlertDialog } from "../../dialogs/generic/show-dialog-box";
import { navigate } from "../../common/navigate";
import type { LocalizeFunc } from "../../common/translations/localize";
import { removeSearchParam } from "../../common/url/search-params";
import "../../components/ha-alert";
import "../../components/ha-file-upload";
import "../../components/ha-icon-button-arrow-prev";
import {
CORE_LOCAL_AGENT,
HASSIO_LOCAL_AGENT,
isSupportedBackupFile,
SUPPORTED_UPLOAD_FORMAT,
} from "../../data/backup";
import type { LocalizeFunc } from "../../common/translations/localize";
import { uploadOnboardingBackup } from "../../data/backup_onboarding";
import { showAlertDialog } from "../../dialogs/generic/show-dialog-box";
import { onBoardingStyles } from "../styles";
import { navigate } from "../../common/navigate";
import { removeSearchParam } from "../../common/url/search-params";
declare global {
interface HASSDomEvents {
@@ -27,7 +28,9 @@ declare global {
class OnboardingRestoreBackupUpload extends LitElement {
@property({ type: Boolean }) public supervisor = false;
@property({ attribute: false }) public localize!: LocalizeFunc;
@state()
@consumeLocalize()
private _localize!: LocalizeFunc;
@state() private _uploading = false;
@@ -36,14 +39,14 @@ class OnboardingRestoreBackupUpload extends LitElement {
render() {
return html`
<ha-icon-button-arrow-prev
.label=${this.localize("ui.panel.page-onboarding.restore.back")}
.label=${this._localize("ui.panel.page-onboarding.restore.back")}
@click=${this._back}
></ha-icon-button-arrow-prev>
<h1>
${this.localize("ui.panel.page-onboarding.restore.upload_backup")}
${this._localize("ui.panel.page-onboarding.restore.upload_backup")}
</h1>
<p>
${this.localize(
${this._localize(
"ui.panel.page-onboarding.restore.upload_backup_subtitle"
)}
</p>
@@ -56,18 +59,17 @@ class OnboardingRestoreBackupUpload extends LitElement {
.uploading=${this._uploading}
.icon=${mdiFolderUpload}
accept=${SUPPORTED_UPLOAD_FORMAT}
.localize=${this.localize}
.label=${this.localize(
.label=${this._localize(
"ui.panel.page-onboarding.restore.upload_input_label"
)}
.secondary=${this.localize(
.secondary=${this._localize(
"ui.panel.page-onboarding.restore.upload_secondary"
)}
.supports=${this.localize(
.supports=${this._localize(
"ui.panel.page-onboarding.restore.upload_supports_tar"
)}
.deleteLabel=${this.localize("ui.panel.page-onboarding.restore.delete")}
.uploadingLabel=${this.localize(
.deleteLabel=${this._localize("ui.panel.page-onboarding.restore.delete")}
.uploadingLabel=${this._localize(
"ui.panel.page-onboarding.restore.uploading"
)}
@file-picked=${this._filePicked}
@@ -81,13 +83,13 @@ class OnboardingRestoreBackupUpload extends LitElement {
if (!file || !isSupportedBackupFile(file)) {
showAlertDialog(this, {
title: this.localize(
title: this._localize(
"ui.panel.page-onboarding.restore.unsupported.title"
),
text: this.localize(
text: this._localize(
"ui.panel.page-onboarding.restore.unsupported.text"
),
confirmText: this.localize("ui.panel.page-onboarding.restore.ok"),
confirmText: this._localize("ui.panel.page-onboarding.restore.ok"),
});
return;
}
@@ -28,6 +28,7 @@ import type { PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import "../trigger/ha-automation-trigger-references";
import { ensureArray } from "../../../../common/array/ensure-array";
import { storage } from "../../../../common/decorators/storage";
import { fireEvent } from "../../../../common/dom/fire_event";
@@ -61,6 +62,7 @@ import type {
ActionSidebarConfig,
AutomationClipboard,
Condition,
TriggerCondition,
} from "../../../../data/automation";
import type { ConditionDescriptions } from "../../../../data/condition";
import { CONDITION_BUILDING_BLOCKS } from "../../../../data/condition";
@@ -230,6 +232,8 @@ export default class HaAutomationActionRow extends LitElement {
private _runResultTimeout?: number;
private _sidebarAction?: Action;
get selected() {
return this._selected;
}
@@ -257,6 +261,21 @@ export default class HaAutomationActionRow extends LitElement {
}
}
protected updated(changedProperties: PropertyValues<this>) {
super.updated(changedProperties);
// Controller updates (trigger selection, ID migration, or cleanup) bypass the
// sidebar's save callback. Refresh its snapshot unless it already has this
// action, so ordinary sidebar edits do not reopen it and disrupt focus.
if (
changedProperties.has("action") &&
this._selected &&
this.optionsInSidebar &&
this.action !== this._sidebarAction
) {
this.openSidebar();
}
}
public disconnectedCallback() {
super.disconnectedCallback();
@@ -360,7 +379,7 @@ export default class HaAutomationActionRow extends LitElement {
this._entityReg,
this.action,
undefined,
false,
{ hideTriggerIds: true },
this._manifests
)
)}
@@ -381,6 +400,14 @@ export default class HaAutomationActionRow extends LitElement {
></ha-automation-row-options>`
: nothing
}
${
this._isTriggerConditionAction(type)
? html` <ha-automation-trigger-references
.condition=${this.action as TriggerCondition}
.hass=${this.hass}
></ha-automation-trigger-references>`
: nothing
}
${
noteTooltipText
? html`
@@ -701,6 +728,14 @@ export default class HaAutomationActionRow extends LitElement {
`;
}
private _isTriggerConditionAction(
type: ReturnType<typeof getAutomationActionType>
) {
return (
type === "condition" && (this.action as Condition).condition === "trigger"
);
}
protected render() {
if (!this.action) return nothing;
@@ -1020,7 +1055,7 @@ export default class HaAutomationActionRow extends LitElement {
this._entityReg,
this.action,
undefined,
true,
{ ignoreAlias: true },
this._manifests
)
),
@@ -1177,10 +1212,12 @@ export default class HaAutomationActionRow extends LitElement {
public openSidebar(action?: Action): void {
const sidebarAction = action ?? this.action;
this._sidebarAction = sidebarAction;
const actionType = getAutomationActionType(sidebarAction);
fireEvent(this, "open-sidebar", {
save: (value) => {
this._sidebarAction = value;
fireEvent(this, "value-changed", { value });
},
close: (focus?: boolean) => {
@@ -15,7 +15,7 @@ import { mainWindow } from "../../../common/dom/get_main_window";
import { computeAreaName } from "../../../common/entity/compute_area_name";
import { computeDeviceName } from "../../../common/entity/compute_device_name";
import { computeDomain } from "../../../common/entity/compute_domain";
import { computeEntityNameList } from "../../../common/entity/compute_entity_name_display";
import { computeEntityPickerDisplay } from "../../../common/entity/compute_entity_name_display";
import { computeFloorName } from "../../../common/entity/compute_floor_name";
import { getDeviceArea } from "../../../common/entity/context/get_device_context";
import { isNumericState } from "../../../common/number/format_number";
@@ -925,9 +925,10 @@ class DialogAddAutomationElement
const area = device
? getDeviceArea(device, this.hass.areas, this.hass.devices)
: undefined;
const parentDevice = device?.parent_device_id
? this.hass.devices[device.parent_device_id]
: undefined;
const parentDevice =
device?.parent_device_id && device.context_source !== "area"
? this.hass.devices[device.parent_device_id]
: undefined;
if (area) {
subtitle = [
computeAreaName(area) || area.area_id,
@@ -950,29 +951,10 @@ class DialogAddAutomationElement
this._manifests?.[domain]
);
} else {
const stateObj = this.hass.states[targetId];
const [entityName, deviceName, parentDeviceName, areaName] =
computeEntityNameList(
stateObj,
[
{ type: "entity" },
{ type: "device" },
{ type: "parent_device" },
{ type: "area" },
],
this.hass.entities,
this.hass.devices,
this.hass.areas,
this.hass.floors
);
subtitle = [
areaName,
parentDeviceName,
entityName ? deviceName : undefined,
]
.filter(Boolean)
.join(separator);
subtitle = computeEntityPickerDisplay(
this.hass,
this.hass.states[targetId]
).secondary;
}
}
@@ -24,6 +24,7 @@ import { LitElement, 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 "../trigger/ha-automation-trigger-references";
import { ensureArray } from "../../../../common/array/ensure-array";
import { storage } from "../../../../common/decorators/storage";
import { fireEvent } from "../../../../common/dom/fire_event";
@@ -50,6 +51,7 @@ import type {
AutomationClipboard,
Condition,
ConditionSidebarConfig,
TriggerCondition,
} from "../../../../data/automation";
import { isCondition, testCondition } from "../../../../data/automation";
import { describeCondition } from "../../../../data/automation_i18n";
@@ -152,6 +154,8 @@ export default class HaAutomationConditionRow extends LitElement {
private _testingTimeout?: number;
private _sidebarCondition?: Condition;
get selected() {
return this._selected;
}
@@ -201,12 +205,24 @@ export default class HaAutomationConditionRow extends LitElement {
<ha-automation-condition-summary
slot="header"
.label=${capitalizeFirstLetter(
describeCondition(this.condition, this.hass, this._entityReg)
describeCondition(this.condition, this.hass, this._entityReg, {
hideTriggerIds: true,
})
)}
.condition=${this.condition}
.description=${this.conditionDescriptions[this.condition.condition]}
.isNew=${this._isNew}
></ha-automation-condition-summary>
>
${
this.condition.condition === "trigger"
? html`<ha-automation-trigger-references
slot="references"
.condition=${this.condition as TriggerCondition}
.hass=${this.hass}
></ha-automation-trigger-references>`
: nothing
}
</ha-automation-condition-summary>
<ha-automation-row-event-chip
.show=${this._testing}
.variant=${this._testingResult ? "success" : "warning"}
@@ -567,6 +583,21 @@ export default class HaAutomationConditionRow extends LitElement {
}
}
protected updated(changedProperties: PropertyValues<this>) {
super.updated(changedProperties);
// Controller updates (trigger selection, ID migration, or cleanup) bypass the
// sidebar's save callback. Refresh its snapshot unless it already has this
// condition, so ordinary sidebar edits do not reopen it and disrupt focus.
if (
changedProperties.has("condition") &&
this._selected &&
this.optionsInSidebar &&
this.condition !== this._sidebarCondition
) {
this.openSidebar();
}
}
public disconnectedCallback() {
super.disconnectedCallback();
if (this._testingTimeout !== undefined) {
@@ -898,8 +929,10 @@ export default class HaAutomationConditionRow extends LitElement {
public openSidebar(condition?: Condition): void {
const sidebarCondition = condition || this.condition;
this._sidebarCondition = sidebarCondition;
fireEvent(this, "open-sidebar", {
save: (value) => {
this._sidebarCondition = value;
fireEvent(this, "value-changed", { value });
},
close: (focus?: boolean) => {
@@ -1,6 +1,7 @@
import type { PropertyValues } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import memoizeOne from "memoize-one";
import {
array,
assert,
@@ -34,32 +35,38 @@ const stateConditionStruct = object({
enabled: optional(boolean()),
});
const SCHEMA = [
{ name: "entity_id", required: true, selector: { entity: {} } },
{
name: "attribute",
selector: {
attribute: {
hide_attributes: STATE_CONDITION_HIDDEN_ATTRIBUTES,
const SCHEMA = memoizeOne(
(hasAttribute: boolean) =>
[
{ name: "entity_id", required: true, selector: { entity: {} } },
{
name: "attribute",
selector: {
attribute: {
hide_attributes: STATE_CONDITION_HIDDEN_ATTRIBUTES,
},
},
context: {
filter_entity: "entity_id",
},
},
},
context: {
filter_entity: "entity_id",
},
},
{
name: "state",
required: true,
selector: {
state: { multiple: true },
},
context: {
filter_entity: "entity_id",
filter_attribute: "attribute",
},
},
{ name: "for", selector: { duration: {} } },
] as const;
{
name: "state",
required: true,
selector: {
state: { multiple: true },
},
context: {
filter_entity: "entity_id",
filter_attribute: "attribute",
},
},
// `for` is not supported together with `attribute`: the legacy state
// condition measures the duration against `last_changed`, which only
// updates on state changes, not attribute changes.
{ name: "for", disabled: hasAttribute, selector: { duration: {} } },
] as const
);
@customElement("ha-automation-condition-state")
export class HaStateCondition extends LitElement implements ConditionElement {
@@ -86,6 +93,7 @@ export class HaStateCondition extends LitElement implements ConditionElement {
}
protected render() {
const hasAttribute = !!this.condition.attribute;
const trgFor = createDurationData(this.condition.for);
const data = {
...this.condition,
@@ -97,10 +105,11 @@ export class HaStateCondition extends LitElement implements ConditionElement {
<ha-form
.hass=${this.hass}
.data=${data}
.schema=${SCHEMA}
.schema=${SCHEMA(hasAttribute)}
.disabled=${this.disabled}
@value-changed=${this._valueChanged}
.computeLabel=${this._computeLabelCallback}
.computeHelper=${this._computeHelperCallback}
></ha-form>
`;
}
@@ -115,6 +124,12 @@ export class HaStateCondition extends LitElement implements ConditionElement {
: {}
);
// `for` is not supported together with `attribute` for the legacy state
// condition, so drop any lingering duration when an attribute is set.
if (newCondition.attribute) {
delete newCondition.for;
}
// Ensure `state` stays an array for multi-select. If absent, set to []
if (newCondition.state === undefined || newCondition.state === "") {
newCondition.state = [];
@@ -124,7 +139,7 @@ export class HaStateCondition extends LitElement implements ConditionElement {
}
private _computeLabelCallback = (
schema: SchemaUnion<typeof SCHEMA>
schema: SchemaUnion<ReturnType<typeof SCHEMA>>
): string => {
switch (schema.name) {
case "entity_id":
@@ -144,6 +159,17 @@ export class HaStateCondition extends LitElement implements ConditionElement {
}
};
private _computeHelperCallback = (
schema: SchemaUnion<ReturnType<typeof SCHEMA>>
): string | undefined => {
if (schema.name === "for" && this.condition.attribute) {
return this.hass.localize(
"ui.panel.config.automation.editor.conditions.type.state.for_unavailable_with_attribute"
);
}
return undefined;
};
static styles = css`
:host {
display: block;
@@ -1,26 +1,29 @@
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
import { html, LitElement } from "lit";
import { consume } from "@lit/context";
import { mdiAlert, mdiLinkVariantOff } from "@mdi/js";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { ensureArray } from "../../../../../common/array/ensure-array";
import { fireEvent } from "../../../../../common/dom/fire_event";
import "../../../../../components/ha-form/ha-form";
import type { SchemaUnion } from "../../../../../components/ha-form/types";
import "../../../../../components/ha-select";
import { capitalizeFirstLetter } from "../../../../../common/string/capitalize-first-letter";
import "../../../../../components/ha-alert";
import "../../../../../components/ha-button";
import "../../../../../components/ha-svg-icon";
import "../../../../../components/ha-trigger-icon";
import "../../../../../components/item/ha-list-item-option";
import type { HaListSelectable } from "../../../../../components/list/ha-list-selectable";
import "../../../../../components/list/ha-list-selectable";
import type { TriggerCondition } from "../../../../../data/automation";
import {
flattenTriggers,
type AutomationConfig,
type Trigger,
type TriggerCondition,
} from "../../../../../data/automation";
automationTriggerContext,
type AutomationTriggerContext,
type TriggerIdOption,
} from "../../trigger/automation-trigger-id";
import { describeTrigger } from "../../../../../data/automation_i18n";
import { fullEntitiesContext } from "../../../../../data/context";
import type { EntityRegistryEntry } from "../../../../../data/entity/entity_registry";
import type { HomeAssistant } from "../../../../../types";
const getTriggersIds = (triggers: Trigger[]): string[] => {
const triggerIds = flattenTriggers(triggers)
.map((t) => ("id" in t ? t.id : undefined))
.filter(Boolean) as string[];
return Array.from(new Set(triggerIds));
};
import { rowStyles } from "../../styles";
@customElement("ha-automation-condition-trigger")
export class HaTriggerCondition extends LitElement {
@@ -30,9 +33,13 @@ export class HaTriggerCondition extends LitElement {
@property({ type: Boolean }) public disabled = false;
@state() private _triggerIds: string[] = [];
@state()
@consume({ context: automationTriggerContext, subscribe: true })
private _triggers?: AutomationTriggerContext;
private _unsub?: UnsubscribeFunc;
@state()
@consume({ context: fullEntitiesContext, subscribe: true })
private _entityReg: EntityRegistryEntry[] = [];
public static get defaultConfig(): TriggerCondition {
return {
@@ -41,89 +48,214 @@ export class HaTriggerCondition extends LitElement {
};
}
private _schema = memoizeOne(
(triggerIds: string[]) =>
[
{
name: "id",
selector: {
select: {
multiple: true,
options: triggerIds,
},
},
required: true,
},
] as const
);
connectedCallback() {
super.connectedCallback();
const details = { callback: (config) => this._automationUpdated(config) };
fireEvent(this, "subscribe-automation-config", details);
this._unsub = (details as any).unsub;
}
disconnectedCallback() {
super.disconnectedCallback();
if (this._unsub) {
this._unsub();
}
}
protected render() {
if (!this._triggerIds.length) {
const selectedIds = ensureArray(this.condition.id).filter(Boolean);
const triggerIdOptions = this._triggers?.options ?? [];
const missingIds = this._missingIds(this.condition.id, triggerIdOptions);
if (!triggerIdOptions.length && !selectedIds.length) {
return this.hass.localize(
"ui.panel.config.automation.editor.conditions.type.trigger.no_triggers"
);
}
const schema = this._schema(this._triggerIds);
return html`
<ha-form
.schema=${schema}
.data=${this.condition}
.hass=${this.hass}
.disabled=${this.disabled}
.computeLabel=${this._computeLabelCallback}
@value-changed=${this._valueChanged}
></ha-form>
${
missingIds.length
? html`<ha-alert alert-type="warning">
${this.hass.localize(
"ui.panel.config.automation.editor.conditions.type.trigger.missing_triggers"
)}
</ha-alert>`
: nothing
}
${
triggerIdOptions.some((option) => option.duplicate)
? html`<ha-alert alert-type="warning">
${this.hass.localize(
"ui.panel.config.automation.editor.conditions.type.trigger.duplicate_ids"
)}
<ha-button
slot="action"
size="s"
appearance="filled"
.disabled=${this.disabled}
@click=${this._fixDuplicateIds}
>
${this.hass.localize(
"ui.panel.config.automation.editor.conditions.type.trigger.duplicate_ids_fix"
)}
</ha-button>
</ha-alert>`
: nothing
}
<ha-list-selectable
multi
controlled
@ha-list-item-selected=${this._handleSelected}
@ha-list-item-deselected=${this._handleDeselected}
>
${triggerIdOptions.map(
(option) => html`
<ha-list-item-option
.value=${option.id}
.selected=${selectedIds.includes(option.id)}
.disabled=${this.disabled}
appearance="checkbox"
>
<span slot="start" class="trigger-row-leading">
<span class="trigger-index-badge">${option.index + 1}</span>
<ha-trigger-icon
.trigger=${
"trigger" in option.trigger ? option.trigger.trigger : ""
}
></ha-trigger-icon>
</span>
<span slot="headline">
${capitalizeFirstLetter(
describeTrigger(option.trigger, this.hass, this._entityReg)
)}
</span>
${
option.duplicate
? html`<span slot="end" class="duplicate-trigger-badge">
<ha-svg-icon .path=${mdiAlert}></ha-svg-icon>
<span class="duplicate-trigger-id">${option.id}</span>
</span>`
: nothing
}
</ha-list-item-option>
`
)}
${missingIds.map(
(id) => html`
<ha-list-item-option
.value=${id}
.selected=${true}
.disabled=${this.disabled}
appearance="checkbox"
>
<span slot="start" class="missing-trigger-badge">
<ha-svg-icon .path=${mdiLinkVariantOff}></ha-svg-icon>
<span class="missing-trigger-id">${id}</span>
</span>
<span slot="headline">
${this.hass.localize(
"ui.panel.config.automation.editor.conditions.type.trigger.missing_trigger"
)}
</span>
</ha-list-item-option>
`
)}
</ha-list-selectable>
`;
}
private _computeLabelCallback = (
schema: SchemaUnion<ReturnType<typeof this._schema>>
): string =>
this.hass.localize(
`ui.panel.config.automation.editor.conditions.type.trigger.${schema.name}`
);
private _automationUpdated(config?: AutomationConfig) {
this._triggerIds = config?.triggers
? getTriggersIds(ensureArray(config.triggers))
: [];
}
private _valueChanged(ev: CustomEvent): void {
private _handleSelected = (ev: CustomEvent<number>) => {
ev.stopPropagation();
const newValue = ev.detail.value;
if (typeof newValue.id === "string") {
if (!this._triggerIds.some((id) => id === newValue.id)) {
newValue.id = "";
}
} else if (Array.isArray(newValue.id)) {
newValue.id = newValue.id.filter((_id) =>
this._triggerIds.some((id) => id === _id)
);
if (!newValue.id.length) {
newValue.id = "";
}
const list = ev.currentTarget as HaListSelectable;
const item = list.items[ev.detail] as HTMLElement & { value?: string };
const id = item?.value;
if (!id) {
return;
}
this._selectId(id, true);
};
fireEvent(this, "value-changed", { value: newValue });
private _handleDeselected = (ev: CustomEvent<number>) => {
ev.stopPropagation();
const list = ev.currentTarget as HaListSelectable;
const item = list.items[ev.detail] as HTMLElement & { value?: string };
const id = item?.value;
if (!id) {
return;
}
this._selectId(id, false);
};
private _selectId(id: string, checked: boolean) {
const ids = ensureArray(this.condition.id).filter(Boolean);
const selectedIds = checked
? ids.includes(id)
? ids
: [...ids, id]
: ids.filter((_id) => _id !== id);
if (this._triggers) {
this._triggers.select(this.condition, selectedIds);
return;
}
fireEvent(this, "value-changed", {
value: {
...this.condition,
id: selectedIds.length ? selectedIds : "",
},
});
}
private _fixDuplicateIds = () => {
if (!this.disabled) {
return this._triggers?.fixDuplicateIds();
}
return undefined;
};
private _missingIds = memoizeOne(
(ids: TriggerCondition["id"], triggerIdOptions: TriggerIdOption[]) => {
const availableIds = new Set(triggerIdOptions.map((option) => option.id));
return ensureArray(ids).filter((id) => id && !availableIds.has(id));
}
);
static styles = [
rowStyles,
css`
ha-alert {
display: block;
margin-bottom: var(--ha-space-4);
}
ha-list-selectable {
--ha-list-gap: var(--ha-space-2);
--ha-list-padding: 0;
}
ha-list-item-option {
--ha-list-item-padding: var(--ha-space-1) var(--ha-space-2);
}
.trigger-row-leading {
display: inline-flex;
align-items: center;
gap: var(--ha-space-2);
}
ha-trigger-icon {
--mdc-icon-size: 20px;
color: var(--ha-color-on-neutral-quiet);
}
.missing-trigger-badge,
.duplicate-trigger-badge {
display: inline-flex;
align-items: center;
gap: var(--ha-space-1);
padding: 2px var(--ha-space-2);
border-radius: var(--ha-border-radius-md);
background: var(--ha-color-fill-warning-normal-resting);
color: var(--ha-color-on-warning-normal);
line-height: 18px;
}
.missing-trigger-badge ha-svg-icon,
.duplicate-trigger-badge ha-svg-icon {
--mdc-icon-size: 18px;
flex-shrink: 0;
}
.missing-trigger-id,
.duplicate-trigger-id {
font-family: var(--ha-font-family-code);
font-size: var(--ha-font-size-s);
max-width: 120px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
`,
];
}
declare global {
@@ -1,4 +1,4 @@
import { html, LitElement, nothing } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import type { LocalizeFunc } from "../../../common/translations/localize";
import "../../../components/ha-alert";
@@ -22,7 +22,7 @@ export class HaAutomationEditorWarning extends LitElement {
>
${
this.warnings.length && this.warnings[0] !== undefined
? html`<ul>
? html`<ul tabindex="0">
${this.warnings.map((warning) => html`<li>${warning}</li>`)}
</ul>`
: nothing
@@ -31,6 +31,15 @@ export class HaAutomationEditorWarning extends LitElement {
</ha-alert>
`;
}
static styles = css`
ul {
max-height: 160px;
overflow-y: auto;
overflow-wrap: anywhere;
outline-offset: -2px;
}
`;
}
declare global {
@@ -42,6 +42,7 @@ import type {
AutomationEntity,
BlueprintAutomationConfig,
Condition,
SidebarConfig,
Trigger,
} from "../../../data/automation";
import {
@@ -88,6 +89,8 @@ import "./manual-automation-editor";
import type { HaManualAutomationEditor } from "./manual-automation-editor";
import type { HaDropdownSelectEvent } from "../../../components/ha-dropdown";
import { AutomationTriggerController } from "./trigger/automation-trigger-controller";
declare global {
interface HTMLElementTagNameMap {
"ha-automation-editor": HaAutomationEditor;
@@ -132,6 +135,15 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
private _configSubscriptionsId = 1;
private _triggerController = new AutomationTriggerController(this, {
getConfig: () => this.config,
canEdit: () => !this.readOnly && !this.saving,
commit: (config) => {
this._manualEditor?.resetPastedConfig();
this._updateConfig(config);
},
});
private _newAutomationId?: string;
protected domainHooks: EditorDomainHooks<AutomationConfig> = {
@@ -503,6 +515,7 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
@value-changed=${this._valueChanged}
@save-automation=${this._handleSaveAutomation}
@editor-save=${this._handleSaveAutomation}
@sidebar-config-changed=${this._sidebarConfigChanged}
>
<div class="alert-wrapper" slot="alerts">
${this._renderDeprecatedMigratedAlert()}
@@ -780,12 +793,26 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
private _valueChanged(ev: ValueChangedEvent<AutomationConfig>) {
ev.stopPropagation();
const config = ev.detail.value;
this._updateConfig(
"use_blueprint" in config
? config
: this._triggerController.cleanupRemovedIds(config)
);
}
private _sidebarConfigChanged = (
ev: CustomEvent<{ value: SidebarConfig | undefined }>
) => {
this._triggerController.checkShowIndices(ev.detail.value);
};
private _updateConfig(config: AutomationConfig) {
if (this.config) {
this._undoRedoController.commit(this.config);
}
this.config = ev.detail.value;
this.config = config;
if (this.readOnly) {
return;
}
@@ -63,7 +63,6 @@ import "../../../components/ha-filter-floor-areas";
import "../../../components/ha-filter-labels";
import "../../../components/ha-filter-voice-assistants";
import "../../../components/ha-icon-button";
import "../../../components/ha-sub-menu";
import "../../../components/ha-svg-icon";
import "../../../components/ha-switch";
import type { HaSwitch } from "../../../components/ha-switch";
@@ -8,6 +8,60 @@ import { nextRender } from "../../../common/util/render-status";
import type { AutomationClipboard } from "../../../data/automation";
import type { Constructor, HomeAssistant } from "../../../types";
const rowKeys = new WeakMap<object, string>();
const getAutomationRowKey = (item: object): string => {
if (!rowKeys.has(item)) {
rowKeys.set(item, Math.random().toString());
}
return rowKeys.get(item)!;
};
// Keep the selected row and its sidebar callbacks alive across immutable edits.
// Keys stay out of the automation YAML and the objects stored for undo.
export const preserveAutomationRowKey = <T>(original: T, updated: T): T => {
if (
original &&
updated &&
typeof original === "object" &&
typeof updated === "object"
) {
const key = rowKeys.get(original);
if (key !== undefined) {
rowKeys.set(updated, key);
}
}
return updated;
};
/**
* Reconcile rows after a transformation that keeps their paths and list order.
* Matching object keys and array indices represent the same logical row, so Lit's
* repeat directive can reuse its element, selection state, and sidebar callbacks.
* Do not use this to reconcile insertions, removals, or reordering between the two
* inputs: matching indices would then associate keys with different rows.
*/
export const preserveAutomationRowKeys = (
original: unknown,
updated: unknown
): void => {
if (
original === updated ||
!original ||
!updated ||
typeof original !== "object" ||
typeof updated !== "object"
) {
return;
}
preserveAutomationRowKey(original, updated);
const before = original as Record<string, unknown>;
const after = updated as Record<string, unknown>;
for (const key of Object.keys(before)) {
preserveAutomationRowKeys(before[key], after[key]);
}
};
export const AutomationSortableListMixin = <T extends object>(
superClass: Constructor<LitElement>
) => {
@@ -36,8 +90,6 @@ export const AutomationSortableListMixin = <T extends object>(
protected focusItemIndexOnChange?: number;
private _itemKeys = new WeakMap<T, string>();
protected get items(): T[] {
throw new Error("Not implemented");
}
@@ -47,11 +99,7 @@ export const AutomationSortableListMixin = <T extends object>(
}
protected getKey(item: T): string {
if (!this._itemKeys.has(item)) {
this._itemKeys.set(item, Math.random().toString());
}
return this._itemKeys.get(item)!;
return getAutomationRowKey(item);
}
protected moveUp(ev) {
@@ -146,10 +194,7 @@ export const AutomationSortableListMixin = <T extends object>(
items.splice(index, 1);
} else {
// Store key on new value.
const key = this.getKey(items[index]);
this._itemKeys.set(newValue, key);
items[index] = newValue;
items[index] = preserveAutomationRowKey(items[index], newValue);
}
this.items = items;
@@ -181,6 +181,13 @@ export const ManualEditorMixin = <TConfig>(
// deselect previous selected row
this.sidebarConfig?.close?.();
this.sidebarConfig = ev.detail;
this.dispatchEvent(
new CustomEvent("sidebar-config-changed", {
detail: { value: this.sidebarConfig },
bubbles: true,
composed: true,
})
);
// be sure the sidebar editor is recreated
this.sidebarKey++;
@@ -199,6 +206,13 @@ export const ManualEditorMixin = <TConfig>(
...this.sidebarConfig,
...ev.detail.value,
};
this.dispatchEvent(
new CustomEvent("sidebar-config-changed", {
detail: { value: this.sidebarConfig },
bubbles: true,
composed: true,
})
);
}
public triggerCloseSidebar() {
@@ -214,6 +228,13 @@ export const ManualEditorMixin = <TConfig>(
protected handleCloseSidebar() {
this.sidebarConfig = undefined;
this.dispatchEvent(
new CustomEvent("sidebar-config-changed", {
detail: { value: undefined },
bubbles: true,
composed: true,
})
);
}
protected replaceExistingConfig(config: TConfig) {
@@ -44,6 +44,12 @@ import {
import { showPasteReplaceDialog } from "./paste-replace-dialog/show-dialog-paste-replace";
import { manualEditorStyles, saveFabStyles } from "./styles";
import "./trigger/ha-automation-trigger";
import {
cleanupRemovedGeneratedTriggerReferences,
getExplicitTriggerIds,
isGeneratedTriggerId,
stripGeneratedTriggerIds,
} from "./trigger/automation-trigger-id";
const baseConfigStruct = object({
alias: optional(string()),
@@ -407,19 +413,29 @@ export class HaManualAutomationEditor extends ManualEditorMixin<ManualAutomation
}
});
let appendedConfig = config;
if ("triggers" in config) {
const pastedTriggers = ensureArray(config.triggers);
// Strip generated trigger IDs and remove matching references from pasted
// conditions/actions so they do not dangle against the stripped triggers.
const strippedIds = new Set(
getExplicitTriggerIds(pastedTriggers).filter(isGeneratedTriggerId)
);
appendedConfig = strippedIds.size
? cleanupRemovedGeneratedTriggerReferences(config, strippedIds)
: config;
workingCopy.triggers = ensureArray(workingCopy.triggers || []).concat(
ensureArray(config.triggers)
pastedTriggers.map((t) => stripGeneratedTriggerIds(t))
);
}
if ("conditions" in config) {
if ("conditions" in appendedConfig) {
workingCopy.conditions = ensureArray(workingCopy.conditions || []).concat(
ensureArray(config.conditions)
ensureArray(appendedConfig.conditions)
);
}
if ("actions" in config) {
if ("actions" in appendedConfig) {
workingCopy.actions = ensureArray(workingCopy.actions || []).concat(
ensureArray(config.actions)
ensureArray(appendedConfig.actions)
) as Action[];
}
@@ -32,6 +32,7 @@ import "../../../../components/ha-svg-icon";
import type {
Condition,
OptionSidebarConfig,
TriggerCondition,
} from "../../../../data/automation";
import { describeCondition } from "../../../../data/automation_i18n";
import { fullEntitiesContext } from "../../../../data/context";
@@ -45,6 +46,7 @@ import type HaAutomationAction from "../action/ha-automation-action";
import "../condition/ha-automation-condition";
import type HaAutomationCondition from "../condition/ha-automation-condition";
import { showEditorToast } from "../editor-toast";
import "../trigger/ha-automation-trigger-references";
import {
editorStyles,
indentStyle,
@@ -106,7 +108,7 @@ export default class HaAutomationOptionRow extends LitElement {
this._expanded = ev.detail.expanded;
}
private _getDescription() {
private _getDescription(withTriggerReferences = false) {
const conditions = ensureArray<Condition | string>(this.option!.conditions);
if (!conditions || conditions.length === 0) {
return this.hass.localize(
@@ -117,9 +119,12 @@ export default class HaAutomationOptionRow extends LitElement {
if (typeof conditions[0] === "string") {
str += conditions[0];
} else {
str += describeCondition(conditions[0], this.hass, this._entityReg);
str += describeCondition(conditions[0], this.hass, this._entityReg, {
hideTriggerIds: withTriggerReferences,
});
}
if (conditions.length > 1) {
// When chips are rendered, the additional-condition count follows them.
if (conditions.length > 1 && !withTriggerReferences) {
str += this.hass.localize(
"ui.panel.config.automation.editor.actions.type.choose.option_description_additional",
{ numberOfAdditionalConditions: conditions.length - 1 }
@@ -144,6 +149,17 @@ export default class HaAutomationOptionRow extends LitElement {
`;
}
private _renderRow() {
const conditions = ensureArray<Condition | string>(
this.option?.conditions ?? []
);
const firstCondition = conditions[0];
const triggerCondition =
!this.option?.alias &&
!this._expanded &&
typeof firstCondition === "object" &&
firstCondition?.condition === "trigger"
? (firstCondition as TriggerCondition)
: undefined;
const noteTooltipText = truncateWithEllipsis(
this.option?.note?.trim() || "",
250
@@ -156,11 +172,27 @@ export default class HaAutomationOptionRow extends LitElement {
? `${this.hass.localize(
"ui.panel.config.automation.editor.actions.type.choose.option",
{ number: this.index + 1 }
)}: ${this.option.alias || (this._expanded ? "" : this._getDescription())}`
)}: ${this.option.alias || (this._expanded ? "" : this._getDescription(!!triggerCondition))}`
: this.hass.localize(
"ui.panel.config.automation.editor.actions.type.choose.default"
)
}
${
triggerCondition
? html`<ha-automation-trigger-references
.condition=${triggerCondition}
.hass=${this.hass}
></ha-automation-trigger-references>`
: nothing
}
${
triggerCondition && conditions.length > 1
? this.hass.localize(
"ui.panel.config.automation.editor.actions.type.choose.option_description_additional",
{ numberOfAdditionalConditions: conditions.length - 1 }
)
: nothing
}
${
this.option?.note?.trim()
? html`
@@ -6,7 +6,6 @@ import {
mdiContentCut,
mdiContentPaste,
mdiDelete,
mdiIdentifier,
mdiPlayCircleOutline,
mdiPlaylistEdit,
mdiPlusCircleMultipleOutline,
@@ -57,8 +56,6 @@ export default class HaAutomationSidebarTrigger extends LitElement {
@property({ type: Number, attribute: "sidebar-key" })
public sidebarKey?: number;
@state() private _requestShowId = false;
@state() private _warnings?: string[];
@query(".sidebar-editor")
@@ -66,7 +63,6 @@ export default class HaAutomationSidebarTrigger extends LitElement {
protected willUpdate(changedProperties: PropertyValues<this>) {
if (changedProperties.has("config")) {
this._requestShowId = false;
this._warnings = undefined;
if (this.config) {
this.yamlMode = this.config.yamlMode;
@@ -153,28 +149,6 @@ export default class HaAutomationSidebarTrigger extends LitElement {
</ha-dropdown-item>`
: nothing
}
${
!this.yamlMode &&
!("id" in this.config.config) &&
!this._requestShowId
? html`<ha-dropdown-item
slot="menu-items"
value="show_id"
.disabled=${this.disabled || type === "list"}
>
<ha-svg-icon slot="icon" .path=${mdiIdentifier}></ha-svg-icon>
<div class="overflow-label">
${this.hass.localize(
"ui.panel.config.automation.editor.triggers.edit_id"
)}
<span
class="shortcut-placeholder ${isMac ? "mac" : ""}"
></span>
</div>
</ha-dropdown-item>`
: nothing
}
<wa-divider slot="menu-items"></wa-divider>
<ha-dropdown-item
@@ -366,7 +340,6 @@ export default class HaAutomationSidebarTrigger extends LitElement {
@value-changed=${this._valueChangedSidebar}
@yaml-changed=${this._yamlChangedSidebar}
.uiSupported=${this.config.uiSupported}
.showId=${this._requestShowId}
.yamlMode=${this.yamlMode}
.disabled=${this.disabled}
@ui-mode-not-available=${this._handleUiModeNotAvailable}
@@ -419,10 +392,6 @@ export default class HaAutomationSidebarTrigger extends LitElement {
fireEvent(this, "toggle-yaml-mode");
};
private _showTriggerId = () => {
this._requestShowId = true;
};
private _handleDropdownSelect(ev: HaDropdownSelectEvent) {
const action = ev.detail?.item?.value;
@@ -437,9 +406,6 @@ export default class HaAutomationSidebarTrigger extends LitElement {
case "edit_note":
this.config.editNote();
break;
case "show_id":
this._showTriggerId();
break;
case "duplicate":
this.config.duplicate();
break;
+48
View File
@@ -77,6 +77,54 @@ export const rowStyles = css`
display: inline-flex;
}
.trigger-leading {
display: inline-flex;
align-items: center;
gap: var(--ha-space-2);
}
.trigger-leading {
color: var(--ha-color-on-neutral-quiet);
}
.trigger-leading ha-trigger-icon,
.trigger-leading ha-svg-icon {
--mdc-icon-size: 20px;
flex-shrink: 0;
}
.trigger-index-badge {
display: inline-flex;
justify-content: center;
align-items: center;
width: 22px;
height: 22px;
border: 2px dotted var(--ha-color-border-neutral-normal);
border-radius: var(--ha-border-radius-circle);
box-sizing: border-box;
color: var(--ha-color-text-secondary);
font-size: var(--ha-font-size-s);
line-height: 1;
text-box-trim: both;
text-box-edge: cap alphabetic;
overflow: hidden;
transition:
opacity 180ms ease-out,
transform 180ms ease-out,
width 180ms ease-out,
margin-inline-end 180ms ease-out,
border-width 180ms ease-out;
}
.trigger-index-badge.hidden {
opacity: 0;
transform: translateX(calc(-8px * var(--scale-direction)));
width: 0;
margin-inline-end: 0;
border-width: 0;
pointer-events: none;
}
.note-indicator {
color: var(--ha-color-on-neutral-normal);
}
@@ -0,0 +1,187 @@
import { ContextConsumer, ContextProvider } from "@lit/context";
import type { LitElement, ReactiveController } from "lit";
import memoizeOne from "memoize-one";
import type {
AutomationConfig,
SidebarConfig,
TriggerCondition,
} from "../../../../data/automation";
import { internationalizationContext } from "../../../../data/context";
import { showConfirmationDialog } from "../../../../dialogs/generic/show-dialog-box";
import {
assignGeneratedTriggerIds,
automationTriggerContext,
cleanupRemovedGeneratedTriggerReferences,
cleanupUnusedGeneratedTriggerIds,
getTriggerIdOptions,
getExplicitTriggerIds,
isGeneratedTriggerId,
makeDuplicateTriggerIdsUnique,
updateTriggerCondition,
} from "./automation-trigger-id";
import { preserveAutomationRowKeys } from "../ha-automation-sortable-list-mixin";
interface AutomationTriggerControllerOptions {
/** Read the editor's current configuration, including after an awaited dialog. */
getConfig: () => AutomationConfig | undefined;
canEdit: () => boolean;
/** Apply one complete change through the editor's undo and dirty-state handling. */
commit: (config: AutomationConfig) => void;
}
/**
* Coordinates trigger-ID editing.
*
* Descendants consume shared options and callbacks through automationTriggerContext.
* A selection assigns any missing trigger IDs and updates the referencing condition
* in one commit. Duplicate-ID migration uses the same commit path. Ordinary trigger
* edits instead pass through cleanupRemovedIds before the editor commits them.
*
* The transformation helpers only change configuration. This controller preserves
* the corresponding row keys so an open sidebar continues to track the edited row.
*/
export class AutomationTriggerController implements ReactiveController {
private _i18n: ContextConsumer<
typeof internationalizationContext,
LitElement
>;
private _provider: ContextProvider<typeof automationTriggerContext>;
// Unassigned options contain candidate IDs. Reuse them until the trigger list
// changes, so rendering and selection use the same IDs across host updates.
private _getTriggerIdOptions = memoizeOne(getTriggerIdOptions);
constructor(
private _host: LitElement,
private _options: AutomationTriggerControllerOptions
) {
this._i18n = new ContextConsumer(_host, {
context: internationalizationContext,
subscribe: true,
});
this._provider = new ContextProvider(_host, {
context: automationTriggerContext,
initialValue: {
options: [],
showIndices: false,
select: this._selectTriggerIds,
fixDuplicateIds: this._fixDuplicateTriggerIds,
},
});
_host.addController(this);
}
/** Publish options before descendants render, including after undo or redo. */
public hostUpdate() {
const config = this._options.getConfig();
if (!config) {
return;
}
const options = this._getTriggerIdOptions(config.triggers);
if (options !== this._provider.value.options) {
this._provider.setValue({ ...this._provider.value, options });
}
}
public checkShowIndices(sidebarConfig: SidebarConfig | undefined) {
const showIndices = this._isTriggerConditionSidebarActive(sidebarConfig);
if (showIndices !== this._provider.value.showIndices) {
this._provider.setValue({ ...this._provider.value, showIndices });
}
}
private _isTriggerConditionSidebarActive(
sidebarConfig: SidebarConfig | undefined
): boolean {
if (!sidebarConfig || !("config" in sidebarConfig)) {
return false;
}
const config = sidebarConfig.config as
{ condition?: unknown } | { action?: { condition?: unknown } };
if ("condition" in config && config.condition === "trigger") {
return true;
}
return !!(
"action" in config &&
config.action &&
"condition" in config.action &&
config.action.condition === "trigger"
);
}
/**
* Prepare an ordinary editor update without committing it. Reconcile keys against
* the incoming configuration, not the previous one: the user may have reordered
* or deleted triggers, while cleanup itself only changes condition references.
*/
public cleanupRemovedIds(config: AutomationConfig): AutomationConfig {
const current = this._options.getConfig();
const previousIds = new Set(
current ? getExplicitTriggerIds(current.triggers) : []
);
const nextIds = new Set(getExplicitTriggerIds(config.triggers));
const removedIds = new Set(
[...previousIds].filter(
(id) => !nextIds.has(id) && isGeneratedTriggerId(id)
)
);
let cleaned = cleanupUnusedGeneratedTriggerIds(config);
cleaned = cleanupRemovedGeneratedTriggerReferences(cleaned, removedIds);
preserveAutomationRowKeys(config, cleaned);
return cleaned;
}
private _selectTriggerIds = (condition: TriggerCondition, ids: string[]) => {
const config = this._options.getConfig();
if (!config || !this._options.canEdit()) {
return;
}
const triggers = assignGeneratedTriggerIds(
config.triggers,
this._getTriggerIdOptions(config.triggers),
ids
);
const updated = updateTriggerCondition(
config,
condition,
{ ...condition, id: ids.length ? ids : "" },
triggers
);
// Generated IDs that no condition or action references are no longer in use.
// Strip them so the YAML does not accumulate stale identifiers.
this._commit(cleanupUnusedGeneratedTriggerIds(updated));
};
private _fixDuplicateTriggerIds = async () => {
const localize = this._i18n.value?.localize;
if (!this._options.getConfig() || !this._options.canEdit() || !localize) {
return;
}
const confirmed = await showConfirmationDialog(this._host, {
title: localize(
"ui.panel.config.automation.editor.conditions.type.trigger.assign_unique_ids_title"
),
text: localize(
"ui.panel.config.automation.editor.conditions.type.trigger.assign_unique_ids_description"
),
confirmText: localize(
"ui.panel.config.automation.editor.conditions.type.trigger.duplicate_ids_fix"
),
dismissText: localize("ui.common.cancel"),
});
const config = this._options.getConfig();
if (confirmed && config && this._options.canEdit()) {
this._commit(makeDuplicateTriggerIdsUnique(config));
}
};
private _commit(config: AutomationConfig) {
const current = this._options.getConfig();
if (config === current) {
return;
}
preserveAutomationRowKeys(current, config);
this._options.commit(config);
}
}

Some files were not shown because too many files have changed in this diff Show More