From 493d650e159b2517adfce9e4f70a860b6c50fd6b Mon Sep 17 00:00:00 2001 From: Wendelin <12148533+wendevlin@users.noreply.github.com> Date: Wed, 26 Nov 2025 12:36:14 +0100 Subject: [PATCH] Automation editor: Add trigger/condition/action from target (#28031) --- package.json | 2 +- src/components/ha-floor-icon.ts | 7 +- src/components/ha-label-picker.ts | 5 +- src/components/ha-picker-combo-box.ts | 2 +- src/components/ha-section-title.ts | 28 + src/components/ha-target-picker.ts | 40 +- src/components/ha-wa-dialog.ts | 2 +- src/data/area_floor.ts | 153 +- src/data/area_registry.ts | 20 +- src/data/device_registry.ts | 16 +- src/data/integration.ts | 4 +- src/data/label_registry.ts | 22 +- src/data/target.ts | 81 +- .../automation/action/ha-automation-action.ts | 4 +- .../add-automation-element-dialog.ts | 1838 ++++++++++------- .../ha-automation-add-from-target.ts | 1612 +++++++++++++++ .../ha-automation-add-items.ts | 384 ++++ .../ha-automation-add-search.ts | 1081 ++++++++++ .../condition/ha-automation-condition.ts | 4 +- .../show-add-automation-element-dialog.ts | 3 +- .../trigger/ha-automation-trigger.ts | 4 +- .../devices/ha-config-devices-dashboard.ts | 12 +- src/resources/theme/color/wa.globals.ts | 4 + src/resources/theme/wa.globals.ts | 5 + src/translations/en.json | 36 +- yarn.lock | 10 +- 26 files changed, 4574 insertions(+), 805 deletions(-) create mode 100644 src/components/ha-section-title.ts create mode 100644 src/panels/config/automation/add-automation-element/ha-automation-add-from-target.ts create mode 100644 src/panels/config/automation/add-automation-element/ha-automation-add-items.ts create mode 100644 src/panels/config/automation/add-automation-element/ha-automation-add-search.ts diff --git a/package.json b/package.json index 8365f54c8c..b02276f065 100644 --- a/package.json +++ b/package.json @@ -52,7 +52,7 @@ "@fullcalendar/list": "6.1.19", "@fullcalendar/luxon3": "6.1.19", "@fullcalendar/timegrid": "6.1.19", - "@home-assistant/webawesome": "3.0.0", + "@home-assistant/webawesome": "3.0.0-ha.0", "@lezer/highlight": "1.2.3", "@lit-labs/motion": "1.0.9", "@lit-labs/observers": "2.0.6", diff --git a/src/components/ha-floor-icon.ts b/src/components/ha-floor-icon.ts index a36b7ec391..66fe8e1d96 100644 --- a/src/components/ha-floor-icon.ts +++ b/src/components/ha-floor-icon.ts @@ -6,7 +6,7 @@ import { mdiHomeFloor3, mdiHomeFloorNegative1, } from "@mdi/js"; -import { LitElement, html } from "lit"; +import { LitElement, html, nothing } from "lit"; import { customElement, property } from "lit/decorators"; import type { FloorRegistryEntry } from "../data/floor_registry"; import "./ha-icon"; @@ -48,7 +48,7 @@ export const floorDefaultIcon = (floor: Pick) => { @customElement("ha-floor-icon") export class HaFloorIcon extends LitElement { - @property({ attribute: false }) public floor!: Pick< + @property({ attribute: false }) public floor?: Pick< FloorRegistryEntry, "icon" | "level" >; @@ -56,6 +56,9 @@ export class HaFloorIcon extends LitElement { @property() public icon?: string; protected render() { + if (!this.floor) { + return nothing; + } if (this.floor.icon) { return html``; } diff --git a/src/components/ha-label-picker.ts b/src/components/ha-label-picker.ts index 07a8c55875..d28a7d47a8 100644 --- a/src/components/ha-label-picker.ts +++ b/src/components/ha-label-picker.ts @@ -154,7 +154,10 @@ export class HaLabelPicker extends SubscribeMixin(LitElement) { } return this._getLabelsMemoized( - this.hass, + this.hass.states, + this.hass.areas, + this.hass.devices, + this.hass.entities, this._labels, this.includeDomains, this.excludeDomains, diff --git a/src/components/ha-picker-combo-box.ts b/src/components/ha-picker-combo-box.ts index fce8befbc3..b2e7f182f8 100644 --- a/src/components/ha-picker-combo-box.ts +++ b/src/components/ha-picker-combo-box.ts @@ -192,7 +192,7 @@ export class HaPickerComboBox extends LitElement { @focus=${this._focusList} @visibilityChanged=${this._visibilityChanged} > - `; + `; } private _renderSectionButtons() { diff --git a/src/components/ha-section-title.ts b/src/components/ha-section-title.ts new file mode 100644 index 0000000000..ae2cc977c6 --- /dev/null +++ b/src/components/ha-section-title.ts @@ -0,0 +1,28 @@ +import { css, html, LitElement } from "lit"; +import { customElement } from "lit/decorators"; + +@customElement("ha-section-title") +class HaSectionTitle extends LitElement { + protected render() { + return html``; + } + + static styles = css` + :host { + background-color: var(--ha-color-fill-neutral-quiet-resting); + padding: var(--ha-space-1) var(--ha-space-2); + font-weight: var(--ha-font-weight-bold); + color: var(--secondary-text-color); + min-height: var(--ha-space-6); + display: flex; + align-items: center; + box-sizing: border-box; + } + `; +} + +declare global { + interface HTMLElementTagNameMap { + "ha-section-title": HaSectionTitle; + } +} diff --git a/src/components/ha-target-picker.ts b/src/components/ha-target-picker.ts index 74683f7590..0d77a72695 100644 --- a/src/components/ha-target-picker.ts +++ b/src/components/ha-target-picker.ts @@ -30,6 +30,7 @@ import { areaMeetsFilter, deviceMeetsFilter, entityRegMeetsFilter, + getTargetComboBoxItemType, type TargetType, type TargetTypeFloorless, } from "../data/target"; @@ -47,7 +48,6 @@ import "./ha-tree-indicator"; import "./target-picker/ha-target-picker-item-group"; import "./target-picker/ha-target-picker-value-chip"; -const EMPTY_SEARCH = "___EMPTY_SEARCH___"; const SEPARATOR = "________"; const CREATE_ID = "___create-new-entity___"; @@ -634,35 +634,6 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) { return undefined; } - private _getRowType = ( - item: - | PickerComboBoxItem - | (FloorComboBoxItem & { last?: boolean | undefined }) - | EntityComboBoxItem - | DevicePickerItem - ) => { - if ( - (item as FloorComboBoxItem).type === "area" || - (item as FloorComboBoxItem).type === "floor" - ) { - return (item as FloorComboBoxItem).type; - } - - if ("domain" in item) { - return "device"; - } - - if ("stateObj" in item) { - return "entity"; - } - - if (item.id === EMPTY_SEARCH) { - return "empty"; - } - - return "label"; - }; - private _sectionTitleFunction = ({ firstIndex, lastIndex, @@ -686,7 +657,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) { return undefined; } - const type = this._getRowType(firstItem as PickerComboBoxItem); + const type = getTargetComboBoxItemType(firstItem as PickerComboBoxItem); const translationType: | "areas" | "entities" @@ -858,7 +829,10 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) { if (!filterType || filterType === "label") { let labels = this._getLabelsMemoized( - this.hass, + this.hass.states, + this.hass.areas, + this.hass.devices, + this.hass.entities, this._labelRegistry, includeDomains, undefined, @@ -974,7 +948,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) { return nothing; } - const type = this._getRowType(item); + const type = getTargetComboBoxItemType(item); let hasFloor = false; let rtl = false; let showEntityId = false; diff --git a/src/components/ha-wa-dialog.ts b/src/components/ha-wa-dialog.ts index 70fb7aac55..b9fa674849 100644 --- a/src/components/ha-wa-dialog.ts +++ b/src/components/ha-wa-dialog.ts @@ -235,7 +235,7 @@ export class HaWaDialog extends LitElement { } :host([width="large"]) wa-dialog { - --width: min(var(--ha-dialog-width-lg, 720px), var(--full-width)); + --width: min(var(--ha-dialog-width-lg, 1024px), var(--full-width)); } :host([width="full"]) wa-dialog { diff --git a/src/data/area_floor.ts b/src/data/area_floor.ts index 5bda9506a3..75d96434b0 100644 --- a/src/data/area_floor.ts +++ b/src/data/area_floor.ts @@ -21,11 +21,52 @@ export interface FloorComboBoxItem extends PickerComboBoxItem { area?: AreaRegistryEntry; } +export interface FloorNestedComboBoxItem extends PickerComboBoxItem { + floor?: FloorRegistryEntry; + areas: FloorComboBoxItem[]; +} + +export interface UnassignedAreasFloorComboBoxItem extends PickerComboBoxItem { + areas: FloorComboBoxItem[]; +} + export interface AreaFloorValue { id: string; type: "floor" | "area"; } +export const getAreasNestedInFloors = ( + states: HomeAssistant["states"], + haFloors: HomeAssistant["floors"], + haAreas: HomeAssistant["areas"], + haDevices: HomeAssistant["devices"], + haEntities: HomeAssistant["entities"], + formatId: (value: AreaFloorValue) => string, + includeDomains?: string[], + excludeDomains?: string[], + includeDeviceClasses?: string[], + deviceFilter?: HaDevicePickerDeviceFilterFunc, + entityFilter?: HaEntityPickerEntityFilterFunc, + excludeAreas?: string[], + excludeFloors?: string[] +) => + getAreasAndFloorsItems( + states, + haFloors, + haAreas, + haDevices, + haEntities, + formatId, + includeDomains, + excludeDomains, + includeDeviceClasses, + deviceFilter, + entityFilter, + excludeAreas, + excludeFloors, + true + ) as (FloorNestedComboBoxItem | UnassignedAreasFloorComboBoxItem)[]; + export const getAreasAndFloors = ( states: HomeAssistant["states"], haFloors: HomeAssistant["floors"], @@ -40,7 +81,43 @@ export const getAreasAndFloors = ( entityFilter?: HaEntityPickerEntityFilterFunc, excludeAreas?: string[], excludeFloors?: string[] -): FloorComboBoxItem[] => { +) => + getAreasAndFloorsItems( + states, + haFloors, + haAreas, + haDevices, + haEntities, + formatId, + includeDomains, + excludeDomains, + includeDeviceClasses, + deviceFilter, + entityFilter, + excludeAreas, + excludeFloors + ) as FloorComboBoxItem[]; + +const getAreasAndFloorsItems = ( + states: HomeAssistant["states"], + haFloors: HomeAssistant["floors"], + haAreas: HomeAssistant["areas"], + haDevices: HomeAssistant["devices"], + haEntities: HomeAssistant["entities"], + formatId: (value: AreaFloorValue) => string, + includeDomains?: string[], + excludeDomains?: string[], + includeDeviceClasses?: string[], + deviceFilter?: HaDevicePickerDeviceFilterFunc, + entityFilter?: HaEntityPickerEntityFilterFunc, + excludeAreas?: string[], + excludeFloors?: string[], + nested = false +): ( + | FloorComboBoxItem + | FloorNestedComboBoxItem + | UnassignedAreasFloorComboBoxItem +)[] => { const floors = Object.values(haFloors); const areas = Object.values(haAreas); const devices = Object.values(haDevices); @@ -181,7 +258,11 @@ export const getAreasAndFloors = ( const hierarchy = getAreasFloorHierarchy(floors, outputAreas); - const items: FloorComboBoxItem[] = []; + const items: ( + | FloorComboBoxItem + | FloorNestedComboBoxItem + | UnassignedAreasFloorComboBoxItem + )[] = []; hierarchy.floors.forEach((f) => { const floor = haFloors[f.id]; @@ -196,7 +277,7 @@ export const getAreasAndFloors = ( }) .flat(); - items.push({ + const floorItem: FloorComboBoxItem | FloorNestedComboBoxItem = { id: formatId({ id: floor.floor_id, type: "floor" }), type: "floor", primary: floorName, @@ -208,41 +289,53 @@ export const getAreasAndFloors = ( ...floor.aliases, ...areaSearchLabels, ], - }); + }; - items.push( - ...floorAreas.map((area) => { - const areaName = computeAreaName(area); - return { - id: formatId({ id: area.area_id, type: "area" }), - type: "area" as const, - primary: areaName || area.area_id, - area: area, - icon: area.icon || undefined, - search_labels: [ - area.area_id, - ...(areaName ? [areaName] : []), - ...area.aliases, - ], - }; - }) - ); - }); + items.push(floorItem); - items.push( - ...hierarchy.areas.map((areaId) => { - const area = haAreas[areaId]; - const areaName = computeAreaName(area) || area.area_id; + const floorAreasItems = floorAreas.map((area) => { + const areaName = computeAreaName(area); return { id: formatId({ id: area.area_id, type: "area" }), type: "area" as const, - primary: areaName, + primary: areaName || area.area_id, area: area, icon: area.icon || undefined, - search_labels: [area.area_id, areaName, ...area.aliases], + search_labels: [ + area.area_id, + ...(areaName ? [areaName] : []), + ...area.aliases, + ], }; - }) - ); + }); + + if (nested && floor) { + (floorItem as unknown as FloorNestedComboBoxItem).areas = floorAreasItems; + } else { + items.push(...floorAreasItems); + } + }); + + const unassignedAreaItems = hierarchy.areas.map((areaId) => { + const area = haAreas[areaId]; + const areaName = computeAreaName(area) || area.area_id; + return { + id: formatId({ id: area.area_id, type: "area" }), + type: "area" as const, + primary: areaName, + area: area, + icon: area.icon || undefined, + search_labels: [area.area_id, areaName, ...area.aliases], + }; + }); + + if (nested && unassignedAreaItems.length) { + items.push({ + areas: unassignedAreaItems, + } as UnassignedAreasFloorComboBoxItem); + } else { + items.push(...unassignedAreaItems); + } return items; }; diff --git a/src/data/area_registry.ts b/src/data/area_registry.ts index 212143784a..8161e8a64e 100644 --- a/src/data/area_registry.ts +++ b/src/data/area_registry.ts @@ -1,7 +1,10 @@ import { stringCompare } from "../common/string/compare"; import type { HomeAssistant } from "../types"; import type { DeviceRegistryEntry } from "./device_registry"; -import type { EntityRegistryEntry } from "./entity_registry"; +import type { + EntityRegistryDisplayEntry, + EntityRegistryEntry, +} from "./entity_registry"; import type { RegistryEntry } from "./registry"; export { subscribeAreaRegistry } from "./ws-area_registry"; @@ -18,7 +21,10 @@ export interface AreaRegistryEntry extends RegistryEntry { temperature_entity_id: string | null; } -export type AreaEntityLookup = Record; +export type AreaEntityLookup = Record< + string, + (EntityRegistryEntry | EntityRegistryDisplayEntry)[] +>; export type AreaDeviceLookup = Record; @@ -69,11 +75,17 @@ export const reorderAreaRegistryEntries = ( }); export const getAreaEntityLookup = ( - entities: EntityRegistryEntry[] + entities: (EntityRegistryEntry | EntityRegistryDisplayEntry)[], + filterHidden = false ): AreaEntityLookup => { const areaEntityLookup: AreaEntityLookup = {}; for (const entity of entities) { - if (!entity.area_id) { + if ( + !entity.area_id || + (filterHidden && + ((entity as EntityRegistryDisplayEntry).hidden || + (entity as EntityRegistryEntry).hidden_by)) + ) { continue; } if (!(entity.area_id in areaEntityLookup)) { diff --git a/src/data/device_registry.ts b/src/data/device_registry.ts index 0b5b3be251..b342832be2 100644 --- a/src/data/device_registry.ts +++ b/src/data/device_registry.ts @@ -50,7 +50,11 @@ export type DeviceEntityDisplayLookup = Record< EntityRegistryDisplayEntry[] >; -export type DeviceEntityLookup = Record; +export type DeviceEntityLookup< + T extends EntityRegistryEntry | EntityRegistryDisplayEntry = + | EntityRegistryEntry + | EntityRegistryDisplayEntry, +> = Record; export interface DeviceRegistryEntryMutableParams { area_id?: string | null; @@ -107,11 +111,17 @@ export const sortDeviceRegistryByName = ( ); export const getDeviceEntityLookup = ( - entities: EntityRegistryEntry[] + entities: (EntityRegistryEntry | EntityRegistryDisplayEntry)[], + filterHidden = false ): DeviceEntityLookup => { const deviceEntityLookup: DeviceEntityLookup = {}; for (const entity of entities) { - if (!entity.device_id) { + if ( + !entity.device_id || + (filterHidden && + ((entity as EntityRegistryDisplayEntry).hidden || + (entity as EntityRegistryEntry).hidden_by)) + ) { continue; } if (!(entity.device_id in deviceEntityLookup)) { diff --git a/src/data/integration.ts b/src/data/integration.ts index cab4d464a7..2347131ab0 100644 --- a/src/data/integration.ts +++ b/src/data/integration.ts @@ -1,8 +1,8 @@ import type { Connection } from "home-assistant-js-websocket"; import { createCollection } from "home-assistant-js-websocket"; import type { LocalizeFunc } from "../common/translations/localize"; -import type { HomeAssistant } from "../types"; import { debounce } from "../common/util/debounce"; +import type { HomeAssistant } from "../types"; export const integrationsWithPanel = { bluetooth: "config/bluetooth", @@ -25,6 +25,8 @@ export type IntegrationType = | "entity" | "system"; +export type DomainManifestLookup = Record; + export interface IntegrationManifest { is_built_in: boolean; overwrites_built_in?: boolean; diff --git a/src/data/label_registry.ts b/src/data/label_registry.ts index f30bbcbfef..286ecbe1b8 100644 --- a/src/data/label_registry.ts +++ b/src/data/label_registry.ts @@ -101,7 +101,10 @@ export const deleteLabelRegistryEntry = ( }); export const getLabels = ( - hass: HomeAssistant, + hassStates: HomeAssistant["states"], + hassAreas: HomeAssistant["areas"], + hassDevices: HomeAssistant["devices"], + hassEntities: HomeAssistant["entities"], labels?: LabelRegistryEntry[], includeDomains?: string[], excludeDomains?: string[], @@ -115,8 +118,8 @@ export const getLabels = ( return []; } - const devices = Object.values(hass.devices); - const entities = Object.values(hass.entities); + const devices = Object.values(hassDevices); + const entities = Object.values(hassEntities); let deviceEntityLookup: DeviceEntityDisplayLookup = {}; let inputDevices: DeviceRegistryEntry[] | undefined; @@ -170,7 +173,7 @@ export const getLabels = ( return false; } return deviceEntityLookup[device.id].some((entity) => { - const stateObj = hass.states[entity.entity_id]; + const stateObj = hassStates[entity.entity_id]; if (!stateObj) { return false; } @@ -181,8 +184,9 @@ export const getLabels = ( }); }); inputEntities = inputEntities!.filter((entity) => { - const stateObj = hass.states[entity.entity_id]; + const stateObj = hassStates[entity.entity_id]; return ( + stateObj && stateObj.attributes.device_class && includeDeviceClasses.includes(stateObj.attributes.device_class) ); @@ -200,7 +204,7 @@ export const getLabels = ( return false; } return deviceEntityLookup[device.id].some((entity) => { - const stateObj = hass.states[entity.entity_id]; + const stateObj = hassStates[entity.entity_id]; if (!stateObj) { return false; } @@ -208,7 +212,7 @@ export const getLabels = ( }); }); inputEntities = inputEntities!.filter((entity) => { - const stateObj = hass.states[entity.entity_id]; + const stateObj = hassStates[entity.entity_id]; if (!stateObj) { return false; } @@ -245,8 +249,8 @@ export const getLabels = ( if (areaIds) { areaIds.forEach((areaId) => { - const area = hass.areas[areaId]; - area.labels.forEach((label) => usedLabels.add(label)); + const area = hassAreas[areaId]; + area?.labels.forEach((label) => usedLabels.add(label)); }); } diff --git a/src/data/target.ts b/src/data/target.ts index b73c6255c9..4543718ba0 100644 --- a/src/data/target.ts +++ b/src/data/target.ts @@ -1,15 +1,30 @@ import type { HassServiceTarget } from "home-assistant-js-websocket"; import { computeDomain } from "../common/entity/compute_domain"; import type { HaDevicePickerDeviceFilterFunc } from "../components/device/ha-device-picker"; +import type { PickerComboBoxItem } from "../components/ha-picker-combo-box"; import type { HomeAssistant } from "../types"; +import type { FloorComboBoxItem } from "./area_floor"; import type { AreaRegistryEntry } from "./area_registry"; -import type { DeviceRegistryEntry } from "./device_registry"; +import type { DevicePickerItem, DeviceRegistryEntry } from "./device_registry"; import type { HaEntityPickerEntityFilterFunc } from "./entity"; -import type { EntityRegistryDisplayEntry } from "./entity_registry"; +import type { + EntityComboBoxItem, + EntityRegistryDisplayEntry, +} from "./entity_registry"; + +export const TARGET_SEPARATOR = "________"; export type TargetType = "entity" | "device" | "area" | "label" | "floor"; export type TargetTypeFloorless = Exclude; +export interface SingleHassServiceTarget { + entity_id?: string; + device_id?: string; + area_id?: string; + floor_id?: string; + label_id?: string; +} + export interface ExtractFromTargetResult { missing_areas: string[]; missing_devices: string[]; @@ -35,6 +50,39 @@ export const extractFromTarget = async ( target, }); +export const getTriggersForTarget = async ( + callWS: HomeAssistant["callWS"], + target: HassServiceTarget, + expandGroup = true +) => + callWS({ + type: "get_triggers_for_target", + target, + expand_group: expandGroup, + }); + +export const getConditionsForTarget = async ( + callWS: HomeAssistant["callWS"], + target: HassServiceTarget, + expandGroup = true +) => + callWS({ + type: "get_conditions_for_target", + target, + expand_group: expandGroup, + }); + +export const getServicesForTarget = async ( + callWS: HomeAssistant["callWS"], + target: HassServiceTarget, + expandGroup = true +) => + callWS({ + type: "get_services_for_target", + target, + expand_group: expandGroup, + }); + export const areaMeetsFilter = ( area: AreaRegistryEntry, devices: HomeAssistant["devices"], @@ -162,3 +210,32 @@ export const entityRegMeetsFilter = ( } return true; }; + +export const getTargetComboBoxItemType = ( + item: + | PickerComboBoxItem + | (FloorComboBoxItem & { last?: boolean | undefined }) + | EntityComboBoxItem + | DevicePickerItem +) => { + if ( + (item as FloorComboBoxItem).type === "area" || + (item as FloorComboBoxItem).type === "floor" + ) { + return (item as FloorComboBoxItem).type; + } + + if ("domain" in item) { + return "device"; + } + + if ("stateObj" in item) { + return "entity"; + } + + if (item.id === "___EMPTY_SEARCH___") { + return "empty"; + } + + return "label"; +}; diff --git a/src/panels/config/automation/action/ha-automation-action.ts b/src/panels/config/automation/action/ha-automation-action.ts index 515dcf91c8..6afb46f152 100644 --- a/src/panels/config/automation/action/ha-automation-action.ts +++ b/src/panels/config/automation/action/ha-automation-action.ts @@ -1,5 +1,6 @@ import { mdiDragHorizontalVariant, mdiPlus } from "@mdi/js"; import deepClone from "deep-clone-simple"; +import type { HassServiceTarget } from "home-assistant-js-websocket"; import type { PropertyValues } from "lit"; import { html, LitElement, nothing } from "lit"; import { customElement, property, queryAll, state } from "lit/decorators"; @@ -213,7 +214,7 @@ export default class HaAutomationAction extends LitElement { }); } - private _addAction = (action: string) => { + private _addAction = (action: string, target?: HassServiceTarget) => { let actions: Action[]; if (action === PASTE_VALUE) { actions = this.actions.concat(deepClone(this._clipboard!.action)); @@ -223,6 +224,7 @@ export default class HaAutomationAction extends LitElement { actions = this.actions.concat({ action: getValueFromDynamic(action), metadata: {}, + target, }); } else { const elClass = customElements.get( diff --git a/src/panels/config/automation/add-automation-element-dialog.ts b/src/panels/config/automation/add-automation-element-dialog.ts index 740f0dcb89..c530f86ea4 100644 --- a/src/panels/config/automation/add-automation-element-dialog.ts +++ b/src/panels/config/automation/add-automation-element-dialog.ts @@ -1,46 +1,54 @@ +import { consume } from "@lit/context"; import { mdiAppleKeyboardCommand, mdiClose, mdiContentPaste, mdiPlus, } from "@mdi/js"; -import Fuse from "fuse.js"; -import type { UnsubscribeFunc } from "home-assistant-js-websocket"; +import type { + HassServiceTarget, + UnsubscribeFunc, +} from "home-assistant-js-websocket"; import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit"; import { LitElement, css, html, nothing } from "lit"; -import { - customElement, - eventOptions, - property, - query, - state, -} from "lit/decorators"; +import { customElement, property, query, state } from "lit/decorators"; import { classMap } from "lit/directives/class-map"; -import { ifDefined } from "lit/directives/if-defined"; import { repeat } from "lit/directives/repeat"; import memoizeOne from "memoize-one"; -import { tinykeys } from "tinykeys"; import { fireEvent } from "../../../common/dom/fire_event"; +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 { computeFloorName } from "../../../common/entity/compute_floor_name"; import { stringCompare } from "../../../common/string/compare"; import type { LocalizeFunc, LocalizeKeys, } from "../../../common/translations/localize"; +import { computeRTL } from "../../../common/util/compute_rtl"; import { debounce } from "../../../common/util/debounce"; import { deepEqual } from "../../../common/util/deep-equal"; +import "../../../components/entity/state-badge"; import "../../../components/ha-bottom-sheet"; +import "../../../components/ha-button"; import "../../../components/ha-button-toggle-group"; +import "../../../components/ha-combo-box-item"; +import { CONDITION_ICONS } from "../../../components/ha-condition-icon"; import "../../../components/ha-dialog-header"; import "../../../components/ha-domain-icon"; +import "../../../components/ha-floor-icon"; +import "../../../components/ha-icon"; import "../../../components/ha-icon-button"; import "../../../components/ha-icon-button-prev"; import "../../../components/ha-icon-next"; import "../../../components/ha-md-divider"; import "../../../components/ha-md-list"; -import type { HaMdList } from "../../../components/ha-md-list"; import "../../../components/ha-md-list-item"; +import type { PickerComboBoxItem } from "../../../components/ha-picker-combo-box"; +import "../../../components/ha-section-title"; import "../../../components/ha-service-icon"; +import "../../../components/ha-tooltip"; import { TRIGGER_ICONS } from "../../../components/ha-trigger-icon"; import "../../../components/ha-wa-dialog"; import "../../../components/search-input"; @@ -49,6 +57,11 @@ import { ACTION_COLLECTIONS, ACTION_ICONS, } from "../../../data/action"; +import type { FloorComboBoxItem } from "../../../data/area_floor"; +import { + getAreaDeviceLookup, + getAreaEntityLookup, +} from "../../../data/area_registry"; import { DYNAMIC_PREFIX, getValueFromDynamic, @@ -64,16 +77,34 @@ import { getConditionObjectId, subscribeConditions, } from "../../../data/condition"; +import { + getConfigEntries, + type ConfigEntry, +} from "../../../data/config_entries"; +import { labelsContext } from "../../../data/context"; +import { getDeviceEntityLookup } from "../../../data/device_registry"; +import type { EntityComboBoxItem } from "../../../data/entity_registry"; +import { getFloorAreaLookup } from "../../../data/floor_registry"; import { getConditionIcons, getServiceIcons, getTriggerIcons, } from "../../../data/icons"; -import type { IntegrationManifest } from "../../../data/integration"; +import type { DomainManifestLookup } from "../../../data/integration"; import { domainToName, fetchIntegrationManifests, } from "../../../data/integration"; +import type { LabelRegistryEntry } from "../../../data/label_registry"; +import { subscribeLabFeatures } from "../../../data/labs"; +import { + TARGET_SEPARATOR, + getConditionsForTarget, + getServicesForTarget, + getTargetComboBoxItemType, + getTriggersForTarget, + type SingleHassServiceTarget, +} from "../../../data/target"; import type { TriggerDescriptions } from "../../../data/trigger"; import { TRIGGER_COLLECTIONS, @@ -83,13 +114,16 @@ import { } from "../../../data/trigger"; import type { HassDialog } from "../../../dialogs/make-dialog-manager"; import { KeyboardShortcutMixin } from "../../../mixins/keyboard-shortcut-mixin"; -import { HaFuse } from "../../../resources/fuse"; +import { SubscribeMixin } from "../../../mixins/subscribe-mixin"; import type { HomeAssistant } from "../../../types"; import { isMac } from "../../../util/is_mac"; import { showToast } from "../../../util/toast"; +import "./add-automation-element/ha-automation-add-from-target"; +import type HaAutomationAddFromTarget from "./add-automation-element/ha-automation-add-from-target"; +import "./add-automation-element/ha-automation-add-items"; +import "./add-automation-element/ha-automation-add-search"; import type { AddAutomationElementDialogParams } from "./show-add-automation-element-dialog"; import { PASTE_VALUE } from "./show-add-automation-element-dialog"; -import { CONDITION_ICONS } from "../../../components/ha-condition-icon"; const TYPES = { trigger: { collections: TRIGGER_COLLECTIONS, icons: TRIGGER_ICONS }, @@ -103,7 +137,12 @@ const TYPES = { }, }; -interface ListItem { +export interface AutomationItemComboBoxItem extends PickerComboBoxItem { + renderedIcon?: TemplateResult; + type: "trigger" | "condition" | "action" | "block"; +} + +export interface AddAutomationElementListItem { key: string; name: string; description: string; @@ -111,8 +150,6 @@ interface ListItem { icon?: TemplateResult; } -type DomainManifestLookup = Record; - const ENTITY_DOMAINS_OTHER = new Set([ "date", "datetime", @@ -131,18 +168,24 @@ const DYNAMIC_KEYWORDS = ["dynamicGroups", "helpers", "other"]; @customElement("add-automation-element-dialog") class DialogAddAutomationElement - extends KeyboardShortcutMixin(LitElement) + extends KeyboardShortcutMixin(SubscribeMixin(LitElement)) implements HassDialog { @property({ attribute: false }) public hass!: HomeAssistant; + // #region state + + @state() private _open = true; + @state() private _params?: AddAutomationElementDialogParams; @state() private _selectedCollectionIndex?: number; @state() private _selectedGroup?: string; - @state() private _tab: "groups" | "blocks" = "groups"; + @state() private _selectedTarget?: SingleHassServiceTarget; + + @state() private _tab: "targets" | "groups" | "blocks" = "targets"; @state() private _filter = ""; @@ -150,35 +193,91 @@ class DialogAddAutomationElement @state() private _domains?: Set; - @state() private _open = true; - - @state() private _itemsScrolled = false; - @state() private _bottomSheetMode = false; @state() private _narrow = false; @state() private _triggerDescriptions: TriggerDescriptions = {}; + @state() private _targetItems?: { + title: string; + items: AddAutomationElementListItem[]; + }[]; + + @state() private _loadItemsError = false; + + @state() private _newTriggersAndConditions = false; + @state() private _conditionDescriptions: ConditionDescriptions = {}; - @query(".items ha-md-list ha-md-list-item") - private _itemsListFirstElement?: HaMdList; + @state() + @consume({ context: labelsContext, subscribe: true }) + private _labelRegistry!: LabelRegistryEntry[]; - @query(".items") + // #endregion state + + // #region queries + + @query("ha-automation-add-from-target") + private _targetPickerElement?: HaAutomationAddFromTarget; + + @query("ha-automation-add-items") private _itemsListElement?: HTMLDivElement; - private _fullScreen = false; + @query(".content") + private _contentElement?: HTMLDivElement; - private _removeKeyboardShortcuts?: () => void; + // #endregion queries + + // #region variables private _unsub?: Promise; + private _configEntryLookup: Record = {}; + + // #endregion variables + + // #region lifecycle + + protected willUpdate(changedProps: PropertyValues) { + if ( + this._params?.type === "action" && + changedProps.has("hass") && + changedProps.get("hass")?.states !== this.hass.states + ) { + this._calculateUsedDomains(); + } + } + + public hassSubscribe() { + return [ + subscribeLabFeatures(this.hass!.connection, (features) => { + this._newTriggersAndConditions = + features.find( + (feature) => + feature.domain === "automation" && + feature.preview_feature === "new_triggers_conditions" + )?.enabled ?? false; + this._tab = + this._newTriggersAndConditions && this._params?.type === "trigger" + ? "targets" + : "groups"; + }), + ]; + } + public showDialog(params): void { this._params = params; + this._tab = + this._newTriggersAndConditions && this._params?.type === "trigger" + ? "targets" + : "groups"; + this.addKeyboardShortcuts(); + this._loadConfigEntries(); + this._unsubscribe(); this._fetchManifests(); @@ -206,10 +305,6 @@ class DialogAddAutomationElement }); } - this._fullScreen = matchMedia( - "all and (max-width: 450px), all and (max-height: 500px)" - ).matches; - window.addEventListener("resize", this._updateNarrow); this._updateNarrow(); @@ -224,18 +319,62 @@ class DialogAddAutomationElement fireEvent(this, "dialog-closed", { dialog: this.localName }); } this._open = true; - this._itemsScrolled = false; - this._bottomSheetMode = false; this._params = undefined; - this._selectedGroup = undefined; - this._tab = "groups"; this._selectedCollectionIndex = undefined; + this._selectedGroup = undefined; + this._selectedTarget = undefined; + this._tab = this._newTriggersAndConditions ? "targets" : "groups"; this._filter = ""; this._manifests = undefined; this._domains = undefined; + this._bottomSheetMode = false; + this._narrow = false; + this._targetItems = undefined; + this._loadItemsError = false; return true; } + private _updateNarrow = () => { + this._narrow = + window.matchMedia("(max-width: 870px)").matches || + window.matchMedia("(max-height: 500px)").matches; + }; + + private _calculateUsedDomains() { + const domains = new Set(Object.keys(this.hass.states).map(computeDomain)); + if (!deepEqual(domains, this._domains)) { + this._domains = domains; + } + } + + private async _loadConfigEntries() { + const configEntries = await getConfigEntries(this.hass); + this._configEntryLookup = Object.fromEntries( + configEntries.map((entry) => [entry.entry_id, entry]) + ); + } + + private async _fetchManifests() { + const manifests = {}; + const fetched = await fetchIntegrationManifests(this.hass); + for (const manifest of fetched) { + manifests[manifest.domain] = manifest; + } + this._manifests = manifests; + } + + public disconnectedCallback(): void { + super.disconnectedCallback(); + window.removeEventListener("resize", this._updateNarrow); + this._unsubscribe(); + } + + protected supportedShortcuts(): SupportedShortcuts { + return { + v: () => this._addClipboard(), + }; + } + private _unsubscribe() { if (this._unsub) { this._unsub.then((unsub) => unsub()); @@ -243,6 +382,423 @@ class DialogAddAutomationElement } } + // #endregion lifecycle + + // #region render + + protected render() { + if (!this._params) { + return nothing; + } + + if (this._bottomSheetMode) { + return html` + + ${this._renderContent()} + + `; + } + + return html` + + ${this._renderContent()} + + `; + } + + private _renderContent() { + const automationElementType = this._params!.type; + + const tabButtons = [ + { + label: this.hass.localize( + `ui.panel.config.automation.editor.${automationElementType}s.name` + ), + value: "groups", + }, + ]; + + if (this._newTriggersAndConditions && automationElementType === "trigger") { + tabButtons.unshift({ + label: this.hass.localize(`ui.panel.config.automation.editor.targets`), + value: "targets", + }); + } + + if (this._params?.type !== "trigger") { + tabButtons.push({ + label: this.hass.localize("ui.panel.config.automation.editor.blocks"), + value: "blocks", + }); + } + + const hideCollections = + this._filter || + this._tab === "blocks" || + this._tab === "targets" || + (this._narrow && this._selectedGroup); + + const collections = hideCollections + ? [] + : this._getCollections( + automationElementType, + TYPES[automationElementType].collections, + this._domains, + this.hass.localize, + this.hass.services, + this._triggerDescriptions, + this._conditionDescriptions, + this._manifests + ); + + return html` +
+ ${this._renderHeader()} + ${!this._narrow || (!this._selectedGroup && !this._selectedTarget) + ? html` + + ` + : nothing} + ${!this._filter && + tabButtons.length > 1 && + (!this._narrow || (!this._selectedGroup && !this._selectedTarget)) + ? html`` + : nothing} +
+
+ ${this._filter + ? html` + ` + : this._tab === "targets" + ? html`` + : html` + + ${this._params!.clipboardItem + ? html` +
+
+
+ ${this.hass.localize( + `ui.panel.config.automation.editor.${automationElementType}s.paste` + )} +
+
+ ${this.hass.localize( + // @ts-ignore + `ui.panel.config.automation.editor.${automationElementType}s.type.${this._params.clipboardItem}.label` + )} +
+
+ ${!this._narrow + ? html` + ${isMac + ? html`` + : this.hass.localize( + "ui.panel.config.automation.editor.ctrl" + )} + + + V + ` + : nothing} +
+ +
+ ` + : nothing} + ${collections.map( + (collection, index) => html` + ${collection.titleKey + ? html` + ${this.hass.localize(collection.titleKey)} + ` + : nothing} + ${repeat( + collection.groups, + (item) => item.key, + (item) => html` + +
${item.name}
+ ${item.icon + ? html`${item.icon}` + : item.iconPath + ? html`` + : nothing} + ${this._narrow + ? html`` + : nothing} +
+ ` + )} + ` + )} +
+ `} + ${!this._filter + ? html` + + + ` + : nothing} +
+ `; + } + + private _renderHeader() { + return html` + + ${this._getDialogTitle()} + + ${this._renderDialogSubtitle()} + ${this._narrow && (this._selectedGroup || this._selectedTarget) + ? html`` + : html``} + + `; + } + + private _renderDialogSubtitle() { + if (!this._narrow) { + return nothing; + } + + if (this._selectedGroup) { + return html`${this.hass.localize( + `ui.panel.config.automation.editor.${this._params!.type}s.add` + )}`; + } + + if (this._selectedTarget) { + let subtitle: string | undefined; + const [targetType, targetId] = this._extractTypeAndIdFromTarget( + this._selectedTarget + ); + + if (targetId) { + if (targetType === "area" && this.hass.areas[targetId]?.floor_id) { + const floorId = this.hass.areas[targetId].floor_id; + subtitle = computeFloorName(this.hass.floors[floorId]) || floorId; + } + if (targetType === "device" && this.hass.devices[targetId]?.area_id) { + const areaId = this.hass.devices[targetId].area_id; + subtitle = computeAreaName(this.hass.areas[areaId]) || areaId; + } + if (targetType === "entity" && this.hass.states[targetId]) { + const entity = this.hass.entities[targetId]; + if (entity && !entity.device_id && !entity.area_id) { + const domain = targetId.split(".", 2)[0]; + subtitle = domainToName( + this.hass.localize, + domain, + this._manifests?.[domain] + ); + } else { + const stateObj = this.hass.states[targetId]; + const [entityName, deviceName, areaName] = computeEntityNameList( + stateObj, + [{ type: "entity" }, { type: "device" }, { type: "area" }], + this.hass.entities, + this.hass.devices, + this.hass.areas, + this.hass.floors + ); + + subtitle = [areaName, entityName ? deviceName : undefined] + .filter(Boolean) + .join(computeRTL(this.hass) ? " ◂ " : " ▸ "); + } + } + } + + if (subtitle) { + return html`${subtitle}`; + } + } + + return nothing; + } + + // #endregion render + + // #region data + + private _getItems = () => + !this._filter && this._tab === "blocks" + ? [ + { + title: this.hass.localize( + "ui.panel.config.automation.editor.blocks" + ), + items: this._getBlockItems(this._params!.type, this.hass.localize), + }, + ] + : !this._filter && this._tab === "groups" && this._selectedGroup + ? [ + { + title: this.hass.localize( + `ui.panel.config.automation.editor.${this._params!.type}s.name` + ), + items: this._getGroupItems( + this._params!.type, + this._selectedGroup, + this._selectedCollectionIndex ?? 0, + this._domains, + this.hass.localize, + this.hass.services, + this._manifests + ), + }, + ] + : !this._filter && + this._tab === "targets" && + this._selectedTarget && + this._targetItems + ? this._targetItems + : undefined; + private _getGroups = ( type: AddAutomationElementDialogParams["type"], group?: string, @@ -262,102 +818,13 @@ class DialogAddAutomationElement ); }; - private _convertToItem = ( - key: string, - options, - type: AddAutomationElementDialogParams["type"], - localize: LocalizeFunc - ): ListItem => ({ - key, - name: localize( - // @ts-ignore - `ui.panel.config.automation.editor.${type}s.${ - options.members ? "groups" : "type" - }.${key}.label` - ), - description: localize( - // @ts-ignore - `ui.panel.config.automation.editor.${type}s.${ - options.members ? "groups" : "type" - }.${key}.description${options.members ? "" : ".picker"}` - ), - iconPath: options.icon || TYPES[type].icons[key], - }); - - private _getFilteredItems = memoizeOne( - ( - type: AddAutomationElementDialogParams["type"], - filter: string, - localize: LocalizeFunc, - services: HomeAssistant["services"], - manifests?: DomainManifestLookup - ): ListItem[] => { - const items = this._items(type, localize, services, manifests); - - const index = this._fuseIndex(items); - - const fuse = new HaFuse( - items, - { - ignoreLocation: true, - includeScore: true, - minMatchCharLength: Math.min(2, this._filter.length), - }, - index - ); - - const results = fuse.multiTermsSearch(filter); - if (results) { - return results.map((result) => result.item).filter((item) => item.name); - } - return items; - } - ); - - private _getFilteredBuildingBlocks = memoizeOne( - ( - type: AddAutomationElementDialogParams["type"], - filter: string, - localize: LocalizeFunc - ): ListItem[] => { - const groups = - type === "action" - ? ACTION_BUILDING_BLOCKS_GROUP - : type === "condition" - ? CONDITION_BUILDING_BLOCKS_GROUP - : {}; - - const items = Object.keys(groups).map((key) => - this._convertToItem(key, {}, type, localize) - ); - - const index = this._fuseIndexBlock(items); - - const fuse = new HaFuse( - items, - { - ignoreLocation: true, - includeScore: true, - minMatchCharLength: Math.min(2, this._filter.length), - }, - index - ); - - const results = fuse.multiTermsSearch(filter); - if (results) { - return results.map((result) => result.item).filter((item) => item.name); - } - return items; - } - ); - private _items = memoizeOne( ( type: AddAutomationElementDialogParams["type"], localize: LocalizeFunc, services: HomeAssistant["services"], manifests?: DomainManifestLookup - ): ListItem[] => { + ): AddAutomationElementListItem[] => { const groups = this._getGroups(type); const flattenGroups = (grp: AutomationElementGroup) => @@ -369,9 +836,7 @@ class DialogAddAutomationElement const items = flattenGroups(groups).flat(); if (type === "trigger") { - items.push( - ...this._triggers(localize, this._triggerDescriptions, manifests) - ); + items.push(...this._triggers(localize, this._triggerDescriptions)); } else if (type === "condition") { items.push( ...this._conditions(localize, this._conditionDescriptions, manifests) @@ -379,18 +844,11 @@ class DialogAddAutomationElement } else if (type === "action") { items.push(...this._services(localize, services, manifests)); } - return items; + + return items.filter(({ name }) => name); } ); - private _fuseIndex = memoizeOne((items: ListItem[]) => - Fuse.createIndex(["key", "name", "description"], items) - ); - - private _fuseIndexBlock = memoizeOne((items: ListItem[]) => - Fuse.createIndex(["key", "name", "description"], items) - ); - private _getCollections = memoizeOne( ( type: AddAutomationElementDialogParams["type"], @@ -403,13 +861,13 @@ class DialogAddAutomationElement manifests?: DomainManifestLookup ): { titleKey?: LocalizeKeys; - groups: ListItem[]; + groups: AddAutomationElementListItem[]; }[] => { const generatedCollections: any = []; collections.forEach((collection) => { let collectionGroups = Object.entries(collection.groups); - const groups: ListItem[] = []; + const groups: AddAutomationElementListItem[] = []; if ( type === "trigger" && @@ -510,7 +968,7 @@ class DialogAddAutomationElement ( type: AddAutomationElementDialogParams["type"], localize: LocalizeFunc - ): ListItem[] => { + ): AddAutomationElementListItem[] => { const groups = type === "action" ? ACTION_BUILDING_BLOCKS_GROUP @@ -535,14 +993,9 @@ class DialogAddAutomationElement localize: LocalizeFunc, services: HomeAssistant["services"], manifests?: DomainManifestLookup - ): ListItem[] => { + ): AddAutomationElementListItem[] => { if (type === "trigger" && isDynamic(group)) { - return this._triggers( - localize, - this._triggerDescriptions, - manifests, - group - ); + return this._triggers(localize, this._triggerDescriptions, group); } if (type === "condition" && isDynamic(group)) { return this._conditions( @@ -608,11 +1061,11 @@ class DialogAddAutomationElement manifests: DomainManifestLookup | undefined, domains: Set | undefined, type: "helper" | "other" | undefined - ): ListItem[] => { + ): AddAutomationElementListItem[] => { if (!services || !manifests) { return []; } - const result: ListItem[] = []; + const result: AddAutomationElementListItem[] = []; Object.keys(services).forEach((domain) => { const manifest = manifests[domain]; const domainUsed = !domains ? true : domains.has(domain); @@ -654,11 +1107,11 @@ class DialogAddAutomationElement manifests: DomainManifestLookup | undefined, domains: Set | undefined, type: "helper" | "other" | undefined - ): ListItem[] => { + ): AddAutomationElementListItem[] => { if (!triggers || !manifests) { return []; } - const result: ListItem[] = []; + const result: AddAutomationElementListItem[] = []; const addedDomains = new Set(); Object.keys(triggers).forEach((trigger) => { const domain = getTriggerDomain(trigger); @@ -707,40 +1160,19 @@ class DialogAddAutomationElement ( localize: LocalizeFunc, triggers: TriggerDescriptions, - _manifests: DomainManifestLookup | undefined, group?: string - ): ListItem[] => { + ): AddAutomationElementListItem[] => { if (!triggers) { return []; } - const result: ListItem[] = []; - for (const trigger of Object.keys(triggers)) { - const domain = getTriggerDomain(trigger); - const triggerName = getTriggerObjectId(trigger); - - if (group && group !== `${DYNAMIC_PREFIX}${domain}`) { - continue; - } - - result.push({ - icon: html` - - `, - key: `${DYNAMIC_PREFIX}${trigger}`, - name: - localize(`component.${domain}.triggers.${triggerName}.name`) || - trigger, - description: - localize( - `component.${domain}.triggers.${triggerName}.description` - ) || trigger, - }); - } - return result; + return this._getTriggerListItems( + localize, + Object.keys(triggers).filter((trigger) => { + const domain = getTriggerDomain(trigger); + return !group || group === `${DYNAMIC_PREFIX}${domain}`; + }) + ); } ); @@ -750,11 +1182,11 @@ class DialogAddAutomationElement manifests: DomainManifestLookup | undefined, domains: Set | undefined, type: "helper" | "other" | undefined - ): ListItem[] => { + ): AddAutomationElementListItem[] => { if (!conditions || !manifests) { return []; } - const result: ListItem[] = []; + const result: AddAutomationElementListItem[] = []; const addedDomains = new Set(); Object.keys(conditions).forEach((condition) => { const domain = getConditionDomain(condition); @@ -805,36 +1237,20 @@ class DialogAddAutomationElement conditions: ConditionDescriptions, _manifests: DomainManifestLookup | undefined, group?: string - ): ListItem[] => { + ): AddAutomationElementListItem[] => { if (!conditions) { return []; } - const result: ListItem[] = []; + const result: AddAutomationElementListItem[] = []; for (const condition of Object.keys(conditions)) { const domain = getConditionDomain(condition); - const conditionName = getConditionObjectId(condition); if (group && group !== `${DYNAMIC_PREFIX}${domain}`) { continue; } - result.push({ - icon: html` - - `, - key: `${DYNAMIC_PREFIX}${condition}`, - name: - localize(`component.${domain}.conditions.${conditionName}.name`) || - condition, - description: - localize( - `component.${domain}.conditions.${conditionName}.description` - ) || condition, - }); + result.push(this._getConditionListItem(localize, domain, condition)); } return result; } @@ -846,11 +1262,11 @@ class DialogAddAutomationElement services: HomeAssistant["services"], manifests: DomainManifestLookup | undefined, group?: string - ): ListItem[] => { + ): AddAutomationElementListItem[] => { if (!services) { return []; } - const result: ListItem[] = []; + const result: AddAutomationElementListItem[] = []; let domain: string | undefined; @@ -921,392 +1337,255 @@ class DialogAddAutomationElement } ); - private async _fetchManifests() { - const manifests = {}; - const fetched = await fetchIntegrationManifests(this.hass); - for (const manifest of fetched) { - manifests[manifest.domain] = manifest; + private _getLabel = memoizeOne((labelId) => + this._labelRegistry?.find(({ label_id }) => label_id === labelId) + ); + + // #endregion data + + // #region data memoize + + private _getFloorAreaLookupMemoized = memoizeOne( + (areas: HomeAssistant["areas"]) => getFloorAreaLookup(Object.values(areas)) + ); + + private _getAreaDeviceLookupMemoized = memoizeOne( + (devices: HomeAssistant["devices"]) => + getAreaDeviceLookup(Object.values(devices)) + ); + + private _getAreaEntityLookupMemoized = memoizeOne( + (entities: HomeAssistant["entities"]) => + getAreaEntityLookup(Object.values(entities), true) + ); + + private _getDeviceEntityLookupMemoized = memoizeOne( + (entities: HomeAssistant["entities"]) => + getDeviceEntityLookup(Object.values(entities), true) + ); + + private _extractTypeAndIdFromTarget = memoizeOne( + (target: SingleHassServiceTarget): [string, string | undefined] => { + const [targetTypeId, targetId] = Object.entries(target)[0]; + const targetType = targetTypeId.replace("_id", ""); + return [targetType, targetId]; } - this._manifests = manifests; - } + ); - private _calculateUsedDomains() { - const domains = new Set(Object.keys(this.hass.states).map(computeDomain)); - if (!deepEqual(domains, this._domains)) { - this._domains = domains; - } - } + // #endregion data memoize - protected willUpdate(changedProperties: PropertyValues): void { - if ( - this._params?.type === "action" && - changedProperties.has("hass") && - changedProperties.get("hass")?.states !== this.hass.states - ) { - this._calculateUsedDomains(); - } - } + // #region render prepare - private _renderContent() { - const automationElementType = this._params!.type; + private _convertToItem = ( + key: string, + options, + type: AddAutomationElementDialogParams["type"], + localize: LocalizeFunc + ): AddAutomationElementListItem => ({ + key, + name: localize( + // @ts-ignore + `ui.panel.config.automation.editor.${type}s.${ + options.members ? "groups" : "type" + }.${key}.label` + ), + description: localize( + // @ts-ignore + `ui.panel.config.automation.editor.${type}s.${ + options.members ? "groups" : "type" + }.${key}.description${options.members ? "" : ".picker"}` + ), + iconPath: options.icon || TYPES[type].icons[key], + }); - const items = this._filter - ? this._getFilteredItems( - automationElementType, - this._filter, - this.hass.localize, - this.hass.services, - this._manifests - ) - : this._tab === "blocks" - ? this._getBlockItems(automationElementType, this.hass.localize) - : this._selectedGroup - ? this._getGroupItems( - automationElementType, - this._selectedGroup, - this._selectedCollectionIndex ?? 0, - this._domains, - this.hass.localize, - this.hass.services, - this._manifests - ) - : undefined; + private _getDomainGroupedTriggerListItems( + localize: LocalizeFunc, + triggerIds: string[] + ): { title: string; items: AddAutomationElementListItem[] }[] { + const items: Record< + string, + { title: string; items: AddAutomationElementListItem[] } + > = {}; - const filteredBlockItems = - this._filter && automationElementType !== "trigger" - ? this._getFilteredBuildingBlocks( - automationElementType, - this._filter, - this.hass.localize - ) - : undefined; + triggerIds.forEach((trigger) => { + const domain = getTriggerDomain(trigger); - const collections = this._getCollections( - automationElementType, - TYPES[automationElementType].collections, - this._domains, - this.hass.localize, - this.hass.services, - this._triggerDescriptions, - this._conditionDescriptions, - this._manifests + if (!items[domain]) { + items[domain] = { + title: domainToName(localize, domain, this._manifests?.[domain]), + items: [], + }; + } + + items[domain].items.push( + this._getTriggerListItem(localize, domain, trigger) + ); + + items[domain].items.sort((a, b) => + stringCompare(a.name, b.name, this.hass.locale.language) + ); + }); + + return Object.values(items).sort((a, b) => + stringCompare(a.title, b.title, this.hass.locale.language) ); + } - const groupName = isDynamic(this._selectedGroup) - ? domainToName( - this.hass.localize, - getValueFromDynamic(this._selectedGroup!), - this._manifests?.[getValueFromDynamic(this._selectedGroup!)] - ) - : this.hass.localize( - `ui.panel.config.automation.editor.${this._params!.type}s.groups.${this._selectedGroup}.label` as LocalizeKeys - ) || - this.hass.localize( - `ui.panel.config.automation.editor.${this._params!.type}s.type.${this._selectedGroup}.label` as LocalizeKeys - ); + private _getTriggerListItems( + localize: LocalizeFunc, + triggerIds: string[] + ): AddAutomationElementListItem[] { + return triggerIds + .map((trigger) => { + const domain = getTriggerDomain(trigger); - const typeTitle = this.hass.localize( - `ui.panel.config.automation.editor.${automationElementType}s.add` + return this._getTriggerListItem(localize, domain, trigger); + }) + .sort((a, b) => stringCompare(a.name, b.name, this.hass.locale.language)); + } + + private _getTriggerListItem( + localize: LocalizeFunc, + domain: string, + trigger: string + ): AddAutomationElementListItem { + const triggerName = getTriggerObjectId(trigger); + return { + icon: html` + + `, + key: `${DYNAMIC_PREFIX}${trigger}`, + name: + localize(`component.${domain}.triggers.${triggerName}.name`) || trigger, + description: + localize(`component.${domain}.triggers.${triggerName}.description`) || + trigger, + }; + } + + private _getConditionListItem( + localize: LocalizeFunc, + domain: string, + condition: string + ): AddAutomationElementListItem { + const conditionName = getConditionObjectId(condition); + return { + icon: html` + + `, + key: `${DYNAMIC_PREFIX}${condition}`, + name: + localize(`component.${domain}.conditions.${conditionName}.name`) || + condition, + description: + localize( + `component.${domain}.conditions.${conditionName}.description` + ) || condition, + }; + } + + private _getDomainGroupedActionListItems( + localize: LocalizeFunc, + serviceIds: string[] + ): { title: string; items: AddAutomationElementListItem[] }[] { + const items: Record< + string, + { title: string; items: AddAutomationElementListItem[] } + > = {}; + + serviceIds.forEach((service) => { + const [domain, serviceName] = service.split(".", 2); + if (!items[domain]) { + items[domain] = { + title: domainToName(localize, domain, this._manifests?.[domain]), + items: [], + }; + } + + items[domain].items.push({ + icon: html` + + `, + key: `${DYNAMIC_PREFIX}${domain}.${serviceName}`, + name: `${domain ? "" : `${domainToName(localize, domain)}: `}${ + this.hass.localize( + `component.${domain}.services.${serviceName}.name` + ) || + this.hass.services[domain][serviceName]?.name || + serviceName + }`, + description: + this.hass.localize( + `component.${domain}.services.${serviceName}.description` + ) || + this.hass.services[domain][serviceName]?.description || + "", + }); + + items[domain].items.sort((a, b) => + stringCompare(a.name, b.name, this.hass.locale.language) + ); + }); + + return Object.values(items).sort((a, b) => + stringCompare(a.title, b.title, this.hass.locale.language) ); - - const tabButtons = [ - { - label: this.hass.localize( - `ui.panel.config.automation.editor.${automationElementType}s.name` - ), - value: "groups", - }, - { - label: this.hass.localize(`ui.panel.config.automation.editor.blocks`), - value: "blocks", - }, - ]; - - const hideCollections = - this._filter || - this._tab === "blocks" || - (this._narrow && this._selectedGroup); - - return html` -
- - ${this._narrow && this._selectedGroup - ? groupName - : typeTitle} - - ${this._narrow && this._selectedGroup - ? html`${typeTitle}` - : nothing} - ${this._narrow && this._selectedGroup - ? html`` - : html``} - - ${!this._narrow || !this._selectedGroup - ? html` - - ` - : nothing} - ${this._params?.type !== "trigger" && - !this._filter && - (!this._narrow || !this._selectedGroup) - ? html`` - : nothing} -
-
- - ${this._params!.clipboardItem && !this._filter - ? html` -
-
-
- ${this.hass.localize( - `ui.panel.config.automation.editor.${automationElementType}s.paste` - )} -
-
- ${this.hass.localize( - // @ts-ignore - `ui.panel.config.automation.editor.${automationElementType}s.type.${this._params.clipboardItem}.label` - )} -
-
- ${!this._narrow - ? html` - ${isMac - ? html`` - : this.hass.localize( - "ui.panel.config.automation.editor.ctrl" - )} - + - V - ` - : nothing} -
- -
- ` - : nothing} - ${collections.map( - (collection, index) => html` - ${collection.titleKey - ? html`
- ${this.hass.localize(collection.titleKey)} -
` - : nothing} - ${repeat( - collection.groups, - (item) => item.key, - (item) => html` - -
${item.name}
- ${item.icon - ? html`${item.icon}` - : item.iconPath - ? html`` - : nothing} -
- ` - )} - ` - )} -
-
- ${filteredBlockItems - ? this._renderItemList( - this.hass.localize(`ui.panel.config.automation.editor.blocks`), - filteredBlockItems - ) - : nothing} - ${this._tab === "groups" && !this._selectedGroup && !this._filter - ? this.hass.localize( - `ui.panel.config.automation.editor.${automationElementType}s.select` - ) - : !items?.length && - this._filter && - (!filteredBlockItems || !filteredBlockItems.length) - ? html`${this.hass.localize( - `ui.panel.config.automation.editor.${automationElementType}s.empty_search`, - { - term: html`‘${this._filter}’`, - } - )}` - : this._renderItemList( - this.hass.localize( - `ui.panel.config.automation.editor.${automationElementType}s.name` - ), - items - )} -
-
- `; } - private _renderItemList(title, items?: ListItem[]) { - if (!items || !items.length) { - return nothing; - } + private _getDomainGroupedConditionListItems( + localize: LocalizeFunc, + conditionIds: string[] + ): { title: string; items: AddAutomationElementListItem[] }[] { + const items: Record< + string, + { title: string; items: AddAutomationElementListItem[] } + > = {}; - return html` -
- ${this._tab === "blocks" && !this._filter - ? this.hass.localize("ui.panel.config.automation.editor.blocks") - : title} -
- - ${repeat( - items, - (item) => item.key, - (item) => html` - -
${item.name}
-
${item.description}
- ${item.icon - ? html`${item.icon}` - : item.iconPath - ? html`` - : nothing} - ${item.group - ? html`` - : html``} -
- ` - )} -
- `; + conditionIds.forEach((condition) => { + const domain = getConditionDomain(condition); + if (!items[domain]) { + items[domain] = { + title: domainToName(localize, domain, this._manifests?.[domain]), + items: [], + }; + } + + items[domain].items.push( + this._getConditionListItem(localize, domain, condition) + ); + + items[domain].items.sort((a, b) => + stringCompare(a.name, b.name, this.hass.locale.language) + ); + }); + + return Object.values(items).sort((a, b) => + stringCompare(a.title, b.title, this.hass.locale.language) + ); } - protected render() { - if (!this._params) { - return nothing; - } + // #endregion render prepare - if (this._bottomSheetMode) { - return html` - - ${this._renderContent()} - - `; - } - - return html` - - ${this._renderContent()} - - `; - } - - public disconnectedCallback(): void { - super.disconnectedCallback(); - window.removeEventListener("resize", this._updateNarrow); - this._removeSearchKeybindings(); - this._unsubscribe(); - } - - private _updateNarrow = () => { - this._narrow = - window.matchMedia("(max-width: 870px)").matches || - window.matchMedia("(max-height: 500px)").matches; - }; + // #region interaction private _close() { this._open = false; } private _back() { + if (this._selectedTarget) { + this._targetPickerElement?.navigateBack(); + return; + } this._selectedGroup = undefined; } @@ -1324,12 +1603,86 @@ class DialogAddAutomationElement }); } - private _selected(ev) { - const item = ev.currentTarget; - this._params!.add(item.value); + private _selected(ev: CustomEvent<{ value: string }>) { + let target: HassServiceTarget | undefined; + if ( + this._tab === "targets" && + this._selectedTarget && + Object.values(this._selectedTarget)[0] + ) { + target = this._selectedTarget; + } + this._params!.add(ev.detail.value, target); this.closeDialog(); } + private _handleTargetSelected = ( + ev: CustomEvent<{ value: SingleHassServiceTarget }> + ) => { + this._targetItems = undefined; + this._loadItemsError = false; + this._selectedTarget = ev.detail.value; + + requestAnimationFrame(() => { + if (this._narrow) { + this._contentElement?.scrollTo(0, 0); + } else { + this._itemsListElement?.scrollTo(0, 0); + } + }); + + this._getItemsByTarget(); + }; + + private async _getItemsByTarget() { + if (!this._selectedTarget) { + return; + } + + try { + if (this._params!.type === "trigger") { + const items = await getTriggersForTarget( + this.hass.callWS, + this._selectedTarget + ); + + this._targetItems = this._getDomainGroupedTriggerListItems( + this.hass.localize, + items + ); + return; + } + if (this._params!.type === "condition") { + const items = await getConditionsForTarget( + this.hass.callWS, + this._selectedTarget + ); + + this._targetItems = this._getDomainGroupedConditionListItems( + this.hass.localize, + items + ); + return; + } + + if (this._params!.type === "action") { + const items = await getServicesForTarget( + this.hass.callWS, + this._selectedTarget + ); + + this._targetItems = this._getDomainGroupedActionListItems( + this.hass.localize, + items + ); + } + } catch (err) { + this._loadItemsError = true; + // eslint-disable-next-line no-console + console.error(`Error fetching ${this._params!.type}s for target`, err); + } + } + private _debounceFilterChanged = debounce( (ev) => this._filterChanged(ev), 200 @@ -1357,74 +1710,181 @@ class DialogAddAutomationElement } }; - protected supportedShortcuts(): SupportedShortcuts { - return { - v: () => this._addClipboard(), - }; - } - private _switchTab(ev) { this._tab = ev.detail.value; } - @eventOptions({ passive: true }) - private _onItemsScroll(ev) { - const top = ev.target.scrollTop ?? 0; - this._itemsScrolled = top > 0; - } + private _searchItemSelected( + ev: CustomEvent + ) { + const item = ev.detail; - private _onSearchFocus(ev) { - this._removeKeyboardShortcuts = tinykeys(ev.target, { - ArrowDown: this._focusSearchList, - Enter: this._pickSingleItem, - }); - } - - private _removeSearchKeybindings() { - this._removeKeyboardShortcuts?.(); - } - - private _focusSearchList = (ev) => { - if (!this._filter || !this._itemsListFirstElement) { + if ( + (item as AutomationItemComboBoxItem).type && + !["floor", "area"].includes((item as AutomationItemComboBoxItem).type) + ) { + this._params!.add(item.id); + this.closeDialog(); return; } - ev.preventDefault(); - this._itemsListFirstElement.focus(); - }; + const targetType = getTargetComboBoxItemType(item); + this._filter = ""; + this._selectedTarget = { + [`${targetType}_id`]: item.id.split(TARGET_SEPARATOR, 2)[1], + }; + this._tab = "targets"; + } - private _pickSingleItem = (ev: KeyboardEvent) => { - if (!this._filter) { - return; + // #region interaction + + // #region render helpers + + private _getSelectedTargetLabel = memoizeOne( + (selectedTarget: SingleHassServiceTarget): string | undefined => { + const [targetType, targetId] = + this._extractTypeAndIdFromTarget(selectedTarget); + + if (targetId === undefined && targetType === "floor") { + return this.hass.localize( + "ui.panel.config.automation.editor.other_areas" + ); + } + + if (targetId === undefined && targetType === "area") { + return this.hass.localize( + "ui.panel.config.automation.editor.unassigned_devices" + ); + } + + if (targetId === undefined && targetType === "service") { + return this.hass.localize("ui.panel.config.automation.editor.services"); + } + + if (targetId === undefined && targetType === "device") { + return this.hass.localize( + "ui.panel.config.automation.editor.unassigned_entities" + ); + } + + if (targetId === undefined && targetType === "helper") { + return this.hass.localize("ui.panel.config.automation.editor.helpers"); + } + + if ( + targetId === undefined && + (targetType.startsWith("entity_") || targetType.startsWith("helper_")) + ) { + const domain = targetType.substring(7); + return domainToName( + this.hass.localize, + domain, + this._manifests?.[domain] + ); + } + + if (targetId) { + if (targetType === "floor") { + return computeFloorName(this.hass.floors[targetId]) || targetId; + } + if (targetType === "area") { + return computeAreaName(this.hass.areas[targetId]) || targetId; + } + if (targetType === "device") { + return computeDeviceName(this.hass.devices[targetId]) || targetId; + } + if (targetType === "entity" && this.hass.states[targetId]) { + const stateObj = this.hass.states[targetId]; + const [entityName, deviceName] = computeEntityNameList( + stateObj, + [{ type: "entity" }, { type: "device" }, { type: "area" }], + this.hass.entities, + this.hass.devices, + this.hass.areas, + this.hass.floors + ); + + return entityName || deviceName || targetId; + } + if (targetType === "label") { + const label = this._getLabel(targetId); + return label?.name || targetId; + } + } + + return undefined; } + ); - ev.preventDefault(); - const automationElementType = this._params!.type; - - const items = [ - ...this._getFilteredItems( - automationElementType, - this._filter, - this.hass.localize, - this.hass.services, - this._manifests - ), - ...(automationElementType !== "trigger" - ? this._getFilteredBuildingBlocks( - automationElementType, - this._filter, - this.hass.localize + private _getDialogTitle() { + if (this._narrow && this._selectedGroup) { + return isDynamic(this._selectedGroup) + ? domainToName( + this.hass.localize, + getValueFromDynamic(this._selectedGroup!), + this._manifests?.[getValueFromDynamic(this._selectedGroup!)] ) - : []), - ]; - - if (items.length !== 1) { - return; + : this.hass.localize( + `ui.panel.config.automation.editor.${this._params!.type}s.groups.${this._selectedGroup}.label` as LocalizeKeys + ) || + this.hass.localize( + `ui.panel.config.automation.editor.${this._params!.type}s.type.${this._selectedGroup}.label` as LocalizeKeys + ); } - this._params!.add(items[0].key); - this.closeDialog(); - }; + if (this._narrow && this._selectedTarget) { + const targetTitle = this._getSelectedTargetLabel(this._selectedTarget); + if (targetTitle) { + return targetTitle; + } + } + + return this.hass.localize( + `ui.panel.config.automation.editor.${this._params!.type}s.add` + ); + } + + private _getAddFromTargetHidden = memoizeOne( + (narrow: boolean, target?: SingleHassServiceTarget) => { + if (narrow && target) { + const [targetType, targetId] = this._extractTypeAndIdFromTarget(target); + + if ( + targetId && + ((targetType === "floor" && + !( + this._getFloorAreaLookupMemoized(this.hass.areas)[targetId] + ?.length > 0 + )) || + (targetType === "area" && + !( + this._getAreaDeviceLookupMemoized(this.hass.devices)[targetId] + ?.length > 0 + ) && + !( + this._getAreaEntityLookupMemoized(this.hass.entities)[targetId] + ?.length > 0 + )) || + (targetType === "device" && + !( + this._getDeviceEntityLookupMemoized(this.hass.entities)[ + targetId + ]?.length > 0 + )) || + targetType === "entity" || + targetType === "label") + ) { + return "hidden"; + } + } + + return ""; + } + ); + + // #endregion render helpers + + // #region styles static get styles(): CSSResultGroup { return [ @@ -1440,7 +1900,6 @@ class DialogAddAutomationElement ha-wa-dialog { --dialog-content-padding: var(--ha-space-0); - --ha-dialog-width-md: 888px; --ha-dialog-min-height: min( 648px, calc( @@ -1479,17 +1938,33 @@ class DialogAddAutomationElement display: flex; } + .content.column { + flex-direction: column; + } + ha-md-list { padding: 0; } + ha-automation-add-from-target, .groups { - overflow: auto; - flex: 3; border-radius: var(--ha-border-radius-xl); border: 1px solid var(--ha-color-border-neutral-quiet); margin: var(--ha-space-3); + } + + ha-automation-add-from-target, + .groups { + overflow: auto; + flex: 4; margin-inline-end: var(--ha-space-0); + } + + ha-automation-add-from-target.hidden { + display: none; + } + + .groups { --md-list-item-leading-space: var(--ha-space-3); --md-list-item-trailing-space: var(--md-list-item-leading-space); --md-list-item-bottom-space: var(--ha-space-1); @@ -1497,7 +1972,8 @@ class DialogAddAutomationElement --md-list-item-supporting-text-font: var(--ha-font-family-body); --md-list-item-one-line-container-height: var(--ha-space-10); } - ha-bottom-sheet .groups { + ha-bottom-sheet .groups, + ha-bottom-sheet ha-automation-add-from-target { margin: var(--ha-space-3); } .groups .selected { @@ -1509,27 +1985,28 @@ class DialogAddAutomationElement color: var(--ha-color-on-primary-normal); } - .collection-title { - background-color: var(--ha-color-fill-neutral-quiet-resting); - padding: var(--ha-space-1) var(--ha-space-2); - font-weight: var(--ha-font-weight-bold); - color: var(--secondary-text-color); + ha-section-title { top: 0; position: sticky; - min-height: var(--ha-space-6); - display: flex; - align-items: center; z-index: 1; } - .items { - display: flex; - flex-direction: column; - overflow: auto; - flex: 7; + ha-automation-add-items { + flex: 6; } - ha-wa-dialog .items { + .content.column ha-automation-add-from-target, + .content.column ha-automation-add-items { + flex: none; + } + .content.column ha-automation-add-items { + min-height: 160px; + } + .content.column ha-automation-add-from-target { + overflow: hidden; + } + + ha-wa-dialog ha-automation-add-items { margin-top: var(--ha-space-3); } @@ -1537,70 +2014,15 @@ class DialogAddAutomationElement padding-bottom: max(var(--safe-area-inset-bottom), var(--ha-space-4)); } - .items.hidden, + ha-automation-add-items.hidden, .groups.hidden { display: none; } - .items.blank, - .items.empty-search { - border-radius: var(--ha-border-radius-xl); - background-color: var(--ha-color-surface-default); - align-items: center; - color: var(--ha-color-text-secondary); - padding: var(--ha-space-0); - margin: var(--ha-space-3) var(--ha-space-4) - max(var(--safe-area-inset-bottom), var(--ha-space-3)); - } - .items ha-md-list { - --md-list-item-two-line-container-height: var(--ha-space-12); - --md-list-item-leading-space: var(--ha-space-3); - --md-list-item-trailing-space: var(--md-list-item-leading-space); - --md-list-item-bottom-space: var(--ha-space-2); - --md-list-item-top-space: var(--md-list-item-bottom-space); - --md-list-item-supporting-text-font: var(--ha-font-family-body); - gap: var(--ha-space-2); - padding: var(--ha-space-0) var(--ha-space-4); - } - .items ha-md-list ha-md-list-item { - border-radius: var(--ha-border-radius-lg); - border: 1px solid var(--ha-color-border-neutral-quiet); - } - - .items ha-md-list, .groups { padding-bottom: max(var(--safe-area-inset-bottom), var(--ha-space-3)); } - .items.blank { - justify-content: center; - } - .items.empty-search { - padding-top: var(--ha-space-6); - justify-content: start; - } - - .items-title { - position: sticky; - display: flex; - align-items: center; - font-weight: var(--ha-font-weight-medium); - padding-top: var(--ha-space-2); - padding-bottom: var(--ha-space-2); - padding-inline-start: var(--ha-space-8); - padding-inline-end: var(--ha-space-3); - top: 0; - z-index: 1; - background-color: var(--card-background-color); - } - ha-bottom-sheet .items-title { - padding-top: var(--ha-space-3); - } - .items-title.scrolled:first-of-type { - box-shadow: var(--bar-box-shadow); - border-bottom: 1px solid var(--ha-color-border-neutral-quiet); - } - ha-icon-next { width: var(--ha-space-6); } @@ -1633,9 +2055,27 @@ class DialogAddAutomationElement font-family: var(--ha-font-family-code); color: var(--ha-color-text-secondary); } + + .section-title-wrapper { + height: 0; + position: relative; + } + + .section-title-wrapper ha-section-title { + position: absolute; + top: 0; + width: calc(100% - var(--ha-space-4)); + z-index: 1; + } + + ha-automation-add-search { + flex: 1; + } `, ]; } + + // #endregion styles } declare global { diff --git a/src/panels/config/automation/add-automation-element/ha-automation-add-from-target.ts b/src/panels/config/automation/add-automation-element/ha-automation-add-from-target.ts new file mode 100644 index 0000000000..9b4f00b89b --- /dev/null +++ b/src/panels/config/automation/add-automation-element/ha-automation-add-from-target.ts @@ -0,0 +1,1612 @@ +import "@home-assistant/webawesome/dist/components/tree-item/tree-item"; +import "@home-assistant/webawesome/dist/components/tree/tree"; +import type { WaSelectionChangeEvent } from "@home-assistant/webawesome/dist/events/selection-change"; +import { consume } from "@lit/context"; +import { mdiTextureBox } from "@mdi/js"; +import type { HassEntity } from "home-assistant-js-websocket"; +import { + css, + html, + LitElement, + nothing, + type PropertyValues, + type TemplateResult, +} from "lit"; +import { customElement, property, state } from "lit/decorators"; +import { ifDefined } from "lit/directives/if-defined"; +import memoizeOne from "memoize-one"; +import { fireEvent } from "../../../../common/dom/fire_event"; +import { computeAreaName } from "../../../../common/entity/compute_area_name"; +import { computeDeviceName } from "../../../../common/entity/compute_device_name"; +import { computeEntityNameList } from "../../../../common/entity/compute_entity_name_display"; +import { stringCompare } from "../../../../common/string/compare"; +import "../../../../components/entity/state-badge"; +import "../../../../components/ha-floor-icon"; +import "../../../../components/ha-icon"; +import "../../../../components/ha-icon-next"; +import "../../../../components/ha-md-list"; +import "../../../../components/ha-md-list-item"; +import "../../../../components/ha-section-title"; +import "../../../../components/ha-svg-icon"; +import { + getAreasNestedInFloors, + type AreaFloorValue, + type FloorComboBoxItem, + type FloorNestedComboBoxItem, + type UnassignedAreasFloorComboBoxItem, +} from "../../../../data/area_floor"; +import { + getAreaDeviceLookup, + getAreaEntityLookup, +} from "../../../../data/area_registry"; +import { + getConfigEntries, + type ConfigEntry, +} from "../../../../data/config_entries"; +import { + areasContext, + devicesContext, + entitiesContext, + floorsContext, + labelsContext, + localizeContext, + statesContext, +} from "../../../../data/context"; +import { getDeviceEntityLookup } from "../../../../data/device_registry"; +import { + domainToName, + type DomainManifestLookup, +} from "../../../../data/integration"; +import { + getLabels, + type LabelRegistryEntry, +} from "../../../../data/label_registry"; +import { + TARGET_SEPARATOR, + type SingleHassServiceTarget, +} from "../../../../data/target"; +import type { HomeAssistant } from "../../../../types"; +import { brandsUrl } from "../../../../util/brands-url"; + +interface Level1Entries { + open: boolean; + areas?: Record; + devices?: Record; +} + +interface Level2Entries { + open: boolean; + devices: Record; + entities: string[]; +} + +interface Level3Entries { + open: boolean; + entities: string[]; +} + +@customElement("ha-automation-add-from-target") +export default class HaAutomationAddFromTarget extends LitElement { + // #region properties + @property({ attribute: false }) public hass!: HomeAssistant; + + @property({ attribute: false }) + public value?: SingleHassServiceTarget; + + @property({ type: Boolean }) public narrow = false; + + @property({ attribute: false }) public manifests?: DomainManifestLookup; + + // #endregion properties + + // #region context + @state() + @consume({ context: localizeContext, subscribe: true }) + private localize!: HomeAssistant["localize"]; + + @state() + @consume({ context: statesContext, subscribe: true }) + private states!: HomeAssistant["states"]; + + @state() + @consume({ context: floorsContext, subscribe: true }) + private floors!: HomeAssistant["floors"]; + + @state() + @consume({ context: areasContext, subscribe: true }) + private areas!: HomeAssistant["areas"]; + + @state() + @consume({ context: devicesContext, subscribe: true }) + private devices!: HomeAssistant["devices"]; + + @state() + @consume({ context: entitiesContext, subscribe: true }) + private entities!: HomeAssistant["entities"]; + + @state() + @consume({ context: labelsContext, subscribe: true }) + private _labelRegistry!: LabelRegistryEntry[]; + // #endregion context + + // #region state and variables + + @state() + private _floorAreas: ( + | FloorNestedComboBoxItem + | UnassignedAreasFloorComboBoxItem + )[] = []; + + @state() private _entries: Record = {}; + + @state() private _showShowMoreButton?: boolean; + + @state() private _fullHeight = false; + + private _configEntryLookup: Record = {}; + + // #endregion state and variables + + // #region lifecycle + + public willUpdate(changedProps: PropertyValues) { + super.willUpdate(changedProps); + + if (!this.hasUpdated) { + this._initialDataLoad(); + } + + if (changedProps.has("value") || changedProps.has("narrow")) { + this._fullHeight = + !this.narrow || !this.value || !Object.values(this.value)[0]; + this.style.setProperty("--max-height", this._fullHeight ? "none" : "50%"); + } + } + + protected updated(changedProps: PropertyValues) { + if ( + changedProps.has("value") || + changedProps.has("narrow") || + this._showShowMoreButton === undefined + ) { + this._setShowTargetShowMoreButton(); + } + } + + private async _initialDataLoad() { + await this._loadConfigEntries(); + this._getTreeData(); + } + + private async _setShowTargetShowMoreButton() { + await this.updateComplete; + this._showShowMoreButton = + this.narrow && + this.value && + !!Object.values(this.value)[0] && + this.scrollHeight > this.clientHeight; + } + + // #endregion lifecycle + + // #region render + protected render() { + if (!this.manifests || !this._configEntryLookup) { + return nothing; + } + + return html` + ${this.narrow && this.value + ? this._renderNarrow(this._entries, this.value) + : html` + ${this._renderFloors(this.narrow, this._entries, this.value)} + ${this._renderUnassigned(this.narrow, this._entries, this.value)} + ${this._renderLabels(this.narrow, this.value)} + `} + ${this.narrow && this._showShowMoreButton && !this._fullHeight + ? html` +
+ + ${this.localize("ui.panel.config.automation.editor.show_more")} + +
+ ` + : nothing} + `; + } + + private _renderNarrow = memoizeOne( + ( + entries: Record, + value: SingleHassServiceTarget + ) => { + const [valueTypeId, valueId] = Object.entries(value)[0]; + const valueType = valueTypeId.replace("_id", ""); + + if (!valueType || valueType === "label") { + return nothing; + } + + // floor areas, unassigned areas + if (valueType === "floor") { + return this._renderAreas( + entries[`floor${TARGET_SEPARATOR}${valueId ?? ""}`].areas! + ); + } + + if (valueType === "area" && valueId) { + const floor = + entries[ + `floor${TARGET_SEPARATOR}${this.areas[valueId]?.floor_id || ""}` + ]; + const { devices, entities } = + floor.areas![`area${TARGET_SEPARATOR}${valueId}`]; + const numberOfDevices = Object.keys(devices).length; + + return html` + ${numberOfDevices ? this._renderDevices(devices) : nothing} + ${entities.length ? this._renderEntities(entities) : nothing} + `; + } + + if ((!valueId && valueType === "area") || valueType === "service") { + const floor = entries[`${valueType}${TARGET_SEPARATOR}`]; + const devices = floor.devices!; + + return this._renderDevices(devices); + } + + if (valueId && valueType === "device") { + const areaId = this.devices[valueId]?.area_id; + if (areaId) { + const floorId = this.areas[areaId]?.floor_id || ""; + const { entities } = + entries[`floor${TARGET_SEPARATOR}${floorId}`].areas![ + `area${TARGET_SEPARATOR}${areaId}` + ].devices![valueId]; + + return entities.length ? this._renderEntities(entities) : nothing; + } + + const device = this.devices[valueId]; + const isService = device.entry_type === "service"; + const { entities } = + entries[`${isService ? "service" : "area"}${TARGET_SEPARATOR}`] + .devices![valueId]; + return entities.length ? this._renderEntities(entities) : nothing; + } + + if (valueType === "device" || valueType === "helper") { + const { devices } = entries[`${valueType}${TARGET_SEPARATOR}`]; + return this._renderDomains( + devices!, + valueType === "device" ? "entity_" : "helper_" + ); + } + + if ( + !valueId && + (valueType.startsWith("entity") || valueType.startsWith("helper")) + ) { + const { entities } = + entries[ + `${valueType.startsWith("entity") ? "device" : "helper"}${TARGET_SEPARATOR}` + ].devices![`${valueType}${TARGET_SEPARATOR}`]; + return this._renderEntities(entities); + } + + return nothing; + } + ); + + private _renderFloors = memoizeOne( + ( + narrow: boolean, + entries: Record, + value?: SingleHassServiceTarget + ) => { + const emptyFloors = + !this._floorAreas.length || + (!this._floorAreas[0].id && !this._floorAreas[0].areas.length); + + const floorAreas = emptyFloors + ? undefined + : this._floorAreas.map((floor, index) => + index === 0 && !floor.id + ? this._renderAreas( + entries[floor.id || `floor${TARGET_SEPARATOR}`].areas! + ) + : this._renderItem( + !floor.id + ? this.localize( + "ui.panel.config.automation.editor.other_areas" + ) + : floor.primary, + floor.id || `floor${TARGET_SEPARATOR}`, + !floor.id, + !!floor.id && this._getSelectedTargetId(value) === floor.id, + !entries[floor.id || `floor${TARGET_SEPARATOR}`].open && + !!Object.keys( + entries[floor.id || `floor${TARGET_SEPARATOR}`].areas! + ).length, + entries[floor.id || `floor${TARGET_SEPARATOR}`].open, + this._renderFloorIcon(floor as FloorNestedComboBoxItem), + entries[floor.id || `floor${TARGET_SEPARATOR}`].open + ? this._renderAreas( + entries[floor.id || `floor${TARGET_SEPARATOR}`].areas! + ) + : undefined + ) + ); + return html`${this.localize( + "ui.panel.config.automation.editor.home" + )} + ${emptyFloors + ? html` + +
+ ${this.localize("ui.components.area-picker.no_areas")} +
+
+
` + : html`${narrow + ? html`${floorAreas}` + : html`${floorAreas}`}`}`; + } + ); + + private _renderLabels = memoizeOne( + (narrow: boolean, value?: SingleHassServiceTarget) => { + const labels = this._getLabelsMemoized( + this.states, + this.areas, + this.devices, + this.entities, + this._labelRegistry, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + `label${TARGET_SEPARATOR}` + ); + + if (!labels.length) { + return nothing; + } + + return html`${this.localize( + "ui.components.label-picker.labels" + )} + + ${labels.map( + (label) => + html`${label.icon + ? html`` + : label.icon_path + ? html`` + : nothing} +
${label.primary}
+ ${narrow + ? html` ` + : nothing} +
` + )} +
`; + } + ); + + private _renderUnassigned = memoizeOne( + ( + narrow: boolean, + entries: Record, + _value?: SingleHassServiceTarget + ) => { + const unassignedDevicesLength = Object.keys( + entries[`area${TARGET_SEPARATOR}`]?.devices || {} + ).length; + const unassignedServicesLength = Object.keys( + entries[`service${TARGET_SEPARATOR}`]?.devices || {} + ).length; + const unassignedEntitiesLength = Object.keys( + entries[`device${TARGET_SEPARATOR}`]?.devices || {} + ).length; + const unassignedHelpersLength = Object.keys( + entries[`helper${TARGET_SEPARATOR}`]?.devices || {} + ).length; + + if ( + !unassignedDevicesLength && + !unassignedServicesLength && + !unassignedEntitiesLength && + !unassignedHelpersLength + ) { + return nothing; + } + + const items: TemplateResult[] = []; + + if (unassignedEntitiesLength) { + const open = entries[`device${TARGET_SEPARATOR}`].open; + items.push( + this._renderItem( + this.localize("ui.components.target-picker.type.entities"), + `device${TARGET_SEPARATOR}`, + true, + false, + !open, + open, + undefined, + entries[`device${TARGET_SEPARATOR}`].open + ? this._renderDomains( + entries[`device${TARGET_SEPARATOR}`].devices!, + "entity_" + ) + : undefined + ) + ); + } + + if (unassignedHelpersLength) { + const open = entries[`helper${TARGET_SEPARATOR}`].open; + items.push( + this._renderItem( + this.localize("ui.panel.config.automation.editor.helpers"), + `helper${TARGET_SEPARATOR}`, + true, + false, + !open, + open, + undefined, + entries[`helper${TARGET_SEPARATOR}`].open + ? this._renderDomains( + entries[`helper${TARGET_SEPARATOR}`].devices!, + "helper_" + ) + : undefined + ) + ); + } + + if (unassignedDevicesLength) { + const open = entries[`area${TARGET_SEPARATOR}`].open; + items.push( + this._renderItem( + this.localize("ui.components.target-picker.type.devices"), + `area${TARGET_SEPARATOR}`, + true, + false, + !open, + open, + undefined, + entries[`area${TARGET_SEPARATOR}`].open + ? this._renderDevices(entries[`area${TARGET_SEPARATOR}`].devices!) + : undefined + ) + ); + } + + if (unassignedServicesLength) { + const open = entries[`service${TARGET_SEPARATOR}`].open; + items.push( + this._renderItem( + this.localize("ui.panel.config.automation.editor.services"), + `service${TARGET_SEPARATOR}`, + true, + false, + !open, + open, + undefined, + entries[`service${TARGET_SEPARATOR}`].open + ? this._renderDevices( + entries[`service${TARGET_SEPARATOR}`].devices! + ) + : undefined + ) + ); + } + + return html`${this.localize( + "ui.panel.config.automation.editor.unassigned" + )}${narrow + ? html`${items}` + : html` + ${items} + `} `; + } + ); + + private _renderAreas(areas: Record) { + const renderedAreas = Object.keys(areas) + .filter((areaTargetId) => { + const [, areaId] = areaTargetId.split(TARGET_SEPARATOR, 2); + return this.areas[areaId]; + }) + .map((areaTargetId) => { + const [, areaId] = areaTargetId.split(TARGET_SEPARATOR, 2); + const area = this.areas[areaId]; + return [ + areaTargetId, + computeAreaName(area) || area.area_id, + area.floor_id || undefined, + area.icon, + ] as [string, string, string | undefined, string | undefined]; + }) + .sort(([, nameA], [, nameB]) => + stringCompare(nameA, nameB, this.hass.locale.language) + ) + .map(([areaTargetId, areaName, floorId, areaIcon]) => { + const { open, devices, entities } = + this._entries[`floor${TARGET_SEPARATOR}${floorId || ""}`].areas![ + areaTargetId + ]; + const numberOfDevices = Object.keys(devices).length; + const numberOfItems = numberOfDevices + entities.length; + + return this._renderItem( + areaName, + areaTargetId, + false, + this._getSelectedTargetId(this.value) === areaTargetId, + !open && !!numberOfItems, + open, + this._renderAreaIcon(areaIcon), + open + ? html` + ${numberOfDevices ? this._renderDevices(devices) : nothing} + ${entities.length ? this._renderEntities(entities) : nothing} + ` + : undefined + ); + }); + + if (this.narrow) { + return html`${this.localize( + "ui.components.target-picker.type.areas" + )} + ${renderedAreas}`; + } + + return renderedAreas; + } + + private _renderDevices(devices: Record) { + const renderedDevices = Object.keys(devices) + .filter((deviceId) => this.devices[deviceId]) + .map((deviceId) => { + const device = this.devices[deviceId]; + const configEntry = device.primary_config_entry + ? this._configEntryLookup?.[device.primary_config_entry] + : undefined; + const domain = configEntry?.domain; + + const deviceName = computeDeviceName(device) || deviceId; + + return [deviceId, deviceName, domain] as [ + string, + string | undefined, + string | undefined, + ]; + }) + .sort(([, deviceNameA = "zzz"], [, deviceNameB = "zzz"]) => + stringCompare(deviceNameA, deviceNameB, this.hass.locale.language) + ) + .map(([deviceId, deviceName, domain]) => { + const { open, entities } = devices[deviceId]; + + return this._renderItem( + deviceName || deviceId, + `device${TARGET_SEPARATOR}${deviceId}`, + false, + this._getSelectedTargetId(this.value) === + `device${TARGET_SEPARATOR}${deviceId}`, + !open && !!entities.length, + open, + domain ? this._renderDomainIcon(domain) : undefined, + open ? this._renderEntities(entities) : undefined + ); + }); + + if (this.narrow) { + return html`${this.localize( + "ui.components.target-picker.type.devices" + )} + ${renderedDevices}`; + } + + return renderedDevices; + } + + private _renderDomains( + domains: Record, + prefix: "helper_" | "entity_" + ) { + const renderedDomains = Object.keys(domains) + .map((domainTargetId) => { + const domain = domainTargetId.substring( + prefix.length, + domainTargetId.length - TARGET_SEPARATOR.length + ); + const label = domainToName( + this.localize, + domain, + this.manifests![domain] + ); + + return [domainTargetId, label, domain] as [string, string, string]; + }) + .sort(([, labelA = "zzz"], [, labelB = "zzz"]) => + stringCompare(labelA, labelB, this.hass.locale.language) + ) + .map(([domainTargetId, label, domain]) => { + const { open, entities } = domains[domainTargetId]; + return this._renderItem( + label, + domainTargetId, + true, + false, + !open && !!entities.length, + open, + this._renderDomainIcon(domain), + open ? this._renderEntities(entities) : undefined + ); + }); + + if (this.narrow) { + return html`${this.localize( + "ui.components.target-picker.type.devices" + )} + ${renderedDomains} `; + } + + return renderedDomains; + } + + private _renderEntities(entities: string[] = []) { + if (!entities.length) { + return nothing; + } + + const renderedEntites = entities + .filter((entityId) => this.states[entityId]) + .map((entityId) => { + const stateObj = this.states[entityId]; + + const [entityName, deviceName] = computeEntityNameList( + stateObj, + [{ type: "entity" }, { type: "device" }, { type: "area" }], + this.entities, + this.devices, + this.areas, + this.floors + ); + + const label = entityName || deviceName || entityId; + + return [entityId, label, stateObj] as [string, string, HassEntity]; + }) + .sort(([, labelA], [, labelB]) => + stringCompare(labelA, labelB, this.hass.locale.language) + ) + .map(([entityId, label, stateObj]) => + this._renderItem( + label, + `entity${TARGET_SEPARATOR}${entityId}`, + false, + this._getSelectedTargetId(this.value) === + `entity${TARGET_SEPARATOR}${entityId}`, + false, + false, + this._renderEntityIcon(stateObj) + ) + ); + + if (this.narrow) { + return html`${this.localize( + "ui.components.target-picker.type.entities" + )} + ${renderedEntites}`; + } + + return renderedEntites; + } + + private _renderFloorIcon = + (floor: FloorNestedComboBoxItem) => (slot: string | undefined) => { + if (floor.id && floor.floor) { + return html``; + } + return html``; + }; + + private _renderAreaIcon = + (areaIcon?: string) => (slot: string | undefined) => + areaIcon + ? html`` + : html``; + + private _renderDomainIcon = + (domain: string) => (slot: string | undefined) => html` + + `; + + private _renderEntityIcon = + (stateObj: HassEntity) => (slot: string | undefined) => + html``; + + private _renderItem( + label: string, + target: string, + preventSelection = false, + selected = false, + lazy = false, + open = false, + icon?: (slot?: string) => TemplateResult, + children?: TemplateResult | TemplateResult[] | typeof nothing + ) { + if (this.narrow) { + return html` + ${icon?.("start")} +
${label}
+ +
`; + } + + return html` + + ${icon?.()} ${label} ${children || nothing} + + `; + } + + // #endregion render + + // #region memoized data helpers + + private _getAreaDeviceLookupMemoized = memoizeOne( + (devices: HomeAssistant["devices"]) => + getAreaDeviceLookup(Object.values(devices)) + ); + + private _getAreaEntityLookupMemoized = memoizeOne( + (entities: HomeAssistant["entities"]) => + getAreaEntityLookup(Object.values(entities), true) + ); + + private _getDeviceEntityLookupMemoized = memoizeOne( + (entities: HomeAssistant["entities"]) => + getDeviceEntityLookup(Object.values(entities), true) + ); + + private _getSelectedTargetId = memoizeOne( + (value: SingleHassServiceTarget | undefined) => + value && Object.keys(value).length + ? `${Object.keys(value)[0].replace("_id", "")}${TARGET_SEPARATOR}${Object.values(value)[0]}` + : undefined + ); + + private _getLabelsMemoized = memoizeOne(getLabels); + + private _formatId = memoizeOne((value: AreaFloorValue): string => + [value.type, value.id].join(TARGET_SEPARATOR) + ); + + // #endregion memoized data helpers + + // #region data + + private _getTreeData() { + this._floorAreas = getAreasNestedInFloors( + this.states, + this.floors, + this.areas, + this.devices, + this.entities, + this._formatId, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined + ); + + this._floorAreas.forEach((floor) => { + this._entries[floor.id || `floor${TARGET_SEPARATOR}`] = { + open: false, + areas: {}, + }; + + floor.areas.forEach((area) => { + this._entries[floor.id || `floor${TARGET_SEPARATOR}`].areas![area.id] = + this._loadArea(area); + }); + }); + + this._loadUnassignedDevices(); + this._loadUnassignedEntities(); + this._entries = { ...this._entries }; + + if (this.value) { + this._valueChanged(this._getSelectedTargetId(this.value)!, !this.narrow); + } + } + + private _loadUnassignedDevices() { + const unassignedDevices = Object.values(this.devices).filter( + (device) => !device.area_id + ); + + const devices: Record = {}; + + const services: Record = {}; + + unassignedDevices.forEach(({ id: deviceId, entry_type }) => { + const deviceEntry = { + open: false, + entities: + this._getDeviceEntityLookupMemoized(this.entities)[deviceId]?.map( + (entity) => entity.entity_id + ) || [], + }; + if (entry_type === "service") { + services[deviceId] = deviceEntry; + return; + } + + devices[deviceId] = deviceEntry; + }); + + if (Object.keys(devices).length) { + this._entries = { + ...this._entries, + [`area${TARGET_SEPARATOR}`]: { + open: false, + devices, + }, + }; + } + + if (Object.keys(services).length) { + this._entries = { + ...this._entries, + [`service${TARGET_SEPARATOR}`]: { + open: false, + devices: services, + }, + }; + } + } + + private _loadUnassignedEntities() { + Object.values(this.entities) + .filter((entity) => !entity.area_id && !entity.device_id) + .forEach(({ entity_id }) => { + const domain = entity_id.split(".", 2)[0]; + const manifest = this.manifests ? this.manifests[domain] : undefined; + if (manifest?.integration_type === "helper") { + if (!this._entries[`helper${TARGET_SEPARATOR}`]) { + this._entries[`helper${TARGET_SEPARATOR}`] = { + open: false, + devices: {}, + }; + } + if ( + !this._entries[`helper${TARGET_SEPARATOR}`].devices![ + `helper_${domain}${TARGET_SEPARATOR}` + ] + ) { + this._entries[`helper${TARGET_SEPARATOR}`].devices![ + `helper_${domain}${TARGET_SEPARATOR}` + ] = { + open: false, + entities: [], + }; + } + this._entries[`helper${TARGET_SEPARATOR}`].devices![ + `helper_${domain}${TARGET_SEPARATOR}` + ].entities.push(entity_id); + return; + } + + if (!this._entries[`device${TARGET_SEPARATOR}`]) { + this._entries[`device${TARGET_SEPARATOR}`] = { + open: false, + devices: {}, + }; + } + if ( + !this._entries[`device${TARGET_SEPARATOR}`].devices![ + `entity_${domain}${TARGET_SEPARATOR}` + ] + ) { + this._entries[`device${TARGET_SEPARATOR}`].devices![ + `entity_${domain}${TARGET_SEPARATOR}` + ] = { + open: false, + entities: [], + }; + } + this._entries[`device${TARGET_SEPARATOR}`].devices![ + `entity_${domain}${TARGET_SEPARATOR}` + ].entities.push(entity_id); + }); + } + + private _loadArea(area: FloorComboBoxItem) { + const [, id] = area.id.split(TARGET_SEPARATOR, 2); + const referenced_devices = + this._getAreaDeviceLookupMemoized(this.devices)[id] || []; + const referenced_entities = + this._getAreaEntityLookupMemoized(this.entities)[id] || []; + + const devices: Record = {}; + + referenced_devices.forEach(({ id: deviceId }) => { + devices[deviceId] = { + open: false, + entities: + this._getDeviceEntityLookupMemoized(this.entities)[deviceId]?.map( + (entity) => entity.entity_id + ) || [], + }; + }); + + const entities: string[] = []; + + referenced_entities.forEach((entity) => { + if (!entity.device_id || !devices[entity.device_id]) { + entities.push(entity.entity_id); + } + }); + + return { + open: false, + devices, + entities, + }; + } + + private _expandTreeToItem(type: string, id: string) { + if (type === "floor" || type === "label") { + return; + } + + if (type === "entity") { + const deviceId = this.entities[id]?.device_id; + const device = deviceId ? this.devices[deviceId] : undefined; + const deviceAreaId = (deviceId && device?.area_id) || undefined; + + if (!deviceAreaId) { + let floor: string; + let area: string; + const entity = this.entities[id]; + + if (!deviceId && entity.area_id) { + floor = `floor${TARGET_SEPARATOR}${this.areas[entity.area_id]?.floor_id || ""}`; + area = `area${TARGET_SEPARATOR}${entity.area_id}`; + } else if (!deviceId) { + const domain = id.split(".", 1)[0]; + const isHelper = + this.manifests![domain]?.integration_type === "helper"; + + floor = isHelper + ? `helper${TARGET_SEPARATOR}` + : `device${TARGET_SEPARATOR}`; + area = `${isHelper ? "helper_" : "entity_"}${domain}${TARGET_SEPARATOR}`; + } else { + floor = `${device!.entry_type === "service" ? "service" : "area"}${TARGET_SEPARATOR}`; + area = deviceId; + } + this._entries = { + ...this._entries, + [floor]: { + ...this._entries[floor], + open: true, + devices: { + ...this._entries[floor].devices!, + [area]: { + ...this._entries[floor].devices![area], + open: true, + }, + }, + }, + }; + return; + } + + const floor = `floor${TARGET_SEPARATOR}${this.areas[deviceAreaId]?.floor_id || ""}`; + const area = `area${TARGET_SEPARATOR}${deviceAreaId}`; + + this._entries = { + ...this._entries, + [floor]: { + ...this._entries[floor], + open: true, + areas: { + ...this._entries[floor].areas!, + [area]: { + ...this._entries[floor].areas![area], + open: true, + devices: { + ...this._entries[floor].areas![area].devices, + [deviceId!]: { + ...this._entries[floor].areas![area].devices![deviceId!], + open: true, + }, + }, + }, + }, + }, + }; + return; + } + + if (type === "device") { + const deviceAreaId = this.devices[id]?.area_id; + + if (!deviceAreaId) { + const device = this.devices[id]; + const floor = `${device.entry_type === "service" ? "service" : "area"}${TARGET_SEPARATOR}`; + this._entries = { + ...this._entries, + [floor]: { + ...this._entries[floor], + open: true, + }, + }; + return; + } + + const floor = `floor${TARGET_SEPARATOR}${this.areas[deviceAreaId]?.floor_id || ""}`; + const area = `area${TARGET_SEPARATOR}${deviceAreaId}`; + + this._entries = { + ...this._entries, + [floor]: { + ...this._entries[floor], + open: true, + areas: { + ...this._entries[floor].areas!, + [area]: { + ...this._entries[floor].areas![area], + open: true, + }, + }, + }, + }; + return; + } + + if (type === "area") { + const floor = `floor${TARGET_SEPARATOR}${this.areas[id]?.floor_id || ""}`; + this._entries = { + ...this._entries, + [floor]: { + ...this._entries[floor], + open: true, + }, + }; + } + } + + // #endregion data + + // #region interactions + + private _handleSelectionChange(ev: WaSelectionChangeEvent) { + const treeItem = ev.detail.selection[0] as unknown as + | { target?: string } + | undefined; + + if (treeItem?.target) { + this._valueChanged(treeItem.target); + } + } + + private _selectItem(ev: CustomEvent) { + const target = (ev.currentTarget as any).target; + + if (target) { + this._valueChanged(target); + } + } + + private async _valueChanged(itemId: string, expand = false) { + const [type, id] = itemId.split(TARGET_SEPARATOR, 2); + + fireEvent(this, "value-changed", { + value: { [`${type}_id`]: id || undefined }, + }); + + if (expand && id) { + this._expandTreeToItem(type, id); + await this.updateComplete; + if (type === "label") { + this.shadowRoot!.querySelector( + "ha-md-list-item.selected" + )?.scrollIntoView({ + block: "center", + }); + } else { + this.shadowRoot!.querySelector( + "wa-tree-item[selected]" + )?.scrollIntoView({ + block: "center", + }); + } + } + } + + private _toggleItem(targetId: string, open: boolean) { + const [type, id] = targetId.split(TARGET_SEPARATOR, 2); + + if (type === "floor") { + this._entries = { + ...this._entries, + [targetId]: { + ...this._entries[targetId], + open, + }, + }; + return; + } + + if (type === "area" && id) { + const floorId = `floor${TARGET_SEPARATOR}${this.areas[id]?.floor_id || ""}`; + + this._entries = { + ...this._entries, + [floorId]: { + ...this._entries[floorId], + areas: { + ...this._entries[floorId].areas, + [targetId]: { + ...this._entries[floorId].areas![targetId], + open, + }, + }, + }, + }; + return; + } + + if (type === "area") { + this._entries = { + ...this._entries, + [targetId]: { + ...this._entries[targetId], + open, + }, + }; + return; + } + + if (type === "service") { + this._entries = { + ...this._entries, + [targetId]: { + ...this._entries[targetId], + open, + }, + }; + return; + } + + if (type === "device" && id) { + const areaId = this.devices[id]?.area_id; + if (areaId) { + const areaTargetId = `area${TARGET_SEPARATOR}${this.devices[id]?.area_id ?? ""}`; + const floorId = `floor${TARGET_SEPARATOR}${(areaId && this.areas[areaId]?.floor_id) || ""}`; + + this._entries = { + ...this._entries, + [floorId]: { + ...this._entries[floorId], + areas: { + ...this._entries[floorId].areas, + [areaTargetId]: { + ...this._entries[floorId].areas![areaTargetId], + devices: { + ...this._entries[floorId].areas![areaTargetId].devices, + [id]: { + ...this._entries[floorId].areas![areaTargetId].devices[id], + open, + }, + }, + }, + }, + }, + }; + return; + } + + const deviceType = + this.devices[id]?.entry_type === "service" ? "service" : "area"; + const floorId = `${deviceType}${TARGET_SEPARATOR}`; + this._entries = { + ...this._entries, + [floorId]: { + ...this._entries[floorId], + devices: { + ...this._entries[floorId].devices, + [id]: { + ...this._entries[floorId].devices![id], + open, + }, + }, + }, + }; + return; + } + + // unassigned entities + if (type === "device") { + this._entries = { + ...this._entries, + [targetId]: { + ...this._entries[targetId], + open, + }, + }; + return; + } + + if (type === "helper") { + this._entries = { + ...this._entries, + [targetId]: { + ...this._entries[targetId], + open, + }, + }; + return; + } + + if (type.startsWith("entity_")) { + this._entries = { + ...this._entries, + [`device${TARGET_SEPARATOR}`]: { + ...this._entries[`device${TARGET_SEPARATOR}`], + devices: { + ...this._entries[`device${TARGET_SEPARATOR}`].devices, + [targetId]: { + ...this._entries[`device${TARGET_SEPARATOR}`].devices![targetId], + open, + }, + }, + }, + }; + return; + } + + if (type.startsWith("helper_")) { + this._entries = { + ...this._entries, + [`helper${TARGET_SEPARATOR}`]: { + ...this._entries[`helper${TARGET_SEPARATOR}`], + devices: { + ...this._entries[`helper${TARGET_SEPARATOR}`].devices, + [targetId]: { + ...this._entries[`helper${TARGET_SEPARATOR}`].devices![targetId], + open, + }, + }, + }, + }; + } + } + + private _expandItem(ev) { + const targetId = ev.target.target; + this._toggleItem(targetId, true); + } + + private _collapseItem(ev) { + const targetId = ev.target.target; + this._toggleItem(targetId, false); + } + + private async _loadConfigEntries() { + const configEntries = await getConfigEntries(this.hass); + this._configEntryLookup = Object.fromEntries( + configEntries.map((entry) => [entry.entry_id, entry]) + ); + } + + public navigateBack() { + if (!this.value) { + return; + } + + const valueType = Object.keys(this.value)[0].replace("_id", ""); + const valueId = this.value[`${valueType}_id`]; + + if ( + valueType === "floor" || + valueType === "label" || + (!valueId && + (valueType === "device" || + valueType === "helper" || + valueType === "service" || + valueType === "area")) + ) { + fireEvent(this, "value-changed", { value: undefined }); + return; + } + + if (valueType === "area") { + fireEvent(this, "value-changed", { + value: { floor_id: this.areas[valueId].floor_id || undefined }, + }); + return; + } + + if (valueType === "device") { + if ( + !this.devices[valueId].area_id && + this.devices[valueId].entry_type === "service" + ) { + fireEvent(this, "value-changed", { + value: { service_id: undefined }, + }); + return; + } + + fireEvent(this, "value-changed", { + value: { area_id: this.devices[valueId].area_id || undefined }, + }); + return; + } + + if (valueType === "entity" && valueId) { + const deviceId = this.entities[valueId].device_id; + if (deviceId) { + fireEvent(this, "value-changed", { + value: { device_id: deviceId }, + }); + return; + } + + const areaId = this.entities[valueId].area_id; + if (areaId) { + fireEvent(this, "value-changed", { + value: { area_id: areaId }, + }); + return; + } + + const domain = valueId.split(".", 2)[0]; + const manifest = this.manifests ? this.manifests[domain] : undefined; + if (manifest?.integration_type === "helper") { + fireEvent(this, "value-changed", { + value: { [`helper_${domain}_id`]: undefined }, + }); + return; + } + + fireEvent(this, "value-changed", { + value: { [`entity_${domain}_id`]: undefined }, + }); + } + + if (valueType.startsWith("helper_") || valueType.startsWith("entity_")) { + fireEvent(this, "value-changed", { + value: { + [`${valueType.startsWith("helper_") ? "helper" : "device"}_id`]: + undefined, + }, + }); + } + } + + private _expandHeight() { + this._fullHeight = true; + this.style.setProperty("--max-height", "none"); + } + + // #endregion interactions + + // #region styles + + static styles = css` + :host { + --wa-color-neutral-fill-quiet: var(--ha-color-fill-primary-normal-active); + position: relative; + } + + ha-section-title { + top: 0; + position: sticky; + z-index: 1; + } + + wa-tree-item::part(item) { + height: var(--ha-space-10); + padding: var(--ha-space-1) var(--ha-space-3); + cursor: pointer; + border-inline-start: 0; + } + wa-tree-item::part(label) { + gap: var(--ha-space-3); + font-family: var(--ha-font-family-heading); + font-weight: var(--ha-font-weight-medium); + overflow: hidden; + } + ha-md-list-item { + --md-list-item-label-text-weight: var(--ha-font-weight-medium); + --md-list-item-label-text-font: var(--ha-font-family-heading); + } + + .item-label { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + ha-svg-icon, + ha-icon, + ha-floor-icon { + padding: var(--ha-space-1); + color: var(--ha-color-on-neutral-quiet); + } + + wa-tree-item::part(item):hover { + background-color: var(--ha-color-fill-neutral-quiet-hover); + } + + img { + width: 24px; + height: 24px; + padding: var(--ha-space-1); + } + + img.domain-icon { + filter: grayscale(100%); + } + + state-badge { + width: 24px; + height: 24px; + } + + wa-tree-item[selected], + wa-tree-item[selected] > ha-svg-icon, + wa-tree-item[selected] > ha-icon, + wa-tree-item[selected] > ha-floor-icon { + color: var(--ha-color-on-primary-normal); + } + + wa-tree-item[selected]::part(item):hover { + background-color: var(--ha-color-fill-primary-normal-hover); + } + + wa-tree-item::part(base).tree-item-selected .item { + background-color: yellow; + } + + ha-md-list { + padding: 0; + --md-list-item-leading-space: var(--ha-space-3); + --md-list-item-trailing-space: var(--md-list-item-leading-space); + --md-list-item-bottom-space: var(--ha-space-1); + --md-list-item-top-space: var(--md-list-item-bottom-space); + --md-list-item-supporting-text-font: var(--ha-font-size-s); + --md-list-item-one-line-container-height: var(--ha-space-10); + } + + ha-md-list-item.selected { + background-color: var(--ha-color-fill-primary-normal-active); + --md-list-item-label-text-color: var(--ha-color-on-primary-normal); + --icon-primary-color: var(--ha-color-on-primary-normal); + } + + ha-md-list-item.selected ha-icon, + ha-md-list-item.selected ha-svg-icon { + color: var(--ha-color-on-primary-normal); + } + + .targets-show-more { + display: flex; + justify-content: center; + position: absolute; + bottom: 0; + width: 100%; + padding-bottom: var(--ha-space-2); + box-shadow: inset var(--ha-shadow-offset-x-lg) + calc(var(--ha-shadow-offset-y-lg) * -1) var(--ha-shadow-blur-lg) + var(--ha-shadow-spread-lg) var(--ha-color-shadow-light); + } + + @media (prefers-color-scheme: dark) { + .targets-show-more { + box-shadow: inset var(--ha-shadow-offset-x-lg) + calc(var(--ha-shadow-offset-y-lg) * -1) var(--ha-shadow-blur-lg) + var(--ha-shadow-spread-lg) var(--ha-color-shadow-dark); + } + } + + @media all and (max-width: 870px), all and (max-height: 500px) { + :host { + max-height: var(--max-height, 50%); + overflow: hidden; + } + } + `; + + // #endregion styles +} + +declare global { + interface HTMLElementTagNameMap { + "ha-automation-add-from-target": HaAutomationAddFromTarget; + } +} diff --git a/src/panels/config/automation/add-automation-element/ha-automation-add-items.ts b/src/panels/config/automation/add-automation-element/ha-automation-add-items.ts new file mode 100644 index 0000000000..27d170a7f7 --- /dev/null +++ b/src/panels/config/automation/add-automation-element/ha-automation-add-items.ts @@ -0,0 +1,384 @@ +import { + mdiInformationOutline, + mdiLabel, + mdiPlus, + mdiTextureBox, +} from "@mdi/js"; +import { LitElement, css, html, nothing, type TemplateResult } from "lit"; +import { + customElement, + eventOptions, + property, + query, + state, +} from "lit/decorators"; +import { classMap } from "lit/directives/class-map"; +import { repeat } from "lit/directives/repeat"; +import memoizeOne from "memoize-one"; +import { fireEvent } from "../../../../common/dom/fire_event"; +import { stopPropagation } from "../../../../common/dom/stop_propagation"; +import "../../../../components/entity/state-badge"; +import "../../../../components/ha-domain-icon"; +import "../../../../components/ha-floor-icon"; +import "../../../../components/ha-icon-next"; +import "../../../../components/ha-md-list"; +import "../../../../components/ha-md-list-item"; +import "../../../../components/ha-svg-icon"; +import "../../../../components/ha-tooltip"; +import type { ConfigEntry } from "../../../../data/config_entries"; +import type { HomeAssistant } from "../../../../types"; +import type { AddAutomationElementListItem } from "../add-automation-element-dialog"; + +type Target = [string, string | undefined, string | undefined]; + +@customElement("ha-automation-add-items") +export class HaAutomationAddItems extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @property({ attribute: false }) public items?: { + title: string; + items: AddAutomationElementListItem[]; + }[]; + + @property() public error?: string; + + @property({ attribute: "select-label" }) public selectLabel!: string; + + @property({ attribute: "empty-label" }) public emptyLabel!: string; + + @property({ attribute: false }) public target?: Target; + + @property({ attribute: false }) public getLabel!: ( + id: string + ) => { name: string; icon?: string } | undefined; + + @property({ attribute: false }) public configEntryLookup: Record< + string, + ConfigEntry + > = {}; + + @property({ type: Boolean, attribute: "tooltip-description" }) + public tooltipDescription = false; + + @state() private _itemsScrolled = false; + + @query(".items") + private _itemsDiv!: HTMLDivElement; + + protected render() { + return html`
+ ${!this.items && !this.error + ? this.selectLabel + : this.error + ? html`${this.error} +
${this._renderTarget(this.target)}
` + : this.items && !this.items.length + ? html`${this.emptyLabel} + ${this.target + ? html`
${this._renderTarget(this.target)}
` + : nothing}` + : repeat( + this.items, + (_, index) => `item-group-${index}`, + (itemGroup) => + this._renderItemList(itemGroup.title, itemGroup.items) + )} +
`; + } + + private _renderItemList(title, items?: AddAutomationElementListItem[]) { + if (!items || !items.length) { + return nothing; + } + + return html` +
${title}
+ + ${repeat( + items, + (item) => item.key, + (item) => html` + +
+ ${item.name}${this._renderTarget(this.target)} +
+ + ${!this.tooltipDescription && item.description + ? html`
${item.description}
` + : nothing} + ${item.icon + ? html`${item.icon}` + : item.iconPath + ? html`` + : nothing} + ${this.tooltipDescription && item.description + ? html` + ${item.description} ` + : nothing} + +
+ ` + )} +
+ `; + } + + private _renderTarget = memoizeOne((target?: Target) => { + if (!target) { + return nothing; + } + + return html`
+ ${this._getSelectedTargetIcon(target[0], target[1])} +
${target[2]}
+
`; + }); + + private _getSelectedTargetIcon( + targetType: string, + targetId: string | undefined + ): TemplateResult | typeof nothing { + if (!targetId) { + return nothing; + } + + if (targetType === "floor") { + return html``; + } + + if (targetType === "area" && this.hass.areas[targetId]) { + const area = this.hass.areas[targetId]; + if (area.icon) { + return html``; + } + return html``; + } + + if (targetType === "device" && this.hass.devices[targetId]) { + const device = this.hass.devices[targetId]; + const configEntry = device.primary_config_entry + ? this.configEntryLookup[device.primary_config_entry] + : undefined; + const domain = configEntry?.domain; + + if (domain) { + return html``; + } + } + + if (targetType === "entity" && this.hass.states[targetId]) { + const stateObj = this.hass.states[targetId]; + if (stateObj) { + return html``; + } + } + + if (targetType === "label") { + const label = this.getLabel(targetId); + if (label?.icon) { + return html``; + } + return html``; + } + + return nothing; + } + + private _selected(ev) { + const item = ev.currentTarget; + fireEvent(this, "value-changed", { + value: item.value, + }); + } + + @eventOptions({ passive: true }) + private _onItemsScroll(ev) { + const top = ev.target.scrollTop ?? 0; + this._itemsScrolled = top > 0; + } + + public override scrollTo(options?: ScrollToOptions): void; + + public override scrollTo(x: number, y: number): void; + + public override scrollTo( + xOrOptions?: number | ScrollToOptions, + y?: number + ): void { + if (typeof xOrOptions === "number") { + this._itemsDiv?.scrollTo(xOrOptions, y!); + } else { + this._itemsDiv?.scrollTo(xOrOptions); + } + } + + static styles = css` + :host { + display: flex; + } + + .items { + display: flex; + flex-direction: column; + overflow: auto; + flex: 1; + } + .items.blank { + border-radius: var(--ha-border-radius-xl); + background-color: var(--ha-color-surface-default); + align-items: center; + color: var(--ha-color-text-secondary); + padding: var(--ha-space-0); + margin: var(--ha-space-3) var(--ha-space-4) + max(var(--safe-area-inset-bottom), var(--ha-space-3)); + line-height: var(--ha-line-height-expanded); + justify-content: center; + } + + .items.error { + background-color: var(--ha-color-fill-danger-quiet-resting); + color: var(--ha-color-on-danger-normal); + } + .items ha-md-list { + --md-list-item-two-line-container-height: var(--ha-space-12); + --md-list-item-leading-space: var(--ha-space-3); + --md-list-item-trailing-space: var(--md-list-item-leading-space); + --md-list-item-bottom-space: var(--ha-space-2); + --md-list-item-top-space: var(--md-list-item-bottom-space); + --md-list-item-supporting-text-font: var(--ha-font-family-body); + --ha-md-list-item-gap: var(--ha-space-3); + gap: var(--ha-space-2); + padding: var(--ha-space-0) var(--ha-space-4); + } + .items ha-md-list ha-md-list-item { + border-radius: var(--ha-border-radius-lg); + border: 1px solid var(--ha-color-border-neutral-quiet); + } + + .items ha-md-list { + padding-bottom: max(var(--safe-area-inset-bottom), var(--ha-space-3)); + } + + .items .item-headline { + display: flex; + align-items: center; + gap: var(--ha-space-1); + min-height: var(--ha-space-9); + flex-wrap: wrap; + } + + .items-title { + position: sticky; + display: flex; + align-items: center; + font-weight: var(--ha-font-weight-medium); + padding-top: var(--ha-space-2); + padding-bottom: var(--ha-space-2); + padding-inline-start: var(--ha-space-8); + padding-inline-end: var(--ha-space-3); + top: 0; + z-index: 1; + background-color: var(--card-background-color); + } + ha-bottom-sheet .items-title { + padding-top: var(--ha-space-3); + } + .scrolled .items-title:first-of-type { + box-shadow: var(--bar-box-shadow); + border-bottom: 1px solid var(--ha-color-border-neutral-quiet); + } + + ha-icon-next { + width: var(--ha-space-6); + } + + ha-svg-icon.plus { + color: var(--primary-color); + } + + .selected-target { + display: inline-flex; + gap: var(--ha-space-1); + justify-content: center; + align-items: center; + border-radius: var(--ha-border-radius-md); + background: var(--ha-color-fill-neutral-normal-resting); + padding: 0 var(--ha-space-2) 0 var(--ha-space-1); + color: var(--ha-color-on-neutral-normal); + overflow: hidden; + } + .selected-target .label { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .selected-target ha-icon, + .selected-target ha-svg-icon, + .selected-target state-badge, + .selected-target ha-domain-icon { + display: flex; + padding: var(--ha-space-1) 0; + } + + .selected-target state-badge { + --mdc-icon-size: 20px; + } + .selected-target state-badge, + .selected-target ha-domain-icon { + width: 24px; + height: 24px; + filter: grayscale(100%); + } + `; +} + +declare global { + interface HTMLElementTagNameMap { + "ha-automation-add-items": HaAutomationAddItems; + } +} diff --git a/src/panels/config/automation/add-automation-element/ha-automation-add-search.ts b/src/panels/config/automation/add-automation-element/ha-automation-add-search.ts new file mode 100644 index 0000000000..f52c6d0f79 --- /dev/null +++ b/src/panels/config/automation/add-automation-element/ha-automation-add-search.ts @@ -0,0 +1,1081 @@ +import type { LitVirtualizer } from "@lit-labs/virtualizer"; +import { consume } from "@lit/context"; +import "@material/mwc-list/mwc-list"; +import { mdiPlus, mdiTextureBox } from "@mdi/js"; +import type { IFuseOptions } from "fuse.js"; +import Fuse from "fuse.js"; +import { css, html, LitElement, nothing, type PropertyValues } from "lit"; +import { + customElement, + eventOptions, + property, + query, + state, +} from "lit/decorators"; +import { styleMap } from "lit/directives/style-map"; +import memoizeOne from "memoize-one"; +import { tinykeys } from "tinykeys"; +import { fireEvent } from "../../../../common/dom/fire_event"; +import type { + LocalizeFunc, + LocalizeKeys, +} from "../../../../common/translations/localize"; +import { computeRTL } from "../../../../common/util/compute_rtl"; +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"; +import "../../../../components/ha-icon-next"; +import type { PickerComboBoxItem } from "../../../../components/ha-picker-combo-box"; +import "../../../../components/ha-section-title"; +import "../../../../components/ha-tree-indicator"; +import { ACTION_BUILDING_BLOCKS_GROUP } from "../../../../data/action"; +import { + getAreasAndFloors, + type AreaFloorValue, + type FloorComboBoxItem, +} from "../../../../data/area_floor"; +import { CONDITION_BUILDING_BLOCKS_GROUP } from "../../../../data/condition"; +import type { ConfigEntry } from "../../../../data/config_entries"; +import { labelsContext } from "../../../../data/context"; +import { + getDevices, + type DevicePickerItem, +} from "../../../../data/device_registry"; +import { + getEntities, + type EntityComboBoxItem, +} from "../../../../data/entity_registry"; +import type { DomainManifestLookup } from "../../../../data/integration"; +import { + getLabels, + type LabelRegistryEntry, +} from "../../../../data/label_registry"; +import { + getTargetComboBoxItemType, + TARGET_SEPARATOR, +} from "../../../../data/target"; +import { HaFuse } from "../../../../resources/fuse"; +import { loadVirtualizer } from "../../../../resources/virtualizer"; +import type { HomeAssistant } from "../../../../types"; +import type { + AddAutomationElementListItem, + AutomationItemComboBoxItem, +} from "../add-automation-element-dialog"; +import type { AddAutomationElementDialogParams } from "../show-add-automation-element-dialog"; + +const TARGET_SEARCH_SECTIONS = [ + "separator", + "entity", + "device", + "area", + "separator", + "label", +] as const; + +type SearchSection = "item" | "block" | "entity" | "device" | "area" | "label"; + +@customElement("ha-automation-add-search") +export class HaAutomationAddSearch extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @property() public filter!: string; + + @property({ attribute: false }) public configEntryLookup: Record< + string, + ConfigEntry + > = {}; + + @property({ attribute: false }) public manifests?: DomainManifestLookup; + + @property({ attribute: false }) public items!: AddAutomationElementListItem[]; + + @property({ type: Boolean }) public narrow = false; + + @property({ type: Boolean, attribute: "new-triggers-and-conditions" }) + public newTriggersAndConditions = false; + + @property({ attribute: false }) + public convertToItem!: ( + key: string, + options, + type: AddAutomationElementDialogParams["type"], + localize: LocalizeFunc + ) => AddAutomationElementListItem; + + @property({ attribute: "add-element-type" }) public addElementType!: + | "trigger" + | "condition" + | "action"; + + @state() private _searchSectionTitle?: string; + + @state() private _selectedSearchSection?: SearchSection; + + @state() private _searchListScrolled = false; + + @state() + @consume({ context: labelsContext, subscribe: true }) + private _labelRegistry!: LabelRegistryEntry[]; + + @query("lit-virtualizer") private _virtualizerElement?: LitVirtualizer; + + private _getDevicesMemoized = memoizeOne(getDevices); + + private _getLabelsMemoized = memoizeOne(getLabels); + + private _getEntitiesMemoized = memoizeOne(getEntities); + + private _getAreasAndFloorsMemoized = memoizeOne(getAreasAndFloors); + + private _selectedSearchItemIndex = -1; + + private _removeKeyboardShortcuts?: () => void; + + private get _showEntityId() { + return this.hass.userData?.showEntityIdPicker; + } + + protected willUpdate(changedProps: PropertyValues) { + if (!this.hasUpdated) { + loadVirtualizer(); + } + + if (!this.hasUpdated || changedProps.has("filter")) { + if (this._removeKeyboardShortcuts) { + if (!this.filter) { + this._removeKeyboardShortcuts(); + this._removeKeyboardShortcuts = undefined; + } + return; + } + + this._removeKeyboardShortcuts = tinykeys(window, { + ArrowUp: this._selectPreviousSearchItem, + ArrowDown: this._selectNextSearchItem, + Home: this._selectFirstSearchItem, + End: this._selectLastSearchItem, + Enter: this._pickSelectedSearchItem, + }); + } + } + + public disconnectedCallback() { + super.disconnectedCallback(); + this._removeKeyboardShortcuts?.(); + } + + protected render() { + const items = this._getFilteredItems( + this.addElementType, + this.hass.localize, + this.filter, + this.configEntryLookup, + this.items, + this.newTriggersAndConditions, + this._selectedSearchSection + ); + + let emptySearchTranslation: string | undefined; + + if (!items.length) { + emptySearchTranslation = !this._selectedSearchSection + ? `ui.panel.config.automation.editor.${this.addElementType}s.empty_search.global` + : this._selectedSearchSection === "item" + ? `ui.panel.config.automation.editor.${this.addElementType}s.empty_search.item` + : `ui.panel.config.automation.editor.empty_section_search.${this._selectedSearchSection}`; + } + + return html` + ${this._renderSections()} + ${emptySearchTranslation + ? html`` + : html` +
+
+ ${!this._selectedSearchSection && this._searchSectionTitle + ? html` + ${this._searchSectionTitle} + ` + : nothing} +
+ + +
+ `} + `; + } + + private _renderSections() { + if (this.addElementType === "trigger" && !this.newTriggersAndConditions) { + return nothing; + } + + const searchSections: ("separator" | SearchSection)[] = ["item"]; + + if (this.addElementType !== "trigger") { + searchSections.push("block"); + } + + if (this.newTriggersAndConditions) { + searchSections.push(...TARGET_SEARCH_SECTIONS); + } + return html` + + ${searchSections.map((section) => + section === "separator" + ? html`
` + : html` + ` + )} +
+ `; + } + + private _renderSearchResultRow = ( + item: + | PickerComboBoxItem + | (FloorComboBoxItem & { last?: boolean | undefined }) + | EntityComboBoxItem + | DevicePickerItem + | AutomationItemComboBoxItem, + index: number + ) => { + if (!item) { + return nothing; + } + + if (typeof item === "string") { + return html`${item}`; + } + + const type = ["trigger", "condition", "action", "block"].includes( + (item as AutomationItemComboBoxItem).type + ) + ? "item" + : getTargetComboBoxItemType(item); + let hasFloor = false; + let rtl = false; + let showEntityId = false; + + if (type === "area" || type === "floor") { + rtl = computeRTL(this.hass); + hasFloor = + type === "area" && !!(item as FloorComboBoxItem).area?.floor_id; + } + + if (type === "entity") { + showEntityId = !!this._showEntityId; + } + + return html` + + ${(item as FloorComboBoxItem).type === "area" && hasFloor + ? html` + + ` + : nothing} + ${item.icon + ? html`` + : item.icon_path + ? html`` + : type === "entity" && (item as EntityComboBoxItem).stateObj + ? html` + + ` + : type === "device" && (item as DevicePickerItem).domain + ? html` + + ` + : type === "floor" + ? html`` + : type === "area" + ? html`` + : nothing} + ${item.primary} + ${item.secondary + ? html`${item.secondary}` + : nothing} + ${(item as EntityComboBoxItem).stateObj && showEntityId + ? html` + + ${(item as EntityComboBoxItem).stateObj?.entity_id} + + ` + : nothing} + ${(item as EntityComboBoxItem).domain_name && + (type !== "entity" || !showEntityId) + ? html` +
+ ${(item as EntityComboBoxItem).domain_name} +
+ ` + : nothing} + ${type === "item" + ? html`` + : this.narrow + ? html`` + : nothing} +
+ `; + }; + + @eventOptions({ passive: true }) + private _onScrollSearchList(ev) { + const top = ev.target.scrollTop ?? 0; + this._searchListScrolled = top > 0; + } + + @eventOptions({ passive: true }) + private _visibilityChanged(ev) { + if (this._virtualizerElement) { + const firstItem = this._virtualizerElement.items[ev.first]; + const secondItem = this._virtualizerElement.items[ev.first + 1]; + + if ( + firstItem === undefined || + secondItem === undefined || + typeof firstItem === "string" || + typeof secondItem === "string" || + ev.first === 0 || + (ev.first === 0 && + ev.last === this._virtualizerElement.items.length - 1) + ) { + this._searchSectionTitle = undefined; + return; + } + + let section: SearchSection; + + if ( + (firstItem as AutomationItemComboBoxItem).type && + !["area", "floor"].includes( + (firstItem as AutomationItemComboBoxItem).type + ) + ) { + section = (firstItem as AutomationItemComboBoxItem) + .type as SearchSection; + } else { + section = getTargetComboBoxItemType(firstItem as any) as SearchSection; + } + + this._searchSectionTitle = this._getSearchSectionLabel(section); + } + } + + private _keyFunction = (item: PickerComboBoxItem | string) => + typeof item === "string" ? item : item.id; + + private _createFuseIndex = (states) => + Fuse.createIndex(["search_labels"], states); + + private _fuseIndexes = { + area: memoizeOne((states: PickerComboBoxItem[]) => + this._createFuseIndex(states) + ), + entity: memoizeOne((states: PickerComboBoxItem[]) => + this._createFuseIndex(states) + ), + device: memoizeOne((states: PickerComboBoxItem[]) => + this._createFuseIndex(states) + ), + label: memoizeOne((states: PickerComboBoxItem[]) => + this._createFuseIndex(states) + ), + item: memoizeOne((states: PickerComboBoxItem[]) => + this._createFuseIndex(states) + ), + block: memoizeOne((states: PickerComboBoxItem[]) => + this._createFuseIndex(states) + ), + }; + + private _getFilteredItems = memoizeOne( + ( + type: AddAutomationElementDialogParams["type"], + localize: HomeAssistant["localize"], + searchTerm: string, + configEntryLookup: Record, + automationItems: AddAutomationElementListItem[], + newTriggersAndConditions: boolean, + selectedSection?: SearchSection + ) => { + const resultItems: ( + | string + | FloorComboBoxItem + | EntityComboBoxItem + | PickerComboBoxItem + )[] = []; + + if (!selectedSection || selectedSection === "item") { + let items = this._convertItemsToComboBoxItems(automationItems, type); + if (searchTerm) { + items = this._filterGroup("item", items, searchTerm, { + ignoreLocation: true, + includeScore: true, + minMatchCharLength: Math.min(2, this.filter.length), + }) as AutomationItemComboBoxItem[]; + } + + if (!selectedSection && items.length) { + // show group title + resultItems.push( + localize(`ui.panel.config.automation.editor.${type}s.name`) + ); + } + + resultItems.push(...items); + } + + if ( + type !== "trigger" && + (!selectedSection || selectedSection === "block") + ) { + const groups = + type === "action" + ? ACTION_BUILDING_BLOCKS_GROUP + : type === "condition" + ? CONDITION_BUILDING_BLOCKS_GROUP + : {}; + + let blocks = this._convertItemsToComboBoxItems( + Object.keys(groups).map((key) => + this.convertToItem(key, {}, type, localize) + ), + "block" + ); + + if (searchTerm) { + blocks = this._filterGroup("block", blocks, searchTerm, { + ignoreLocation: true, + includeScore: true, + minMatchCharLength: Math.min(2, this.filter.length), + }) as AutomationItemComboBoxItem[]; + } + + if (!selectedSection && blocks.length) { + // show group title + resultItems.push( + localize("ui.panel.config.automation.editor.blocks") + ); + } + resultItems.push(...blocks); + } + + if (newTriggersAndConditions) { + if (!selectedSection || selectedSection === "entity") { + let entityItems = this._getEntitiesMemoized( + this.hass, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + `entity${TARGET_SEPARATOR}` + ); + + if (searchTerm) { + entityItems = this._filterGroup( + "entity", + entityItems, + searchTerm, + undefined, + (item: EntityComboBoxItem) => + item.stateObj?.entity_id === searchTerm + ) as EntityComboBoxItem[]; + } + + if (!selectedSection && entityItems.length) { + // show group title + resultItems.push( + localize("ui.components.target-picker.type.entities") + ); + } + + resultItems.push(...entityItems); + } + + if (!selectedSection || selectedSection === "device") { + let deviceItems = this._getDevicesMemoized( + this.hass, + configEntryLookup, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + `device${TARGET_SEPARATOR}` + ); + + if (searchTerm) { + deviceItems = this._filterGroup("device", deviceItems, searchTerm); + } + + if (!selectedSection && deviceItems.length) { + // show group title + resultItems.push( + localize("ui.components.target-picker.type.devices") + ); + } + + resultItems.push(...deviceItems); + } + + if (!selectedSection || selectedSection === "area") { + let areasAndFloors = this._getAreasAndFloorsMemoized( + this.hass.states, + this.hass.floors, + this.hass.areas, + this.hass.devices, + this.hass.entities, + memoizeOne((value: AreaFloorValue): string => + [value.type, value.id].join(TARGET_SEPARATOR) + ), + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined + ); + + if (searchTerm) { + areasAndFloors = this._filterGroup( + "area", + areasAndFloors, + searchTerm + ) as FloorComboBoxItem[]; + } + + if (!selectedSection && areasAndFloors.length) { + // show group title + resultItems.push( + localize("ui.components.target-picker.type.areas") + ); + } + + resultItems.push( + ...areasAndFloors.map((item, index) => { + const nextItem = areasAndFloors[index + 1]; + + if ( + !nextItem || + (item.type === "area" && nextItem.type === "floor") + ) { + return { + ...item, + last: true, + }; + } + + return item; + }) + ); + } + + if (!selectedSection || selectedSection === "label") { + let labels = this._getLabelsMemoized( + this.hass.states, + this.hass.areas, + this.hass.devices, + this.hass.entities, + this._labelRegistry, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + `label${TARGET_SEPARATOR}` + ); + + if (searchTerm) { + labels = this._filterGroup("label", labels, searchTerm); + } + + if (!selectedSection && labels.length) { + // show group title + resultItems.push( + localize("ui.components.target-picker.type.labels") + ); + } + + resultItems.push(...labels); + } + } + + return resultItems; + } + ); + + private _filterGroup( + type: SearchSection, + items: ( + | FloorComboBoxItem + | PickerComboBoxItem + | EntityComboBoxItem + | AutomationItemComboBoxItem + )[], + searchTerm: string, + fuseOptions?: IFuseOptions, + checkExact?: ( + item: + | FloorComboBoxItem + | PickerComboBoxItem + | EntityComboBoxItem + | AutomationItemComboBoxItem + ) => boolean + ) { + const fuseIndex = this._fuseIndexes[type](items); + const fuse = new HaFuse< + | FloorComboBoxItem + | PickerComboBoxItem + | EntityComboBoxItem + | AutomationItemComboBoxItem + >( + items, + fuseOptions || { + shouldSort: false, + minMatchCharLength: Math.min(searchTerm.length, 2), + }, + fuseIndex + ); + + const results = fuse.multiTermsSearch(searchTerm); + let filteredItems = items; + if (results) { + filteredItems = results.map((result) => result.item); + } + + if (!checkExact) { + return filteredItems; + } + + // If there is exact match for entity id, put it first + const index = filteredItems.findIndex((item) => checkExact(item)); + if (index === -1) { + return filteredItems; + } + + const [exactMatch] = filteredItems.splice(index, 1); + filteredItems.unshift(exactMatch); + + return filteredItems; + } + + private _toggleSection(ev: Event) { + ev.stopPropagation(); + // this._resetSelectedItem(); + this._searchSectionTitle = undefined; + const section = (ev.target as HTMLElement)["section-id"] as string; + if (!section) { + return; + } + if (this._selectedSearchSection === section) { + this._selectedSearchSection = undefined; + } else { + this._selectedSearchSection = section as SearchSection; + } + + // Reset scroll position when filter changes + if (this._virtualizerElement) { + this._virtualizerElement.scrollToIndex(0); + } + } + + private _getSearchSectionLabel(section: SearchSection) { + if (section === "block") { + return this.hass.localize("ui.panel.config.automation.editor.blocks"); + } + + if ( + section === "item" || + ["trigger", "condition", "action"].includes(section) + ) { + return this.hass.localize( + `ui.panel.config.automation.editor.${this.addElementType}s.name` + ); + } + + return this.hass.localize( + `ui.components.target-picker.type.${section === "entity" ? "entities" : `${section as "area" | "device" | "floor"}s`}` as LocalizeKeys + ); + } + + private _convertItemsToComboBoxItems = ( + items: AddAutomationElementListItem[], + type: "trigger" | "condition" | "action" | "block" + ): AutomationItemComboBoxItem[] => + items.map(({ key, name, description, iconPath, icon }) => ({ + id: key, + primary: name, + secondary: description, + iconPath, + renderedIcon: icon, + type, + search_labels: [key, name, description], + })); + + private _focusSearchList = () => { + if (this._selectedSearchItemIndex === -1) { + this._selectNextSearchItem(); + } + }; + + private _selectSearchResult = (ev: Event) => { + ev.stopPropagation(); + const value = (ev.currentTarget as any).value as + | PickerComboBoxItem + | FloorComboBoxItem + | EntityComboBoxItem + | DevicePickerItem + | AutomationItemComboBoxItem; + + if (!value) { + return; + } + + this._selectSearchItem(value); + }; + + private _resetSelectedSearchItem() { + // TODO run on filter change! + this._virtualizerElement + ?.querySelector(".selected") + ?.classList.remove("selected"); + this._selectedSearchItemIndex = -1; + } + + private _selectNextSearchItem = (ev?: KeyboardEvent) => { + ev?.stopPropagation(); + ev?.preventDefault(); + if (!this._virtualizerElement) { + return; + } + + const items = this._virtualizerElement.items as PickerComboBoxItem[]; + + const maxItems = items.length - 1; + + if (maxItems === -1) { + this._resetSelectedSearchItem(); + return; + } + + const nextIndex = + maxItems === this._selectedSearchItemIndex + ? this._selectedSearchItemIndex + : this._selectedSearchItemIndex + 1; + + if (!items[nextIndex]) { + return; + } + + if (typeof items[nextIndex] === "string") { + // Skip titles, padding and empty search + if (nextIndex === maxItems) { + return; + } + this._selectedSearchItemIndex = nextIndex + 1; + } else { + this._selectedSearchItemIndex = nextIndex; + } + + this._scrollToSelectedSearchItem(); + }; + + private _scrollToSelectedSearchItem = () => { + this._virtualizerElement + ?.querySelector(".selected") + ?.classList.remove("selected"); + + this._virtualizerElement?.scrollToIndex( + this._selectedSearchItemIndex, + "end" + ); + + requestAnimationFrame(() => { + this._virtualizerElement + ?.querySelector(`#search-list-item-${this._selectedSearchItemIndex}`) + ?.classList.add("selected"); + }); + }; + + private _selectPreviousSearchItem = (ev: KeyboardEvent) => { + ev.stopPropagation(); + ev.preventDefault(); + if (!this._virtualizerElement) { + return; + } + + if (this._selectedSearchItemIndex > 0) { + const nextIndex = this._selectedSearchItemIndex - 1; + + const items = this._virtualizerElement.items as PickerComboBoxItem[]; + + if (!items[nextIndex]) { + return; + } + + if (typeof items[nextIndex] === "string") { + // Skip titles, padding and empty search + if (nextIndex === 0) { + return; + } + this._selectedSearchItemIndex = nextIndex - 1; + } else { + this._selectedSearchItemIndex = nextIndex; + } + + this._scrollToSelectedSearchItem(); + } + }; + + private _selectFirstSearchItem = (ev: KeyboardEvent) => { + ev.stopPropagation(); + if (!this._virtualizerElement || !this._virtualizerElement.items.length) { + return; + } + + const nextIndex = 0; + + if (typeof this._virtualizerElement.items[nextIndex] === "string") { + this._selectedSearchItemIndex = nextIndex + 1; + } else { + this._selectedSearchItemIndex = nextIndex; + } + + this._scrollToSelectedSearchItem(); + }; + + private _selectLastSearchItem = (ev: KeyboardEvent) => { + ev.stopPropagation(); + if (!this._virtualizerElement || !this._virtualizerElement.items.length) { + return; + } + + const nextIndex = this._virtualizerElement.items.length - 1; + + if (typeof this._virtualizerElement.items[nextIndex] === "string") { + this._selectedSearchItemIndex = nextIndex - 1; + } else { + this._selectedSearchItemIndex = nextIndex; + } + + this._scrollToSelectedSearchItem(); + }; + + private _pickSelectedSearchItem = (ev: KeyboardEvent) => { + ev.stopPropagation(); + + const filteredItems = this._virtualizerElement?.items.filter( + (item) => typeof item !== "string" + ); + + if (filteredItems && filteredItems.length === 1) { + const firstItem = filteredItems[0] as PickerComboBoxItem; + + this._selectSearchItem(firstItem as PickerComboBoxItem); + return; + } + + if (this._selectedSearchItemIndex === -1) { + return; + } + + // if filter button is focused + ev.preventDefault(); + + const item = this._virtualizerElement?.items[ + this._selectedSearchItemIndex + ] as PickerComboBoxItem; + if (item) { + this._selectSearchItem(item); + } + }; + + private _selectSearchItem( + item: + | PickerComboBoxItem + | FloorComboBoxItem + | EntityComboBoxItem + | DevicePickerItem + | AutomationItemComboBoxItem + ) { + fireEvent(this, "search-element-picked", item); + } + + static styles = css` + :host { + display: flex; + flex-direction: column; + } + .empty-search { + display: flex; + flex-direction: column; + flex: 1; + padding: var(--ha-space-3); + border-radius: var(--ha-border-radius-xl); + background-color: var(--ha-color-surface-default); + align-items: center; + color: var(--ha-color-text-secondary); + margin: var(--ha-space-3) var(--ha-space-4) + max(var(--safe-area-inset-bottom), var(--ha-space-3)); + line-height: var(--ha-line-height-expanded); + padding-top: var(--ha-space-6); + justify-content: start; + } + + .sections { + display: flex; + flex-wrap: nowrap; + gap: var(--ha-space-2); + padding: var(--ha-space-3); + margin-bottom: calc(var(--ha-space-3) * -1); + overflow: auto; + overflow-x: auto; + overflow-y: hidden; + } + + .sections ha-filter-chip { + flex-shrink: 0; + --md-filter-chip-selected-container-color: var( + --ha-color-fill-primary-normal-hover + ); + color: var(--primary-color); + } + + .sections .separator { + height: var(--ha-space-8); + width: 0; + border: 1px solid var(--ha-color-border-neutral-quiet); + } + + .search-results { + border-radius: var(--ha-border-radius-xl); + border: 1px solid var(--ha-color-border-neutral-quiet); + margin: var(--ha-space-3); + overflow: hidden; + flex: 1; + display: flex; + flex-direction: column; + } + + lit-virtualizer ha-section-title { + width: 100%; + } + + lit-virtualizer { + flex: 1; + } + + lit-virtualizer:focus-visible { + outline: none; + } + + ha-combo-box-item { + width: 100%; + } + + ha-combo-box-item.selected { + background-color: var(--ha-color-fill-neutral-quiet-hover); + } + + @media (prefers-color-scheme: dark) { + ha-combo-box-item.selected { + background-color: var(--ha-color-fill-neutral-normal-hover); + } + } + + ha-svg-icon.plus { + color: var(--primary-color); + } + `; +} + +declare global { + interface HTMLElementTagNameMap { + "ha-automation-add-search": HaAutomationAddSearch; + } + + interface HASSDomEvents { + "search-element-picked": + | PickerComboBoxItem + | FloorComboBoxItem + | EntityComboBoxItem; + } +} diff --git a/src/panels/config/automation/condition/ha-automation-condition.ts b/src/panels/config/automation/condition/ha-automation-condition.ts index b1b024eec7..9a6aaf4a57 100644 --- a/src/panels/config/automation/condition/ha-automation-condition.ts +++ b/src/panels/config/automation/condition/ha-automation-condition.ts @@ -1,5 +1,6 @@ import { mdiDragHorizontalVariant, mdiPlus } from "@mdi/js"; import deepClone from "deep-clone-simple"; +import type { HassServiceTarget } from "home-assistant-js-websocket"; import type { PropertyValues } from "lit"; import { html, LitElement, nothing } from "lit"; import { customElement, property, queryAll, state } from "lit/decorators"; @@ -261,7 +262,7 @@ export default class HaAutomationCondition extends SubscribeMixin(LitElement) { }); } - private _addCondition = (value) => { + private _addCondition = (value: string, target?: HassServiceTarget) => { let conditions: Condition[]; if (value === PASTE_VALUE) { conditions = this.conditions.concat( @@ -270,6 +271,7 @@ export default class HaAutomationCondition extends SubscribeMixin(LitElement) { } else if (isDynamic(value)) { conditions = this.conditions.concat({ condition: getValueFromDynamic(value), + target, }); } else { const condition = value as Condition["condition"]; diff --git a/src/panels/config/automation/show-add-automation-element-dialog.ts b/src/panels/config/automation/show-add-automation-element-dialog.ts index 90a4ab417c..8ae17c48a6 100644 --- a/src/panels/config/automation/show-add-automation-element-dialog.ts +++ b/src/panels/config/automation/show-add-automation-element-dialog.ts @@ -1,10 +1,11 @@ +import type { HassServiceTarget } from "home-assistant-js-websocket"; import { fireEvent } from "../../../common/dom/fire_event"; export const PASTE_VALUE = "__paste__"; export interface AddAutomationElementDialogParams { type: "trigger" | "condition" | "action"; - add: (key: string) => void; + add: (key: string, target?: HassServiceTarget) => void; clipboardItem: string | undefined; } const loadDialog = () => import("./add-automation-element-dialog"); diff --git a/src/panels/config/automation/trigger/ha-automation-trigger.ts b/src/panels/config/automation/trigger/ha-automation-trigger.ts index 8e1c78d1d1..5df376a004 100644 --- a/src/panels/config/automation/trigger/ha-automation-trigger.ts +++ b/src/panels/config/automation/trigger/ha-automation-trigger.ts @@ -1,5 +1,6 @@ import { mdiDragHorizontalVariant, mdiPlus } from "@mdi/js"; import deepClone from "deep-clone-simple"; +import type { HassServiceTarget } from "home-assistant-js-websocket"; import type { PropertyValues } from "lit"; import { html, LitElement, nothing } from "lit"; import { customElement, property, state } from "lit/decorators"; @@ -174,13 +175,14 @@ export default class HaAutomationTrigger extends SubscribeMixin(LitElement) { }); } - private _addTrigger = (value: string) => { + private _addTrigger = (value: string, target?: HassServiceTarget) => { let triggers: Trigger[]; if (value === PASTE_VALUE) { triggers = this.triggers.concat(deepClone(this._clipboard!.trigger)); } else if (isDynamic(value)) { triggers = this.triggers.concat({ trigger: getValueFromDynamic(value), + target, }); } else { const trigger = value as Exclude["trigger"]; diff --git a/src/panels/config/devices/ha-config-devices-dashboard.ts b/src/panels/config/devices/ha-config-devices-dashboard.ts index 6de2bcf7c0..cf913876ac 100644 --- a/src/panels/config/devices/ha-config-devices-dashboard.ts +++ b/src/panels/config/devices/ha-config-devices-dashboard.ts @@ -1,19 +1,19 @@ import { consume } from "@lit/context"; import { + mdiCancel, mdiChevronRight, + mdiDelete, mdiDotsVertical, mdiMenuDown, mdiPlus, mdiTextureBox, - mdiCancel, - mdiDelete, } from "@mdi/js"; import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit"; import { LitElement, css, html, nothing } from "lit"; import { ResizeController } from "@lit-labs/observers/resize-controller"; import type { UnsubscribeFunc } from "home-assistant-js-websocket"; -import { customElement, property, state, query } from "lit/decorators"; +import { customElement, property, query, state } from "lit/decorators"; import memoizeOne from "memoize-one"; import { computeCssColor } from "../../../common/color/compute-color"; import { formatShortDateTime } from "../../../common/datetime/format_date_time"; @@ -68,8 +68,8 @@ import type { DeviceRegistryEntry, } from "../../../data/device_registry"; import { - updateDeviceRegistryEntry, removeConfigEntryFromDevice, + updateDeviceRegistryEntry, } from "../../../data/device_registry"; import type { EntityRegistryEntry } from "../../../data/entity_registry"; import { @@ -86,8 +86,8 @@ import { showAlertDialog, showConfirmationDialog, } from "../../../dialogs/generic/show-dialog-box"; -import type { HaTabsSubpageDataTable } from "../../../layouts/hass-tabs-subpage-data-table"; import "../../../layouts/hass-tabs-subpage-data-table"; +import type { HaTabsSubpageDataTable } from "../../../layouts/hass-tabs-subpage-data-table"; import { SubscribeMixin } from "../../../mixins/subscribe-mixin"; import { haStyle } from "../../../resources/styles"; import type { HomeAssistant, Route } from "../../../types"; @@ -318,7 +318,7 @@ export class HaConfigDeviceDashboard extends SubscribeMixin(LitElement) { }) ); - const deviceEntityLookup: DeviceEntityLookup = {}; + const deviceEntityLookup: DeviceEntityLookup = {}; for (const entity of entities) { if (!entity.device_id) { continue; diff --git a/src/resources/theme/color/wa.globals.ts b/src/resources/theme/color/wa.globals.ts index d3c83d8afd..40a173f2dc 100644 --- a/src/resources/theme/color/wa.globals.ts +++ b/src/resources/theme/color/wa.globals.ts @@ -52,6 +52,8 @@ export const waColorStyles = css` --wa-color-danger-on-normal: var(--ha-color-on-danger-normal); --wa-color-danger-on-quiet: var(--ha-color-on-danger-quiet); + --wa-color-text-quiet: var(--ha-color-text-secondary); + --wa-color-text-normal: var(--ha-color-text-primary); --wa-color-surface-default: var(--card-background-color); --wa-color-surface-raised: var(--ha-dialog-surface-background, var(--mdc-theme-surface, #fff)); @@ -62,5 +64,7 @@ export const waColorStyles = css` --wa-focus-ring-color: var(--ha-color-neutral-60); --wa-shadow-l: 4px 8px 12px 0 rgba(0, 0, 0, 0.3); + + --wa-color-text-normal: var(--ha-color-text-primary); } `; diff --git a/src/resources/theme/wa.globals.ts b/src/resources/theme/wa.globals.ts index 3599684dce..5239b4607f 100644 --- a/src/resources/theme/wa.globals.ts +++ b/src/resources/theme/wa.globals.ts @@ -9,12 +9,16 @@ export const waMainStyles = css` --wa-focus-ring-offset: 2px; --wa-focus-ring: var(--wa-focus-ring-style) var(--wa-focus-ring-width) var(--wa-focus-ring-color); + --wa-space-xs: var(--ha-space-2); + --wa-space-m: var(--ha-space-4); --wa-space-l: var(--ha-space-6); --wa-space-xl: var(--ha-space-8); --wa-form-control-padding-block: 0.75em; + --wa-form-control-value-line-height: var(--ha-line-height-condensed); --wa-font-weight-action: var(--ha-font-weight-medium); + --wa-transition-normal: 150ms; --wa-transition-fast: 75ms; --wa-transition-easing: ease; @@ -28,6 +32,7 @@ export const waMainStyles = css` --wa-line-height-condensed: var(--ha-line-height-condensed); + --wa-font-size-m: var(--ha-font-size-m); --wa-shadow-s: var(--ha-box-shadow-s); --wa-shadow-m: var(--ha-box-shadow-m); --wa-shadow-l: var(--ha-box-shadow-l); diff --git a/src/translations/en.json b/src/translations/en.json index 83019378e6..be2079851d 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -4026,7 +4026,25 @@ "item_pasted": "{item} pasted", "ctrl": "Ctrl", "del": "Del", + "targets": "Targets", + "select_target": "Select a target", + "home": "Home", + "unassigned": "Unassigned", "blocks": "Blocks", + "show_more": "Show more", + "unassigned_entities": "Unassigned entities", + "unassigned_devices": "Unassigned devices", + "empty_section_search": { + "block": "No blocks found for {term}", + "entity": "No entities found for {term}", + "device": "No devices found for {term}", + "area": "No areas or floors found for {term}", + "label": "No labels found for {term}" + }, + "load_target_items_failed": "Failed to load target items for", + "other_areas": "Other areas", + "services": "Services", + "helpers": "Helpers", "triggers": { "name": "Triggers", "header": "When", @@ -4034,7 +4052,10 @@ "learn_more": "Learn more about triggers", "triggered": "Triggered", "add": "Add trigger", - "empty_search": "No triggers found for {term}", + "empty_search": { + "global": "No triggers and targets found for {term}", + "item": "No triggers found for {term}" + }, "id": "Trigger ID", "optional": "Optional", "edit_id": "Edit ID", @@ -4055,6 +4076,7 @@ "copied_to_clipboard": "Trigger copied to clipboard", "cut_to_clipboard": "Trigger cut to clipboard", "select": "Select a trigger", + "no_items_for_target": "No triggers available for", "groups": { "device": { "label": "Device" @@ -4296,7 +4318,10 @@ "description": "All conditions added here need to be satisfied for the automation to run. A condition can be satisfied or not at any given time, for example: ''If {user} is home''. You can use building blocks to create more complex conditions.", "learn_more": "Learn more about conditions", "add": "Add condition", - "empty_search": "No conditions and blocks found for {term}", + "empty_search": { + "global": "No conditions, blocks and targets found for {term}", + "item": "No conditions found for {term}" + }, "add_building_block": "Add building block", "test": "Test", "testing_error": "Condition did not pass", @@ -4319,6 +4344,7 @@ "copied_to_clipboard": "Condition copied to clipboard", "cut_to_clipboard": "Condition cut to clipboard", "select": "Select a condition", + "no_items_for_target": "No conditions available for", "groups": { "device": { "label": "Device" @@ -4464,7 +4490,10 @@ "description": "All actions added here will be performed in sequence when the automation runs. An action usually controls one of your areas, devices, or entities, for example: 'Turn on the lights'. You can use building blocks to create more complex sequences of actions.", "learn_more": "Learn more about actions", "add": "Add action", - "empty_search": "No actions and blocks found for {term}", + "empty_search": { + "global": "No actions, blocks and targets found for {term}", + "item": "No actions found for {term}" + }, "add_building_block": "Add building block", "invalid_action": "Invalid action", "run": "Run action", @@ -4489,6 +4518,7 @@ "copied_to_clipboard": "Action copied to clipboard", "cut_to_clipboard": "Action cut to clipboard", "select": "Select an action", + "no_items_for_target": "No actions available for", "groups": { "device_id": { "label": "Device" diff --git a/yarn.lock b/yarn.lock index a75ed9bdc1..ae07882af0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1940,9 +1940,9 @@ __metadata: languageName: node linkType: hard -"@home-assistant/webawesome@npm:3.0.0": - version: 3.0.0 - resolution: "@home-assistant/webawesome@npm:3.0.0" +"@home-assistant/webawesome@npm:3.0.0-ha.0": + version: 3.0.0-ha.0 + resolution: "@home-assistant/webawesome@npm:3.0.0-ha.0" dependencies: "@ctrl/tinycolor": "npm:4.1.0" "@floating-ui/dom": "npm:^1.6.13" @@ -1953,7 +1953,7 @@ __metadata: lit: "npm:^3.2.1" nanoid: "npm:^5.1.5" qr-creator: "npm:^1.0.0" - checksum: 10/03400894cfee8548fd5b1f5c56d31d253830e704b18ba69d36ce6b761d8b1bef2fb52cffba8d9b033033bb582f2f51a2d6444d82622f66d70150e2104fcb49e2 + checksum: 10/2034d498d5b26bb0573ebc2c9aadd144604bb48c04becbae0c67b16857d8e5d6562626e795974362c3fc41e9b593a9005595d8b5ff434b1569b2d724af13043b languageName: node linkType: hard @@ -9226,7 +9226,7 @@ __metadata: "@fullcalendar/list": "npm:6.1.19" "@fullcalendar/luxon3": "npm:6.1.19" "@fullcalendar/timegrid": "npm:6.1.19" - "@home-assistant/webawesome": "npm:3.0.0" + "@home-assistant/webawesome": "npm:3.0.0-ha.0" "@lezer/highlight": "npm:1.2.3" "@lit-labs/motion": "npm:1.0.9" "@lit-labs/observers": "npm:2.0.6"