Compare commits

..
Author SHA1 Message Date
Petar Petrov 717ae1dbc3 Guard calendar midnight refresh against invalid time-zone delay 2026-09-16 16:30:26 +03:00
Jan-Philipp Benecke afe6e84e3c Make trace pages resizable two-pane layouts (#54216)
* Make trace pages resizable two-pane layouts

* Remove cast

* Remove cast
2026-09-16 16:06:56 +03:00
Bruno Pantaleão Gonçalves 3ddbc8f4e3 Communicate entity control to external bus (#54215)
* Communicate entity control to external bus

* Prefer target entity ids and report disconnecting calls

* Split and lowercase entity ids like Core does

* Keep entity control reporting best effort

* Assert wildcard entity ids are dropped
2026-09-16 15:25:32 +03:00
Jan-Philipp Benecke 216468cf50 Strip unreferenced duplicate trigger IDs (#54214) 2026-09-16 13:52:04 +02:00
Bram KragtenandClaude Opus 5 0ef3e9c71a Resolve statistic labels with structured entity names (#54202)
* Resolve statistic labels with structured entity names

getStatisticLabel resolved names through computeStateName, so every
statistic-shaped label — the energy dashboard cards, the sankey cards,
hui-statistic-card, chart series names and the energy configuration
dialogs — showed the flat friendly_name attribute while the rest of the
UI composed names from the device, area and floor.

It now asks hass.formatEntityName for the default composition, which
also makes the entity branch of computeEnergyLabel redundant.

The metadata fallback stays: external statistics have no entity to
resolve a name against.

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

* Keep the entity name shape out of the core chunk

recorder.ts sits in the core entry chunk, so importing DEFAULT_ENTITY_NAME
from compute_entity_name_display dragged the registry-aware formatters and
their dependencies in with it and blew the core bundle budget by 4.1%.

The constant and the name types have no runtime dependencies, so they move
to their own module; compute_entity_name_display re-exports them so nothing
else has to change.

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

---------

Co-authored-by: Claude Opus 5 <[email protected]>
2026-09-16 09:59:12 +03:00
Bram KragtenandClaude Opus 5 1266b0c87a Remove unused element registration imports (#54206)
ESLint cannot judge side-effect imports like `import "../../components/ha-card";`,
so dead component registrations accumulate unnoticed. A repo-wide scan matching
every side-effect import against the tag its module registers found 308 imports
whose element is never referenced anywhere in the importing file.

This removes the 84 that carry no registration risk: no other file instantiates
the tag without directly importing the defining module (checked for `<tag>`,
createElement/dynamicElement/customElements.get/whenDefined, and `custom:<tag>`
in Lovelace configs), and every affected module keeps at least one other
importer.

hui-weather-entity-row got ha-state-icon transitively through the dropped
hui-generic-entity-row import, so it now imports ha-state-icon directly, which
is the element it actually renders.

The gauge gallery page is left alone even though its .ts never names the tag: a
gallery page exists to register what its sibling .markdown demos render, and
that import is the registration.

The remaining 224 are deliberately out of scope. Their component is rendered
somewhere else without its own import, so they need the matching import added in
the same change.

Co-authored-by: Claude Opus 5 <[email protected]>
2026-09-16 09:52:42 +03:00
Wendelin a695710beb Migrate md lists to new lists in 20 files - 2 (#54208)
* migrate to ha-list-base in 10 files

* migrate lists in 10 files
2026-09-15 19:23:46 +02:00
Wendelin f991efbc0a Migrate md lists to ha-list-base in 20 files (#54197)
* Migrated 10 files from md list to new lists

* migrate 10 files

* copilot review

* fix prettier
2026-09-15 19:06:53 +02:00
renovate[bot] 47306d1afd Update dependency intl-messageformat to v11.2.15 (#54213)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-09-15 16:33:19 +00:00
karwostsandCopilot Autofix powered by AI 1692067392 Fix create helper dialog (#54211)
* Fix create helper dialog

* Ensure loading state resets after import

Wrap import and dirty tracking in try-finally to ensure loading state is reset.

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

---------

Co-authored-by: Copilot Autofix powered by AI <[email protected]>
2026-09-15 18:26:53 +02: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
175 changed files with 4202 additions and 2393 deletions
-1
View File
@@ -3,7 +3,6 @@ import { customElement, property } from "lit/decorators";
import "../../../src/components/ha-card";
import "../../../src/dialogs/more-info/more-info-content";
import "../../../src/state-summary/state-card-content";
import "../ha-demo-options";
import type { HomeAssistant } from "../../../src/types";
import { computeShowNewMoreInfo } from "../../../src/dialogs/more-info/const";
-2
View File
@@ -1,9 +1,7 @@
import { html, css, LitElement } from "lit";
import { customElement } from "lit/decorators";
import "../../src/components/ha-icon-button";
import "../../src/managers/notification-manager";
import { haStyle } from "../../src/resources/styles";
import "./components/page-description";
@customElement("ha-demo-options")
class HaDemoOptions extends LitElement {
+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,10 +103,10 @@
"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",
"intl-messageformat": "11.2.15",
"js-yaml": "5.4.1",
"leaflet": "1.9.4",
"leaflet.markercluster": "1.5.3",
@@ -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",
+6
View File
@@ -1 +1,7 @@
export const stopPropagation = (ev) => ev.stopPropagation();
export const stopKeydownEnterSpacePropagation = (ev: KeyboardEvent) => {
if (ev.key === "Enter" || ev.key === " " || ev.key === "Spacebar") {
ev.stopPropagation();
}
};
@@ -1,44 +1,34 @@
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,
computeEntityName,
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 {
getEntityContext,
getEntityEntryContext,
} from "./context/get_entity_context";
import type { EntityNameItem, EntityNameOptions } from "./entity_name_config";
const DEFAULT_SEPARATOR = " ";
export const DEFAULT_ENTITY_NAME = [
{ type: "parent_device" },
{ type: "device" },
{ type: "entity" },
] satisfies EntityNameItem[];
export const ENTITY_NAME_TYPES = [
"floor",
"area",
"parent_device",
"device",
"entity",
] as const;
export type EntityNameType = (typeof ENTITY_NAME_TYPES)[number];
export type EntityNameItem =
| {
type: EntityNameType;
}
| {
type: "text";
text: string;
};
export interface EntityNameOptions {
separator?: string;
}
export { DEFAULT_ENTITY_NAME, ENTITY_NAME_TYPES } from "./entity_name_config";
export type {
EntityNameItem,
EntityNameOptions,
EntityNameType,
} from "./entity_name_config";
// Joins items that need no entity context. Returns undefined as soon as one
// item references the registries (device, area, ...).
@@ -139,18 +129,49 @@ export const computeEntityNameList = (
areas: HomeAssistant["areas"],
floors: HomeAssistant["floors"]
): (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
);
};
export const computeEntityEntryNameList = (
entry: EntityRegistryDisplayEntry | EntityRegistryEntry,
name: EntityNameItem[],
entities: HomeAssistant["entities"],
devices: HomeAssistant["devices"],
areas: HomeAssistant["areas"],
floors: HomeAssistant["floors"]
): (string | undefined)[] => {
const { device, parentDevice, area, floor } = getEntityEntryContext(
entry,
entities,
devices,
areas,
floors
);
const names = name.map((item) => {
return name.map((item) => {
switch (item.type) {
case "entity":
return computeEntityName(stateObj, entities, devices);
return computeEntityEntryName(entry, devices);
case "device":
return device ? computeDeviceName(device) : undefined;
case "parent_device":
@@ -165,8 +186,29 @@ export const computeEntityNameList = (
return "";
}
});
};
return names;
export const computeEntitySearchLabels = (
stateObj: HassEntity,
entities: HomeAssistant["entities"],
devices: HomeAssistant["devices"],
areas: HomeAssistant["areas"],
floors: HomeAssistant["floors"]
) => {
const { device, parentDevice, area } = getEntityContext(
stateObj,
entities,
devices,
areas,
floors
);
return {
entityName: computeEntityName(stateObj, entities, devices) || null,
friendlyName: computeStateName(stateObj) || null,
deviceName: (device && computeDeviceName(device)) || null,
parentDeviceName: (parentDevice && computeDeviceName(parentDevice)) || null,
areaName: (area && computeAreaName(area)) || null,
};
};
export interface EntityPickerDisplay {
+34
View File
@@ -0,0 +1,34 @@
/**
* Shape of a composed entity name, kept free of runtime dependencies so that
* modules in the core entry chunk can name an entity without pulling in the
* registry-aware formatters from `compute_entity_name_display`.
*/
export const ENTITY_NAME_TYPES = [
"floor",
"area",
"parent_device",
"device",
"entity",
] as const;
export type EntityNameType = (typeof ENTITY_NAME_TYPES)[number];
export type EntityNameItem =
| {
type: EntityNameType;
}
| {
type: "text";
text: string;
};
export interface EntityNameOptions {
separator?: string;
}
export const DEFAULT_ENTITY_NAME = [
{ type: "parent_device" },
{ type: "device" },
{ type: "entity" },
] satisfies EntityNameItem[];
@@ -90,6 +90,7 @@ export class HaAutomationConditionSummary extends LitElement {
></ha-automation-row-options>`
: nothing
}
<slot name="references"></slot>
${
note
? html`
+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 },
};
}
+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",
@@ -22,7 +22,6 @@ import "./ha-list";
import "./ha-list-item";
import "./voice-assistant-brand-icon";
import { voiceAssistants } from "../data/expose";
import "../panels/config/voice-assistants/expose/expose-assistant-icon";
@customElement("ha-filter-voice-assistants")
export class HaFilterVoiceAssistants extends LitElement {
@@ -3,7 +3,6 @@ import { css, html, LitElement } from "lit";
import { customElement, property, query } from "lit/decorators";
import { fireEvent } from "../../common/dom/fire_event";
import type { HASSDomTargetEvent } from "../../common/dom/fire_event";
import "../ha-check-list-item";
import "../ha-checkbox";
import type { HaCheckbox } from "../ha-checkbox";
import "../ha-dropdown";
-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;
}
}
-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;
}
}
@@ -33,7 +33,6 @@ import "../ha-list";
import "../ha-spinner";
import "../ha-svg-icon";
import "../ha-tip";
import "./ha-media-player-browse";
import "./ha-media-upload-button";
import type { MediaManageDialogParams } from "./show-media-manage-dialog";
@@ -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
);
@@ -19,7 +19,6 @@ import type { HassDialog } from "../../../dialogs/make-dialog-manager";
import type { HomeAssistant } from "../../../types";
import type { HaDevicePickerDeviceFilterFunc } from "../../device/ha-device-picker";
import "../../ha-adaptive-dialog";
import "../../ha-dialog-header";
import "../../ha-icon-button";
import "../../ha-icon-next";
import "../../ha-svg-icon";
@@ -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";
@@ -672,40 +671,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) {
-7
View File
@@ -21,7 +21,6 @@ import {
} from "../common/datetime/calc_date";
import type { DateRange } from "../common/datetime/calc_date_range";
import { calcDateRange } from "../common/datetime/calc_date_range";
import { DEFAULT_ENTITY_NAME } from "../common/entity/compute_entity_name_display";
import { formatNumber } from "../common/number/format_number";
import { normalizeValueBySIPrefix } from "../common/number/normalize-by-si-prefix";
import { groupBy } from "../common/util/group-by";
@@ -328,12 +327,6 @@ export const computeEnergyLabel = (
return customName;
}
const stateObj = hass.states[statisticId];
if (stateObj) {
return hass.formatEntityName(stateObj, DEFAULT_ENTITY_NAME);
}
return getStatisticLabel(hass, statisticId, statisticsMetaData);
};
+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,
+3 -2
View File
@@ -1,5 +1,5 @@
import type { Connection } from "home-assistant-js-websocket";
import { computeStateName } from "../common/entity/compute_state_name";
import { DEFAULT_ENTITY_NAME } from "../common/entity/entity_name_config";
import type { HaDurationData } from "../components/ha-duration-input";
import type { HomeAssistant } from "../types";
import { firstWeekday } from "../common/datetime/first_weekday";
@@ -348,8 +348,9 @@ export const getStatisticLabel = (
): string => {
const entity = hass.states[statisticsId];
if (entity) {
return computeStateName(entity);
return hass.formatEntityName(entity, DEFAULT_ENTITY_NAME);
}
// External statistics have no entity to resolve a name against.
return statisticsMetaData?.name || statisticsId;
};
+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
),
}
);
}
+19 -1
View File
@@ -1,4 +1,6 @@
import type { Context, HomeAssistant } from "../types";
import { ensureArray } from "../common/array/ensure-array";
import { isValidEntityId } from "../common/entity/valid_entity_id";
import type { Context, HomeAssistant, ServiceCallRequest } from "../types";
import type { Action } from "./script";
export const callExecuteScript = (
@@ -22,3 +24,19 @@ export const serviceCallWillDisconnect = (
"update.home_assistant_core_update",
"update.home_assistant_operating_system_update",
].includes(serviceData?.entity_id));
// Core merges the target into the service data, so a target entity_id
// replaces the legacy service data one rather than adding to it. Its schema
// also accepts comma separated ids and lowercases them.
export const getServiceCallEntityIds = (
serviceData?: ServiceCallRequest["serviceData"],
target?: ServiceCallRequest["target"]
): string[] => [
...new Set(
(ensureArray(target?.entity_id ?? serviceData?.entity_id) ?? [])
.filter((id): id is string => typeof id === "string")
.flatMap((id) => id.split(","))
.map((id) => id.trim().toLowerCase())
.filter(isValidEntityId)
),
];
@@ -17,7 +17,6 @@ import type { LocalizeFunc } from "../../../../common/translations/localize";
import { throttle } from "../../../../common/util/throttle";
import "../../../../components/ha-hs-color-picker";
import "../../../../components/ha-icon";
import "../../../../components/ha-icon-button-prev";
import "../../../../components/ha-labeled-slider";
import { apiContext } from "../../../../data/context";
import type { LightColor, LightEntity } from "../../../../data/light";
@@ -4,7 +4,6 @@ import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { transform } from "../../../common/decorators/transform";
import "../../../components/ha-date-input";
import "../../../components/ha-time-input";
import { apiContext, internationalizationContext } from "../../../data/context";
import { setDateValue } from "../../../data/date";
import { UNAVAILABLE, UNKNOWN } from "../../../data/entity/entity";
+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
@@ -38,7 +38,6 @@ import "../../../state-control/light/ha-state-control-light-brightness";
import { apiContext, formattersContext } from "../../../data/context";
import "../components/ha-more-info-control-select-container";
import "../components/ha-more-info-state-header";
import "../components/lights/ha-favorite-color-button";
import "../components/lights/ha-more-info-light-favorite-colors";
import "../components/lights/light-color-rgb-picker";
import "../components/lights/light-color-temp-picker";
@@ -10,7 +10,6 @@ import { supportsFeature } from "../../../common/entity/supports-feature";
import type { LocalizeFunc } from "../../../common/translations/localize";
import "../../../components/ha-control-button";
import "../../../components/ha-control-button-group";
import "../../../components/ha-outlined-icon-button";
import "../../../components/ha-state-icon";
import { apiContext } from "../../../data/context";
import type { LockEntity } from "../../../data/lock";
@@ -31,7 +31,6 @@ import "../../../components/ha-dropdown-item";
import "../../../components/ha-icon-button";
import "../../../components/ha-list-item";
import "../../../components/ha-marquee-text";
import "../../../components/ha-select";
import type { HaSlider } from "../../../components/ha-slider";
import "../../../components/ha-svg-icon";
import "../../../components/ha-tooltip";
@@ -5,11 +5,9 @@ import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { listenMediaQuery } from "../../../common/dom/media_query";
import { computeObjectId } from "../../../common/entity/compute_object_id";
import "../../../components/entity/state-info";
import "../../../components/ha-control-button";
import "../../../components/ha-control-button-group";
import "../../../components/ha-markdown";
import "../../../components/ha-relative-time";
import "../../../components/ha-service-control";
import { UNAVAILABLE } from "../../../data/entity/entity";
import type { ExtEntityRegistryEntry } from "../../../data/entity/entity_registry";
@@ -3,7 +3,6 @@ import type { HassEntity } from "home-assistant-js-websocket";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { transform } from "../../../common/decorators/transform";
import "../../../components/ha-date-input";
import "../../../components/ha-time-input";
import { apiContext, internationalizationContext } from "../../../data/context";
import { UNAVAILABLE, UNKNOWN } from "../../../data/entity/entity";
@@ -6,10 +6,8 @@ import { consumeLocalize } from "../../../common/decorators/consume-context-entr
import { supportsFeature } from "../../../common/entity/supports-feature";
import type { LocalizeFunc } from "../../../common/translations/localize";
import { sanitizeHttpUrl } from "../../../common/url/sanitize-http-url";
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/progress/ha-progress-bar";
+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>) {
@@ -27,7 +27,6 @@ import { updateDeviceRegistryEntry } from "../../data/device/device_registry";
import type { InputSelectEntity } from "../../data/input_select";
import { setSelectOption } from "../../data/select";
import { showVoiceAssistantPipelineDetailDialog } from "../../panels/config/voice-assistants/show-dialog-voice-assistant-pipeline-detail";
import "../../panels/lovelace/entity-rows/hui-select-entity-row";
import type { HomeAssistant } from "../../types";
import { getTranslation } from "../../util/common-translation";
import { AssistantSetupStyles } from "./styles";
@@ -6,7 +6,6 @@ import memoizeOne from "memoize-one";
import { fireEvent } from "../../common/dom/fire_event";
import { computeDomain } from "../../common/entity/compute_domain";
import "../../components/ha-button";
import "../../components/ha-dialog-header";
import "../../components/ha-spinner";
import type { AssistSatelliteConfiguration } from "../../data/assist_satellite";
import { interceptWakeWord } from "../../data/assist_satellite";
+26
View File
@@ -184,6 +184,29 @@ interface EMOutgoingMessageAddEntityTo extends EMMessage {
};
}
interface EMOutgoingMessageEntityControlled extends EMMessage {
type: "entity/controlled";
payload: {
entity_ids: string[];
domain: string;
service: string;
};
}
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 +239,8 @@ type EMOutgoingMessageWithoutAnswer =
| EMOutgoingMessageHaptic
| EMOutgoingMessageImportThreadCredentials
| EMOutgoingMessageMatterCommission
| EMOutgoingMessageMoreInfoOpened
| EMOutgoingMessageMoreInfoClosed
| EMOutgoingMessageSidebarShow
| EMOutgoingMessageTagWrite
| EMOutgoingMessageThemeUpdate
@@ -223,6 +248,7 @@ type EMOutgoingMessageWithoutAnswer =
| EMOutgoingMessageImprovScan
| EMOutgoingMessageImprovConfigureDevice
| EMOutgoingMessageAddEntityTo
| EMOutgoingMessageEntityControlled
| EMOutgoingMessageFocusElement
| EMOutgoingMessageReloadAndClearCache
| EMOutgoingMessageAssistSettings;
@@ -12,15 +12,12 @@ import { isDate } from "../../common/string/is_date";
import "../../components/entity/state-info";
import "../../components/ha-alert";
import "../../components/ha-button";
import "../../components/ha-date-input";
import "../../components/ha-dialog-footer";
import "../../components/ha-time-input";
import "../../components/ha-dialog";
import type { CalendarEventMutableParams } from "../../data/calendar";
import { deleteCalendarEvent } from "../../data/calendar";
import { haStyleDialog } from "../../resources/styles";
import type { HomeAssistant } from "../../types";
import "../lovelace/components/hui-generic-entity-row";
import { renderRRuleAsText } from "./recurrence";
import { showConfirmEventDialog } from "./show-confirm-event-dialog-box";
import type { CalendarEventDetailDialogParams } from "./show-dialog-calendar-event-detail";
@@ -42,7 +42,6 @@ import {
import { DirtyStateProviderMixin } from "../../mixins/dirty-state-provider-mixin";
import { haStyleDialog } from "../../resources/styles";
import type { HomeAssistant } from "../../types";
import "../lovelace/components/hui-generic-entity-row";
import "./ha-recurrence-rule-editor";
import { showConfirmEventDialog } from "./show-confirm-event-dialog-box";
import type { CalendarEventEditDialogParams } from "./show-dialog-calendar-event-editor";
+8 -1
View File
@@ -476,6 +476,13 @@ export class HAFullCalendar extends LitElement {
const wasShowingToday = this._isShowingToday();
const nextMidnight = new TZDate(new Date(), this._calendarTimeZone());
nextMidnight.setHours(24, 0, 0, 0);
const delay = nextMidnight.getTime() - Date.now();
// Guard against a NaN/negative delay (e.g. Intl longOffset unsupported on
// Chromium < 95) so the midnight refresh can't fire in a tight loop (#54182).
if (!Number.isFinite(delay) || delay <= 0) {
return;
}
this._midnightRefreshTimeout = window.setTimeout(() => {
if (wasShowingToday) {
@@ -485,7 +492,7 @@ export class HAFullCalendar extends LitElement {
}
this._scheduleMidnightRefresh();
}, nextMidnight.getTime() - Date.now());
}, delay);
}
private _clearMidnightRefreshTimeout(): void {
@@ -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) => {
@@ -8,9 +8,7 @@ import type {
LocalizeKeys,
} from "../../../../../common/translations/localize";
import { CONDITION_ICONS } from "../../../../../components/ha-condition-icon";
import "../../../../../components/ha-dropdown-item";
import type { PickerComboBoxItem } from "../../../../../components/ha-picker-combo-box";
import "../../../../../components/ha-select";
import {
DYNAMIC_PREFIX,
getValueFromDynamic,
@@ -3,7 +3,6 @@ import { html, LitElement } from "lit";
import { customElement, property, query } from "lit/decorators";
import { fireEvent } from "../../../../../common/dom/fire_event";
import "../../../../../components/entity/ha-entity-picker";
import "../../../../../components/ha-service-picker";
import "../../../../../components/ha-yaml-editor";
import type { HaYamlEditor } from "../../../../../components/ha-yaml-editor";
import "../../../../../components/input/ha-input";
@@ -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";
@@ -950,29 +950,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;
}
}
@@ -27,7 +27,6 @@ import {
import "../../../../components/chips/ha-chip-set";
import "../../../../components/chips/ha-filter-chip";
import "../../../../components/entity/state-badge";
import "../../../../components/ha-button-toggle-group";
import "../../../../components/ha-combo-box-item";
import "../../../../components/ha-domain-icon";
import "../../../../components/ha-floor-icon";
@@ -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,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 {
@@ -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;
@@ -13,9 +13,13 @@ import type { CSSResultGroup, TemplateResult, PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import { fireEvent } from "../../../common/dom/fire_event";
import {
fireEvent,
type HASSDomTargetEvent,
} from "../../../common/dom/fire_event";
import { navigate, replaceCurrentUrl } from "../../../common/navigate";
import { computeRTL } from "../../../common/util/compute_rtl";
import { debounce } from "../../../common/util/debounce";
import "../../../components/ha-button";
import "../../../components/ha-dropdown";
import type { HaDropdownSelectEvent } from "../../../components/ha-dropdown";
@@ -49,9 +53,14 @@ import { haStyle } from "../../../resources/styles";
import type { HomeAssistant, Route } from "../../../types";
import { fileDownload } from "../../../util/file_download";
import "../../../components/ha-trace-picker";
import "../../../components/ha-split-panel";
import type { HaSplitPanel } from "../../../components/ha-split-panel";
const TABS = ["details", "timeline", "logbook", "automation_config"] as const;
const STORAGE_KEY_SPLIT_POSITION = "automation-trace-split-position";
const DEFAULT_SPLIT_POSITION = 20;
@customElement("ha-automation-trace")
export class HaAutomationTrace extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -84,6 +93,8 @@ export class HaAutomationTrace extends LitElement {
@state() private _view: (typeof TABS)[number] | "blueprint" = "details";
@state() private _splitPosition = DEFAULT_SPLIT_POSITION;
@query("hat-script-graph") private _graph?: HatScriptGraph;
protected render(): TemplateResult {
@@ -91,10 +102,6 @@ export class HaAutomationTrace extends LitElement {
? this.hass.states[this._entityId]
: undefined;
const graph = this._graph;
const trackedNodes = graph?.trackedNodes;
const renderedNodes = graph?.renderedNodes;
const title = stateObj?.attributes.friendly_name || this._entityId;
let devButtons: TemplateResult | string = "";
@@ -240,94 +247,19 @@ export class HaAutomationTrace extends LitElement {
? ""
: html`
<div class="main">
<div class="graph">
<hat-script-graph
.trace=${this._trace}
.selected=${this._selected?.path}
@graph-node-selected=${this._pickNode}
></hat-script-graph>
</div>
<div class="info">
<ha-tab-group @wa-tab-show=${this._handleTabChanged}>
${TABS.map(
(view) => html`
<ha-tab-group-tab
slot="nav"
.active=${this._view === view}
.panel=${view}
${
this.narrow
? this._renderPanes()
: html`
<ha-split-panel
class="split"
.position=${this._splitPosition}
@wa-reposition=${this._splitRepositioned}
>
${this.hass!.localize(
`ui.panel.config.automation.trace.tabs.${view}`
)}
</ha-tab-group-tab>
${this._renderPanes()}
</ha-split-panel>
`
)}
${
this._trace.blueprint_inputs
? html`
<ha-tab-group-tab
slot="nav"
.active=${this._view === "blueprint"}
panel="blueprint"
>
${this.hass!.localize(
`ui.panel.config.automation.trace.tabs.blueprint_config`
)}
</ha-tab-group-tab>
`
: ""
}
</ha-tab-group>
${
this._selected === undefined ||
this._logbookEntries === undefined ||
trackedNodes === undefined
? nothing
: this._view === "details"
? html`
<ha-trace-path-details
.hass=${this.hass}
.narrow=${this.narrow}
.trace=${this._trace}
.selected=${this._selected}
.logbookEntries=${this._logbookEntries}
.trackedNodes=${trackedNodes}
.renderedNodes=${renderedNodes!}
></ha-trace-path-details>
`
: this._view === "automation_config"
? html`
<ha-trace-config
.trace=${this._trace}
></ha-trace-config>
`
: this._view === "logbook"
? html`
<ha-trace-logbook
.hass=${this.hass}
.narrow=${this.narrow}
.trace=${this._trace}
.logbookEntries=${this._logbookEntries}
></ha-trace-logbook>
`
: this._view === "blueprint"
? html`
<ha-trace-blueprint-config
.trace=${this._trace}
></ha-trace-blueprint-config>
`
: html`
<ha-trace-timeline
.hass=${this.hass}
.trace=${this._trace}
.logbookEntries=${this._logbookEntries}
.selected=${this._selected}
@value-changed=${this._timelinePathPicked}
></ha-trace-timeline>
`
}
</div>
}
</div>
`
}
@@ -335,12 +267,115 @@ export class HaAutomationTrace extends LitElement {
`;
}
private _renderPanes(): TemplateResult {
const graph = this._graph;
const trackedNodes = graph?.trackedNodes;
const renderedNodes = graph?.renderedNodes;
return html`
<div class="graph" slot="start">
<hat-script-graph
.trace=${this._trace}
.selected=${this._selected?.path}
@graph-node-selected=${this._pickNode}
></hat-script-graph>
</div>
<div class="info" slot="end">
<ha-tab-group @wa-tab-show=${this._handleTabChanged}>
${TABS.map(
(view) => html`
<ha-tab-group-tab
slot="nav"
.active=${this._view === view}
.panel=${view}
>
${this.hass!.localize(
`ui.panel.config.automation.trace.tabs.${view}`
)}
</ha-tab-group-tab>
`
)}
${
this._trace!.blueprint_inputs
? html`
<ha-tab-group-tab
slot="nav"
.active=${this._view === "blueprint"}
panel="blueprint"
>
${this.hass!.localize(
`ui.panel.config.automation.trace.tabs.blueprint_config`
)}
</ha-tab-group-tab>
`
: ""
}
</ha-tab-group>
${
this._selected === undefined ||
this._logbookEntries === undefined ||
trackedNodes === undefined
? nothing
: this._view === "details"
? html`
<ha-trace-path-details
.hass=${this.hass}
.narrow=${this.narrow}
.trace=${this._trace}
.selected=${this._selected}
.logbookEntries=${this._logbookEntries}
.trackedNodes=${trackedNodes}
.renderedNodes=${renderedNodes!}
></ha-trace-path-details>
`
: this._view === "automation_config"
? html`
<ha-trace-config .trace=${this._trace}></ha-trace-config>
`
: this._view === "logbook"
? html`
<ha-trace-logbook
.hass=${this.hass}
.narrow=${this.narrow}
.trace=${this._trace}
.logbookEntries=${this._logbookEntries}
></ha-trace-logbook>
`
: this._view === "blueprint"
? html`
<ha-trace-blueprint-config
.trace=${this._trace}
></ha-trace-blueprint-config>
`
: html`
<ha-trace-timeline
.hass=${this.hass}
.trace=${this._trace}
.logbookEntries=${this._logbookEntries}
.selected=${this._selected}
@value-changed=${this._timelinePathPicked}
></ha-trace-timeline>
`
}
</div>
`;
}
protected firstUpdated(changedProps: PropertyValues<this>) {
super.firstUpdated(changedProps);
this.hass.loadBackendTranslation("triggers");
this.hass.loadBackendTranslation("conditions");
const storedPosition = localStorage?.[STORAGE_KEY_SPLIT_POSITION];
if (storedPosition) {
const parsed = parseFloat(storedPosition);
if (!isNaN(parsed) && parsed > 0 && parsed < 100) {
this._splitPosition = parsed;
}
}
if (!this.automationId) {
return;
}
@@ -428,6 +463,19 @@ export class HaAutomationTrace extends LitElement {
this._selected = ev.detail;
}
private _splitRepositioned(ev: HASSDomTargetEvent<HaSplitPanel>) {
this._splitPosition = ev.target.position;
this._storeSplitPosition();
}
private _storeSplitPosition = debounce(
() => {
localStorage[STORAGE_KEY_SPLIT_POSITION] = String(this._splitPosition);
},
500,
false
);
private _refreshTraces() {
this._loadTraces();
}
@@ -618,13 +666,22 @@ export class HaAutomationTrace extends LitElement {
padding: 16px;
}
ha-split-panel.split {
flex: 1;
min-height: 0;
min-width: 0;
--ha-split-panel-min: 10%;
--ha-split-panel-max: 80%;
--ha-split-panel-divider-hit-area: var(--ha-space-4);
}
.graph {
border-right: 1px solid var(--divider-color);
max-width: 50%;
box-sizing: border-box;
display: flex;
flex-direction: column;
overflow: hidden;
min-width: 0;
min-height: 0;
}
hat-script-graph {
flex: 1;
@@ -642,6 +699,8 @@ export class HaAutomationTrace extends LitElement {
}
.info {
flex: 1;
min-width: 0;
min-height: 0;
overflow-y: auto;
background-color: var(--card-background-color);
}
@@ -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);
}
}
@@ -0,0 +1,535 @@
import { createContext } from "@lit/context";
import { nanoid } from "nanoid";
import { ensureArray } from "../../../../common/array/ensure-array";
import {
expandConditionWithShorthand,
flattenTriggers,
} from "../../../../data/automation";
import type {
AutomationConfig,
Condition,
LogicalCondition,
Trigger,
TriggerCondition,
} from "../../../../data/automation";
import { getActionType } from "../../../../data/script";
import { isTriggerList } from "../../../../data/trigger";
import type {
Action,
ChooseAction,
IfAction,
Option,
ParallelAction,
RepeatAction,
SequenceAction,
} from "../../../../data/script";
export const GENERATED_TRIGGER_ID_PREFIX = "generated-";
export interface TriggerIdOption {
/** Existing ID, or a candidate that is stored on the trigger when selected. */
id: string;
trigger: Trigger;
/** Zero-based position among flattened leaf triggers; list wrappers are excluded. */
index: number;
/** The ID is an unstored candidate, not an existing ID already on the trigger. */
draft: boolean;
/** Another leaf trigger has the same nonempty stored ID. */
duplicate: boolean;
}
/** Controller-provided options and operations for editors, sidebars, and row summaries. */
export interface AutomationTriggerContext {
options: TriggerIdOption[];
/** True while the user is editing a `triggered_by` condition; trigger rows show their index badges only in this state. */
showIndices: boolean;
/** Pass the original condition object so identical conditions remain distinguishable. */
select: (condition: TriggerCondition, ids: string[]) => void;
/** Requests confirmation before migrating duplicate IDs throughout the automation. */
fixDuplicateIds: () => Promise<void>;
}
export const automationTriggerContext =
createContext<AutomationTriggerContext>("automationTriggers");
/**
* Identify IDs reserved for automatic UI cleanup by their generated prefix.
* IDs without this prefix remain user-owned, including references to missing triggers.
*/
export const isGeneratedTriggerId = (id: unknown): id is string =>
typeof id === "string" && id.startsWith(GENERATED_TRIGGER_ID_PREFIX);
/** Read a leaf trigger's stored ID; list wrappers do not have selectable IDs. */
const getTriggerId = (trigger: Trigger): string | undefined =>
isTriggerList(trigger) ? undefined : trigger.id;
/** Collect each ID that occurs more than once. */
const duplicateIds = (ids: string[]) =>
new Set(ids.filter((id, index) => ids.indexOf(id) !== index));
/** Map array items or a truthy scalar, leaving absent or falsy scalar values unchanged. */
const mapScalarOrArray = <T>(
value: T | T[] | undefined | null,
mapper: (item: T) => T
): T | T[] | undefined | null => {
return Array.isArray(value)
? value.map(mapper)
: value
? mapper(value)
: value;
};
/**
* Generate an ID absent from both sets and reserve it in `generatedIds`.
* `reservedIds` contains existing IDs that the new ID must not collide with.
*/
const getGeneratedTriggerId = (
reservedIds: Set<string>,
generatedIds: Set<string>
): string => {
let id = `${GENERATED_TRIGGER_ID_PREFIX}${nanoid(4)}`;
while (reservedIds.has(id) || generatedIds.has(id)) {
id = `${GENERATED_TRIGGER_ID_PREFIX}${nanoid(4)}`;
}
generatedIds.add(id);
return id;
};
/** Collect nonempty stored leaf IDs, including generated IDs and duplicates. */
export const getExplicitTriggerIds = (
triggers: Trigger | Trigger[] | undefined
) =>
flattenTriggers(triggers)
.map(getTriggerId)
.filter((id): id is string => Boolean(id));
/**
* Build selectable options in flattened leaf order without modifying the triggers.
* Each call creates fresh candidates for missing or empty IDs. The controller
* memoizes the result until the trigger-list reference changes.
*/
export const getTriggerIdOptions = (
triggers: Trigger | Trigger[] | undefined
): TriggerIdOption[] => {
const explicitIds = getExplicitTriggerIds(triggers);
const duplicates = duplicateIds(explicitIds);
const reservedIds = new Set(explicitIds);
const generatedIds = new Set<string>();
return flattenTriggers(triggers).map((trigger, index) => {
const explicitId = getTriggerId(trigger);
return {
id: explicitId || getGeneratedTriggerId(reservedIds, generatedIds),
trigger,
index,
draft: !explicitId,
duplicate: explicitId ? duplicates.has(explicitId) : false,
};
});
};
/**
* Transform leaf triggers recursively, retaining list wrappers and scalar/array shape.
* Copy only changed branches; callbacks must return replacements rather than mutate leaves.
*/
const walkLeafTriggers = (
triggers: Trigger | Trigger[],
callback: (trigger: Trigger) => Trigger
): Trigger | Trigger[] => {
if (Array.isArray(triggers)) {
let changed = false;
const mapped = triggers.map((trigger) => {
const result = walkLeafTriggers(trigger, callback) as Trigger;
if (result !== trigger) {
changed = true;
}
return result;
});
return changed ? mapped : triggers;
}
if (isTriggerList(triggers)) {
const newInner = triggers.triggers
? (walkLeafTriggers(triggers.triggers, callback) as Trigger | Trigger[])
: triggers.triggers;
if (newInner === triggers.triggers) {
return triggers;
}
return { ...triggers, triggers: newInner };
}
return callback(triggers);
};
/** Store only selected candidate IDs, matching options to their original trigger objects. */
export const assignGeneratedTriggerIds = (
triggers: Trigger | Trigger[],
triggerIdOptions: TriggerIdOption[],
selectedIds: string | string[]
): Trigger | Trigger[] => {
const selected = new Set(ensureArray(selectedIds));
const assignments = new Map<Trigger, string>();
triggerIdOptions.forEach((option) => {
if (option.draft && selected.has(option.id)) {
assignments.set(option.trigger, option.id);
}
});
if (!assignments.size) {
return triggers;
}
return walkLeafTriggers(triggers, (trigger) =>
assignments.has(trigger)
? { ...trigger, id: assignments.get(trigger) }
: trigger
);
};
/**
* Replace, expand, or remove each referenced ID; `undefined` removes a reference.
* Return the original condition when every mapped ID is unchanged. Otherwise
* deduplicate IDs and preserve array form, or use it for multiple replacement IDs.
* An emptied scalar reference becomes an empty string.
*/
const mapReferencedTriggerIds = (
condition: TriggerCondition,
mapper: (id: string) => string | string[] | undefined
): TriggerCondition => {
let changed = false;
const ids = ensureArray(condition.id).flatMap((id) => {
const mappedId = mapper(id);
if (mappedId !== id) {
changed = true;
}
return mappedId ?? [];
});
if (!changed) {
return condition;
}
const uniqueIds = Array.from(new Set(ids));
return {
...condition,
id:
Array.isArray(condition.id) || uniqueIds.length > 1
? uniqueIds
: uniqueIds[0] || "",
};
};
/**
* Update trigger conditions throughout an automation without mutating its input.
* The traversal follows only schema-defined condition/action fields, preserving
* shorthand syntax, templates, and arbitrary action data.
*/
class AutomationTriggerConditionMapper {
/** The callback must return a replacement or the original condition without mutating it. */
constructor(
private _update: (condition: TriggerCondition) => TriggerCondition
) {}
/** Return a new configuration with mapped conditions and actions; leave triggers untouched. */
public map(config: AutomationConfig): AutomationConfig {
return {
...config,
conditions: this._mapConditions(config.conditions),
actions: this._mapActions(config.actions) as Action | Action[],
};
}
/** Map conditions while preserving scalar/array form and an absent value. */
private _mapConditions(conditions: Condition | Condition[] | undefined) {
return mapScalarOrArray(conditions, this._mapCondition) as
Condition | Condition[] | undefined;
}
/** Apply the update to trigger conditions, descending into logical groups. */
private _mapCondition = (condition: Condition): Condition => {
if (typeof condition !== "object" || condition === null) {
return condition;
}
const expanded = expandConditionWithShorthand(condition);
if (expanded !== condition) {
const key = Array.isArray(condition.condition)
? "condition"
: expanded.condition;
return {
...condition,
[key]: this._mapConditions((expanded as LogicalCondition).conditions),
};
}
if (condition.condition === "trigger" && "id" in condition) {
return this._update(condition);
}
if (
"conditions" in condition &&
(condition.condition === "and" ||
condition.condition === "or" ||
condition.condition === "not")
) {
return {
...condition,
conditions: this._mapConditions(condition.conditions),
} as LogicalCondition;
}
return condition;
};
/** Map actions while preserving scalar/array form and an absent value. */
private _mapActions(actions: Action | Action[] | undefined) {
return mapScalarOrArray(actions, this._mapAction) as
Action | Action[] | undefined;
}
/** Map a choose branch's conditions and sequence, leaving template strings untouched. */
private _mapChooseOption = (option: Option): Option => {
return {
...option,
conditions:
typeof option.conditions === "string"
? option.conditions
: (this._mapConditions(option.conditions) as Condition[]),
sequence: this._mapActions(option.sequence) as Action | Action[],
};
};
/** Map condition actions and nested blocks; return other action types unchanged. */
private _mapAction = (action: Action): Action => {
switch (getActionType(action)) {
case "check_condition":
return this._mapCondition(action as Condition) as Action;
case "choose": {
const result = { ...(action as ChooseAction) };
result.choose = mapScalarOrArray(
result.choose,
this._mapChooseOption
) as Option | Option[] | null;
if (result.default) {
result.default = this._mapActions(result.default);
}
return result;
}
case "if": {
const result = { ...(action as IfAction) };
if (typeof result.if !== "string") {
result.if = this._mapConditions(result.if) as Condition[];
}
result.then = this._mapActions(result.then) as Action | Action[];
if (result.else) {
result.else = this._mapActions(result.else);
}
return result;
}
case "repeat": {
const repeat = { ...(action as RepeatAction).repeat };
if ("while" in repeat) {
repeat.while = this._mapConditions(repeat.while) as Condition[];
}
if ("until" in repeat) {
repeat.until = this._mapConditions(repeat.until) as Condition[];
}
repeat.sequence = this._mapActions(repeat.sequence) as
Action | Action[];
return { ...action, repeat };
}
case "sequence": {
const result = { ...(action as SequenceAction) };
result.sequence = this._mapActions(result.sequence as Action[]) as
Action[] | null;
return result;
}
case "parallel": {
const result = { ...(action as ParallelAction) };
result.parallel = this._mapActions(result.parallel as Action[]) as
Action | Action[];
return result;
}
default:
return action;
}
};
}
/**
* Build a configuration with the supplied triggers and replace the selected condition
* by object identity throughout conditions and actions. The controller commits it.
*/
export const updateTriggerCondition = (
config: AutomationConfig,
original: TriggerCondition,
updated: TriggerCondition,
triggers: Trigger | Trigger[]
): AutomationConfig => {
/** Replace only the selected object, even when another condition has equal values. */
const replace = (condition: TriggerCondition) =>
condition === original ? updated : condition;
return {
...new AutomationTriggerConditionMapper(replace).map(config),
triggers,
};
};
/**
* Recursively strip generated IDs from a trigger or trigger list. Manual IDs
* are preserved. Trigger-list wrappers keep their structure; their inner
* triggers are stripped the same way.
*/
export const stripGeneratedTriggerIds = (trigger: Trigger): Trigger => {
if (isTriggerList(trigger)) {
if (!trigger.triggers) {
return trigger;
}
const newInner = Array.isArray(trigger.triggers)
? trigger.triggers.map(stripGeneratedTriggerIds)
: stripGeneratedTriggerIds(trigger.triggers);
if (newInner === trigger.triggers) {
return trigger;
}
return { ...trigger, triggers: newInner };
}
if (!trigger.id || !isGeneratedTriggerId(trigger.id)) {
return trigger;
}
const { id: _id, ...rest } = trigger;
return rest as Trigger;
};
/** Remove generated IDs from triggers that no trigger condition references. */
export const cleanupUnusedGeneratedTriggerIds = (
config: AutomationConfig
): AutomationConfig => {
const referencedIds = new Set<string>();
const recordRef = (id: string) => {
referencedIds.add(id);
return id;
};
new AutomationTriggerConditionMapper((condition) =>
mapReferencedTriggerIds(condition, recordRef)
).map(config);
let changed = false;
const newTriggers = walkLeafTriggers(config.triggers, (trigger) => {
if (
isTriggerList(trigger) ||
!trigger.id ||
!isGeneratedTriggerId(trigger.id) ||
referencedIds.has(trigger.id)
) {
return trigger;
}
changed = true;
const { id: _id, ...rest } = trigger;
return rest as Trigger;
});
if (!changed) {
return config;
}
return {
...config,
triggers: newTriggers,
};
};
/** Remove references to generated IDs belonging to triggers removed from the config. */
export const cleanupRemovedGeneratedTriggerReferences = (
config: AutomationConfig,
removedIds: Set<string>
): AutomationConfig => {
if (!removedIds.size) {
return config;
}
let changed = false;
const cleaned = new AutomationTriggerConditionMapper((condition) =>
mapReferencedTriggerIds(condition, (id) => {
if (isGeneratedTriggerId(id) && removedIds.has(id)) {
changed = true;
return undefined;
}
return id;
})
).map(config);
return changed ? cleaned : config;
};
/**
* Assign a fresh generated ID to every referenced leaf sharing a stored ID.
* Expand trigger-condition references to all replacements for the old ID, preserving
* their original "any of these triggers" meaning. Duplicate IDs that no trigger
* condition references are stripped instead, so the fix does not create generated
* IDs nobody uses. Return the original config if IDs are unique.
* Templates and action data that inspect trigger.id directly are not rewritten;
* the controller's confirmation dialog warns about that limitation.
*/
export const makeDuplicateTriggerIdsUnique = (
config: AutomationConfig
): AutomationConfig => {
const ids = getExplicitTriggerIds(config.triggers);
const duplicates = duplicateIds(ids);
if (!duplicates.size) {
return config;
}
const referencedIds = new Set<string>();
new AutomationTriggerConditionMapper((condition) =>
mapReferencedTriggerIds(condition, (id) => {
referencedIds.add(id);
return id;
})
).map(config);
const referencedDuplicates = new Set(
[...duplicates].filter((id) => referencedIds.has(id))
);
const unreferencedDuplicates = new Set(
[...duplicates].filter((id) => !referencedIds.has(id))
);
const reservedIds = new Set(ids.filter((id) => !duplicates.has(id)));
const generatedIds = new Set<string>();
const assignments = new Map<Trigger, string>();
const replacementIds = new Map<string, string[]>();
// A duplicate ID used to mean "any of these triggers". After assigning each
// trigger its own ID, references to the old ID must expand to every new ID.
flattenTriggers(config.triggers).forEach((trigger) => {
const id = getTriggerId(trigger);
if (!id || !referencedDuplicates.has(id)) {
return;
}
const generatedId = getGeneratedTriggerId(reservedIds, generatedIds);
assignments.set(trigger, generatedId);
replacementIds.set(id, [...(replacementIds.get(id) || []), generatedId]);
});
const triggers = walkLeafTriggers(config.triggers, (trigger) => {
if (assignments.has(trigger)) {
return { ...trigger, id: assignments.get(trigger) };
}
if (isTriggerList(trigger)) {
return trigger;
}
const id = getTriggerId(trigger);
if (id && unreferencedDuplicates.has(id)) {
const { id: _id, ...rest } = trigger;
return rest as Trigger;
}
return trigger;
}) as Trigger | Trigger[];
if (!referencedDuplicates.size) {
return {
...config,
triggers,
};
}
return {
...new AutomationTriggerConditionMapper((condition) =>
mapReferencedTriggerIds(condition, (id) => replacementIds.get(id) ?? id)
).map(config),
triggers,
};
};
@@ -6,7 +6,6 @@ import { dynamicElement } from "../../../../common/dom/dynamic-element-directive
import { fireEvent } from "../../../../common/dom/fire_event";
import "../../../../components/ha-yaml-editor";
import type { HaYamlEditor } from "../../../../components/ha-yaml-editor";
import "../../../../components/input/ha-input";
import type { Trigger } from "../../../../data/automation";
import {
TRIGGER_ROW_CONFIG_KEYS,
@@ -35,8 +34,6 @@ export default class HaAutomationTriggerEditor extends LitElement {
@property({ type: Boolean, attribute: "sidebar" }) public inSidebar = false;
@property({ type: Boolean, attribute: "show-id" }) public showId = false;
@property({ attribute: false }) public description?: TriggerDescription;
@query("ha-yaml-editor") public yamlEditor?: HaYamlEditor;
@@ -46,8 +43,6 @@ export default class HaAutomationTriggerEditor extends LitElement {
const yamlMode = this.yamlMode || !this.uiSupported;
const showId = "id" in this.trigger || this.showId;
return html`
<div
class=${classMap({
@@ -80,20 +75,6 @@ export default class HaAutomationTriggerEditor extends LitElement {
></ha-yaml-editor>
`
: html`
${
showId && !isTriggerList(this.trigger)
? html`
<ha-input
.label=${this.hass.localize(
"ui.panel.config.automation.editor.triggers.id"
)}
.value=${this.trigger.id || ""}
.disabled=${this.disabled}
@change=${this._idChanged}
></ha-input>
`
: nothing
}
<div @value-changed=${this._onUiChanged}>
${
this.description
@@ -116,24 +97,6 @@ export default class HaAutomationTriggerEditor extends LitElement {
`;
}
private _idChanged(ev: CustomEvent) {
if (isTriggerList(this.trigger)) return;
const newId = (ev.target as any).value;
if (newId === (this.trigger.id ?? "")) {
return;
}
const value = { ...this.trigger };
if (!newId) {
delete value.id;
} else {
value.id = newId;
}
fireEvent(this, "value-changed", {
value,
});
}
private _onYamlChange(ev: CustomEvent) {
ev.stopPropagation();
if (!ev.detail.isValid) {
@@ -167,9 +130,6 @@ export default class HaAutomationTriggerEditor extends LitElement {
border-top: 1px solid var(--divider-color);
border-bottom: 1px solid var(--divider-color);
}
ha-input {
margin-bottom: var(--ha-space-3);
}
`,
];
}
@@ -0,0 +1,143 @@
import { consume } from "@lit/context";
import { mdiLinkVariantOff } from "@mdi/js";
import { css, html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import { ensureArray } from "../../../../common/array/ensure-array";
import { capitalizeFirstLetter } from "../../../../common/string/capitalize-first-letter";
import "../../../../components/ha-svg-icon";
import "../../../../components/ha-trigger-icon";
import type { TriggerCondition } from "../../../../data/automation";
import { describeTrigger } from "../../../../data/automation_i18n";
import { fullEntitiesContext } from "../../../../data/context";
import type { EntityRegistryEntry } from "../../../../data/entity/entity_registry";
import type { HomeAssistant } from "../../../../types";
import {
automationTriggerContext,
type AutomationTriggerContext,
} from "./automation-trigger-id";
@customElement("ha-automation-trigger-references")
export class HaAutomationTriggerReferences extends LitElement {
@property({ attribute: false }) public condition!: TriggerCondition;
@property({ attribute: false }) public hass!: HomeAssistant;
@state()
@consume({ context: fullEntitiesContext, subscribe: true })
private _entityReg: EntityRegistryEntry[] = [];
@state()
@consume({ context: automationTriggerContext, subscribe: true })
private _triggers?: AutomationTriggerContext;
protected render() {
const options = this._triggers?.options ?? [];
const selectedIds = ensureArray(this.condition.id).filter(Boolean);
const selectedTriggers = options.filter((option) =>
selectedIds.includes(option.id)
);
const missingIds = selectedIds.filter(
(id) => !options.some((option) => option.id === id)
);
if (options.length && !selectedIds.length) {
return html`<span class="trigger-reference no-selection">
${this.hass.localize(
"ui.panel.config.automation.editor.conditions.type.trigger.no_triggers_selected"
)}
</span>`;
}
return [
selectedTriggers.map(
(option) => html`
<span class="trigger-reference">
<span class="trigger-index-badge">${option.index + 1}</span>
<ha-trigger-icon
.trigger=${"trigger" in option.trigger ? option.trigger.trigger : ""}
></ha-trigger-icon>
<span class="trigger-reference-label">
${capitalizeFirstLetter(
describeTrigger(option.trigger, this.hass, this._entityReg)
)}
</span>
</span>
`
),
missingIds.map(
() => html`
<span class="trigger-reference missing">
<ha-svg-icon .path=${mdiLinkVariantOff}></ha-svg-icon>
<span>
${this.hass.localize(
"ui.panel.config.automation.editor.conditions.type.trigger.missing_trigger"
)}
</span>
</span>
`
),
];
}
static styles = css`
:host {
display: inline-flex;
align-items: center;
flex-wrap: wrap;
gap: var(--ha-space-2);
}
.trigger-reference {
display: inline-flex;
align-items: center;
gap: var(--ha-space-2);
min-width: 0;
max-width: 100%;
padding: var(--ha-space-1) var(--ha-space-2);
border-radius: var(--ha-border-radius-md);
background: var(--ha-color-fill-neutral-normal-resting);
color: var(--ha-color-text-secondary);
}
.trigger-reference.missing,
.trigger-reference.no-selection {
background: var(--ha-color-fill-warning-normal-resting);
color: var(--ha-color-on-warning-normal);
}
ha-trigger-icon,
ha-svg-icon {
--mdc-icon-size: 20px;
flex-shrink: 0;
}
.trigger-reference-label {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.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;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-automation-trigger-references": HaAutomationTriggerReferences;
}
}
@@ -48,6 +48,7 @@ import "../../../../components/ha-dropdown-item";
import "../../../../components/ha-expansion-panel";
import "../../../../components/ha-icon-button";
import "../../../../components/ha-svg-icon";
import "../../../../components/ha-tooltip";
import { TRIGGER_ICONS } from "../../../../components/ha-trigger-icon";
import type {
AutomationClipboard,
@@ -78,6 +79,10 @@ import { overflowStyles, rowStyles } from "../styles";
import { getDeviceTarget } from "../target/get_device_target";
import { getEntityTarget } from "../target/get_entity_target";
import "../target/ha-automation-row-targets";
import {
automationTriggerContext,
type AutomationTriggerContext,
} from "./automation-trigger-id";
import "./ha-automation-trigger-editor";
import type HaAutomationTriggerEditor from "./ha-automation-trigger-editor";
import "./types/ha-automation-trigger-calendar";
@@ -137,6 +142,8 @@ export default class HaAutomationTriggerRow extends LitElement {
@property({ type: Boolean }) public last?: boolean;
@property({ type: Number }) public index?: number;
@property({ type: Boolean }) public highlight?: boolean;
@property({ type: Boolean, attribute: "sidebar" })
@@ -181,6 +188,10 @@ export default class HaAutomationTriggerRow extends LitElement {
@consume({ context: fullEntitiesContext, subscribe: true })
_entityReg: EntityRegistryEntry[] = [];
@state()
@consume({ context: automationTriggerContext, subscribe: true })
private _triggers?: AutomationTriggerContext;
get selected() {
return this._selected;
}
@@ -206,6 +217,9 @@ export default class HaAutomationTriggerRow extends LitElement {
}
private _renderRow() {
const triggerIndex = this._triggers?.options.find(
(option) => option.trigger === this.trigger
)?.index;
const type = this._getType(this.trigger, this.triggerDescriptions);
const supported = this._uiSupported(type);
@@ -238,19 +252,51 @@ export default class HaAutomationTriggerRow extends LitElement {
);
return html`
${
type === "list"
? html`<ha-svg-icon
slot="leading-icon"
class="trigger-icon"
.path=${TRIGGER_ICONS[type]}
></ha-svg-icon>`
: html`<ha-trigger-icon
slot="leading-icon"
.hass=${this.hass}
.trigger=${(this.trigger as Exclude<Trigger, TriggerList>).trigger}
></ha-trigger-icon>`
}
<div slot="leading-icon" class="trigger-leading">
${
triggerIndex !== undefined
? html`
<span
id="trigger-index-badge-${triggerIndex}"
tabindex=${this._triggers?.showIndices ? "0" : "-1"}
class="trigger-index-badge ${
this._triggers?.showIndices ? "" : "hidden"
}"
aria-label=${this.hass.localize(
"ui.panel.config.automation.editor.triggers.trigger_index_aria_label",
{ number: triggerIndex + 1 }
)}
aria-hidden=${this._triggers?.showIndices ? "false" : "true"}
>${triggerIndex + 1}</span
>
${
this._triggers?.showIndices
? html`<ha-tooltip for="trigger-index-badge-${triggerIndex}"
><p>
${this.hass.localize(
"ui.panel.config.automation.editor.triggers.trigger_index_tooltip"
)}
</p></ha-tooltip
>`
: nothing
}
`
: nothing
}
${
type === "list"
? html`<ha-svg-icon
class="trigger-icon"
.path=${TRIGGER_ICONS[type]}
></ha-svg-icon>`
: html`<ha-trigger-icon
.hass=${this.hass}
.trigger=${
(this.trigger as Exclude<Trigger, TriggerList>).trigger
}
></ha-trigger-icon>`
}
</div>
<h3 slot="header">
${capitalizeFirstLetter(
describeTrigger(this.trigger, this.hass, this._entityReg)
@@ -6,6 +6,7 @@ import type { PropertyValues } from "lit";
import { html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { repeat } from "lit/directives/repeat";
import { ensureArray } from "../../../../common/array/ensure-array";
import { fireEvent } from "../../../../common/dom/fire_event";
import { stopPropagation } from "../../../../common/dom/stop_propagation";
import "../../../../components/ha-button";
@@ -28,6 +29,7 @@ import {
} from "../show-add-automation-element-dialog";
import { AutomationSortableListMixin } from "../ha-automation-sortable-list-mixin";
import { automationRowsStyles } from "../styles";
import { stripGeneratedTriggerIds } from "./automation-trigger-id";
import "./ha-automation-trigger-row";
import type HaAutomationTriggerRow from "./ha-automation-trigger-row";
@@ -166,7 +168,9 @@ export default class HaAutomationTrigger extends AutomationSortableListMixin<Tri
private _addTrigger = (value: string, target?: HassServiceTarget) => {
let triggers: Trigger[];
if (value === PASTE_VALUE) {
triggers = this.triggers.concat(deepClone(this._clipboard!.trigger!));
triggers = this.triggers.concat(
stripGeneratedTriggerIds(deepClone(this._clipboard!.trigger!))
);
} else if (isDynamic(value)) {
triggers = this.triggers.concat({
trigger: getValueFromDynamic(value),
@@ -258,6 +262,47 @@ export default class HaAutomationTrigger extends AutomationSortableListMixin<Tri
});
}
protected override duplicateItem(ev: CustomEvent) {
ev.stopPropagation();
const index = (ev.target as any).index;
fireEvent(this, "value-changed", {
// @ts-expect-error Requires library bump to ES2023
value: this.items.toSpliced(
index + 1,
0,
stripGeneratedTriggerIds(deepClone(this.items[index]))
),
});
}
protected override pasteItem(ev: CustomEvent) {
ev.stopPropagation();
if (!ev.detail.item) return;
const index = (ev.target as any).index;
const clonedItem = stripGeneratedTriggerIds(deepClone(ev.detail.item));
this.setHighlightedItems(ensureArray(clonedItem));
fireEvent(this, "value-changed", {
// @ts-expect-error Requires library bump to ES2023
value: this.items.toSpliced(index + 1, 0, clonedItem),
});
}
protected override insertAfter(ev: CustomEvent) {
ev.stopPropagation();
const index = (ev.target as any).index;
const inserted = ensureArray(ev.detail.value).map((item) =>
stripGeneratedTriggerIds(deepClone(item))
);
this.setHighlightedItems(inserted);
fireEvent(this, "value-changed", {
// @ts-expect-error Requires library bump to ES2023
value: this.items.toSpliced(index + 1, 0, ...inserted),
});
}
static styles = automationRowsStyles;
}
@@ -4,7 +4,6 @@ import { html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import { fireEvent } from "../../../../../common/dom/fire_event";
import type { LocalizeFunc } from "../../../../../common/translations/localize";
import "../../../../../components/ha-check-list-item";
import "../../../../../components/ha-form/ha-form";
import type { SchemaUnion } from "../../../../../components/ha-form/types";
import "../../../../../components/ha-icon-button";
@@ -8,7 +8,6 @@ import "../../../../components/ha-button";
import "../../../../components/ha-dialog";
import "../../../../components/ha-dialog-footer";
import "../../../../components/ha-icon-button";
import "../../../../components/ha-icon-button-prev";
import "../../../../components/item/ha-row-item";
import { downloadEmergencyKit } from "../../../../data/backup";
import type { HassDialog } from "../../../../dialogs/make-dialog-manager";
@@ -34,7 +34,6 @@ import "../../../components/ha-dropdown-item";
import "../../../components/ha-filter-states";
import "../../../components/ha-icon";
import "../../../components/ha-icon-next";
import "../../../components/ha-icon-overflow-menu";
import "../../../components/ha-spinner";
import "../../../components/ha-svg-icon";
import type {
@@ -11,7 +11,6 @@ import type { HaDropdownSelectEvent } from "../../../components/ha-dropdown";
import "../../../components/ha-dropdown-item";
import "../../../components/ha-icon";
import "../../../components/ha-icon-next";
import "../../../components/ha-icon-overflow-menu";
import "../../../components/ha-spinner";
import "../../../components/ha-svg-icon";
import type {
@@ -29,7 +28,6 @@ import {
import type { ManagerStateEvent } from "../../../data/backup_manager";
import type { CloudStatus } from "../../../data/cloud";
import "../../../layouts/hass-subpage";
import "../../../layouts/hass-tabs-subpage-data-table";
import { haStyle } from "../../../resources/styles";
import type { HomeAssistant, Route } from "../../../types";
import { showAlertDialog } from "../../lovelace/custom-card-helpers";
@@ -22,7 +22,6 @@ import type { CloudStatus } from "../../../data/cloud";
import { subscribeConfigEntries } from "../../../data/config_entries";
import type { RouterOptions } from "../../../layouts/hass-router-page";
import { HassRouterPage } from "../../../layouts/hass-router-page";
import "../../../layouts/hass-tabs-subpage-data-table";
import { SubscribeMixin } from "../../../mixins/subscribe-mixin";
import type { HomeAssistant } from "../../../types";
import { showToast } from "../../../util/toast";
@@ -27,7 +27,6 @@ import type {
RowClickedEvent,
SortingChangedEvent,
} from "../../../components/data-table/ha-data-table";
import "../../../components/entity/ha-entity-toggle";
import "../../../components/ha-button";
import "../../../components/ha-icon-button";
import "../../../components/ha-icon-overflow-menu";
@@ -9,9 +9,9 @@ import {
mdiVideo,
mdiWebhook,
} from "@mdi/js";
import type { TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import type { TemplateResult } from "lit";
import { formatDate } from "../../../../common/datetime/format_date";
import { relativeTime } from "../../../../common/datetime/relative_time";
import { fireEvent } from "../../../../common/dom/fire_event";
@@ -20,11 +20,13 @@ import "../../../../components/ha-button";
import "../../../../components/ha-card";
import "../../../../components/ha-icon-button";
import "../../../../components/ha-icon-next";
import "../../../../components/ha-md-list";
import "../../../../components/ha-md-list-item";
import "../../../../components/ha-svg-icon";
import "../../../../components/item/ha-list-item-base";
import "../../../../components/item/ha-list-item-button";
import "../../../../components/list/ha-list-base";
import "../../../../components/list/ha-list-nav";
import type { BackupConfig } from "../../../../data/backup";
import { cloudBackupHealth, cloudBackupEnabled } from "../../../../data/backup";
import { cloudBackupEnabled, cloudBackupHealth } from "../../../../data/backup";
import type {
CloudStatusLoggedIn,
SubscriptionInfo,
@@ -260,8 +262,8 @@ export class CloudAccountOverview extends LitElement {
/>
</div>
<div class="card-content">
<ha-md-list>
<ha-md-list-item>
<ha-list-base>
<ha-list-item-base>
<span slot="headline"
>${this.hass.localize(
"ui.panel.config.cloud.account.email"
@@ -283,16 +285,16 @@ export class CloudAccountOverview extends LitElement {
@click=${this._toggleEmail}
></ha-icon-button>
</span>
</ha-md-list-item>
<ha-md-list-item>
</ha-list-item-base>
<ha-list-item-base>
<span slot="headline"
>${this.hass.localize(
"ui.panel.config.cloud.account.subscription"
)}</span
>
<span slot="supporting-text">${this._subscriptionDetail()}</span>
</ha-md-list-item>
</ha-md-list>
</ha-list-item-base>
</ha-list-base>
</div>
<div class="card-actions split">
<ha-button
@@ -348,7 +350,11 @@ export class CloudAccountOverview extends LitElement {
"ui.panel.config.cloud.account.overview.features_intro"
)}
</p>
<ha-md-list>
<ha-list-nav
.ariaLabel=${this.hass.localize(
"ui.panel.config.cloud.account.overview.features_title"
)}
>
${this._featureRow(
mdiEarth,
this.hass.localize(
@@ -411,7 +417,7 @@ export class CloudAccountOverview extends LitElement {
webhookStatus,
"/config/cloud/webhooks"
)}
</ha-md-list>
</ha-list-nav>
</div>
</ha-card>
`;
@@ -424,12 +430,12 @@ export class CloudAccountOverview extends LitElement {
href: string
): TemplateResult {
return html`
<ha-md-list-item type="link" href=${href}>
<ha-list-item-button href=${href}>
<ha-svg-icon slot="start" .path=${icon}></ha-svg-icon>
<span slot="headline">${title}</span>
<span slot="supporting-text">${supporting}</span>
<ha-icon-next slot="end"></ha-icon-next>
</ha-md-list-item>
</ha-list-item-button>
`;
}
@@ -661,9 +667,6 @@ export class CloudAccountOverview extends LitElement {
width: 36px;
height: auto;
}
ha-md-list-item {
--md-item-overflow: visible;
}
.muted {
color: var(--secondary-text-color);
}
@@ -692,10 +695,8 @@ export class CloudAccountOverview extends LitElement {
.status-dot.disabled {
background-color: var(--error-color);
}
ha-md-list {
padding: 0;
--md-list-item-leading-space: 0;
--md-list-item-trailing-space: 0;
ha-list-base {
--ha-row-item-padding-inline: 0;
}
.extras-header {
display: flex;
@@ -721,14 +722,12 @@ export class CloudAccountOverview extends LitElement {
padding-inline: var(--ha-space-4);
margin-top: 0;
}
.extras-content ha-md-list {
.extras-content ha-list-nav {
padding-top: 0;
--md-list-item-leading-space: var(--ha-space-4);
--md-list-item-trailing-space: var(--ha-space-4);
--ha-row-item-padding-inline: var(--ha-space-4);
}
.extras-content ha-md-list-item {
--md-list-item-top-space: var(--ha-space-2);
--md-list-item-bottom-space: var(--ha-space-2);
.extras-content ha-list-item-button {
--ha-row-item-padding-block: var(--ha-space-2);
}
.extras-content ha-svg-icon[slot="start"],
.extras-content ha-icon-next {
@@ -11,9 +11,9 @@ import { relativeTime } from "../../../../common/datetime/relative_time";
import "../../../../components/ha-button";
import "../../../../components/ha-card";
import "../../../../components/ha-spinner";
import "../../../../components/ha-md-list";
import "../../../../components/ha-md-list-item";
import "../../../../components/ha-svg-icon";
import "../../../../components/item/ha-list-item-base";
import "../../../../components/list/ha-list-base";
import type { BackupConfig, BackupInfo } from "../../../../data/backup";
import {
CLOUD_AGENT,
@@ -137,8 +137,8 @@ export class CloudBackupPref extends LitElement {
)}
>
<div class="card-content">
<ha-md-list>
<ha-md-list-item>
<ha-list-base>
<ha-list-item-base>
<ha-svg-icon slot="start" .path=${mdiBackupRestore}></ha-svg-icon>
<span slot="headline">
${
@@ -159,11 +159,11 @@ export class CloudBackupPref extends LitElement {
)
}
</span>
</ha-md-list-item>
</ha-list-item-base>
${
cloudAgent
? html`
<ha-md-list-item>
<ha-list-item-base>
<ha-svg-icon
slot="start"
.path=${mdiHarddisk}
@@ -171,8 +171,8 @@ export class CloudBackupPref extends LitElement {
<span slot="headline">
${bytesToString(cloudAgent.size)}
</span>
</ha-md-list-item>
<ha-md-list-item>
</ha-list-item-base>
<ha-list-item-base>
<ha-svg-icon
slot="start"
.path=${mdiShieldLock}
@@ -188,11 +188,11 @@ export class CloudBackupPref extends LitElement {
)
}
</span>
</ha-md-list-item>
</ha-list-item-base>
`
: nothing
}
<ha-md-list-item>
<ha-list-item-base>
<ha-svg-icon slot="start" .path=${mdiCalendar}></ha-svg-icon>
<span slot="headline">
${
@@ -213,8 +213,8 @@ export class CloudBackupPref extends LitElement {
)
}
</span>
</ha-md-list-item>
</ha-md-list>
</ha-list-item-base>
</ha-list-base>
</div>
<div class="card-actions">
<ha-button appearance="filled" href="/config/backup?historyBack=1">
@@ -308,15 +308,12 @@ export class CloudBackupPref extends LitElement {
display: flex;
justify-content: flex-end;
}
ha-md-list {
background: none;
--md-list-item-leading-space: 0;
--md-list-item-trailing-space: 0;
ha-list-base {
--ha-row-item-padding-inline: 0;
}
ha-md-list-item {
--md-list-item-top-space: var(--ha-space-2);
--md-list-item-bottom-space: var(--ha-space-2);
--md-list-item-one-line-container-height: 40px;
ha-list-item-base {
--ha-row-item-padding-block: var(--ha-space-2);
--ha-row-item-min-height: 40px;
}
ha-svg-icon[slot="start"] {
color: var(--secondary-text-color);
@@ -4,10 +4,10 @@ import { fireEvent } from "../../../../common/dom/fire_event";
import "../../../../components/ha-alert";
import "../../../../components/ha-button";
import "../../../../components/ha-card";
import "../../../../components/ha-md-list";
import "../../../../components/ha-md-list-item";
import "../../../../components/ha-switch";
import "../../../../components/ha-tip";
import "../../../../components/item/ha-list-item-base";
import "../../../../components/list/ha-list-base";
import { formatDate } from "../../../../common/datetime/format_date";
import type { HaSwitch } from "../../../../components/ha-switch";
@@ -179,8 +179,8 @@ export class CloudRemotePref extends LitElement {
)}
>
<div class="card-content">
<ha-md-list>
<ha-md-list-item>
<ha-list-base>
<ha-list-item-base>
<span slot="headline"
>${this.hass.localize(
"ui.panel.config.cloud.account.remote.external_activation"
@@ -196,8 +196,8 @@ export class CloudRemotePref extends LitElement {
.checked=${remote_allow_remote_enable}
@change=${this._toggleAllowRemoteEnabledChanged}
></ha-switch>
</ha-md-list-item>
<ha-md-list-item>
</ha-list-item-base>
<ha-list-item-base>
<span slot="headline"
>${this.hass.localize(
"ui.panel.config.cloud.account.remote.certificate_info"
@@ -232,8 +232,8 @@ export class CloudRemotePref extends LitElement {
"ui.panel.config.cloud.account.remote.more_info"
)}
</ha-button>
</ha-md-list-item>
</ha-md-list>
</ha-list-item-base>
</ha-list-base>
</div>
</ha-card>
<ha-tip .hass=${this.hass}>
@@ -318,13 +318,8 @@ export class CloudRemotePref extends LitElement {
display: flex;
justify-content: flex-end;
}
ha-md-list {
background: none;
--md-list-item-leading-space: 0;
--md-list-item-trailing-space: 0;
}
ha-md-list-item {
--md-item-overflow: visible;
ha-list-base {
--ha-row-item-padding-inline: 0;
}
ha-tip {
max-width: 600px;
@@ -5,26 +5,26 @@ import { customElement, property, query, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { storage } from "../../../../common/decorators/storage";
import { fireEvent } from "../../../../common/dom/fire_event";
import { copyToClipboard } from "../../../../common/util/copy-clipboard";
import { computeStateDomain } from "../../../../common/entity/compute_state_domain";
import { computeStateName } from "../../../../common/entity/compute_state_name";
import { supportsFeature } from "../../../../common/entity/supports-feature";
import { copyToClipboard } from "../../../../common/util/copy-clipboard";
import "../../../../components/ha-button";
import "../../../../components/ha-card";
import "../../../../components/ha-icon-button";
import "../../../../components/ha-language-picker";
import "../../../../components/ha-md-list";
import "../../../../components/ha-md-list-item";
import "../../../../components/ha-select";
import "../../../../components/ha-svg-icon";
import "../../../../components/ha-textarea";
import "../../../../components/ha-tip";
import "../../../../components/voice-assistant-brand-icon";
import type {
HaSelectOption,
HaSelectSelectEvent,
} from "../../../../components/ha-select";
import "../../../../components/ha-svg-icon";
import "../../../../components/ha-textarea";
import type { HaTextArea } from "../../../../components/ha-textarea";
import "../../../../components/ha-tip";
import "../../../../components/item/ha-list-item-base";
import "../../../../components/list/ha-list-base";
import "../../../../components/voice-assistant-brand-icon";
import { showAutomationEditor } from "../../../../data/automation";
import type { CloudStatusLoggedIn } from "../../../../data/cloud";
import { updateCloudPref } from "../../../../data/cloud";
@@ -153,8 +153,8 @@ export class CloudTTSPref extends LitElement {
)}
>
<div class="card-content">
<ha-md-list>
<ha-md-list-item>
<ha-list-base>
<ha-list-item-base>
<span slot="headline">
${this.hass.localize(
"ui.panel.config.cloud.account.tts.default_language"
@@ -176,8 +176,8 @@ export class CloudTTSPref extends LitElement {
@value-changed=${this._handleLanguageChange}
>
</ha-language-picker>
</ha-md-list-item>
<ha-md-list-item>
</ha-list-item-base>
<ha-list-item-base>
<span slot="headline">
${this.hass.localize(
"ui.panel.config.cloud.account.tts.default_voice"
@@ -199,8 +199,8 @@ export class CloudTTSPref extends LitElement {
}))}
>
</ha-select>
</ha-md-list-item>
<ha-md-list-item>
</ha-list-item-base>
<ha-list-item-base>
<span slot="headline">
${this.hass.localize(
"ui.components.media-browser.tts.selected_voice_id"
@@ -215,8 +215,8 @@ export class CloudTTSPref extends LitElement {
)}
@click=${this._copyVoiceId}
></ha-icon-button>
</ha-md-list-item>
</ha-md-list>
</ha-list-item-base>
</ha-list-base>
<div class="try-tts">
<span class="try-heading">
${this.hass.localize(
@@ -541,13 +541,8 @@ export class CloudTTSPref extends LitElement {
gap: var(--ha-space-2);
flex-wrap: wrap;
}
ha-md-list {
background: none;
--md-list-item-leading-space: var(--ha-space-4);
--md-list-item-trailing-space: var(--ha-space-4);
}
ha-md-list-item {
--md-item-overflow: visible;
ha-list-base {
--ha-row-item-padding-inline: var(--ha-space-4);
}
ha-language-picker,
ha-select {
@@ -6,7 +6,6 @@ import "../../../../components/ha-button";
import "../../../../components/ha-dialog-footer";
import "../../../../components/ha-markdown-element";
import "../../../../components/ha-dialog";
import "../../../../components/ha-select";
import "../../../../components/ha-spinner";
import { fetchSupportPackage } from "../../../../data/cloud";
import type { HomeAssistant } from "../../../../types";
@@ -43,17 +43,14 @@ import "../../../components/data-table/ha-data-table-labels";
import "../../../components/entity/ha-battery-icon";
import "../../../components/ha-alert";
import "../../../components/ha-button";
import "../../../components/ha-check-list-item";
import "../../../components/ha-dropdown";
import type { HaDropdownSelectEvent } from "../../../components/ha-dropdown";
import "../../../components/ha-dropdown-item";
import "../../../components/ha-filter-devices";
import "../../../components/ha-filter-floor-areas";
import "../../../components/ha-filter-integrations";
import "../../../components/ha-filter-labels";
import "../../../components/ha-filter-states";
import "../../../components/ha-icon-button";
import "../../../components/ha-sub-menu";
import { createAreaRegistryEntry } from "../../../data/area/area_registry";
import type { ConfigEntry, SubEntry } from "../../../data/config_entries";
import { getSubEntries, sortConfigEntries } from "../../../data/config_entries";
@@ -4,8 +4,10 @@ import type { HassEntity } from "home-assistant-js-websocket";
import { html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import memoizeOne from "memoize-one";
import { computeEntityNameList } from "../../../../common/entity/compute_entity_name_display";
import { computeStateName } from "../../../../common/entity/compute_state_name";
import {
computeEntityNameList,
computeEntitySearchLabels,
} from "../../../../common/entity/compute_entity_name_display";
import { fireEvent } from "../../../../common/dom/fire_event";
import "../../../../components/entity/state-badge";
import "../../../../components/ha-combo-box-item";
@@ -65,22 +67,14 @@ export class HaEnergyUpstreamDevicePicker 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 friendlyName = computeStateName(stateObj); // Keep this for search
const [areaName] = computeEntityNameList(
stateObj,
[{ type: "area" }],
this.hass.entities,
this.hass.devices,
this.hass.areas,
this.hass.floors
);
return {
id: statisticId,
@@ -93,13 +87,13 @@ export class HaEnergyUpstreamDevicePicker extends LitElement {
),
secondary: areaName,
stateObj,
search_labels: {
entityName: entityName || null,
deviceName: deviceName || null,
parentDeviceName: parentDeviceName || null,
areaName: areaName || null,
friendlyName,
},
search_labels: computeEntitySearchLabels(
stateObj,
this.hass.entities,
this.hass.devices,
this.hass.areas,
this.hass.floors
),
};
}
@@ -2,16 +2,11 @@ import type { CSSResultGroup } from "lit";
import { html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { fireEvent } from "../../../../common/dom/fire_event";
import { computeAreaName } from "../../../../common/entity/compute_area_name";
import { computeDeviceName } from "../../../../common/entity/compute_device_name";
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 { computeRTL } from "../../../../common/util/compute_rtl";
import "../../../../components/ha-button";
import "../../../../components/ha-dialog-footer";
@@ -32,6 +27,13 @@ import { haStyleDialog } from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types";
import type { VacuumSegmentMappingDialogParams } from "./show-dialog-vacuum-segment-mapping";
const BREADCRUMB_NAME: EntityNameItem[] = [
{ type: "area" },
{ type: "parent_device" },
{ type: "device" },
{ type: "entity" },
];
interface VacuumSegmentMappingState {
areaMapping: Record<string, string[]>;
}
@@ -133,44 +135,27 @@ export class DialogVacuumSegmentMapping
const stateObj = this.hass.states[this._params.entityId];
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)
: this._params.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
)
: [this._params.entityId]
).filter((v): v is string => Boolean(v));
return html`
<ha-dialog
@@ -27,7 +27,6 @@ import "../../../helpers/forms/ha-input_select-form";
import "../../../helpers/forms/ha-input_text-form";
import "../../../helpers/forms/ha-schedule-form";
import "../../../helpers/forms/ha-timer-form";
import "../../../voice-assistants/entity-voice-settings";
import "../../entity-registry-settings-editor";
import type { EntityRegistrySettingsEditor } from "../../entity-registry-settings-editor";
import { getDeleteConfirmationText } from "../../get-delete-confirmation-text";
@@ -1,4 +1,5 @@
import "@home-assistant/webawesome/dist/components/divider/divider";
import { consume } from "@lit/context";
import { mdiContentCopy, mdiRestore } from "@mdi/js";
import type { HassEntity } from "home-assistant-js-websocket";
import type { CSSResultGroup, PropertyValues } from "lit";
@@ -6,7 +7,6 @@ import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { until } from "lit/directives/until";
import memoizeOne from "memoize-one";
import { consume } from "@lit/context";
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import { computeDomain } from "../../../common/entity/compute_domain";
import { computeObjectId } from "../../../common/entity/compute_object_id";
@@ -19,23 +19,23 @@ import type {
LocalizeKeys,
} from "../../../common/translations/localize";
import { copyToClipboard } from "../../../common/util/copy-clipboard";
import "../../../components/entity/ha-entity-picker";
import "../../../components/ha-alert";
import "../../../components/ha-area-picker";
import "../../../components/ha-color-picker";
import "../../../components/ha-dropdown-item";
import "../../../components/entity/ha-entity-picker";
import "../../../components/ha-icon";
import "../../../components/ha-icon-button-next";
import "../../../components/ha-icon-picker";
import "../../../components/ha-labels-picker";
import "../../../components/ha-list-item";
import "../../../components/ha-md-list-item";
import "../../../components/ha-select";
import type { HaSelectSelectEvent } from "../../../components/ha-select";
import "../../../components/ha-state-icon";
import "../../../components/ha-switch";
import type { HaSwitch } from "../../../components/ha-switch";
import "../../../components/input/ha-input";
import "../../../components/item/ha-list-item-button";
import "../../../components/item/ha-row-item";
import "../../../components/list/ha-list-base";
import {
CAMERA_ORIENTATIONS,
CameraEntityFeature,
@@ -45,16 +45,16 @@ import {
STREAM_TYPE_HLS,
updateCameraPrefs,
} from "../../../data/camera";
import {
dirtyStateContext,
type DirtyStateContext,
} from "../../../data/context/dirty-state";
import type { ConfigEntry } from "../../../data/config_entries";
import { deleteConfigEntry } from "../../../data/config_entries";
import {
createConfigFlow,
handleConfigFlowStep,
} from "../../../data/config_flow";
import {
dirtyStateContext,
type DirtyStateContext,
} from "../../../data/context/dirty-state";
import type { DataEntryFlowStepCreateEntry } from "../../../data/data_entry_flow";
import type { DeviceRegistryEntry } from "../../../data/device/device_registry";
import { updateDeviceRegistryEntry } from "../../../data/device/device_registry";
@@ -627,7 +627,7 @@ export class EntityRegistrySettingsEditor extends LitElement {
${
SWITCH_AS_DOMAINS_INVERT.includes(this._switchAsDomain)
? html`
<ha-md-list-item>
<ha-row-item>
<span slot="headline"
>${this.hass.localize(
"ui.dialogs.entity_registry.editor.invert.label"
@@ -643,7 +643,7 @@ export class EntityRegistrySettingsEditor extends LitElement {
.checked=${!!this.entry.options?.switch_as_x?.invert}
@change=${this._switchAsInvertChanged}
></ha-switch>
</ha-md-list-item>
</ha-row-item>
`
: nothing
} `
@@ -960,7 +960,7 @@ export class EntityRegistrySettingsEditor extends LitElement {
${
this._cameraPrefs
? html`
<ha-md-list-item>
<ha-row-item>
<span slot="headline"
>${this.hass.localize(
"ui.dialogs.entity_registry.editor.stream.preload_stream"
@@ -977,8 +977,8 @@ export class EntityRegistrySettingsEditor extends LitElement {
.disabled=${this.disabled}
@change=${this._handleCameraPrefsChanged}
></ha-switch>
</ha-md-list-item>
<ha-md-list-item>
</ha-row-item>
<ha-row-item>
<span slot="headline"
>${this.hass.localize(
"ui.dialogs.entity_registry.editor.stream.stream_orientation"
@@ -1006,103 +1006,97 @@ export class EntityRegistrySettingsEditor extends LitElement {
.value=${this._cameraPrefs.orientation.toString()}
>
</ha-select>
</ha-md-list-item>
</ha-row-item>
`
: nothing
}
${
this.helperConfigEntry &&
this.helperConfigEntry.supports_options &&
this.helperConfigEntry.domain !== "switch_as_x"
? html`
<ha-list-item
class="menu-item"
twoline
hasMeta
.disabled=${this.disabled}
@click=${this._showOptionsFlow}
>
<span
>${this.hass.localize(
"ui.dialogs.entity_registry.editor.configure_state",
{
integration: domainToName(
this.hass.localize,
this.helperConfigEntry.domain
),
}
)}</span
<ha-list-base>
${
this.helperConfigEntry &&
this.helperConfigEntry.supports_options &&
this.helperConfigEntry.domain !== "switch_as_x"
? html`
<ha-list-item-button
class="menu-item"
.disabled=${this.disabled}
@click=${this._showOptionsFlow}
>
<span slot="secondary"
>${this.hass.localize(
"ui.dialogs.entity_registry.editor.configure_state_secondary",
{
integration: domainToName(
this.hass.localize,
this.helperConfigEntry.domain
),
}
)}</span
>
<ha-icon-next slot="meta"></ha-icon-next>
</ha-list-item>
`
: nothing
}
<ha-list-item
class="menu-item"
twoline
hasMeta
.disabled=${this.disabled}
@click=${this._handleVoiceAssistantsClicked}
>
<span
>${this.hass.localize(
"ui.dialogs.entity_registry.editor.voice_assistants"
)}</span
<span slot="headline"
>${this.hass.localize(
"ui.dialogs.entity_registry.editor.configure_state",
{
integration: domainToName(
this.hass.localize,
this.helperConfigEntry.domain
),
}
)}</span
>
<span slot="supporting-text"
>${this.hass.localize(
"ui.dialogs.entity_registry.editor.configure_state_secondary",
{
integration: domainToName(
this.hass.localize,
this.helperConfigEntry.domain
),
}
)}</span
>
<ha-icon-next slot="end"></ha-icon-next>
</ha-list-item-button>
`
: nothing
}
<ha-list-item-button
class="menu-item"
.disabled=${this.disabled}
@click=${this._handleVoiceAssistantsClicked}
>
<span slot="secondary">
${
this.entry.aliases.filter((a) => a !== null).length
? this.entry.aliases
.filter((a): a is string => a !== null)
.join(", ")
: this.hass.localize(
"ui.dialogs.entity_registry.editor.no_aliases"
)
}
</span>
<ha-icon-next slot="meta"></ha-icon-next>
</ha-list-item>
${
domain === "vacuum" &&
stateObj &&
supportsFeature(stateObj, VacuumEntityFeature.CLEAN_AREA)
? html`
<ha-list-item
class="menu-item"
twoline
hasMeta
.disabled=${this.disabled}
@click=${this._handleVacuumSegmentMappingClicked}
>
<span
>${this.hass.localize(
"ui.dialogs.vacuum_segment_mapping.title"
)}</span
<span slot="headline"
>${this.hass.localize(
"ui.dialogs.entity_registry.editor.voice_assistants"
)}</span
>
<span slot="supporting-text">
${
this.entry.aliases.filter((a) => a !== null).length
? this.entry.aliases
.filter((a): a is string => a !== null)
.join(", ")
: this.hass.localize(
"ui.dialogs.entity_registry.editor.no_aliases"
)
}
</span>
<ha-icon-next slot="end"></ha-icon-next>
</ha-list-item-button>
${
domain === "vacuum" &&
stateObj &&
supportsFeature(stateObj, VacuumEntityFeature.CLEAN_AREA)
? html`
<ha-list-item-button
class="menu-item"
.disabled=${this.disabled}
@click=${this._handleVacuumSegmentMappingClicked}
>
<span slot="secondary">
${this.hass.localize(
"ui.dialogs.vacuum_segment_mapping.description"
)}
</span>
<ha-icon-next slot="meta"></ha-icon-next>
</ha-list-item>
`
: nothing
}
<span slot="headline"
>${this.hass.localize(
"ui.dialogs.vacuum_segment_mapping.title"
)}</span
>
<span slot="supporting-text">
${this.hass.localize(
"ui.dialogs.vacuum_segment_mapping.description"
)}
</span>
<ha-icon-next slot="end"></ha-icon-next>
</ha-list-item-button>
`
: nothing
}
</ha-list-base>
${
this._disabledBy &&
this._disabledBy !== "user" &&
@@ -1120,7 +1114,7 @@ export class EntityRegistrySettingsEditor extends LitElement {
: nothing
}
<ha-md-list-item>
<ha-row-item>
<span slot="headline"
>${this.hass.localize(
"ui.dialogs.entity_registry.editor.enabled_label"
@@ -1143,7 +1137,7 @@ export class EntityRegistrySettingsEditor extends LitElement {
}
@change=${this._enabledChanged}
></ha-switch>
</ha-md-list-item>
</ha-row-item>
${
this._hiddenBy && this._hiddenBy !== "user"
@@ -1160,7 +1154,7 @@ export class EntityRegistrySettingsEditor extends LitElement {
: nothing
}
<ha-md-list-item>
<ha-row-item>
<span slot="headline"
>${this.hass.localize(
"ui.dialogs.entity_registry.editor.visible_label"
@@ -1177,12 +1171,12 @@ export class EntityRegistrySettingsEditor extends LitElement {
.disabled=${this.disabled || this._disabledBy}
@change=${this._hiddenChanged}
></ha-switch>
</ha-md-list-item>
</ha-row-item>
${
this.entry.device_id
? html`
<ha-md-list-item>
<ha-row-item>
<span slot="headline"
>${this.hass.localize(
"ui.dialogs.entity_registry.editor.use_device_area"
@@ -1218,7 +1212,7 @@ export class EntityRegistrySettingsEditor extends LitElement {
.disabled=${this.disabled}
@change=${this._useDeviceAreaChanged}
></ha-switch>
</ha-md-list-item>
</ha-row-item>
${
this._areaId || this._noDeviceArea
? html`<ha-area-picker
@@ -1811,7 +1805,10 @@ export class EntityRegistrySettingsEditor extends LitElement {
margin-inline-start: 0;
}
ha-md-list-item ha-select {
ha-row-item {
--ha-row-item-padding-inline: 0;
}
ha-row-item ha-select {
width: auto;
}
ha-input,
@@ -1823,11 +1820,11 @@ export class EntityRegistrySettingsEditor extends LitElement {
width: 100%;
}
.menu-item {
--ha-row-item-padding-inline: 0;
border-radius: var(--ha-border-radius-sm);
margin-top: 3px;
margin-bottom: 3px;
overflow: hidden;
--mdc-list-side-padding: 13px;
}
.entityId {
direction: ltr;
@@ -54,7 +54,6 @@ import type {
import "../../../components/data-table/ha-data-table-labels";
import "../../../components/ha-alert";
import "../../../components/ha-button";
import "../../../components/ha-check-list-item";
import "../../../components/ha-dropdown";
import type { HaDropdownSelectEvent } from "../../../components/ha-dropdown";
import "../../../components/ha-dropdown-item";
@@ -69,7 +68,6 @@ import "../../../components/ha-filter-voice-assistants";
import "../../../components/ha-icon";
import "../../../components/ha-icon-button";
import "../../../components/ha-icon-next";
import "../../../components/ha-sub-menu";
import "../../../components/ha-svg-icon";
import "../../../components/ha-tooltip";
import type { CloudStatus } from "../../../data/cloud";
@@ -9,15 +9,16 @@ import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import { round } from "../../../common/number/round";
import { blankBeforePercent } from "../../../common/translations/blank_before_percent";
import { sanitizeHttpUrl } from "../../../common/url/sanitize-http-url";
import "../../../components/animation/ha-fade-in";
import "../../../components/chart/ha-chart-base";
import "../../../components/ha-alert";
import "../../../components/ha-button";
import "../../../components/ha-card";
import "../../../components/animation/ha-fade-in";
import "../../../components/ha-icon-button";
import "../../../components/ha-icon-next";
import "../../../components/ha-md-list-item";
import "../../../components/ha-spinner";
import "../../../components/item/ha-list-item-button";
import "../../../components/list/ha-list-nav";
import type { ConfigEntry } from "../../../data/config_entries";
import { subscribeConfigEntries } from "../../../data/config_entries";
import type {
@@ -305,24 +306,29 @@ class HaConfigHardwareOverview extends SubscribeMixin(LitElement) {
${
documentationURL
? html`
<ha-md-list-item
.href=${documentationURL}
type="link"
target="_blank"
rel="noopener noreferrer"
<ha-list-nav
.ariaLabel=${this.hass.localize(
"ui.panel.config.hardware.documentation"
)}
>
<span
>${this.hass.localize(
"ui.panel.config.hardware.documentation"
)}</span
<ha-list-item-button
.href=${documentationURL}
target="_blank"
rel="noopener noreferrer"
>
<span slot="supporting-text"
>${this.hass.localize(
"ui.panel.config.hardware.documentation_description"
)}</span
>
<ha-icon-next slot="end"></ha-icon-next>
</ha-md-list-item>
<span slot="headline"
>${this.hass.localize(
"ui.panel.config.hardware.documentation"
)}</span
>
<span slot="supporting-text"
>${this.hass.localize(
"ui.panel.config.hardware.documentation_description"
)}</span
>
<ha-icon-next slot="end"></ha-icon-next>
</ha-list-item-button>
</ha-list-nav>
`
: nothing
}
@@ -140,8 +140,13 @@ export class DialogHelperDetail extends DirtyStateProviderMixin<
this._domain = params.domain;
this._item = undefined;
if (this._domain && this._domain in HELPERS) {
await HELPERS[this._domain].import();
this._initDirtyTracking({ type: "deep" }, undefined);
this._loading = true;
try {
await HELPERS[this._domain].import();
this._initDirtyTracking({ type: "deep" }, undefined);
} finally {
this._loading = false;
}
}
this._open = true;
await this.updateComplete;
@@ -185,7 +190,7 @@ export class DialogHelperDetail extends DirtyStateProviderMixin<
let content: TemplateResult;
let footer: TemplateResult | typeof nothing = nothing;
if (this._domain) {
if (this._domain && !this._loading) {
content = html`
<div class="form" @value-changed=${this._valueChanged}>
${this._error ? html`<div class="error">${this._error}</div>` : ""}
@@ -48,7 +48,6 @@ import type { HaDropdownSelectEvent } from "../../../components/ha-dropdown";
import "../../../components/ha-dropdown-item";
import "../../../components/ha-filter-categories";
import "../../../components/ha-filter-devices";
import "../../../components/ha-filter-entities";
import "../../../components/ha-filter-floor-areas";
import "../../../components/ha-filter-labels";
import "../../../components/ha-filter-voice-assistants";
@@ -20,7 +20,6 @@ import { navigate } from "../../../common/navigate";
import { caseInsensitiveStringCompare } from "../../../common/string/compare";
import type { LocalizeFunc } from "../../../common/translations/localize";
import "../../../components/ha-dialog";
import "../../../components/ha-domain-icon";
import "../../../components/ha-icon-button-prev";
import "../../../components/ha-icon-next";
import "../../../components/ha-svg-icon";
@@ -1,9 +1,9 @@
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../../../common/dom/fire_event";
import "../../../components/ha-md-list";
import "../../../components/ha-md-list-item";
import "../../../components/ha-dialog";
import "../../../components/item/ha-list-item-button";
import "../../../components/list/ha-list-base";
import { ERROR_STATES, RECOVERABLE_STATES } from "../../../data/config_entries";
import type { HomeAssistant } from "../../../types";
import type { PickConfigEntryDialogParams } from "./show-pick-config-entry-dialog";
@@ -43,21 +43,22 @@ export class DialogPickConfigEntry extends LitElement {
header-title=${title}
@closed=${this._dialogClosed}
>
<ha-md-list>
<ha-list-base>
${this._params.configEntries.map(
(entry) =>
html`<ha-md-list-item
type="button"
html`<ha-list-item-button
@click=${this._itemPicked}
.entry=${entry}
.disabled=${
!ERROR_STATES.includes(entry.state) &&
!RECOVERABLE_STATES.includes(entry.state)
}
>${entry.title}</ha-md-list-item
><span slot="headline"
>${entry.title}</span
></ha-list-item-button
>`
)}
</ha-md-list>
</ha-list-base>
</ha-dialog>
`;
}
@@ -37,7 +37,6 @@ import {
showConfirmationDialog,
} from "../../lovelace/custom-card-helpers";
import { showDeviceRegistryDetailDialog } from "../devices/device-registry-detail/show-dialog-device-registry-detail";
import "./ha-config-sub-entry-row";
@customElement("ha-config-entry-device-row")
class HaConfigEntryDeviceRow extends LitElement {
@@ -68,7 +68,6 @@ import { QUALITY_SCALE_MAP } from "../../../data/integration_quality_scale";
import { showConfigFlowDialog } from "../../../dialogs/config-flow/show-dialog-config-flow";
import { showSubConfigFlowDialog } from "../../../dialogs/config-flow/show-dialog-sub-config-flow";
import { showAlertDialog } from "../../../dialogs/generic/show-dialog-box";
import "../../../layouts/hass-error-screen";
import "../../../layouts/hass-subpage";
import { SubscribeMixin } from "../../../mixins/subscribe-mixin";
import { multiTermSearch } from "../../../resources/fuseMultiTerm";
@@ -10,7 +10,6 @@ import {
} from "../../../data/config_flow";
import type { DataEntryFlowProgress } from "../../../data/data_entry_flow";
import { domainToName } from "../../../data/integration";
import "../../../layouts/hass-loading-screen";
import { ChildPanelReady } from "../../../layouts/panel-ready";
import type { RouterOptions } from "../../../layouts/hass-router-page";
import { HassRouterPage } from "../../../layouts/hass-router-page";
@@ -4,15 +4,17 @@ import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import type { HASSDomCurrentTargetEvent } from "../../../../../common/dom/fire_event";
import { stopKeydownEnterSpacePropagation } from "../../../../../common/dom/stop_propagation";
import { computeDeviceName } from "../../../../../common/entity/compute_device_name";
import "../../../../../components/ha-alert";
import "../../../../../components/ha-button";
import "../../../../../components/ha-card";
import "../../../../../components/ha-icon-button";
import "../../../../../components/ha-icon-next";
import "../../../../../components/ha-md-list";
import "../../../../../components/ha-md-list-item";
import "../../../../../components/ha-svg-icon";
import "../../../../../components/item/ha-list-item-base";
import "../../../../../components/item/ha-list-item-button";
import "../../../../../components/list/ha-list-base";
import type {
BluetoothAllocationsData,
BluetoothScannerState,
@@ -191,7 +193,7 @@ export class BluetoothAdapterInfoPage extends LitElement {
>
<div class="container">
<ha-card>
<ha-md-list>
<ha-list-base>
${this._renderAdaptersList(enabledDevices)}
${disabledDevices.map(({ device, entry }) => {
const areaName =
@@ -205,7 +207,7 @@ export class BluetoothAdapterInfoPage extends LitElement {
.filter(Boolean)
.join(" · ");
return html`
<ha-md-list-item class="disabled">
<ha-list-item-base class="disabled">
<ha-svg-icon slot="start" .path=${mdiDevices}></ha-svg-icon>
<div slot="headline">
${computeDeviceName(device) || entry.title}
@@ -218,12 +220,12 @@ export class BluetoothAdapterInfoPage extends LitElement {
>
${this.hass.localize("ui.common.enable")}
</ha-button>
</ha-md-list-item>
</ha-list-item-base>
`;
})}
${disabledEntriesWithoutDevice.map(
(entry) => html`
<ha-md-list-item class="disabled">
<ha-list-item-base class="disabled">
<ha-svg-icon slot="start" .path=${mdiDevices}></ha-svg-icon>
<div slot="headline">${entry.title}</div>
<div slot="supporting-text">
@@ -238,10 +240,10 @@ export class BluetoothAdapterInfoPage extends LitElement {
>
${this.hass.localize("ui.common.enable")}
</ha-button>
</ha-md-list-item>
</ha-list-item-base>
`
)}
</ha-md-list>
</ha-list-base>
<div class="card-actions">
<ha-button
appearance="plain"
@@ -262,13 +264,13 @@ export class BluetoothAdapterInfoPage extends LitElement {
devices: { device: DeviceRegistryEntry; entry: ConfigEntry }[]
) {
if (devices.length === 0) {
return html`<ha-md-list-item>
return html`<ha-list-item-base>
<div slot="headline">
${this.hass.localize(
"ui.panel.config.bluetooth.no_scanner_state_available"
)}
</div>
</ha-md-list-item>`;
</ha-list-item-base>`;
}
return devices.map(({ device, entry }) => {
@@ -314,8 +316,7 @@ export class BluetoothAdapterInfoPage extends LitElement {
);
return html`
<ha-md-list-item
type="link"
<ha-list-item-button
href=${`/config/bluetooth/connection-monitor?source=${btAddress}`}
>
<ha-svg-icon slot="start" .path=${mdiDevices}></ha-svg-icon>
@@ -328,6 +329,7 @@ export class BluetoothAdapterInfoPage extends LitElement {
.path=${mdiCogOutline}
.entry=${entry}
@click=${this._openOptionFlow}
@keydown=${stopKeydownEnterSpacePropagation}
.label=${this.hass.localize(
"ui.panel.config.bluetooth.option_flow"
)}
@@ -335,7 +337,7 @@ export class BluetoothAdapterInfoPage extends LitElement {
: nothing
}
<ha-icon-next slot="end"></ha-icon-next>
</ha-md-list-item>
</ha-list-item-button>
${
hasMismatch && scannerDetails
? this._renderScannerMismatchWarning(
@@ -499,22 +501,14 @@ export class BluetoothAdapterInfoPage extends LitElement {
margin: 0 auto var(--ha-space-4);
}
ha-md-list {
background: none;
padding: 0;
}
ha-md-list-item {
--md-item-overflow: visible;
}
ha-md-list-item ha-svg-icon[slot="start"] {
ha-list-item-button ha-svg-icon[slot="start"],
ha-list-item-base ha-svg-icon[slot="start"] {
color: var(--secondary-text-color);
}
ha-md-list-item.disabled ha-svg-icon[slot="start"],
ha-md-list-item.disabled [slot="headline"],
ha-md-list-item.disabled [slot="supporting-text"] {
ha-list-item-base.disabled ha-svg-icon[slot="start"],
ha-list-item-base.disabled [slot="headline"],
ha-list-item-base.disabled [slot="supporting-text"] {
opacity: 0.5;
}
@@ -7,16 +7,16 @@ import {
mdiLinkVariant,
mdiVectorPolyline,
} from "@mdi/js";
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
import type { CSSResultGroup, TemplateResult } from "lit";
import { LitElement, css, html } from "lit";
import { customElement, property, state } from "lit/decorators";
import "../../../../../components/ha-button";
import "../../../../../components/ha-card";
import "../../../../../components/ha-icon-next";
import "../../../../../components/ha-md-list";
import "../../../../../components/ha-md-list-item";
import "../../../../../components/ha-svg-icon";
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
import "../../../../../components/item/ha-list-item-button";
import "../../../../../components/list/ha-list-nav";
import type {
BluetoothAllocationsData,
BluetoothDeviceData,
@@ -213,11 +213,12 @@ export class BluetoothConfigDashboard extends LitElement {
</ha-button>
</div>
<div class="card-content network-card-content">
<ha-md-list>
<ha-md-list-item
type="link"
href="/config/bluetooth/adapter-info"
>
<ha-list-nav
.ariaLabel=${this.hass.localize(
"ui.panel.config.bluetooth.my_network"
)}
>
<ha-list-item-button href="/config/bluetooth/adapter-info">
<ha-svg-icon
slot="start"
.path=${mdiAccessPoint}
@@ -229,10 +230,9 @@ export class BluetoothConfigDashboard extends LitElement {
)}
</div>
<ha-icon-next slot="end"></ha-icon-next>
</ha-md-list-item>
</ha-list-item-button>
<ha-md-list-item
type="link"
<ha-list-item-button
href="/config/bluetooth/connection-monitor"
>
<ha-svg-icon
@@ -246,10 +246,9 @@ export class BluetoothConfigDashboard extends LitElement {
)}
</div>
<ha-icon-next slot="end"></ha-icon-next>
</ha-md-list-item>
</ha-list-item-button>
<ha-md-list-item
type="link"
<ha-list-item-button
href="/config/bluetooth/advertisement-monitor"
>
<ha-svg-icon slot="start" .path=${mdiBroadcast}></ha-svg-icon>
@@ -260,8 +259,8 @@ export class BluetoothConfigDashboard extends LitElement {
)}
</div>
<ha-icon-next slot="end"></ha-icon-next>
</ha-md-list-item>
</ha-md-list>
</ha-list-item-button>
</ha-list-nav>
</div>
</ha-card>
</div>
@@ -286,11 +285,6 @@ export class BluetoothConfigDashboard extends LitElement {
margin-top: var(--ha-space-6);
}
ha-md-list {
background: none;
padding: 0;
}
.network-card {
overflow: hidden;
}
@@ -9,8 +9,6 @@ import { extractSearchParamsObject } from "../../../../../common/url/search-para
import type { DataTableColumnContainer } from "../../../../../components/data-table/ha-data-table";
import "../../../../../components/ha-icon-button";
import "../../../../../components/ha-metric";
import "../../../../../components/ha-relative-time";
import type {
BluetoothAllocationsData,
BluetoothConnectionData,
@@ -9,9 +9,9 @@ import { LitElement, css, html } from "lit";
import { customElement, property } from "lit/decorators";
import "../../../../../components/ha-card";
import "../../../../../components/ha-icon-next";
import "../../../../../components/ha-md-list";
import "../../../../../components/ha-md-list-item";
import "../../../../../components/ha-svg-icon";
import "../../../../../components/item/ha-list-item-button";
import "../../../../../components/list/ha-list-nav";
import type { InfraredDevice } from "../../../../../data/infrared";
import "../../../../../layouts/hass-subpage";
import { haStyle } from "../../../../../resources/styles";
@@ -82,8 +82,12 @@ export class InfraredConfigDashboard extends LitElement {
${this.hass.localize("ui.panel.config.infrared.my_devices")}
</div>
<div class="card-content network-card-content">
<ha-md-list>
<ha-md-list-item type="link" href="/config/infrared/devices">
<ha-list-nav
.ariaLabel=${this.hass.localize(
"ui.panel.config.infrared.my_devices"
)}
>
<ha-list-item-button href="/config/infrared/devices">
<ha-svg-icon slot="start" .path=${mdiRemote}></ha-svg-icon>
<div slot="headline">
${this.hass.localize(
@@ -92,8 +96,8 @@ export class InfraredConfigDashboard extends LitElement {
)}
</div>
<ha-icon-next slot="end"></ha-icon-next>
</ha-md-list-item>
</ha-md-list>
</ha-list-item-button>
</ha-list-nav>
</div>
</ha-card>
</div>
@@ -118,11 +122,6 @@ export class InfraredConfigDashboard extends LitElement {
margin-top: var(--ha-space-6);
}
ha-md-list {
background: none;
padding: 0;
}
.network-card {
overflow: hidden;
}
@@ -2,25 +2,26 @@ import { mdiDelete, mdiLock, mdiPlus } from "@mdi/js";
import type { CSSResultGroup } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../../../../../common/dom/fire_event";
import type { HASSDomCurrentTargetEvent } from "../../../../../common/dom/fire_event";
import { fireEvent } from "../../../../../common/dom/fire_event";
import { stopKeydownEnterSpacePropagation } from "../../../../../common/dom/stop_propagation";
import "../../../../../components/ha-alert";
import "../../../../../components/ha-button";
import "../../../../../components/ha-dialog";
import "../../../../../components/ha-dialog-footer";
import "../../../../../components/ha-icon-button";
import "../../../../../components/ha-md-list";
import "../../../../../components/ha-md-list-item";
import "../../../../../components/ha-spinner";
import "../../../../../components/ha-svg-icon";
import "../../../../../components/ha-dialog";
import "../../../../../components/item/ha-list-item-button";
import "../../../../../components/list/ha-list-base";
import type {
MatterLockInfo,
MatterLockUser,
} from "../../../../../data/matter-lock";
import {
clearMatterLockUser,
getMatterLockInfo,
getMatterLockUsers,
clearMatterLockUser,
} from "../../../../../data/matter-lock";
import {
showAlertDialog,
@@ -162,11 +163,10 @@ class DialogMatterLockManage extends LitElement {
)}
</p>`
: html`
<ha-md-list>
<ha-list-base>
${occupiedUsers.map(
(user) => html`
<ha-md-list-item
type="button"
<ha-list-item-button
.user=${user}
@click=${this._handleUserClick}
>
@@ -191,11 +191,12 @@ class DialogMatterLockManage extends LitElement {
.path=${mdiDelete}
.user=${user}
@click=${this._handleDeleteUserClick}
@keydown=${stopKeydownEnterSpacePropagation}
></ha-icon-button>
</ha-md-list-item>
</ha-list-item-button>
`
)}
</ha-md-list>
</ha-list-base>
`
}
${
@@ -3,8 +3,8 @@ import { LitElement, css, html } from "lit";
import { customElement, property } from "lit/decorators";
import { fireEvent } from "../../../../../../common/dom/fire_event";
import "../../../../../../components/ha-icon-next";
import "../../../../../../components/ha-md-list";
import "../../../../../../components/ha-md-list-item";
import "../../../../../../components/item/ha-list-item-button";
import "../../../../../../components/list/ha-list-base";
import type { HomeAssistant } from "../../../../../../types";
import type { MatterAddDeviceStep } from "../dialog-matter-add-device";
import { sharedStyles } from "./matter-add-device-shared-styles";
@@ -23,14 +23,8 @@ class MatterAddDeviceExisting extends LitElement {
</p>
</div>
<ha-md-list>
<ha-md-list-item
interactive
type="button"
.step=${"google_home"}
@click=${this._onItemClick}
@keydown=${this._onItemClick}
>
<ha-list-base>
<ha-list-item-button .step=${"google_home"} @click=${this._onItemClick}>
<img
src="/static/images/logo_google_home.png"
alt=""
@@ -43,14 +37,8 @@ class MatterAddDeviceExisting extends LitElement {
)}
</span>
<ha-icon-next slot="end"></ha-icon-next>
</ha-md-list-item>
<ha-md-list-item
interactive
type="button"
.step=${"apple_home"}
@click=${this._onItemClick}
@keydown=${this._onItemClick}
>
</ha-list-item-button>
<ha-list-item-button .step=${"apple_home"} @click=${this._onItemClick}>
<img
src="/static/images/logo_apple_home.png"
alt=""
@@ -63,14 +51,8 @@ class MatterAddDeviceExisting extends LitElement {
)}
</span>
<ha-icon-next slot="end"></ha-icon-next>
</ha-md-list-item>
<ha-md-list-item
interactive
type="button"
.step=${"generic"}
@click=${this._onItemClick}
@keydown=${this._onItemClick}
>
</ha-list-item-button>
<ha-list-item-button .step=${"generic"} @click=${this._onItemClick}>
<div class="logo" slot="start">
<ha-svg-icon path=${mdiHomeAutomation}></ha-svg-icon>
</div>
@@ -80,15 +62,12 @@ class MatterAddDeviceExisting extends LitElement {
)}
</span>
<ha-icon-next slot="end"></ha-icon-next>
</ha-md-list-item>
</ha-md-list>
</ha-list-item-button>
</ha-list-base>
`;
}
private _onItemClick(ev) {
if (ev.type === "keydown" && ev.key !== "Enter" && ev.key !== " ") {
return;
}
const item = ev.currentTarget as any;
const step = item.step as MatterAddDeviceStep;
fireEvent(this, "step-selected", { step });
@@ -2,8 +2,8 @@ import { LitElement, html } from "lit";
import { customElement, property } from "lit/decorators";
import { fireEvent } from "../../../../../../common/dom/fire_event";
import "../../../../../../components/ha-icon-next";
import "../../../../../../components/ha-md-list-item";
import "../../../../../../components/ha-md-list";
import "../../../../../../components/item/ha-list-item-button";
import "../../../../../../components/list/ha-list-base";
import type { HomeAssistant } from "../../../../../../types";
import { sharedStyles } from "./matter-add-device-shared-styles";
@@ -18,14 +18,8 @@ class MatterAddDeviceMain extends LitElement {
${this.hass.localize(`ui.dialogs.matter-add-device.main.question`)}
</p>
</div>
<ha-md-list>
<ha-md-list-item
interactive
type="button"
.step=${"new"}
@click=${this._onItemClick}
@keydown=${this._onItemClick}
>
<ha-list-base>
<ha-list-item-button .step=${"new"} @click=${this._onItemClick}>
<span slot="headline">
${this.hass.localize(
`ui.dialogs.matter-add-device.main.answer_new`
@@ -37,14 +31,8 @@ class MatterAddDeviceMain extends LitElement {
)}
</span>
<ha-icon-next slot="end"></ha-icon-next>
</ha-md-list-item>
<ha-md-list-item
interactive
type="button"
.step=${"existing"}
@click=${this._onItemClick}
@keydown=${this._onItemClick}
>
</ha-list-item-button>
<ha-list-item-button .step=${"existing"} @click=${this._onItemClick}>
<span slot="headline">
${this.hass.localize(
`ui.dialogs.matter-add-device.main.answer_existing`
@@ -56,15 +44,12 @@ class MatterAddDeviceMain extends LitElement {
)}
</span>
<ha-icon-next slot="end"></ha-icon-next>
</ha-md-list-item>
</ha-md-list>
</ha-list-item-button>
</ha-list-base>
`;
}
private _onItemClick(ev) {
if (ev.type === "keydown" && ev.key !== "Enter" && ev.key !== " ") {
return;
}
const item = ev.currentTarget as any;
const step = item.step as "new" | "existing";
fireEvent(this, "step-selected", { step });
@@ -23,10 +23,9 @@ export const sharedStyles = css`
cursor: pointer;
text-decoration: underline;
}
ha-md-list {
ha-list-base {
padding: 0;
--md-list-item-leading-space: var(--horizontal-padding, 16px);
--md-list-item-trailing-space: var(--horizontal-padding, 16px);
--ha-row-item-padding-inline: var(--horizontal-padding, 16px);
margin-bottom: 16px;
}
ha-input {
@@ -16,9 +16,9 @@ import "../../../../../components/ha-button";
import "../../../../../components/ha-card";
import "../../../../../components/ha-icon-next";
import "../../../../../components/ha-md-list";
import "../../../../../components/ha-md-list-item";
import "../../../../../components/ha-svg-icon";
import "../../../../../components/item/ha-list-item-button";
import "../../../../../components/list/ha-list-nav";
import type { ConfigEntry } from "../../../../../data/config_entries";
import { getConfigEntries } from "../../../../../data/config_entries";
import { fetchMatterNetworkTopology } from "../../../../../data/matter";
@@ -167,9 +167,12 @@ export class MatterConfigDashboard extends LitElement {
</ha-button>
</div>
<div class="card-content">
<ha-md-list>
<ha-md-list-item
type="link"
<ha-list-nav
.ariaLabel=${this.hass.localize(
"ui.panel.config.matter.panel.my_network_title"
)}
>
<ha-list-item-button
href=${`/config/devices/dashboard?historyBack=1&config_entry=${this._configEntry?.entry_id}`}
>
<ha-svg-icon slot="start" .path=${mdiDevices}></ha-svg-icon>
@@ -180,9 +183,8 @@ export class MatterConfigDashboard extends LitElement {
)}
</div>
<ha-icon-next slot="end"></ha-icon-next>
</ha-md-list-item>
<ha-md-list-item
type="link"
</ha-list-item-button>
<ha-list-item-button
href=${`/config/entities/dashboard?historyBack=1&config_entry=${this._configEntry?.entry_id}`}
>
<ha-svg-icon slot="start" .path=${mdiShape}></ha-svg-icon>
@@ -193,8 +195,8 @@ export class MatterConfigDashboard extends LitElement {
)}
</div>
<ha-icon-next slot="end"></ha-icon-next>
</ha-md-list-item>
</ha-md-list>
</ha-list-item-button>
</ha-list-nav>
</div>
</ha-card>
`;
@@ -204,8 +206,12 @@ export class MatterConfigDashboard extends LitElement {
return html`
<ha-card class="nav-card">
<div class="card-content">
<ha-md-list>
<ha-md-list-item type="link" href="/config/matter/options">
<ha-list-nav
.ariaLabel=${this.hass.localize(
"ui.panel.config.matter.panel.options_title"
)}
>
<ha-list-item-button href="/config/matter/options">
<ha-svg-icon slot="start" .path=${mdiTune}></ha-svg-icon>
<div slot="headline">
${this.hass.localize(
@@ -218,10 +224,10 @@ export class MatterConfigDashboard extends LitElement {
)}
</div>
<ha-icon-next slot="end"></ha-icon-next>
</ha-md-list-item>
</ha-list-item-button>
${
isComponentLoaded(this.hass.config, "thread")
? html`<ha-md-list-item type="link" href="/config/thread">
? html`<ha-list-item-button href="/config/thread">
<ha-svg-icon
slot="start"
.path=${THREAD_ICON}
@@ -237,10 +243,10 @@ export class MatterConfigDashboard extends LitElement {
)}
</div>
<ha-icon-next slot="end"></ha-icon-next>
</ha-md-list-item>`
</ha-list-item-button>`
: nothing
}
</ha-md-list>
</ha-list-nav>
</div>
</ha-card>
`;
@@ -307,15 +313,6 @@ export class MatterConfigDashboard extends LitElement {
margin-top: var(--ha-space-6);
}
ha-md-list {
background: none;
padding: 0;
}
ha-md-list-item {
--md-item-overflow: visible;
}
.network-status div.heading {
display: flex;
align-items: center;
@@ -1,12 +1,11 @@
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
import type { CSSResultGroup } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
import "../../../../../components/ha-alert";
import "../../../../../components/ha-button";
import "../../../../../components/ha-card";
import "../../../../../components/ha-md-list";
import "../../../../../components/ha-md-list-item";
import "../../../../../components/item/ha-row-item";
import {
acceptSharedMatterDevice,
canCommissionMatterExternal,
@@ -55,122 +54,120 @@ class MatterOptionsPage extends LitElement {
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
: nothing
}
<ha-md-list>
${
canCommissionMatterExternal(this.hass)
? html`<ha-md-list-item>
<span slot="headline">
${this.hass.localize(
"ui.panel.config.matter.panel.mobile_app_commisioning"
)}
</span>
<span slot="supporting-text">
${this.hass.localize(
"ui.panel.config.matter.panel.mobile_app_commisioning_description"
)}
</span>
<ha-button
appearance="plain"
slot="end"
size="s"
@click=${this._startMobileCommissioning}
>
${this.hass.localize(
"ui.panel.config.matter.panel.mobile_app_commisioning_action"
)}
</ha-button>
</ha-md-list-item>`
: nothing
}
<ha-md-list-item>
<span slot="headline">
${this.hass.localize(
"ui.panel.config.matter.panel.commission_device"
)}
</span>
<span slot="supporting-text">
${this.hass.localize(
"ui.panel.config.matter.panel.commission_device_description"
)}
</span>
<ha-button
appearance="plain"
slot="end"
size="s"
@click=${this._commission}
>
${this.hass.localize(
"ui.panel.config.matter.panel.commission_device_action"
)}
</ha-button>
</ha-md-list-item>
<ha-md-list-item>
<span slot="headline">
${this.hass.localize(
"ui.panel.config.matter.panel.add_shared_device"
)}
</span>
<span slot="supporting-text">
${this.hass.localize(
"ui.panel.config.matter.panel.add_shared_device_description"
)}
</span>
<ha-button
appearance="plain"
slot="end"
size="s"
@click=${this._acceptSharedDevice}
>
${this.hass.localize(
"ui.panel.config.matter.panel.add_shared_device_action"
)}
</ha-button>
</ha-md-list-item>
<ha-md-list-item>
<span slot="headline">
${this.hass.localize(
"ui.panel.config.matter.panel.set_wifi_credentials"
)}
</span>
<span slot="supporting-text">
${this.hass.localize(
"ui.panel.config.matter.panel.set_wifi_credentials_description"
)}
</span>
<ha-button
appearance="plain"
slot="end"
size="s"
@click=${this._setWifi}
>
${this.hass.localize(
"ui.panel.config.matter.panel.set_wifi_credentials_action"
)}
</ha-button>
</ha-md-list-item>
<ha-md-list-item>
<span slot="headline">
${this.hass.localize(
"ui.panel.config.matter.panel.set_thread_credentials"
)}
</span>
<span slot="supporting-text">
${this.hass.localize(
"ui.panel.config.matter.panel.set_thread_credentials_description"
)}
</span>
<ha-button
appearance="plain"
slot="end"
size="s"
@click=${this._setThread}
>
${this.hass.localize(
"ui.panel.config.matter.panel.set_thread_credentials_action"
)}
</ha-button>
</ha-md-list-item>
</ha-md-list>
${
canCommissionMatterExternal(this.hass)
? html`<ha-row-item>
<span slot="headline">
${this.hass.localize(
"ui.panel.config.matter.panel.mobile_app_commisioning"
)}
</span>
<span slot="supporting-text">
${this.hass.localize(
"ui.panel.config.matter.panel.mobile_app_commisioning_description"
)}
</span>
<ha-button
appearance="plain"
slot="end"
size="s"
@click=${this._startMobileCommissioning}
>
${this.hass.localize(
"ui.panel.config.matter.panel.mobile_app_commisioning_action"
)}
</ha-button>
</ha-row-item>`
: nothing
}
<ha-row-item>
<span slot="headline">
${this.hass.localize(
"ui.panel.config.matter.panel.commission_device"
)}
</span>
<span slot="supporting-text">
${this.hass.localize(
"ui.panel.config.matter.panel.commission_device_description"
)}
</span>
<ha-button
appearance="plain"
slot="end"
size="s"
@click=${this._commission}
>
${this.hass.localize(
"ui.panel.config.matter.panel.commission_device_action"
)}
</ha-button>
</ha-row-item>
<ha-row-item>
<span slot="headline">
${this.hass.localize(
"ui.panel.config.matter.panel.add_shared_device"
)}
</span>
<span slot="supporting-text">
${this.hass.localize(
"ui.panel.config.matter.panel.add_shared_device_description"
)}
</span>
<ha-button
appearance="plain"
slot="end"
size="s"
@click=${this._acceptSharedDevice}
>
${this.hass.localize(
"ui.panel.config.matter.panel.add_shared_device_action"
)}
</ha-button>
</ha-row-item>
<ha-row-item>
<span slot="headline">
${this.hass.localize(
"ui.panel.config.matter.panel.set_wifi_credentials"
)}
</span>
<span slot="supporting-text">
${this.hass.localize(
"ui.panel.config.matter.panel.set_wifi_credentials_description"
)}
</span>
<ha-button
appearance="plain"
slot="end"
size="s"
@click=${this._setWifi}
>
${this.hass.localize(
"ui.panel.config.matter.panel.set_wifi_credentials_action"
)}
</ha-button>
</ha-row-item>
<ha-row-item>
<span slot="headline">
${this.hass.localize(
"ui.panel.config.matter.panel.set_thread_credentials"
)}
</span>
<span slot="supporting-text">
${this.hass.localize(
"ui.panel.config.matter.panel.set_thread_credentials_description"
)}
</span>
<ha-button
appearance="plain"
slot="end"
size="s"
@click=${this._setThread}
>
${this.hass.localize(
"ui.panel.config.matter.panel.set_thread_credentials_action"
)}
</ha-button>
</ha-row-item>
</ha-card>
</div>
</hass-subpage>
@@ -323,15 +320,6 @@ class MatterOptionsPage extends LitElement {
max-width: 600px;
margin: auto;
}
ha-md-list {
background: none;
padding: 0;
}
ha-md-list-item {
--md-item-overflow: visible;
}
`,
];
}

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