diff --git a/src/common/entity/context/get_area_context.ts b/src/common/entity/context/get_area_context.ts index 69debf4609..44531a0c07 100644 --- a/src/common/entity/context/get_area_context.ts +++ b/src/common/entity/context/get_area_context.ts @@ -8,10 +8,10 @@ interface AreaContext { } export const getAreaContext = ( area: AreaRegistryEntry, - hass: HomeAssistant + hassFloors: HomeAssistant["floors"] ): AreaContext => { const floorId = area.floor_id; - const floor = floorId ? hass.floors[floorId] : undefined; + const floor = floorId ? hassFloors[floorId] : undefined; return { area: area, diff --git a/src/components/device/ha-device-picker.ts b/src/components/device/ha-device-picker.ts index 38431a19e3..7096ffc2f6 100644 --- a/src/components/device/ha-device-picker.ts +++ b/src/components/device/ha-device-picker.ts @@ -5,24 +5,18 @@ import { customElement, property, query, state } from "lit/decorators"; import memoizeOne from "memoize-one"; import { fireEvent } from "../../common/dom/fire_event"; import { computeAreaName } from "../../common/entity/compute_area_name"; -import { - computeDeviceName, - computeDeviceNameDisplay, -} from "../../common/entity/compute_device_name"; -import { computeDomain } from "../../common/entity/compute_domain"; +import { computeDeviceName } from "../../common/entity/compute_device_name"; import { getDeviceContext } from "../../common/entity/context/get_device_context"; import { getConfigEntries, type ConfigEntry } from "../../data/config_entries"; import { - getDeviceEntityDisplayLookup, - type DeviceEntityDisplayLookup, + getDevices, + type DevicePickerItem, type DeviceRegistryEntry, } from "../../data/device_registry"; -import { domainToName } from "../../data/integration"; import type { HomeAssistant } from "../../types"; import { brandsUrl } from "../../util/brands-url"; import "../ha-generic-picker"; import type { HaGenericPicker } from "../ha-generic-picker"; -import type { PickerComboBoxItem } from "../ha-picker-combo-box"; export type HaDevicePickerDeviceFilterFunc = ( device: DeviceRegistryEntry @@ -30,11 +24,6 @@ export type HaDevicePickerDeviceFilterFunc = ( export type HaDevicePickerEntityFilterFunc = (entity: HassEntity) => boolean; -interface DevicePickerItem extends PickerComboBoxItem { - domain?: string; - domain_name?: string; -} - @customElement("ha-device-picker") export class HaDevicePicker extends LitElement { @property({ attribute: false }) public hass!: HomeAssistant; @@ -104,6 +93,8 @@ export class HaDevicePicker extends LitElement { @state() private _configEntryLookup: Record = {}; + private _getDevicesMemoized = memoizeOne(getDevices); + protected firstUpdated(_changedProperties: PropertyValues): void { super.firstUpdated(_changedProperties); this._loadConfigEntries(); @@ -117,162 +108,18 @@ export class HaDevicePicker extends LitElement { } private _getItems = () => - this._getDevices( - this.hass.devices, - this.hass.entities, + this._getDevicesMemoized( + this.hass, this._configEntryLookup, this.includeDomains, this.excludeDomains, this.includeDeviceClasses, this.deviceFilter, this.entityFilter, - this.excludeDevices + this.excludeDevices, + this.value ); - private _getDevices = memoizeOne( - ( - haDevices: HomeAssistant["devices"], - haEntities: HomeAssistant["entities"], - configEntryLookup: Record, - includeDomains: this["includeDomains"], - excludeDomains: this["excludeDomains"], - includeDeviceClasses: this["includeDeviceClasses"], - deviceFilter: this["deviceFilter"], - entityFilter: this["entityFilter"], - excludeDevices: this["excludeDevices"] - ): DevicePickerItem[] => { - const devices = Object.values(haDevices); - const entities = Object.values(haEntities); - - let deviceEntityLookup: DeviceEntityDisplayLookup = {}; - - if ( - includeDomains || - excludeDomains || - includeDeviceClasses || - entityFilter - ) { - deviceEntityLookup = getDeviceEntityDisplayLookup(entities); - } - - let inputDevices = devices.filter( - (device) => device.id === this.value || !device.disabled_by - ); - - if (includeDomains) { - inputDevices = inputDevices.filter((device) => { - const devEntities = deviceEntityLookup[device.id]; - if (!devEntities || !devEntities.length) { - return false; - } - return deviceEntityLookup[device.id].some((entity) => - includeDomains.includes(computeDomain(entity.entity_id)) - ); - }); - } - - if (excludeDomains) { - inputDevices = inputDevices.filter((device) => { - const devEntities = deviceEntityLookup[device.id]; - if (!devEntities || !devEntities.length) { - return true; - } - return entities.every( - (entity) => - !excludeDomains.includes(computeDomain(entity.entity_id)) - ); - }); - } - - if (excludeDevices) { - inputDevices = inputDevices.filter( - (device) => !excludeDevices!.includes(device.id) - ); - } - - if (includeDeviceClasses) { - inputDevices = inputDevices.filter((device) => { - const devEntities = deviceEntityLookup[device.id]; - if (!devEntities || !devEntities.length) { - return false; - } - return deviceEntityLookup[device.id].some((entity) => { - const stateObj = this.hass.states[entity.entity_id]; - if (!stateObj) { - return false; - } - return ( - stateObj.attributes.device_class && - includeDeviceClasses.includes(stateObj.attributes.device_class) - ); - }); - }); - } - - if (entityFilter) { - inputDevices = inputDevices.filter((device) => { - const devEntities = deviceEntityLookup[device.id]; - if (!devEntities || !devEntities.length) { - return false; - } - return devEntities.some((entity) => { - const stateObj = this.hass.states[entity.entity_id]; - if (!stateObj) { - return false; - } - return entityFilter(stateObj); - }); - }); - } - - if (deviceFilter) { - inputDevices = inputDevices.filter( - (device) => - // We always want to include the device of the current value - device.id === this.value || deviceFilter!(device) - ); - } - - const outputDevices = inputDevices.map((device) => { - const deviceName = computeDeviceNameDisplay( - device, - this.hass, - deviceEntityLookup[device.id] - ); - - const { area } = getDeviceContext(device, this.hass); - - const areaName = area ? computeAreaName(area) : undefined; - - const configEntry = device.primary_config_entry - ? configEntryLookup?.[device.primary_config_entry] - : undefined; - - const domain = configEntry?.domain; - const domainName = domain - ? domainToName(this.hass.localize, domain) - : undefined; - - return { - id: device.id, - label: "", - primary: - deviceName || - this.hass.localize("ui.components.device-picker.unnamed_device"), - secondary: areaName, - domain: configEntry?.domain, - domain_name: domainName, - search_labels: [deviceName, areaName, domain, domainName].filter( - Boolean - ) as string[], - sorting_label: deviceName || "zzz", - }; - }); - - return outputDevices; - } - ); - private _valueRenderer = memoizeOne( (configEntriesLookup: Record) => (value: string) => { const deviceId = value; diff --git a/src/components/entity/ha-entities-picker.ts b/src/components/entity/ha-entities-picker.ts index f544cffbf1..338c21f428 100644 --- a/src/components/entity/ha-entities-picker.ts +++ b/src/components/entity/ha-entities-picker.ts @@ -7,7 +7,7 @@ import { isValidEntityId } from "../../common/entity/valid_entity_id"; import type { HomeAssistant, ValueChangedEvent } from "../../types"; import "../ha-sortable"; import "./ha-entity-picker"; -import type { HaEntityPickerEntityFilterFunc } from "./ha-entity-picker"; +import type { HaEntityPickerEntityFilterFunc } from "../../data/entity"; @customElement("ha-entities-picker") class HaEntitiesPicker extends LitElement { diff --git a/src/components/entity/ha-entity-attribute-picker.ts b/src/components/entity/ha-entity-attribute-picker.ts index eaa62ff761..6278b67fc9 100644 --- a/src/components/entity/ha-entity-attribute-picker.ts +++ b/src/components/entity/ha-entity-attribute-picker.ts @@ -1,4 +1,3 @@ -import type { HassEntity } from "home-assistant-js-websocket"; import type { PropertyValues } from "lit"; import { LitElement, html, nothing } from "lit"; import { customElement, property, query, state } from "lit/decorators"; @@ -8,8 +7,6 @@ import type { HomeAssistant, ValueChangedEvent } from "../../types"; import "../ha-combo-box"; import type { HaComboBox } from "../ha-combo-box"; -export type HaEntityPickerEntityFilterFunc = (entityId: HassEntity) => boolean; - interface AttributeOption { value: string; label: string; diff --git a/src/components/entity/ha-entity-picker.ts b/src/components/entity/ha-entity-picker.ts index bf12d37c6f..affdbafe9d 100644 --- a/src/components/entity/ha-entity-picker.ts +++ b/src/components/entity/ha-entity-picker.ts @@ -1,15 +1,17 @@ import { mdiPlus, mdiShape } from "@mdi/js"; import type { ComboBoxLitRenderer } from "@vaadin/combo-box/lit"; -import type { HassEntity } from "home-assistant-js-websocket"; import { html, LitElement, nothing, type PropertyValues } from "lit"; import { customElement, property, query } from "lit/decorators"; import memoizeOne from "memoize-one"; import { fireEvent } from "../../common/dom/fire_event"; -import { computeDomain } from "../../common/entity/compute_domain"; import { computeEntityNameList } from "../../common/entity/compute_entity_name_display"; -import { computeStateName } from "../../common/entity/compute_state_name"; import { isValidEntityId } from "../../common/entity/valid_entity_id"; import { computeRTL } from "../../common/util/compute_rtl"; +import type { HaEntityPickerEntityFilterFunc } from "../../data/entity"; +import { + getEntities, + type EntityComboBoxItem, +} from "../../data/entity_registry"; import { domainToName } from "../../data/integration"; import { isHelperDomain, @@ -20,21 +22,11 @@ import type { HomeAssistant } from "../../types"; import "../ha-combo-box-item"; import "../ha-generic-picker"; import type { HaGenericPicker } from "../ha-generic-picker"; -import type { - PickerComboBoxItem, - PickerComboBoxSearchFn, -} from "../ha-picker-combo-box"; +import type { PickerComboBoxSearchFn } from "../ha-picker-combo-box"; import type { PickerValueRenderer } from "../ha-picker-field"; import "../ha-svg-icon"; import "./state-badge"; -interface EntityComboBoxItem extends PickerComboBoxItem { - domain_name?: string; - stateObj?: HassEntity; -} - -export type HaEntityPickerEntityFilterFunc = (entity: HassEntity) => boolean; - const CREATE_ID = "___create-new-entity___"; @customElement("ha-entity-picker") @@ -255,8 +247,10 @@ export class HaEntityPicker extends LitElement { } ); + private _getEntitiesMemoized = memoizeOne(getEntities); + private _getItems = () => - this._getEntities( + this._getEntitiesMemoized( this.hass, this.includeDomains, this.excludeDomains, @@ -264,128 +258,10 @@ export class HaEntityPicker extends LitElement { this.includeDeviceClasses, this.includeUnitOfMeasurement, this.includeEntities, - this.excludeEntities + this.excludeEntities, + this.value ); - private _getEntities = memoizeOne( - ( - hass: this["hass"], - includeDomains: this["includeDomains"], - excludeDomains: this["excludeDomains"], - entityFilter: this["entityFilter"], - includeDeviceClasses: this["includeDeviceClasses"], - includeUnitOfMeasurement: this["includeUnitOfMeasurement"], - includeEntities: this["includeEntities"], - excludeEntities: this["excludeEntities"] - ): EntityComboBoxItem[] => { - let items: EntityComboBoxItem[] = []; - - let entityIds = Object.keys(hass.states); - - if (includeEntities) { - entityIds = entityIds.filter((entityId) => - includeEntities.includes(entityId) - ); - } - - if (excludeEntities) { - entityIds = entityIds.filter( - (entityId) => !excludeEntities.includes(entityId) - ); - } - - if (includeDomains) { - entityIds = entityIds.filter((eid) => - includeDomains.includes(computeDomain(eid)) - ); - } - - if (excludeDomains) { - entityIds = entityIds.filter( - (eid) => !excludeDomains.includes(computeDomain(eid)) - ); - } - - const isRTL = computeRTL(hass); - - items = entityIds.map((entityId) => { - const stateObj = hass.states[entityId]; - - const friendlyName = computeStateName(stateObj); // Keep this for search - - const [entityName, deviceName, areaName] = computeEntityNameList( - stateObj, - [{ type: "entity" }, { type: "device" }, { type: "area" }], - hass.entities, - hass.devices, - hass.areas, - hass.floors - ); - - const domainName = domainToName(hass.localize, computeDomain(entityId)); - - const primary = entityName || deviceName || entityId; - const secondary = [areaName, entityName ? deviceName : undefined] - .filter(Boolean) - .join(isRTL ? " ◂ " : " ▸ "); - const a11yLabel = [deviceName, entityName].filter(Boolean).join(" - "); - - return { - id: entityId, - primary: primary, - secondary: secondary, - domain_name: domainName, - sorting_label: [deviceName, entityName].filter(Boolean).join("_"), - search_labels: [ - entityName, - deviceName, - areaName, - domainName, - friendlyName, - entityId, - ].filter(Boolean) as string[], - a11y_label: a11yLabel, - stateObj: stateObj, - }; - }); - - if (includeDeviceClasses) { - items = items.filter( - (item) => - // We always want to include the entity of the current value - item.id === this.value || - (item.stateObj?.attributes.device_class && - includeDeviceClasses.includes( - item.stateObj.attributes.device_class - )) - ); - } - - if (includeUnitOfMeasurement) { - items = items.filter( - (item) => - // We always want to include the entity of the current value - item.id === this.value || - (item.stateObj?.attributes.unit_of_measurement && - includeUnitOfMeasurement.includes( - item.stateObj.attributes.unit_of_measurement - )) - ); - } - - if (entityFilter) { - items = items.filter( - (item) => - // We always want to include the entity of the current value - item.id === this.value || - (item.stateObj && entityFilter!(item.stateObj)) - ); - } - - return items; - } - ); - protected render() { const placeholder = this.placeholder ?? diff --git a/src/components/entity/ha-entity-state-picker.ts b/src/components/entity/ha-entity-state-picker.ts index 0ecf1e9703..d8f48df8b7 100644 --- a/src/components/entity/ha-entity-state-picker.ts +++ b/src/components/entity/ha-entity-state-picker.ts @@ -1,4 +1,3 @@ -import type { HassEntity } from "home-assistant-js-websocket"; import type { PropertyValues } from "lit"; import { LitElement, html, nothing } from "lit"; import { customElement, property, query, state } from "lit/decorators"; @@ -9,8 +8,6 @@ import type { HomeAssistant, ValueChangedEvent } from "../../types"; import "../ha-combo-box"; import type { HaComboBox } from "../ha-combo-box"; -export type HaEntityPickerEntityFilterFunc = (entityId: HassEntity) => boolean; - interface StateOption { value: string; label: string; diff --git a/src/components/ha-area-floor-picker.ts b/src/components/ha-area-floor-picker.ts index 1b2f4afb91..ca342a57d6 100644 --- a/src/components/ha-area-floor-picker.ts +++ b/src/components/ha-area-floor-picker.ts @@ -8,21 +8,13 @@ import { styleMap } from "lit/directives/style-map"; import memoizeOne from "memoize-one"; import { fireEvent } from "../common/dom/fire_event"; import { computeAreaName } from "../common/entity/compute_area_name"; -import { computeDomain } from "../common/entity/compute_domain"; import { computeFloorName } from "../common/entity/compute_floor_name"; -import { stringCompare } from "../common/string/compare"; import { computeRTL } from "../common/util/compute_rtl"; -import type { AreaRegistryEntry } from "../data/area_registry"; -import type { - DeviceEntityDisplayLookup, - DeviceRegistryEntry, -} from "../data/device_registry"; -import { getDeviceEntityDisplayLookup } from "../data/device_registry"; -import type { EntityRegistryDisplayEntry } from "../data/entity_registry"; import { - getFloorAreaLookup, - type FloorRegistryEntry, -} from "../data/floor_registry"; + getAreasAndFloors, + type AreaFloorValue, + type FloorComboBoxItem, +} from "../data/area_floor"; import type { HomeAssistant, ValueChangedEvent } from "../types"; import type { HaDevicePickerDeviceFilterFunc } from "./device/ha-device-picker"; import "./ha-combo-box-item"; @@ -30,24 +22,12 @@ import "./ha-floor-icon"; import "./ha-generic-picker"; import type { HaGenericPicker } from "./ha-generic-picker"; import "./ha-icon-button"; -import type { PickerComboBoxItem } from "./ha-picker-combo-box"; import type { PickerValueRenderer } from "./ha-picker-field"; import "./ha-svg-icon"; import "./ha-tree-indicator"; const SEPARATOR = "________"; -interface FloorComboBoxItem extends PickerComboBoxItem { - type: "floor" | "area"; - floor?: FloorRegistryEntry; - area?: AreaRegistryEntry; -} - -interface AreaFloorValue { - id: string; - type: "floor" | "area"; -} - @customElement("ha-area-floor-picker") export class HaAreaFloorPicker extends LitElement { @property({ attribute: false }) public hass!: HomeAssistant; @@ -154,243 +134,6 @@ export class HaAreaFloorPicker extends LitElement { `; }; - private _getAreasAndFloors = memoizeOne( - ( - haFloors: HomeAssistant["floors"], - haAreas: HomeAssistant["areas"], - haDevices: HomeAssistant["devices"], - haEntities: HomeAssistant["entities"], - includeDomains: this["includeDomains"], - excludeDomains: this["excludeDomains"], - includeDeviceClasses: this["includeDeviceClasses"], - deviceFilter: this["deviceFilter"], - entityFilter: this["entityFilter"], - excludeAreas: this["excludeAreas"], - excludeFloors: this["excludeFloors"] - ): FloorComboBoxItem[] => { - const floors = Object.values(haFloors); - const areas = Object.values(haAreas); - const devices = Object.values(haDevices); - const entities = Object.values(haEntities); - - let deviceEntityLookup: DeviceEntityDisplayLookup = {}; - let inputDevices: DeviceRegistryEntry[] | undefined; - let inputEntities: EntityRegistryDisplayEntry[] | undefined; - - if ( - includeDomains || - excludeDomains || - includeDeviceClasses || - deviceFilter || - entityFilter - ) { - deviceEntityLookup = getDeviceEntityDisplayLookup(entities); - inputDevices = devices; - inputEntities = entities.filter((entity) => entity.area_id); - - if (includeDomains) { - inputDevices = inputDevices!.filter((device) => { - const devEntities = deviceEntityLookup[device.id]; - if (!devEntities || !devEntities.length) { - return false; - } - return deviceEntityLookup[device.id].some((entity) => - includeDomains.includes(computeDomain(entity.entity_id)) - ); - }); - inputEntities = inputEntities!.filter((entity) => - includeDomains.includes(computeDomain(entity.entity_id)) - ); - } - - if (excludeDomains) { - inputDevices = inputDevices!.filter((device) => { - const devEntities = deviceEntityLookup[device.id]; - if (!devEntities || !devEntities.length) { - return true; - } - return entities.every( - (entity) => - !excludeDomains.includes(computeDomain(entity.entity_id)) - ); - }); - inputEntities = inputEntities!.filter( - (entity) => - !excludeDomains.includes(computeDomain(entity.entity_id)) - ); - } - - if (includeDeviceClasses) { - inputDevices = inputDevices!.filter((device) => { - const devEntities = deviceEntityLookup[device.id]; - if (!devEntities || !devEntities.length) { - return false; - } - return deviceEntityLookup[device.id].some((entity) => { - const stateObj = this.hass.states[entity.entity_id]; - if (!stateObj) { - return false; - } - return ( - stateObj.attributes.device_class && - includeDeviceClasses.includes(stateObj.attributes.device_class) - ); - }); - }); - inputEntities = inputEntities!.filter((entity) => { - const stateObj = this.hass.states[entity.entity_id]; - return ( - stateObj.attributes.device_class && - includeDeviceClasses.includes(stateObj.attributes.device_class) - ); - }); - } - - if (deviceFilter) { - inputDevices = inputDevices!.filter((device) => - deviceFilter!(device) - ); - } - - if (entityFilter) { - inputDevices = inputDevices!.filter((device) => { - const devEntities = deviceEntityLookup[device.id]; - if (!devEntities || !devEntities.length) { - return false; - } - return deviceEntityLookup[device.id].some((entity) => { - const stateObj = this.hass.states[entity.entity_id]; - if (!stateObj) { - return false; - } - return entityFilter(stateObj); - }); - }); - inputEntities = inputEntities!.filter((entity) => { - const stateObj = this.hass.states[entity.entity_id]; - if (!stateObj) { - return false; - } - return entityFilter!(stateObj); - }); - } - } - - let outputAreas = areas; - - let areaIds: string[] | undefined; - - if (inputDevices) { - areaIds = inputDevices - .filter((device) => device.area_id) - .map((device) => device.area_id!); - } - - if (inputEntities) { - areaIds = (areaIds ?? []).concat( - inputEntities - .filter((entity) => entity.area_id) - .map((entity) => entity.area_id!) - ); - } - - if (areaIds) { - outputAreas = outputAreas.filter((area) => - areaIds!.includes(area.area_id) - ); - } - - if (excludeAreas) { - outputAreas = outputAreas.filter( - (area) => !excludeAreas!.includes(area.area_id) - ); - } - - if (excludeFloors) { - outputAreas = outputAreas.filter( - (area) => !area.floor_id || !excludeFloors!.includes(area.floor_id) - ); - } - - const floorAreaLookup = getFloorAreaLookup(outputAreas); - const unassisgnedAreas = Object.values(outputAreas).filter( - (area) => !area.floor_id || !floorAreaLookup[area.floor_id] - ); - - // @ts-ignore - const floorAreaEntries: [ - FloorRegistryEntry | undefined, - AreaRegistryEntry[], - ][] = Object.entries(floorAreaLookup) - .map(([floorId, floorAreas]) => { - const floor = floors.find((fl) => fl.floor_id === floorId)!; - return [floor, floorAreas] as const; - }) - .sort(([floorA], [floorB]) => { - if (floorA.level !== floorB.level) { - return (floorA.level ?? 0) - (floorB.level ?? 0); - } - return stringCompare(floorA.name, floorB.name); - }); - - const items: FloorComboBoxItem[] = []; - - floorAreaEntries.forEach(([floor, floorAreas]) => { - if (floor) { - const floorName = computeFloorName(floor); - - const areaSearchLabels = floorAreas - .map((area) => { - const areaName = computeAreaName(area) || area.area_id; - return [area.area_id, areaName, ...area.aliases]; - }) - .flat(); - - items.push({ - id: this._formatValue({ id: floor.floor_id, type: "floor" }), - type: "floor", - primary: floorName, - floor: floor, - search_labels: [ - floor.floor_id, - floorName, - ...floor.aliases, - ...areaSearchLabels, - ], - }); - } - items.push( - ...floorAreas.map((area) => { - const areaName = computeAreaName(area) || area.area_id; - return { - id: this._formatValue({ 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], - }; - }) - ); - }); - - items.push( - ...unassisgnedAreas.map((area) => { - const areaName = computeAreaName(area) || area.area_id; - return { - id: this._formatValue({ id: area.area_id, type: "area" }), - type: "area" as const, - primary: areaName, - icon: area.icon || undefined, - search_labels: [area.area_id, areaName, ...area.aliases], - }; - }) - ); - - return items; - } - ); - private _rowRenderer: ComboBoxLitRenderer = ( item, { index }, @@ -445,12 +188,16 @@ export class HaAreaFloorPicker extends LitElement { `; }; + private _getAreasAndFloorsMemoized = memoizeOne(getAreasAndFloors); + private _getItems = () => - this._getAreasAndFloors( + this._getAreasAndFloorsMemoized( + this.hass.states, this.hass.floors, this.hass.areas, this.hass.devices, this.hass.entities, + this._formatValue, this.includeDomains, this.excludeDomains, this.includeDeviceClasses, diff --git a/src/components/ha-area-picker.ts b/src/components/ha-area-picker.ts index 83d7c9d20a..8777c513b9 100644 --- a/src/components/ha-area-picker.ts +++ b/src/components/ha-area-picker.ts @@ -107,7 +107,7 @@ export class HaAreaPicker extends LitElement { `; } - const { floor } = getAreaContext(area, this.hass); + const { floor } = getAreaContext(area, this.hass.floors); const areaName = area ? computeAreaName(area) : undefined; const floorName = floor ? computeFloorName(floor) : undefined; @@ -279,7 +279,7 @@ export class HaAreaPicker extends LitElement { } const items = outputAreas.map((area) => { - const { floor } = getAreaContext(area, this.hass); + const { floor } = getAreaContext(area, this.hass.floors); const floorName = floor ? computeFloorName(floor) : undefined; const areaName = computeAreaName(area); return { diff --git a/src/components/ha-areas-display-editor.ts b/src/components/ha-areas-display-editor.ts index 9a63729ccf..443d7b057c 100644 --- a/src/components/ha-areas-display-editor.ts +++ b/src/components/ha-areas-display-editor.ts @@ -44,7 +44,7 @@ export class HaAreasDisplayEditor extends LitElement { ); const items: DisplayItem[] = areas.map((area) => { - const { floor } = getAreaContext(area, this.hass!); + const { floor } = getAreaContext(area, this.hass.floors); return { value: area.area_id, label: area.name, diff --git a/src/components/ha-areas-floors-display-editor.ts b/src/components/ha-areas-floors-display-editor.ts index de26786871..2adfdc277f 100644 --- a/src/components/ha-areas-floors-display-editor.ts +++ b/src/components/ha-areas-floors-display-editor.ts @@ -138,7 +138,7 @@ export class HaAreasFloorsDisplayEditor extends LitElement { ); const groupedItems: Record = areas.reduce( (acc, area) => { - const { floor } = getAreaContext(area, this.hass!); + const { floor } = getAreaContext(area, this.hass.floors); const floorId = floor?.floor_id ?? UNASSIGNED_FLOOR; if (!acc[floorId]) { diff --git a/src/components/ha-bottom-sheet.ts b/src/components/ha-bottom-sheet.ts index 6bad3d5902..604d5956e6 100644 --- a/src/components/ha-bottom-sheet.ts +++ b/src/components/ha-bottom-sheet.ts @@ -1,5 +1,5 @@ -import { css, html, LitElement, type PropertyValues } from "lit"; import "@home-assistant/webawesome/dist/components/drawer/drawer"; +import { css, html, LitElement, type PropertyValues } from "lit"; import { customElement, property, state } from "lit/decorators"; export const BOTTOM_SHEET_ANIMATION_DURATION_MS = 300; @@ -8,6 +8,9 @@ export const BOTTOM_SHEET_ANIMATION_DURATION_MS = 300; export class HaBottomSheet extends LitElement { @property({ type: Boolean }) public open = false; + @property({ type: Boolean, reflect: true, attribute: "flexcontent" }) + public flexContent = false; + @state() private _drawerOpen = false; private _handleAfterHide() { @@ -41,16 +44,19 @@ export class HaBottomSheet extends LitElement { static styles = css` wa-drawer { - --wa-color-surface-raised: var( - --ha-bottom-sheet-surface-background, - var(--ha-dialog-surface-background, var(--mdc-theme-surface, #fff)), - ); + --wa-color-surface-raised: transparent; --spacing: 0; - --size: auto; + --size: var(--ha-bottom-sheet-height, auto); --show-duration: ${BOTTOM_SHEET_ANIMATION_DURATION_MS}ms; --hide-duration: ${BOTTOM_SHEET_ANIMATION_DURATION_MS}ms; } wa-drawer::part(dialog) { + max-height: var(--ha-bottom-sheet-max-height, 90vh); + align-items: center; + } + wa-drawer::part(body) { + max-width: var(--ha-bottom-sheet-max-width); + width: 100%; border-top-left-radius: var( --ha-bottom-sheet-border-radius, var(--ha-dialog-border-radius, var(--ha-border-radius-2xl)) @@ -59,10 +65,19 @@ export class HaBottomSheet extends LitElement { --ha-bottom-sheet-border-radius, var(--ha-dialog-border-radius, var(--ha-border-radius-2xl)) ); - max-height: 90vh; - padding-bottom: var(--safe-area-inset-bottom); - padding-left: var(--safe-area-inset-left); - padding-right: var(--safe-area-inset-right); + background-color: var( + --ha-bottom-sheet-surface-background, + var(--ha-dialog-surface-background, var(--mdc-theme-surface, #fff)), + ); + padding: var( + --ha-bottom-sheet-padding, + 0 var(--safe-area-inset-right) var(--safe-area-inset-bottom) + var(--safe-area-inset-left) + ); + } + + :host([flexcontent]) wa-drawer::part(body) { + display: flex; } `; } diff --git a/src/components/ha-expansion-panel.ts b/src/components/ha-expansion-panel.ts index 9f82c63477..4bb35a2908 100644 --- a/src/components/ha-expansion-panel.ts +++ b/src/components/ha-expansion-panel.ts @@ -49,6 +49,7 @@ export class HaExpansionPanel extends LitElement { tabindex=${this.noCollapse ? -1 : 0} aria-expanded=${this.expanded} aria-controls="sect1" + part="summary" > ${this.leftChevron ? chevronIcon : nothing} @@ -170,6 +171,11 @@ export class HaExpansionPanel extends LitElement { margin-left: 8px; margin-inline-start: 8px; margin-inline-end: initial; + border-radius: var(--ha-border-radius-circle); + } + + #summary:focus-visible ha-svg-icon.summary-icon { + background-color: var(--ha-color-fill-neutral-normal-active); } :host([left-chevron]) .summary-icon, diff --git a/src/components/ha-generic-picker.ts b/src/components/ha-generic-picker.ts index 98496a4b40..154e8cb869 100644 --- a/src/components/ha-generic-picker.ts +++ b/src/components/ha-generic-picker.ts @@ -79,6 +79,7 @@ export class HaGenericPicker extends LitElement { ${!this._opened ? html` { - if (!labels || labels.length === 0) { - return [ - { - id: NO_LABELS, - primary: this.hass.localize("ui.components.label-picker.no_labels"), - icon_path: mdiLabel, - }, - ]; - } + private _getLabelsMemoized = memoizeOne(getLabels); - const devices = Object.values(haDevices); - const entities = Object.values(haEntities); - - let deviceEntityLookup: DeviceEntityDisplayLookup = {}; - let inputDevices: DeviceRegistryEntry[] | undefined; - let inputEntities: EntityRegistryDisplayEntry[] | undefined; - - if ( - includeDomains || - excludeDomains || - includeDeviceClasses || - deviceFilter || - entityFilter - ) { - deviceEntityLookup = getDeviceEntityDisplayLookup(entities); - inputDevices = devices; - inputEntities = entities.filter((entity) => entity.labels.length > 0); - - if (includeDomains) { - inputDevices = inputDevices!.filter((device) => { - const devEntities = deviceEntityLookup[device.id]; - if (!devEntities || !devEntities.length) { - return false; - } - return deviceEntityLookup[device.id].some((entity) => - includeDomains.includes(computeDomain(entity.entity_id)) - ); - }); - inputEntities = inputEntities!.filter((entity) => - includeDomains.includes(computeDomain(entity.entity_id)) - ); - } - - if (excludeDomains) { - inputDevices = inputDevices!.filter((device) => { - const devEntities = deviceEntityLookup[device.id]; - if (!devEntities || !devEntities.length) { - return true; - } - return entities.every( - (entity) => - !excludeDomains.includes(computeDomain(entity.entity_id)) - ); - }); - inputEntities = inputEntities!.filter( - (entity) => - !excludeDomains.includes(computeDomain(entity.entity_id)) - ); - } - - if (includeDeviceClasses) { - inputDevices = inputDevices!.filter((device) => { - const devEntities = deviceEntityLookup[device.id]; - if (!devEntities || !devEntities.length) { - return false; - } - return deviceEntityLookup[device.id].some((entity) => { - const stateObj = this.hass.states[entity.entity_id]; - if (!stateObj) { - return false; - } - return ( - stateObj.attributes.device_class && - includeDeviceClasses.includes(stateObj.attributes.device_class) - ); - }); - }); - inputEntities = inputEntities!.filter((entity) => { - const stateObj = this.hass.states[entity.entity_id]; - return ( - stateObj.attributes.device_class && - includeDeviceClasses.includes(stateObj.attributes.device_class) - ); - }); - } - - if (deviceFilter) { - inputDevices = inputDevices!.filter((device) => - deviceFilter!(device) - ); - } - - if (entityFilter) { - inputDevices = inputDevices!.filter((device) => { - const devEntities = deviceEntityLookup[device.id]; - if (!devEntities || !devEntities.length) { - return false; - } - return deviceEntityLookup[device.id].some((entity) => { - const stateObj = this.hass.states[entity.entity_id]; - if (!stateObj) { - return false; - } - return entityFilter(stateObj); - }); - }); - inputEntities = inputEntities!.filter((entity) => { - const stateObj = this.hass.states[entity.entity_id]; - if (!stateObj) { - return false; - } - return entityFilter!(stateObj); - }); - } - } - - let outputLabels = labels; - const usedLabels = new Set(); - - let areaIds: string[] | undefined; - - if (inputDevices) { - areaIds = inputDevices - .filter((device) => device.area_id) - .map((device) => device.area_id!); - - inputDevices.forEach((device) => { - device.labels.forEach((label) => usedLabels.add(label)); - }); - } - - if (inputEntities) { - areaIds = (areaIds ?? []).concat( - inputEntities - .filter((entity) => entity.area_id) - .map((entity) => entity.area_id!) - ); - inputEntities.forEach((entity) => { - entity.labels.forEach((label) => usedLabels.add(label)); - }); - } - - if (areaIds) { - areaIds.forEach((areaId) => { - const area = haAreas[areaId]; - area.labels.forEach((label) => usedLabels.add(label)); - }); - } - - if (excludeLabels) { - outputLabels = outputLabels.filter( - (label) => !excludeLabels!.includes(label.label_id) - ); - } - - if (inputDevices || inputEntities) { - outputLabels = outputLabels.filter((label) => - usedLabels.has(label.label_id) - ); - } - - const items = outputLabels.map((label) => ({ - id: label.label_id, - primary: label.name, - icon: label.icon || undefined, - icon_path: label.icon ? undefined : mdiLabel, - sorting_label: label.name, - search_labels: [label.name, label.label_id, label.description].filter( - (v): v is string => Boolean(v) - ), - })); - - return items; + private _getItems = () => { + if (!this._labels || this._labels.length === 0) { + return [ + { + id: NO_LABELS, + primary: this.hass.localize("ui.components.label-picker.no_labels"), + icon_path: mdiLabel, + }, + ]; } - ); - private _getItems = () => - this._getLabels( + return this._getLabelsMemoized( + this.hass, this._labels, - this.hass.areas, - this.hass.devices, - this.hass.entities, this.includeDomains, this.excludeDomains, this.includeDeviceClasses, @@ -339,6 +154,7 @@ export class HaLabelPicker extends SubscribeMixin(LitElement) { this.entityFilter, this.excludeLabels ); + }; private _allLabelNames = memoizeOne((labels?: LabelRegistryEntry[]) => { if (!labels) { diff --git a/src/components/ha-target-picker.ts b/src/components/ha-target-picker.ts index 7a6b860c9c..5e8b1d1eb7 100644 --- a/src/components/ha-target-picker.ts +++ b/src/components/ha-target-picker.ts @@ -1,52 +1,34 @@ +import "@home-assistant/webawesome/dist/components/popover/popover"; // @ts-ignore import chipStyles from "@material/chips/dist/mdc.chips.min.css"; -import "@material/mwc-menu/mwc-menu-surface"; -import { - mdiClose, - mdiDevices, - mdiHome, - mdiLabel, - mdiPlus, - mdiTextureBox, - mdiUnfoldMoreVertical, -} from "@mdi/js"; -import type { ComboBoxLightOpenedChangedEvent } from "@vaadin/combo-box/vaadin-combo-box-light"; -import type { - HassEntity, - HassServiceTarget, - UnsubscribeFunc, -} from "home-assistant-js-websocket"; +import { mdiPlaylistPlus } from "@mdi/js"; +import type { HassServiceTarget } from "home-assistant-js-websocket"; import type { CSSResultGroup } from "lit"; import { LitElement, css, html, nothing, unsafeCSS } from "lit"; import { customElement, property, query, state } from "lit/decorators"; -import { classMap } from "lit/directives/class-map"; import { ensureArray } from "../common/array/ensure-array"; -import { computeCssColor } from "../common/color/compute-color"; -import { hex2rgb } from "../common/color/convert-color"; import { fireEvent } from "../common/dom/fire_event"; -import { stopPropagation } from "../common/dom/stop_propagation"; -import { computeDeviceNameDisplay } from "../common/entity/compute_device_name"; -import { computeDomain } from "../common/entity/compute_domain"; -import { computeStateName } from "../common/entity/compute_state_name"; import { isValidEntityId } from "../common/entity/valid_entity_id"; -import type { AreaRegistryEntry } from "../data/area_registry"; -import type { DeviceRegistryEntry } from "../data/device_registry"; -import type { EntityRegistryDisplayEntry } from "../data/entity_registry"; -import type { LabelRegistryEntry } from "../data/label_registry"; -import { subscribeLabelRegistry } from "../data/label_registry"; +import type { HaEntityPickerEntityFilterFunc } from "../data/entity"; +import { + areaMeetsFilter, + deviceMeetsFilter, + entityRegMeetsFilter, + type TargetType, + type TargetTypeFloorless, +} from "../data/target"; import { SubscribeMixin } from "../mixins/subscribe-mixin"; +import { showHelperDetailDialog } from "../panels/config/helpers/show-dialog-helper-detail"; import type { HomeAssistant } from "../types"; -import "./device/ha-device-picker"; import type { HaDevicePickerDeviceFilterFunc } from "./device/ha-device-picker"; -import "./entity/ha-entity-picker"; -import type { HaEntityPickerEntityFilterFunc } from "./entity/ha-entity-picker"; -import "./ha-area-floor-picker"; -import { floorDefaultIconPath } from "./ha-floor-icon"; -import "./ha-icon-button"; +import "./ha-bottom-sheet"; +import "./ha-button"; import "./ha-input-helper-text"; -import "./ha-label-picker"; import "./ha-svg-icon"; -import "./ha-tooltip"; +import "./target-picker/ha-target-picker-item-group"; +import "./target-picker/ha-target-picker-selector"; +import type { HaTargetPickerSelector } from "./target-picker/ha-target-picker-selector"; +import "./target-picker/ha-target-picker-value-chip"; @customElement("ha-target-picker") export class HaTargetPicker extends SubscribeMixin(LitElement) { @@ -58,6 +40,8 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) { @property() public helper?: string; + @property({ type: Boolean, reflect: true }) public compact = false; + @property({ attribute: false, type: Array }) public createDomains?: string[]; /** @@ -86,27 +70,22 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) { @property({ attribute: "add-on-top", type: Boolean }) public addOnTop = false; - @state() private _addMode?: - | "area_id" - | "entity_id" - | "device_id" - | "label_id"; + @state() private _open = false; - @query("#input") private _inputElement?; + @state() private _addTargetWidth = 0; - @query(".add-container", true) private _addContainer?: HTMLDivElement; + @state() private _narrow = false; - @state() private _labels?: LabelRegistryEntry[]; + @state() private _pickerFilters: TargetTypeFloorless[] = []; - private _opened = false; + @state() private _pickerWrapperOpen = false; - protected hassSubscribe(): (UnsubscribeFunc | Promise)[] { - return [ - subscribeLabelRegistry(this.hass.connection, (labels) => { - this._labels = labels; - }), - ]; - } + @query(".add-target-wrapper") private _addTargetWrapper?: HTMLDivElement; + + @query("ha-target-picker-selector") + private _targetPickerSelectorElement?: HaTargetPickerSelector; + + private _newTarget?: { type: TargetType; id: string }; protected render() { if (this.addOnTop) { @@ -115,396 +94,293 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) { return html` ${this._renderItems()} ${this._renderChips()} `; } + private _renderValueChips() { + return html`
+ ${this.value?.floor_id + ? ensureArray(this.value.floor_id).map( + (floor_id) => html` + + ` + ) + : nothing} + ${this.value?.area_id + ? ensureArray(this.value.area_id).map( + (area_id) => html` + + ` + ) + : nothing} + ${this.value?.device_id + ? ensureArray(this.value.device_id).map( + (device_id) => html` + + ` + ) + : nothing} + ${this.value?.entity_id + ? ensureArray(this.value.entity_id).map( + (entity_id) => html` + + ` + ) + : nothing} + ${this.value?.label_id + ? ensureArray(this.value.label_id).map( + (label_id) => html` + + ` + ) + : nothing} +
`; + } + + private _renderValueGroups() { + return html`
+ ${this.value?.entity_id + ? html` + + + ` + : nothing} + ${this.value?.device_id + ? html` + + + ` + : nothing} + ${this.value?.floor_id || this.value?.area_id + ? html` + + + ` + : nothing} + ${this.value?.label_id + ? html` + + + ` + : nothing} +
`; + } + private _renderItems() { + if ( + !this.value?.floor_id && + !this.value?.area_id && + !this.value?.device_id && + !this.value?.entity_id && + !this.value?.label_id + ) { + return nothing; + } + return html` -
- ${this.value?.floor_id - ? ensureArray(this.value.floor_id).map((floor_id) => { - const floor = this.hass.floors[floor_id]; - return this._renderChip( - "floor_id", - floor_id, - floor?.name || floor_id, - undefined, - floor?.icon, - floor ? floorDefaultIconPath(floor) : mdiHome - ); - }) - : ""} - ${this.value?.area_id - ? ensureArray(this.value.area_id).map((area_id) => { - const area = this.hass.areas![area_id]; - return this._renderChip( - "area_id", - area_id, - area?.name || area_id, - undefined, - area?.icon, - mdiTextureBox - ); - }) - : nothing} - ${this.value?.device_id - ? ensureArray(this.value.device_id).map((device_id) => { - const device = this.hass.devices![device_id]; - return this._renderChip( - "device_id", - device_id, - device - ? computeDeviceNameDisplay(device, this.hass) - : device_id, - undefined, - undefined, - mdiDevices - ); - }) - : nothing} - ${this.value?.entity_id - ? ensureArray(this.value.entity_id).map((entity_id) => { - const entity = this.hass.states[entity_id]; - return this._renderChip( - "entity_id", - entity_id, - entity ? computeStateName(entity) : entity_id, - entity - ); - }) - : nothing} - ${this.value?.label_id - ? ensureArray(this.value.label_id).map((label_id) => { - const label = this._labels?.find( - (lbl) => lbl.label_id === label_id - ); - let color = label?.color - ? computeCssColor(label.color) - : undefined; - if (color?.startsWith("var(")) { - const computedStyles = getComputedStyle(this); - color = computedStyles.getPropertyValue( - color.substring(4, color.length - 1) - ); - } - if (color?.startsWith("#")) { - color = hex2rgb(color).join(","); - } - return this._renderChip( - "label_id", - label_id, - label ? label.name : label_id, - undefined, - label?.icon, - mdiLabel, - color - ); - }) - : nothing} -
+ ${this.compact ? this._renderValueChips() : this._renderValueGroups()} `; } private _renderChips() { return html` -
-
+ -
- - - - ${this.hass.localize( - "ui.components.target-picker.add_area_id" - )} + ${this.hass.localize("ui.components.target-picker.add_target")} +
+ ${!this._narrow && (this._pickerWrapperOpen || this._open) + ? html` + - - -
-
-
- - - - ${this.hass.localize( - "ui.components.target-picker.add_device_id" - )} + ` + : this._pickerWrapperOpen || this._open + ? html` - - -
-
-
- - - - ${this.hass.localize( - "ui.components.target-picker.add_entity_id" - )} - - -
-
-
- - - - ${this.hass.localize( - "ui.components.target-picker.add_label_id" - )} - - -
- ${this._renderPicker()} + ${this._renderTargetSelector(true)} + ` + : nothing}
${this.helper ? html`${this.helper}` - : ""} + : nothing} `; } - private _showPicker(ev) { - this._addMode = ev.currentTarget.type; + connectedCallback() { + super.connectedCallback(); + this._handleResize(); + window.addEventListener("resize", this._handleResize); } - private _renderChip( - type: "floor_id" | "area_id" | "device_id" | "entity_id" | "label_id", - id: string, - name: string, - entityState?: HassEntity, - icon?: string | null, - fallbackIconPath?: string, - color?: string - ) { - return html` -
- ${icon - ? html`` - : fallbackIconPath - ? html`` - : ""} - ${entityState - ? html`` - : ""} - - - ${name} - - - ${type === "entity_id" - ? "" - : html` - ${this.hass.localize( - `ui.components.target-picker.expand_${type}` - )} - - - `} - - - ${this.hass.localize(`ui.components.target-picker.remove_${type}`)} - - - -
- `; + public disconnectedCallback() { + super.disconnectedCallback(); + window.removeEventListener("resize", this._handleResize); } - private _renderPicker() { - if (!this._addMode) { + private _handleResize = () => { + this._narrow = + window.matchMedia("(max-width: 870px)").matches || + window.matchMedia("(max-height: 500px)").matches; + }; + + private _showPicker() { + this._addTargetWidth = this._addTargetWrapper?.offsetWidth || 0; + this._pickerWrapperOpen = true; + } + + // wait for drawer animation to finish + private _showSelector = () => { + this._open = true; + requestAnimationFrame(() => { + this._targetPickerSelectorElement?.focus(); + }); + }; + + private _handleUpdatePickerFilters(ev: CustomEvent) { + this._updatePickerFilters(ev.detail); + } + + private _updatePickerFilters = (filters: TargetTypeFloorless[]) => { + this._pickerFilters = filters; + }; + + private _hidePicker() { + this._open = false; + this._pickerWrapperOpen = false; + + if (this._newTarget) { + this._addTarget(this._newTarget.id, this._newTarget.type); + this._newTarget = undefined; + } + } + + private _renderTargetSelector(dialogMode = false) { + if (!this._open) { return nothing; } - - return html`${this._addMode === "area_id" - ? html` - - ` - : this._addMode === "device_id" - ? html` - - ` - : this._addMode === "label_id" - ? html` - - ` - : html` - - `} `; + return html` + + `; } - private _targetPicked(ev) { - ev.stopPropagation(); - if (!ev.detail.value) { - return; - } - let value = ev.detail.value; - const target = ev.currentTarget; - let type = target.type; + private _addTarget(id: string, type: TargetType) { + const typeId = `${type}_id`; - if (type === "entity_id" && !isValidEntityId(value)) { + if (typeId === "entity_id" && !isValidEntityId(id)) { return; } - if (type === "area_id") { - value = ev.detail.value.id; - type = `${ev.detail.value.type}_id`; - } - - target.value = ""; if ( this.value && - this.value[type] && - ensureArray(this.value[type]).includes(value) + this.value[typeId] && + ensureArray(this.value[typeId]).includes(id) ) { return; } @@ -512,84 +388,179 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) { value: this.value ? { ...this.value, - [type]: this.value[type] - ? [...ensureArray(this.value[type]), value] - : value, + [typeId]: this.value[typeId] + ? [...ensureArray(this.value[typeId]), id] + : id, } - : { [type]: value }, + : { [typeId]: id }, + }); + } + + private _handleTargetPicked = async ( + ev: CustomEvent<{ type: TargetType; id: string }> + ) => { + ev.stopPropagation(); + + this._pickerWrapperOpen = false; + + if (!ev.detail.type || !ev.detail.id) { + return; + } + + // save new target temporarily to add it after dialog closes + this._newTarget = ev.detail; + }; + + private _handleCreateDomain = (ev: CustomEvent) => { + this._pickerWrapperOpen = false; + + const domain = ev.detail; + + showHelperDetailDialog(this, { + domain, + dialogClosedCallback: (item) => { + if (item.entityId) { + // prevent error that new entity_id isn't in hass object + requestAnimationFrame(() => { + this._addTarget(item.entityId!, "entity"); + }); + } + }, + }); + }; + + private _handleRemove(ev) { + const { type, id } = ev.detail; + fireEvent(this, "value-changed", { + value: this._removeItem(this.value, type, id), }); } private _handleExpand(ev) { - const target = ev.currentTarget as any; - const id = target.id.replace(/^expand-/, ""); + const type = ev.detail.type; + const itemId = ev.detail.id; const newAreas: string[] = []; const newDevices: string[] = []; const newEntities: string[] = []; - if (target.type === "floor_id") { + if (type === "floor") { Object.values(this.hass.areas).forEach((area) => { if ( - area.floor_id === id && + area.floor_id === itemId && !this.value!.area_id?.includes(area.area_id) && - this._areaMeetsFilter(area) + areaMeetsFilter( + area, + this.hass.devices, + this.hass.entities, + this.deviceFilter, + this.includeDomains, + this.includeDeviceClasses, + this.hass.states, + this.entityFilter + ) ) { newAreas.push(area.area_id); } }); - } else if (target.type === "area_id") { + } else if (type === "area") { Object.values(this.hass.devices).forEach((device) => { if ( - device.area_id === id && + device.area_id === itemId && !this.value!.device_id?.includes(device.id) && - this._deviceMeetsFilter(device) + deviceMeetsFilter( + device, + this.hass.entities, + this.deviceFilter, + this.includeDomains, + this.includeDeviceClasses, + this.hass.states, + this.entityFilter + ) ) { newDevices.push(device.id); } }); Object.values(this.hass.entities).forEach((entity) => { if ( - entity.area_id === id && + entity.area_id === itemId && !this.value!.entity_id?.includes(entity.entity_id) && - this._entityRegMeetsFilter(entity) + entityRegMeetsFilter( + entity, + false, + this.includeDomains, + this.includeDeviceClasses, + this.hass.states, + this.entityFilter + ) ) { newEntities.push(entity.entity_id); } }); - } else if (target.type === "device_id") { + } else if (type === "device") { Object.values(this.hass.entities).forEach((entity) => { if ( - entity.device_id === id && + entity.device_id === itemId && !this.value!.entity_id?.includes(entity.entity_id) && - this._entityRegMeetsFilter(entity) + entityRegMeetsFilter( + entity, + false, + this.includeDomains, + this.includeDeviceClasses, + this.hass.states, + this.entityFilter + ) ) { newEntities.push(entity.entity_id); } }); - } else if (target.type === "label_id") { + } else if (type === "label") { Object.values(this.hass.areas).forEach((area) => { if ( - area.labels.includes(id) && + area.labels.includes(itemId) && !this.value!.area_id?.includes(area.area_id) && - this._areaMeetsFilter(area) + areaMeetsFilter( + area, + this.hass.devices, + this.hass.entities, + this.deviceFilter, + this.includeDomains, + this.includeDeviceClasses, + this.hass.states, + this.entityFilter + ) ) { newAreas.push(area.area_id); } }); Object.values(this.hass.devices).forEach((device) => { if ( - device.labels.includes(id) && + device.labels.includes(itemId) && !this.value!.device_id?.includes(device.id) && - this._deviceMeetsFilter(device) + deviceMeetsFilter( + device, + this.hass.entities, + this.deviceFilter, + this.includeDomains, + this.includeDeviceClasses, + this.hass.states, + this.entityFilter + ) ) { newDevices.push(device.id); } }); Object.values(this.hass.entities).forEach((entity) => { if ( - entity.labels.includes(id) && + entity.labels.includes(itemId) && !this.value!.entity_id?.includes(entity.entity_id) && - this._entityRegMeetsFilter(entity, true) + entityRegMeetsFilter( + entity, + true, + this.includeDomains, + this.includeDeviceClasses, + this.hass.states, + this.entityFilter + ) ) { newEntities.push(entity.entity_id); } @@ -607,18 +578,10 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) { if (newAreas.length) { value = this._addItems(value, "area_id", newAreas); } - value = this._removeItem(value, target.type, id); + value = this._removeItem(value, type, itemId); fireEvent(this, "value-changed", { value }); } - private _handleRemove(ev) { - const target = ev.currentTarget as any; - const id = target.id.replace(/^remove-/, ""); - fireEvent(this, "value-changed", { - value: this._removeItem(this.value, target.type, id), - }); - } - private _addItems( value: this["value"], type: string, @@ -632,239 +595,76 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) { private _removeItem( value: this["value"], - type: string, + type: TargetType, id: string ): this["value"] { - const newVal = ensureArray(value![type])!.filter( + const typeId = `${type}_id`; + + const newVal = ensureArray(value![typeId])!.filter( (val) => String(val) !== id ); if (newVal.length) { return { ...value, - [type]: newVal, + [typeId]: newVal, }; } const val = { ...value }!; - delete val[type]; + delete val[typeId]; if (Object.keys(val).length) { return val; } return undefined; } - private _onClosed(ev) { - ev.stopPropagation(); - ev.target.open = true; - } - - private async _onOpened() { - if (!this._addMode) { - return; - } - await this._inputElement?.focus(); - await this._inputElement?.open(); - this._opened = true; - } - - private _openedChanged(ev: ComboBoxLightOpenedChangedEvent) { - if (this._opened && !ev.detail.value) { - this._opened = false; - this._addMode = undefined; - } - } - - private _preventDefault(ev: Event) { - ev.preventDefault(); - } - - private _areaMeetsFilter(area: AreaRegistryEntry): boolean { - const areaDevices = Object.values(this.hass.devices).filter( - (device) => device.area_id === area.area_id - ); - - if (areaDevices.some((device) => this._deviceMeetsFilter(device))) { - return true; - } - - const areaEntities = Object.values(this.hass.entities).filter( - (entity) => entity.area_id === area.area_id - ); - - if (areaEntities.some((entity) => this._entityRegMeetsFilter(entity))) { - return true; - } - - return false; - } - - private _deviceMeetsFilter(device: DeviceRegistryEntry): boolean { - const devEntities = Object.values(this.hass.entities).filter( - (entity) => entity.device_id === device.id - ); - - if (!devEntities.some((entity) => this._entityRegMeetsFilter(entity))) { - return false; - } - - if (this.deviceFilter) { - if (!this.deviceFilter(device)) { - return false; - } - } - - return true; - } - - private _entityRegMeetsFilter( - entity: EntityRegistryDisplayEntry, - includeSecondary = false - ): boolean { - if (entity.hidden || (entity.entity_category && !includeSecondary)) { - return false; - } - - if ( - this.includeDomains && - !this.includeDomains.includes(computeDomain(entity.entity_id)) - ) { - return false; - } - if (this.includeDeviceClasses) { - const stateObj = this.hass.states[entity.entity_id]; - if (!stateObj) { - return false; - } - if ( - !stateObj.attributes.device_class || - !this.includeDeviceClasses!.includes(stateObj.attributes.device_class) - ) { - return false; - } - } - - if (this.entityFilter) { - const stateObj = this.hass.states[entity.entity_id]; - if (!stateObj) { - return false; - } - if (!this.entityFilter!(stateObj)) { - return false; - } - } - return true; - } - static get styles(): CSSResultGroup { return css` - ${unsafeCSS(chipStyles)} - .mdc-chip { - color: var(--primary-text-color); + .add-target-wrapper { + display: flex; + justify-content: flex-start; + margin-top: var(--ha-space-3); } + + wa-popover { + --wa-space-l: var(--ha-space-0); + } + + wa-popover::part(body) { + width: min(max(var(--body-width), 336px), 600px); + max-width: min(max(var(--body-width), 336px), 600px); + max-height: 500px; + height: 70vh; + overflow: hidden; + } + + @media (max-height: 1000px) { + wa-popover::part(body) { + max-height: 400px; + } + } + + ha-bottom-sheet { + --ha-bottom-sheet-height: 90vh; + --ha-bottom-sheet-height: calc(100dvh - var(--ha-space-12)); + --ha-bottom-sheet-max-height: var(--ha-bottom-sheet-height); + --ha-bottom-sheet-max-width: 600px; + --ha-bottom-sheet-padding: var(--ha-space-0); + --ha-bottom-sheet-surface-background: var(--card-background-color); + } + + ${unsafeCSS(chipStyles)} .items { z-index: 2; } .mdc-chip-set { - padding: 4px 0; + padding: var(--ha-space-1) var(--ha-space-0); + gap: var(--ha-space-2); } - .mdc-chip.add { - color: rgba(0, 0, 0, 0.87); - } - .add-container { - position: relative; - display: inline-flex; - } - .mdc-chip:not(.add) { - cursor: default; - } - .mdc-chip ha-icon-button { - --mdc-icon-button-size: 24px; - display: flex; - align-items: center; - outline: none; - } - .mdc-chip ha-icon-button ha-svg-icon { - border-radius: var(--ha-border-radius-circle); - background: var(--secondary-text-color); - } - .mdc-chip__icon.mdc-chip__icon--trailing { - width: 16px; - height: 16px; - --mdc-icon-size: 14px; - color: var(--secondary-text-color); - margin-inline-start: 4px !important; - margin-inline-end: -4px !important; - direction: var(--direction); - } - .mdc-chip__icon--leading { - display: flex; - align-items: center; - justify-content: center; - --mdc-icon-size: 20px; - border-radius: var(--ha-border-radius-circle); - padding: 6px; - margin-left: -13px !important; - margin-inline-start: -13px !important; - margin-inline-end: 4px !important; - direction: var(--direction); - } - .expand-btn { - margin-right: 0; - margin-inline-end: 0; - margin-inline-start: initial; - } - .mdc-chip.area_id:not(.add), - .mdc-chip.floor_id:not(.add) { - border: 1px solid #fed6a4; - background: var(--card-background-color); - } - .mdc-chip.area_id:not(.add) .mdc-chip__icon--leading, - .mdc-chip.area_id.add, - .mdc-chip.floor_id:not(.add) .mdc-chip__icon--leading, - .mdc-chip.floor_id.add { - background: #fed6a4; - } - .mdc-chip.device_id:not(.add) { - border: 1px solid #a8e1fb; - background: var(--card-background-color); - } - .mdc-chip.device_id:not(.add) .mdc-chip__icon--leading, - .mdc-chip.device_id.add { - background: #a8e1fb; - } - .mdc-chip.entity_id:not(.add) { - border: 1px solid #d2e7b9; - background: var(--card-background-color); - } - .mdc-chip.entity_id:not(.add) .mdc-chip__icon--leading, - .mdc-chip.entity_id.add { - background: #d2e7b9; - } - .mdc-chip.label_id:not(.add) { - border: 1px solid var(--color, #e0e0e0); - background: var(--card-background-color); - } - .mdc-chip.label_id:not(.add) .mdc-chip__icon--leading, - .mdc-chip.label_id.add { - background: var(--background-color, #e0e0e0); - } - .mdc-chip:hover { - z-index: 5; - } - :host([disabled]) .mdc-chip { - opacity: var(--light-disabled-opacity); - pointer-events: none; - } - mwc-menu-surface { - --mdc-menu-min-width: 100%; - } - ha-entity-picker, - ha-device-picker, - ha-area-floor-picker { - display: block; - width: 100%; - } - ha-tooltip { - --ha-tooltip-arrow-size: 0; + + .item-groups { + overflow: hidden; + border: 2px solid var(--divider-color); + border-radius: var(--ha-border-radius-lg); } `; } @@ -874,4 +674,16 @@ declare global { interface HTMLElementTagNameMap { "ha-target-picker": HaTargetPicker; } + + interface HASSDomEvents { + "remove-target-item": { + type: string; + id: string; + }; + "expand-target-item": { + type: string; + id: string; + }; + "remove-target-group": string; + } } diff --git a/src/components/target-picker/dialog/dialog-target-details.ts b/src/components/target-picker/dialog/dialog-target-details.ts new file mode 100644 index 0000000000..e231d14a00 --- /dev/null +++ b/src/components/target-picker/dialog/dialog-target-details.ts @@ -0,0 +1,104 @@ +import { mdiClose } from "@mdi/js"; +import { css, html, LitElement, nothing } from "lit"; +import { customElement, property, query, state } from "lit/decorators"; +import { fireEvent } from "../../../common/dom/fire_event"; +import type { HassDialog } from "../../../dialogs/make-dialog-manager"; +import type { HomeAssistant } from "../../../types"; +import "../../ha-dialog-header"; +import "../../ha-icon-button"; +import "../../ha-icon-next"; +import "../../ha-md-dialog"; +import type { HaMdDialog } from "../../ha-md-dialog"; +import "../../ha-md-list"; +import "../../ha-md-list-item"; +import "../../ha-svg-icon"; +import "../ha-target-picker-item-row"; +import type { TargetDetailsDialogParams } from "./show-dialog-target-details"; + +@customElement("ha-dialog-target-details") +class DialogTargetDetails extends LitElement implements HassDialog { + @property({ attribute: false }) public hass!: HomeAssistant; + + @state() private _params?: TargetDetailsDialogParams; + + @query("ha-md-dialog") private _dialog?: HaMdDialog; + + public showDialog(params: TargetDetailsDialogParams): void { + this._params = params; + } + + public closeDialog() { + this._dialog?.close(); + return true; + } + + private _dialogClosed() { + fireEvent(this, "dialog-closed", { dialog: this.localName }); + this._params = undefined; + } + + protected render() { + if (!this._params) { + return nothing; + } + + return html` + + + + ${this.hass.localize( + "ui.components.target-picker.target_details" + )} + ${this.hass.localize( + `ui.components.target-picker.type.${this._params.type}` + )}: + ${this._params.title} + +
+ +
+
+ `; + } + + static styles = css` + ha-md-dialog { + min-width: 400px; + max-height: 90%; + --dialog-content-padding: var(--ha-space-2) var(--ha-space-6) + max(var(--safe-area-inset-bottom, var(--ha-space-0)), var(--ha-space-8)); + } + + @media all and (max-width: 600px), all and (max-height: 500px) { + ha-md-dialog { + --md-dialog-container-shape: var(--ha-space-0); + min-width: 100%; + min-height: 100%; + } + } + `; +} + +declare global { + interface HTMLElementTagNameMap { + "ha-dialog-target-details": DialogTargetDetails; + } +} diff --git a/src/components/target-picker/dialog/show-dialog-target-details.ts b/src/components/target-picker/dialog/show-dialog-target-details.ts new file mode 100644 index 0000000000..8758703e95 --- /dev/null +++ b/src/components/target-picker/dialog/show-dialog-target-details.ts @@ -0,0 +1,28 @@ +import { fireEvent } from "../../../common/dom/fire_event"; +import type { HaEntityPickerEntityFilterFunc } from "../../../data/entity"; +import type { TargetType } from "../../../data/target"; +import type { HaDevicePickerDeviceFilterFunc } from "../../device/ha-device-picker"; + +export type NewBackupType = "automatic" | "manual"; + +export interface TargetDetailsDialogParams { + title: string; + type: TargetType; + itemId: string; + deviceFilter?: HaDevicePickerDeviceFilterFunc; + entityFilter?: HaEntityPickerEntityFilterFunc; + includeDomains?: string[]; + includeDeviceClasses?: string[]; +} + +export const loadTargetDetailsDialog = () => import("./dialog-target-details"); + +export const showTargetDetailsDialog = ( + element: HTMLElement, + params: TargetDetailsDialogParams +) => + fireEvent(element, "show-dialog", { + dialogTag: "ha-dialog-target-details", + dialogImport: loadTargetDetailsDialog, + dialogParams: params, + }); diff --git a/src/components/target-picker/ha-target-picker-item-group.ts b/src/components/target-picker/ha-target-picker-item-group.ts new file mode 100644 index 0000000000..738be525d2 --- /dev/null +++ b/src/components/target-picker/ha-target-picker-item-group.ts @@ -0,0 +1,105 @@ +import { css, html, LitElement, nothing } from "lit"; +import { customElement, property } from "lit/decorators"; +import type { HaEntityPickerEntityFilterFunc } from "../../data/entity"; +import type { TargetType, TargetTypeFloorless } from "../../data/target"; +import type { HomeAssistant } from "../../types"; +import type { HaDevicePickerDeviceFilterFunc } from "../device/ha-device-picker"; +import "../ha-expansion-panel"; +import "../ha-md-list"; +import "./ha-target-picker-item-row"; + +@customElement("ha-target-picker-item-group") +export class HaTargetPickerItemGroup extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @property() public type!: TargetTypeFloorless; + + @property({ attribute: false }) public items!: Partial< + Record + >; + + @property({ type: Boolean }) public collapsed = false; + + @property({ attribute: false }) + public deviceFilter?: HaDevicePickerDeviceFilterFunc; + + @property({ attribute: false }) + public entityFilter?: HaEntityPickerEntityFilterFunc; + + /** + * Show only targets with entities from specific domains. + * @type {Array} + * @attr include-domains + */ + @property({ type: Array, attribute: "include-domains" }) + public includeDomains?: string[]; + + /** + * Show only targets with entities of these device classes. + * @type {Array} + * @attr include-device-classes + */ + @property({ type: Array, attribute: "include-device-classes" }) + public includeDeviceClasses?: string[]; + + protected render() { + let count = 0; + Object.values(this.items).forEach((items) => { + if (items) { + count += items.length; + } + }); + + return html` +
+ ${this.hass.localize( + `ui.components.target-picker.selected.${this.type}`, + { + count, + } + )} +
+ ${Object.entries(this.items).map(([type, items]) => + items + ? items.map( + (item) => + html`` + ) + : nothing + )} +
`; + } + + static styles = css` + :host { + display: block; + --expansion-panel-content-padding: var(--ha-space-0); + } + ha-expansion-panel::part(summary) { + 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); + display: flex; + justify-content: space-between; + min-height: unset; + } + ha-md-list { + padding: var(--ha-space-0); + } + `; +} + +declare global { + interface HTMLElementTagNameMap { + "ha-target-picker-item-group": HaTargetPickerItemGroup; + } +} diff --git a/src/components/target-picker/ha-target-picker-item-row.ts b/src/components/target-picker/ha-target-picker-item-row.ts new file mode 100644 index 0000000000..ee21d3527c --- /dev/null +++ b/src/components/target-picker/ha-target-picker-item-row.ts @@ -0,0 +1,690 @@ +import { consume } from "@lit/context"; +import { + mdiClose, + mdiDevices, + mdiHome, + mdiLabel, + mdiTextureBox, +} from "@mdi/js"; +import { css, html, LitElement, nothing, type PropertyValues } from "lit"; +import { customElement, property, query, state } from "lit/decorators"; +import memoizeOne from "memoize-one"; +import { fireEvent } from "../../common/dom/fire_event"; +import { computeAreaName } from "../../common/entity/compute_area_name"; +import { + computeDeviceName, + computeDeviceNameDisplay, +} from "../../common/entity/compute_device_name"; +import { computeDomain } from "../../common/entity/compute_domain"; +import { computeEntityName } from "../../common/entity/compute_entity_name"; +import { getEntityContext } from "../../common/entity/context/get_entity_context"; +import { computeRTL } from "../../common/util/compute_rtl"; +import { getConfigEntry } from "../../data/config_entries"; +import { labelsContext } from "../../data/context"; +import type { HaEntityPickerEntityFilterFunc } from "../../data/entity"; +import { domainToName } from "../../data/integration"; +import type { LabelRegistryEntry } from "../../data/label_registry"; +import { + areaMeetsFilter, + deviceMeetsFilter, + entityRegMeetsFilter, + extractFromTarget, + type ExtractFromTargetResult, + type ExtractFromTargetResultReferenced, + type TargetType, +} from "../../data/target"; +import { buttonLinkStyle } from "../../resources/styles"; +import type { HomeAssistant } from "../../types"; +import { brandsUrl } from "../../util/brands-url"; +import type { HaDevicePickerDeviceFilterFunc } from "../device/ha-device-picker"; +import { floorDefaultIconPath } from "../ha-floor-icon"; +import "../ha-icon-button"; +import "../ha-md-list"; +import type { HaMdList } from "../ha-md-list"; +import "../ha-md-list-item"; +import type { HaMdListItem } from "../ha-md-list-item"; +import "../ha-state-icon"; +import "../ha-svg-icon"; +import { showTargetDetailsDialog } from "./dialog/show-dialog-target-details"; + +@customElement("ha-target-picker-item-row") +export class HaTargetPickerItemRow extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @property({ reflect: true }) public type!: TargetType; + + @property({ attribute: "item-id" }) public itemId!: string; + + @property({ type: Boolean }) public expand = false; + + @property({ type: Boolean, attribute: "sub-entry", reflect: true }) + public subEntry = false; + + @property({ type: Boolean, attribute: "hide-context" }) + public hideContext = false; + + @property({ attribute: false }) + public parentEntries?: ExtractFromTargetResultReferenced; + + @property({ attribute: false }) + public deviceFilter?: HaDevicePickerDeviceFilterFunc; + + @property({ attribute: false }) + public entityFilter?: HaEntityPickerEntityFilterFunc; + + /** + * Show only targets with entities from specific domains. + * @type {Array} + * @attr include-domains + */ + @property({ type: Array, attribute: "include-domains" }) + public includeDomains?: string[]; + + /** + * Show only targets with entities of these device classes. + * @type {Array} + * @attr include-device-classes + */ + @property({ type: Array, attribute: "include-device-classes" }) + public includeDeviceClasses?: string[]; + + @state() private _iconImg?: string; + + @state() private _domainName?: string; + + @state() private _entries?: ExtractFromTargetResult; + + @state() + @consume({ context: labelsContext, subscribe: true }) + _labelRegistry!: LabelRegistryEntry[]; + + @query("ha-md-list-item") public item?: HaMdListItem; + + @query("ha-md-list") public list?: HaMdList; + + @query("ha-target-picker-item-row") public itemRow?: HaTargetPickerItemRow; + + protected willUpdate(changedProps: PropertyValues) { + if (!this.subEntry && changedProps.has("itemId")) { + this._updateItemData(); + } + } + + protected render() { + const { name, context, iconPath, fallbackIconPath, stateObject } = + this._itemData(this.type, this.itemId); + + const showDevices = ["floor", "area", "label"].includes(this.type); + const showEntities = this.type !== "entity"; + + const entries = this.parentEntries || this._entries; + + // Don't show sub entries that have no entities + if ( + this.subEntry && + this.type !== "entity" && + (!entries || entries.referenced_entities.length === 0) + ) { + return nothing; + } + + return html` + +
+ ${this.subEntry + ? html` +
+
+
+ ` + : nothing} + ${iconPath + ? html`` + : this._iconImg + ? html`${this._domainName` + : fallbackIconPath + ? html`` + : stateObject + ? html` + + + ` + : nothing} +
+ +
${name}
+ ${context && !this.hideContext + ? html`${context}` + : this._domainName && this.subEntry + ? html`${this._domainName}` + : nothing} + ${!this.subEntry && + ((entries && (showEntities || showDevices)) || this._domainName) + ? html` +
+ ${showEntities && !this.expand + ? html`` + : showEntities + ? html` + ${this.hass.localize( + "ui.components.target-picker.entities_count", + { + count: entries?.referenced_entities.length, + } + )} + ` + : nothing} + ${showDevices + ? html`${this.hass.localize( + "ui.components.target-picker.devices_count", + { + count: entries?.referenced_devices.length, + } + )}` + : nothing} + ${this._domainName && !showDevices + ? html`${this._domainName}` + : nothing} +
+ ` + : nothing} + ${!this.expand && !this.subEntry + ? html` + + ` + : nothing} +
+ ${this.expand && entries && entries.referenced_entities + ? this._renderEntries() + : nothing} + `; + } + + private _renderEntries() { + const entries = this.parentEntries || this._entries; + + let nextType: TargetType = + this.type === "floor" + ? "area" + : this.type === "area" + ? "device" + : "entity"; + + if (this.type === "label") { + if (entries?.referenced_areas.length) { + nextType = "area"; + } else if (entries?.referenced_devices.length) { + nextType = "device"; + } + } + + const rows1 = + (nextType === "area" + ? entries?.referenced_areas + : nextType === "device" + ? entries?.referenced_devices + : entries?.referenced_entities) || []; + + const devicesInAreas = [] as string[]; + + const rows1Entries = + nextType === "entity" + ? undefined + : rows1.map((rowItem) => { + const nextEntries = { + referenced_areas: [] as string[], + referenced_devices: [] as string[], + referenced_entities: [] as string[], + }; + + if (nextType === "area") { + nextEntries.referenced_devices = + entries?.referenced_devices.filter( + (device_id) => + this.hass.devices?.[device_id]?.area_id === rowItem && + entries?.referenced_entities.some( + (entity_id) => + this.hass.entities?.[entity_id]?.device_id === device_id + ) + ) || ([] as string[]); + + devicesInAreas.push(...nextEntries.referenced_devices); + + nextEntries.referenced_entities = + entries?.referenced_entities.filter((entity_id) => { + const entity = this.hass.entities[entity_id]; + return ( + entity.area_id === rowItem || + !entity.device_id || + nextEntries.referenced_devices.includes(entity.device_id) + ); + }) || ([] as string[]); + + return nextEntries; + } + + nextEntries.referenced_entities = + entries?.referenced_entities.filter( + (entity_id) => + this.hass.entities?.[entity_id]?.device_id === rowItem + ) || ([] as string[]); + + return nextEntries; + }); + + const entityRows = + this.type === "label" && entries + ? entries.referenced_entities.filter((entity_id) => + this.hass.entities[entity_id].labels.includes(this.itemId) + ) + : nextType === "device" && entries + ? entries.referenced_entities.filter( + (entity_id) => + this.hass.entities[entity_id].area_id === this.itemId + ) + : []; + + const deviceRows = + this.type === "label" && entries + ? entries.referenced_devices.filter( + (device_id) => + !devicesInAreas.includes(device_id) && + this.hass.devices[device_id].labels.includes(this.itemId) + ) + : []; + + const deviceRowsEntries = + deviceRows.length === 0 + ? undefined + : deviceRows.map((device_id) => ({ + referenced_areas: [] as string[], + referenced_devices: [] as string[], + referenced_entities: + entries?.referenced_entities.filter( + (entity_id) => + this.hass.entities?.[entity_id]?.device_id === device_id + ) || ([] as string[]), + })); + + return html` +
+
+
+
+ + ${rows1.map( + (itemId, index) => html` + + ` + )} + ${deviceRows.map( + (itemId, index) => html` + + ` + )} + ${entityRows.map( + (itemId) => html` + + ` + )} + +
+ `; + } + + private async _updateItemData() { + if (this.type === "entity") { + this._entries = undefined; + return; + } + try { + const entries = await extractFromTarget(this.hass, { + [`${this.type}_id`]: [this.itemId], + }); + + const hiddenAreaIds: string[] = []; + if (this.type === "floor" || this.type === "label") { + entries.referenced_areas = entries.referenced_areas.filter( + (area_id) => { + const area = this.hass.areas[area_id]; + if ( + (this.type === "floor" || area.labels.includes(this.itemId)) && + areaMeetsFilter( + area, + this.hass.devices, + this.hass.entities, + this.deviceFilter, + this.includeDomains, + this.includeDeviceClasses, + this.hass.states, + this.entityFilter + ) + ) { + return true; + } + + hiddenAreaIds.push(area_id); + return false; + } + ); + } + + const hiddenDeviceIds: string[] = []; + if ( + this.type === "floor" || + this.type === "area" || + this.type === "label" + ) { + entries.referenced_devices = entries.referenced_devices.filter( + (device_id) => { + const device = this.hass.devices[device_id]; + if ( + !hiddenAreaIds.includes(device.area_id || "") && + (this.type !== "label" || device.labels.includes(this.itemId)) && + deviceMeetsFilter( + device, + this.hass.entities, + this.deviceFilter, + this.includeDomains, + this.includeDeviceClasses, + this.hass.states, + this.entityFilter + ) + ) { + return true; + } + + hiddenDeviceIds.push(device_id); + return false; + } + ); + } + + entries.referenced_entities = entries.referenced_entities.filter( + (entity_id) => { + const entity = this.hass.entities[entity_id]; + if (hiddenDeviceIds.includes(entity.device_id || "")) { + return false; + } + if ( + (this.type === "area" && entity.area_id === this.itemId) || + (this.type === "label" && entity.labels.includes(this.itemId)) || + entries.referenced_devices.includes(entity.device_id || "") + ) { + return entityRegMeetsFilter( + entity, + this.type === "label", + this.includeDomains, + this.includeDeviceClasses, + this.hass.states, + this.entityFilter + ); + } + return false; + } + ); + + this._entries = entries; + } catch (e) { + // eslint-disable-next-line no-console + console.error("Failed to extract target", e); + } + } + + private _itemData = memoizeOne((type: TargetType, item: string) => { + if (type === "floor") { + const floor = this.hass.floors?.[item]; + return { + name: floor?.name || item, + iconPath: floor?.icon, + fallbackIconPath: floor ? floorDefaultIconPath(floor) : mdiHome, + }; + } + if (type === "area") { + const area = this.hass.areas?.[item]; + return { + name: area?.name || item, + context: area.floor_id && this.hass.floors?.[area.floor_id]?.name, + iconPath: area?.icon, + fallbackIconPath: mdiTextureBox, + }; + } + if (type === "device") { + const device = this.hass.devices?.[item]; + + if (device.primary_config_entry) { + this._getDeviceDomain(device.primary_config_entry); + } + + return { + name: device ? computeDeviceNameDisplay(device, this.hass) : item, + context: device?.area_id && this.hass.areas?.[device.area_id]?.name, + fallbackIconPath: mdiDevices, + }; + } + if (type === "entity") { + this._setDomainName(computeDomain(item)); + + const stateObject = this.hass.states[item]; + const entityName = computeEntityName( + stateObject, + this.hass.entities, + this.hass.devices + ); + const { area, device } = getEntityContext( + stateObject, + this.hass.entities, + this.hass.devices, + this.hass.areas, + this.hass.floors + ); + const deviceName = device ? computeDeviceName(device) : undefined; + const areaName = area ? computeAreaName(area) : undefined; + const context = [areaName, entityName ? deviceName : undefined] + .filter(Boolean) + .join(computeRTL(this.hass) ? " ◂ " : " ▸ "); + return { + name: entityName || deviceName || item, + context, + stateObject, + }; + } + + // type label + const label = this._labelRegistry.find((lab) => lab.label_id === item); + return { + name: label?.name || item, + iconPath: label?.icon, + fallbackIconPath: mdiLabel, + }; + }); + + private _setDomainName(domain: string) { + this._domainName = domainToName(this.hass.localize, domain); + } + + private _removeItem(ev) { + ev.stopPropagation(); + fireEvent(this, "remove-target-item", { + type: this.type, + id: this.itemId, + }); + } + + private async _getDeviceDomain(configEntryId: string) { + try { + const data = await getConfigEntry(this.hass, configEntryId); + const domain = data.config_entry.domain; + this._iconImg = brandsUrl({ + domain: domain, + type: "icon", + darkOptimized: this.hass.themes?.darkMode, + }); + + this._setDomainName(domain); + } catch { + // failed to load config entry -> ignore + } + } + + private _openDetails() { + showTargetDetailsDialog(this, { + title: this._itemData(this.type, this.itemId).name, + type: this.type, + itemId: this.itemId, + deviceFilter: this.deviceFilter, + entityFilter: this.entityFilter, + includeDomains: this.includeDomains, + includeDeviceClasses: this.includeDeviceClasses, + }); + } + + static styles = [ + buttonLinkStyle, + css` + :host { + --md-list-item-top-space: var(--ha-space-0); + --md-list-item-bottom-space: var(--ha-space-0); + --md-list-item-leading-space: var(--ha-space-2); + --md-list-item-trailing-space: var(--ha-space-2); + --md-list-item-two-line-container-height: 56px; + } + + :host([expand]:not([sub-entry])) ha-md-list-item { + border: 2px solid var(--ha-color-border-neutral-loud); + background-color: var(--ha-color-fill-neutral-quiet-resting); + border-radius: var(--ha-card-border-radius, var(--ha-border-radius-lg)); + } + + state-badge { + color: var(--ha-color-on-neutral-quiet); + } + img { + width: 24px; + height: 24px; + } + ha-icon-button { + --mdc-icon-button-size: 32px; + } + .summary { + display: flex; + flex-direction: column; + align-items: flex-end; + line-height: var(--ha-line-height-condensed); + } + :host([sub-entry]) .summary { + margin-right: var(--ha-space-12); + } + .summary .main { + font-weight: var(--ha-font-weight-medium); + } + .summary .secondary { + font-size: var(--ha-font-size-s); + color: var(--secondary-text-color); + } + .domain { + font-family: var(--ha-font-family-code); + } + + .entries-tree { + display: flex; + position: relative; + } + + .entries-tree .line-wrapper { + padding: var(--ha-space-5); + } + + .entries-tree .line-wrapper .line { + border-left: 2px dashed var(--divider-color); + height: calc(100% - 28px); + position: absolute; + top: 0; + } + + :host([sub-entry]) .entries-tree .line-wrapper .line { + height: calc(100% - 12px); + top: -18px; + } + + .entries { + padding: 0; + --md-item-overflow: visible; + } + + .horizontal-line-wrapper { + position: relative; + } + .horizontal-line-wrapper .horizontal-line { + position: absolute; + top: 11px; + margin-inline-start: -28px; + width: 29px; + border-top: 2px dashed var(--divider-color); + } + + button.link { + text-decoration: none; + color: var(--primary-color); + } + + button.link:hover, + button.link:focus { + text-decoration: underline; + } + `, + ]; +} + +declare global { + interface HTMLElementTagNameMap { + "ha-target-picker-item-row": HaTargetPickerItemRow; + } +} diff --git a/src/components/target-picker/ha-target-picker-selector.ts b/src/components/target-picker/ha-target-picker-selector.ts new file mode 100644 index 0000000000..7f7708cbfb --- /dev/null +++ b/src/components/target-picker/ha-target-picker-selector.ts @@ -0,0 +1,1107 @@ +import type { LitVirtualizer } from "@lit-labs/virtualizer"; +import { consume } from "@lit/context"; +import { mdiCheck, mdiPlus, mdiTextureBox } from "@mdi/js"; +import Fuse from "fuse.js"; +import type { HassServiceTarget } from "home-assistant-js-websocket"; +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 { ensureArray } from "../../common/array/ensure-array"; +import { fireEvent } from "../../common/dom/fire_event"; +import type { LocalizeKeys } from "../../common/translations/localize"; +import { computeRTL } from "../../common/util/compute_rtl"; +import { + getAreasAndFloors, + type AreaFloorValue, + type FloorComboBoxItem, +} from "../../data/area_floor"; +import { getConfigEntries, type ConfigEntry } from "../../data/config_entries"; +import { labelsContext } from "../../data/context"; +import { getDevices, type DevicePickerItem } from "../../data/device_registry"; +import type { HaEntityPickerEntityFilterFunc } from "../../data/entity"; +import { + getEntities, + type EntityComboBoxItem, +} from "../../data/entity_registry"; +import { domainToName } from "../../data/integration"; +import { getLabels, type LabelRegistryEntry } from "../../data/label_registry"; +import type { TargetType, TargetTypeFloorless } from "../../data/target"; +import { + isHelperDomain, + type HelperDomain, +} from "../../panels/config/helpers/const"; +import { HaFuse } from "../../resources/fuse"; +import { haStyleScrollbar } from "../../resources/styles"; +import { loadVirtualizer } from "../../resources/virtualizer"; +import type { HomeAssistant } from "../../types"; +import { brandsUrl } from "../../util/brands-url"; +import type { HaDevicePickerDeviceFilterFunc } from "../device/ha-device-picker"; +import "../entity/state-badge"; +import "../ha-button"; +import "../ha-combo-box-item"; +import "../ha-floor-icon"; +import "../ha-md-list"; +import type { PickerComboBoxItem } from "../ha-picker-combo-box"; +import "../ha-svg-icon"; +import "../ha-textfield"; +import type { HaTextField } from "../ha-textfield"; +import "../ha-tree-indicator"; + +const SEPARATOR = "________"; +const EMPTY_SEARCH = "___EMPTY_SEARCH___"; +const CREATE_ID = "___create-new-entity___"; + +@customElement("ha-target-picker-selector") +export class HaTargetPickerSelector extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @property({ attribute: false }) public filterTypes: TargetTypeFloorless[] = + []; + + @property({ reflect: true }) public mode: "popover" | "dialog" = "popover"; + + /** + * Show only targets with entities from specific domains. + * @type {Array} + * @attr include-domains + */ + @property({ type: Array, attribute: "include-domains" }) + public includeDomains?: string[]; + + /** + * Show only targets with entities of these device classes. + * @type {Array} + * @attr include-device-classes + */ + @property({ type: Array, attribute: "include-device-classes" }) + public includeDeviceClasses?: string[]; + + @property({ attribute: false }) + public deviceFilter?: HaDevicePickerDeviceFilterFunc; + + @property({ attribute: false }) + public entityFilter?: HaEntityPickerEntityFilterFunc; + + @property({ attribute: false }) public targetValue?: HassServiceTarget; + + @property({ attribute: false, type: Array }) public createDomains?: string[]; + + @query("lit-virtualizer") private _virtualizerElement?: LitVirtualizer; + + @query("ha-textfield") private _searchFieldElement?: HaTextField; + + @state() private _searchTerm = ""; + + @state() private _listScrolled = false; + + @state() private _configEntryLookup: Record = {}; + + private _selectedItemIndex = -1; + + @state() private _filterHeader?: string; + + @state() + @consume({ context: labelsContext, subscribe: true }) + private _labelRegistry!: LabelRegistryEntry[]; + + private _getDevicesMemoized = memoizeOne(getDevices); + + private _getLabelsMemoized = memoizeOne(getLabels); + + private _getEntitiesMemoized = memoizeOne(getEntities); + + private _getAreasAndFloorsMemoized = memoizeOne(getAreasAndFloors); + + static shadowRootOptions = { + ...LitElement.shadowRootOptions, + delegatesFocus: true, + }; + + private _removeKeyboardShortcuts?: () => void; + + public willUpdate(changedProps: PropertyValues) { + super.willUpdate(changedProps); + + if (!this.hasUpdated) { + this._loadConfigEntries(); + loadVirtualizer(); + } + } + + protected firstUpdated() { + this._registerKeyboardShortcuts(); + } + + disconnectedCallback() { + super.disconnectedCallback(); + this._removeKeyboardShortcuts?.(); + } + + private async _loadConfigEntries() { + const configEntries = await getConfigEntries(this.hass); + this._configEntryLookup = Object.fromEntries( + configEntries.map((entry) => [entry.entry_id, entry]) + ); + } + + protected render() { + return html` + +
${this._renderFilterButtons()}
+
+
+ ${this._filterHeader} +
+
+ + + `; + } + + @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" && secondItem !== "padding") || + (ev.first === 0 && + ev.last === this._virtualizerElement.items.length - 1) + ) { + this._filterHeader = undefined; + return; + } + + const type = this._getRowType(firstItem as PickerComboBoxItem); + const translationType: + | "areas" + | "entities" + | "devices" + | "labels" + | undefined = + type === "area" || type === "floor" + ? "areas" + : type === "entity" + ? "entities" + : type && type !== "empty" + ? `${type}s` + : undefined; + + this._filterHeader = translationType + ? (this._filterHeader = this.hass.localize( + `ui.components.target-picker.type.${translationType}` + )) + : undefined; + } + } + + private _registerKeyboardShortcuts() { + this._removeKeyboardShortcuts = tinykeys(this, { + ArrowUp: this._selectPreviousItem, + ArrowDown: this._selectNextItem, + Home: this._selectFirstItem, + End: this._selectLastItem, + Enter: this._pickSelectedItem, + }); + } + + private _focusList() { + if (this._selectedItemIndex === -1) { + this._selectNextItem(); + } + } + + private _selectNextItem = (ev?: KeyboardEvent) => { + ev?.stopPropagation(); + ev?.preventDefault(); + if (!this._virtualizerElement) { + return; + } + + this._searchFieldElement?.focus(); + + const items = this._virtualizerElement.items; + + const maxItems = items.length - 1; + + if (maxItems === -1) { + this._resetSelectedItem(); + return; + } + + const nextIndex = + maxItems === this._selectedItemIndex + ? this._selectedItemIndex + : this._selectedItemIndex + 1; + + if (!items[nextIndex]) { + return; + } + + if ( + typeof items[nextIndex] === "string" || + (items[nextIndex] as PickerComboBoxItem)?.id === EMPTY_SEARCH + ) { + // Skip titles, padding and empty search + if (nextIndex === maxItems) { + return; + } + this._selectedItemIndex = nextIndex + 1; + } else { + this._selectedItemIndex = nextIndex; + } + + this._scrollToSelectedItem(); + }; + + private _selectPreviousItem = (ev: KeyboardEvent) => { + ev.stopPropagation(); + ev.preventDefault(); + if (!this._virtualizerElement) { + return; + } + + if (this._selectedItemIndex > 0) { + const nextIndex = this._selectedItemIndex - 1; + + const items = this._virtualizerElement.items; + + if (!items[nextIndex]) { + return; + } + + if ( + typeof items[nextIndex] === "string" || + (items[nextIndex] as PickerComboBoxItem)?.id === EMPTY_SEARCH + ) { + // Skip titles, padding and empty search + if (nextIndex === 0) { + return; + } + this._selectedItemIndex = nextIndex - 1; + } else { + this._selectedItemIndex = nextIndex; + } + + this._scrollToSelectedItem(); + } + }; + + private _selectFirstItem = (ev: KeyboardEvent) => { + ev.stopPropagation(); + if (!this._virtualizerElement || !this._virtualizerElement.items.length) { + return; + } + + const nextIndex = 0; + + if ( + (this._virtualizerElement.items[nextIndex] as PickerComboBoxItem)?.id === + EMPTY_SEARCH + ) { + return; + } + + if (typeof this._virtualizerElement.items[nextIndex] === "string") { + this._selectedItemIndex = nextIndex + 1; + } else { + this._selectedItemIndex = nextIndex; + } + + this._scrollToSelectedItem(); + }; + + private _selectLastItem = (ev: KeyboardEvent) => { + ev.stopPropagation(); + if (!this._virtualizerElement || !this._virtualizerElement.items.length) { + return; + } + + const nextIndex = this._virtualizerElement.items.length - 1; + + if ( + (this._virtualizerElement.items[nextIndex] as PickerComboBoxItem)?.id === + EMPTY_SEARCH + ) { + return; + } + + if (typeof this._virtualizerElement.items[nextIndex] === "string") { + this._selectedItemIndex = nextIndex - 1; + } else { + this._selectedItemIndex = nextIndex; + } + + this._scrollToSelectedItem(); + }; + + private _scrollToSelectedItem = () => { + this._virtualizerElement + ?.querySelector(".selected") + ?.classList.remove("selected"); + + this._virtualizerElement?.scrollToIndex(this._selectedItemIndex, "end"); + + requestAnimationFrame(() => { + this._virtualizerElement + ?.querySelector(`#list-item-${this._selectedItemIndex}`) + ?.classList.add("selected"); + }); + }; + + private _pickSelectedItem = (ev: KeyboardEvent) => { + if (this._selectedItemIndex === -1) { + return; + } + + // if filter button is focused + ev.preventDefault(); + + const item: any = this._virtualizerElement?.items[this._selectedItemIndex]; + if (item && typeof item !== "string") { + this._pickTarget( + item.id, + "domain" in item + ? "device" + : "stateObj" in item + ? "entity" + : item.type + ? "area" + : "label" + ); + } + }; + + private _renderFilterButtons() { + const filter: (TargetTypeFloorless | "separator")[] = [ + "entity", + "device", + "area", + "separator", + "label", + ]; + return filter.map((filterType) => { + if (filterType === "separator") { + return html`
`; + } + + const selected = this.filterTypes.includes(filterType); + return html` + + ${selected + ? html`` + : nothing} + ${this.hass.localize( + `ui.components.target-picker.type.${filterType === "entity" ? "entities" : `${filterType}s`}` as LocalizeKeys + )} + + `; + }); + } + + 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 _renderRow = ( + item: + | PickerComboBoxItem + | (FloorComboBoxItem & { last?: boolean | undefined }) + | EntityComboBoxItem + | DevicePickerItem + | string, + index: number + ) => { + if (!item) { + return nothing; + } + + if (typeof item === "string") { + if (item === "padding") { + return html`
`; + } + return html`
${item}
`; + } + + const type = this._getRowType(item); + let hasFloor = false; + let rtl = false; + let showEntityId = false; + + if (type === "area" || type === "floor") { + item.id = item[type]?.[`${type}_id`]; + + 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 === "area" && + (item as FloorComboBoxItem).type === "floor" && + (item as FloorComboBoxItem).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} +
+ `; + }; + + private _filterGroup( + type: TargetType, + items: (FloorComboBoxItem | PickerComboBoxItem | EntityComboBoxItem)[], + checkExact?: ( + item: FloorComboBoxItem | PickerComboBoxItem | EntityComboBoxItem + ) => boolean + ) { + const fuseIndex = this._fuseIndexes[type](items); + const fuse = new HaFuse( + items, + { + shouldSort: false, + minMatchCharLength: Math.min(this._searchTerm.length, 2), + }, + fuseIndex + ); + + const results = fuse.multiTermsSearch(this._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 _keyFunction = ( + item: + | PickerComboBoxItem + | (FloorComboBoxItem & { last?: boolean | undefined }) + | EntityComboBoxItem + | DevicePickerItem + | string + ) => { + if (typeof item === "string") { + return item === "padding" ? "padding" : `title-${item}`; + } + const type = this._getRowType(item); + if (type === "empty") { + return `empty-search`; + } + if (type === "area" || type === "floor") { + return `${type}-${item[type]?.[`${type}_id`]}`; + } + return `${type}-${item.id}`; + }; + + private _getItems = memoizeOne( + ( + filterTypes: TargetTypeFloorless[], + entityFilter: this["entityFilter"], + deviceFilter: this["deviceFilter"], + includeDomains: this["includeDomains"], + includeDeviceClasses: this["includeDeviceClasses"], + targetValue: this["targetValue"], + searchTerm: string, + createDomains: this["createDomains"], + configEntryLookup: Record, + mode: this["mode"] + ) => { + const items: ( + | string + | FloorComboBoxItem + | EntityComboBoxItem + | PickerComboBoxItem + )[] = []; + + if (filterTypes.length === 0 || filterTypes.includes("entity")) { + let entities = this._getEntitiesMemoized( + this.hass, + includeDomains, + undefined, + entityFilter, + includeDeviceClasses, + undefined, + undefined, + targetValue?.entity_id + ? ensureArray(targetValue.entity_id) + : undefined + ); + + if (searchTerm) { + entities = this._filterGroup( + "entity", + entities, + (item: EntityComboBoxItem) => + item.stateObj?.entity_id === searchTerm + ) as EntityComboBoxItem[]; + } + + if (entities.length > 0 && filterTypes.length !== 1) { + // show group title + items.push( + this.hass.localize("ui.components.target-picker.type.entities") + ); + } + + items.push(...entities); + } + + if (filterTypes.length === 0 || filterTypes.includes("device")) { + let devices = this._getDevicesMemoized( + this.hass, + configEntryLookup, + includeDomains, + undefined, + includeDeviceClasses, + deviceFilter, + entityFilter, + targetValue?.device_id + ? ensureArray(targetValue.device_id) + : undefined + ); + + if (searchTerm) { + devices = this._filterGroup("device", devices); + } + + if (devices.length > 0 && filterTypes.length !== 1) { + // show group title + items.push( + this.hass.localize("ui.components.target-picker.type.devices") + ); + } + + items.push(...devices); + } + + if (filterTypes.length === 0 || filterTypes.includes("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(SEPARATOR) + ), + includeDomains, + undefined, + includeDeviceClasses, + deviceFilter, + entityFilter, + targetValue?.area_id ? ensureArray(targetValue.area_id) : undefined, + targetValue?.floor_id ? ensureArray(targetValue.floor_id) : undefined + ); + + if (searchTerm) { + areasAndFloors = this._filterGroup( + "area", + areasAndFloors + ) as FloorComboBoxItem[]; + } + + if (areasAndFloors.length > 0 && filterTypes.length !== 1) { + // show group title + items.push( + this.hass.localize("ui.components.target-picker.type.areas") + ); + } + + items.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 (filterTypes.length === 0 || filterTypes.includes("label")) { + let labels = this._getLabelsMemoized( + this.hass, + this._labelRegistry, + includeDomains, + undefined, + includeDeviceClasses, + deviceFilter, + entityFilter, + targetValue?.label_id ? ensureArray(targetValue.label_id) : undefined + ); + + if (searchTerm) { + labels = this._filterGroup("label", labels); + } + + if (labels.length > 0 && filterTypes.length !== 1) { + // show group title + items.push( + this.hass.localize("ui.components.target-picker.type.labels") + ); + } + + items.push(...labels); + } + + items.push(...this._getCreateItems(createDomains)); + + if (searchTerm && items.length === 0) { + items.push({ + id: EMPTY_SEARCH, + primary: this.hass.localize( + "ui.components.target-picker.no_target_found", + { term: html`"${searchTerm}"` } + ), + }); + } else if (items.length === 0) { + items.push({ + id: EMPTY_SEARCH, + primary: this.hass.localize("ui.components.target-picker.no_targets"), + }); + } + + if (mode === "dialog") { + items.push("padding"); // padding for safe area inset + } + + return items; + } + ); + + private _getCreateItems = memoizeOne( + (createDomains: this["createDomains"]) => { + if (!createDomains?.length) { + return []; + } + + return createDomains.map((domain) => { + const primary = this.hass.localize( + "ui.components.entity.entity-picker.create_helper", + { + domain: isHelperDomain(domain) + ? this.hass.localize( + `ui.panel.config.helpers.types.${domain as HelperDomain}` + ) + : domainToName(this.hass.localize, domain), + } + ); + + return { + id: CREATE_ID + domain, + primary: primary, + secondary: this.hass.localize( + "ui.components.entity.entity-picker.new_entity" + ), + icon_path: mdiPlus, + } satisfies EntityComboBoxItem; + }); + } + ); + + private _fuseIndexes = { + area: memoizeOne((states: FloorComboBoxItem[]) => + this._createFuseIndex(states) + ), + entity: memoizeOne((states: EntityComboBoxItem[]) => + this._createFuseIndex(states) + ), + device: memoizeOne((states: DevicePickerItem[]) => + this._createFuseIndex(states) + ), + label: memoizeOne((states: PickerComboBoxItem[]) => + this._createFuseIndex(states) + ), + }; + + private _createFuseIndex = (states) => + Fuse.createIndex(["search_labels"], states); + + private _searchChanged(ev: Event) { + const textfield = ev.target as HaTextField; + const value = textfield.value.trim(); + this._searchTerm = value; + + this._resetSelectedItem(); + } + + private _handlePickTarget = (ev) => { + const id = ev.currentTarget?.targetId as string; + const type = ev.currentTarget?.targetType as TargetType; + + if (!id || !type) { + return; + } + + this._pickTarget(id, type); + }; + + private _pickTarget = (id: string, type: TargetType) => { + if (type === "label" && id === EMPTY_SEARCH) { + return; + } + + if (id.startsWith(CREATE_ID)) { + const domain = id.substring(CREATE_ID.length); + + fireEvent(this, "create-domain-picked", domain); + return; + } + + fireEvent(this, "target-picked", { + id, + type, + }); + }; + + private get _showEntityId() { + return this.hass.userData?.showEntityIdPicker; + } + + private _toggleFilter(ev: any) { + this._resetSelectedItem(); + this._filterHeader = undefined; + const type = ev.target.type as TargetTypeFloorless; + if (!type) { + return; + } + const index = this.filterTypes.indexOf(type); + if (index === -1) { + this.filterTypes = [...this.filterTypes, type]; + } else { + this.filterTypes = this.filterTypes.filter((t) => t !== type); + } + + // Reset scroll position when filter changes + if (this._virtualizerElement) { + this._virtualizerElement.scrollToIndex(0); + } + + fireEvent(this, "filter-types-changed", this.filterTypes); + } + + @eventOptions({ passive: true }) + private _onScrollList(ev) { + const top = ev.target.scrollTop ?? 0; + this._listScrolled = top > 0; + } + + private _resetSelectedItem() { + this._virtualizerElement + ?.querySelector(".selected") + ?.classList.remove("selected"); + this._selectedItemIndex = -1; + } + + static styles = [ + haStyleScrollbar, + css` + :host { + display: flex; + flex-direction: column; + padding-top: var(--ha-space-3); + flex: 1; + } + + ha-textfield { + padding: 0 var(--ha-space-3); + } + + .filter { + display: flex; + gap: var(--ha-space-2); + padding: var(--ha-space-3) var(--ha-space-3); + overflow: auto; + --ha-button-border-radius: var(--ha-border-radius-md); + } + + :host([mode="dialog"]) .filter { + padding: var(--ha-space-3) var(--ha-space-4); + } + + .filter ha-button { + flex-shrink: 0; + } + + .filter .separator { + height: var(--ha-space-8); + width: 0; + border: 1px solid var(--ha-color-border-neutral-quiet); + } + + .filter-header, + .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); + } + + .title { + width: 100%; + } + + :host([mode="dialog"]) .title { + padding: var(--ha-space-1) var(--ha-space-4); + } + + :host([mode="dialog"]) ha-textfield { + padding: 0 var(--ha-space-4); + } + + 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); + } + } + + .filter-header-wrapper { + height: 0; + position: relative; + } + + .filter-header { + opacity: 0; + transition: opacity 300ms ease-in; + position: absolute; + top: 1px; + width: calc(100% - var(--ha-space-8)); + } + + .filter-header.show { + opacity: 1; + z-index: 1; + } + + lit-virtualizer { + flex: 1; + } + + lit-virtualizer:focus-visible { + outline: none; + } + + lit-virtualizer.scrolled { + border-top: 1px solid var(--ha-color-border-neutral-quiet); + } + + .bottom-padding { + height: max(var(--safe-area-inset-bottom, 0px), var(--ha-space-8)); + width: 100%; + } + + .search-term { + color: var(--primary-color); + font-weight: var(--ha-font-weight-medium); + } + `, + ]; +} + +declare global { + interface HTMLElementTagNameMap { + "ha-target-picker-selector": HaTargetPickerSelector; + } + + interface HASSDomEvents { + "filter-types-changed": TargetTypeFloorless[]; + "target-picked": { + type: TargetType; + id: string; + }; + "create-domain-picked": string; + } +} diff --git a/src/components/target-picker/ha-target-picker-value-chip.ts b/src/components/target-picker/ha-target-picker-value-chip.ts new file mode 100644 index 0000000000..d25bd19643 --- /dev/null +++ b/src/components/target-picker/ha-target-picker-value-chip.ts @@ -0,0 +1,354 @@ +import { consume } from "@lit/context"; +// @ts-ignore +import chipStyles from "@material/chips/dist/mdc.chips.min.css"; +import { + mdiClose, + mdiDevices, + mdiHome, + mdiLabel, + mdiTextureBox, + mdiUnfoldMoreVertical, +} from "@mdi/js"; +import { css, html, LitElement, nothing, unsafeCSS } from "lit"; +import { customElement, property, state } from "lit/decorators"; +import { classMap } from "lit/directives/class-map"; +import memoizeOne from "memoize-one"; +import { computeCssColor } from "../../common/color/compute-color"; +import { hex2rgb } from "../../common/color/convert-color"; +import { fireEvent } from "../../common/dom/fire_event"; +import { + computeDeviceName, + computeDeviceNameDisplay, +} from "../../common/entity/compute_device_name"; +import { computeDomain } from "../../common/entity/compute_domain"; +import { computeEntityName } from "../../common/entity/compute_entity_name"; +import { getEntityContext } from "../../common/entity/context/get_entity_context"; +import { getConfigEntry } from "../../data/config_entries"; +import { labelsContext } from "../../data/context"; +import { domainToName } from "../../data/integration"; +import type { LabelRegistryEntry } from "../../data/label_registry"; +import type { TargetType } from "../../data/target"; +import type { HomeAssistant } from "../../types"; +import { brandsUrl } from "../../util/brands-url"; +import { floorDefaultIconPath } from "../ha-floor-icon"; +import "../ha-icon"; +import "../ha-icon-button"; +import "../ha-md-list"; +import "../ha-md-list-item"; +import "../ha-state-icon"; +import "../ha-tooltip"; + +@customElement("ha-target-picker-value-chip") +export class HaTargetPickerValueChip extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @property() public type!: TargetType; + + @property({ attribute: "item-id" }) public itemId!: string; + + @state() private _domainName?: string; + + @state() private _iconImg?: string; + + @state() + @consume({ context: labelsContext, subscribe: true }) + _labelRegistry!: LabelRegistryEntry[]; + + protected render() { + const { name, iconPath, fallbackIconPath, stateObject, color } = + this._itemData(this.type, this.itemId); + + return html` +
+ ${iconPath + ? html`` + : this._iconImg + ? html`${this._domainName` + : fallbackIconPath + ? html`` + : stateObject + ? html`` + : nothing} + + + ${name} + + + ${this.type === "entity" + ? nothing + : html` + ${this.hass.localize( + `ui.components.target-picker.expand_${this.type}_id` + )} + + + `} + + + ${this.hass.localize( + `ui.components.target-picker.remove_${this.type}_id` + )} + + + +
+ `; + } + + private _itemData = memoizeOne((type: TargetType, itemId: string) => { + if (type === "floor") { + const floor = this.hass.floors?.[itemId]; + return { + name: floor?.name || itemId, + iconPath: floor?.icon, + fallbackIconPath: floor ? floorDefaultIconPath(floor) : mdiHome, + }; + } + if (type === "area") { + const area = this.hass.areas?.[itemId]; + return { + name: area?.name || itemId, + iconPath: area?.icon, + fallbackIconPath: mdiTextureBox, + }; + } + if (type === "device") { + const device = this.hass.devices?.[itemId]; + + if (device.primary_config_entry) { + this._getDeviceDomain(device.primary_config_entry); + } + + return { + name: device ? computeDeviceNameDisplay(device, this.hass) : itemId, + fallbackIconPath: mdiDevices, + }; + } + if (type === "entity") { + this._setDomainName(computeDomain(itemId)); + + const stateObject = this.hass.states[itemId]; + const entityName = computeEntityName( + stateObject, + this.hass.entities, + this.hass.devices + ); + const { device } = getEntityContext( + stateObject, + this.hass.entities, + this.hass.devices, + this.hass.areas, + this.hass.floors + ); + const deviceName = device ? computeDeviceName(device) : undefined; + return { + name: entityName || deviceName || itemId, + stateObject, + }; + } + + // type label + const label = this._labelRegistry.find((lab) => lab.label_id === itemId); + let color = label?.color ? computeCssColor(label.color) : undefined; + if (color?.startsWith("var(")) { + const computedStyles = getComputedStyle(this); + color = computedStyles.getPropertyValue( + color.substring(4, color.length - 1) + ); + } + if (color?.startsWith("#")) { + color = hex2rgb(color).join(","); + } + return { + name: label?.name || itemId, + iconPath: label?.icon, + fallbackIconPath: mdiLabel, + color, + }; + }); + + private _setDomainName(domain: string) { + this._domainName = domainToName(this.hass.localize, domain); + } + + private async _getDeviceDomain(configEntryId: string) { + try { + const data = await getConfigEntry(this.hass, configEntryId); + const domain = data.config_entry.domain; + this._iconImg = brandsUrl({ + domain: domain, + type: "icon", + darkOptimized: this.hass.themes?.darkMode, + }); + + this._setDomainName(domain); + } catch { + // failed to load config entry -> ignore + } + } + + private _removeItem(ev) { + ev.stopPropagation(); + fireEvent(this, "remove-target-item", { + type: this.type, + id: this.itemId, + }); + } + + private _handleExpand(ev) { + ev.stopPropagation(); + fireEvent(this, "expand-target-item", { + type: this.type, + id: this.itemId, + }); + } + + static styles = css` + ${unsafeCSS(chipStyles)} + .mdc-chip { + color: var(--primary-text-color); + } + .mdc-chip.add { + color: rgba(0, 0, 0, 0.87); + } + .add-container { + position: relative; + display: inline-flex; + } + .mdc-chip:not(.add) { + cursor: default; + } + .mdc-chip ha-icon-button { + --mdc-icon-button-size: 24px; + display: flex; + align-items: center; + outline: none; + } + .mdc-chip ha-icon-button ha-svg-icon { + border-radius: 50%; + background: var(--secondary-text-color); + } + .mdc-chip__icon.mdc-chip__icon--trailing { + width: var(--ha-space-4); + height: var(--ha-space-4); + --mdc-icon-size: 14px; + color: var(--secondary-text-color); + margin-inline-start: var(--ha-space-1) !important; + margin-inline-end: calc(-1 * var(--ha-space-1)) !important; + direction: var(--direction); + } + .mdc-chip__icon--leading { + display: flex; + align-items: center; + justify-content: center; + --mdc-icon-size: 20px; + border-radius: var(--ha-border-radius-circle); + padding: 6px; + margin-left: -13px !important; + margin-inline-start: -13px !important; + margin-inline-end: var(--ha-space-1) !important; + direction: var(--direction); + } + .expand-btn { + margin-right: var(--ha-space-0); + margin-inline-end: var(--ha-space-0); + margin-inline-start: initial; + } + .mdc-chip.area:not(.add), + .mdc-chip.floor:not(.add) { + border: 1px solid #fed6a4; + background: var(--card-background-color); + } + .mdc-chip.area:not(.add) .mdc-chip__icon--leading, + .mdc-chip.area.add, + .mdc-chip.floor:not(.add) .mdc-chip__icon--leading, + .mdc-chip.floor.add { + background: #fed6a4; + } + .mdc-chip.device:not(.add) { + border: 1px solid #a8e1fb; + background: var(--card-background-color); + } + .mdc-chip.device:not(.add) .mdc-chip__icon--leading, + .mdc-chip.device.add { + background: #a8e1fb; + } + .mdc-chip.entity:not(.add) { + border: 1px solid #d2e7b9; + background: var(--card-background-color); + } + .mdc-chip.entity:not(.add) .mdc-chip__icon--leading, + .mdc-chip.entity.add { + background: #d2e7b9; + } + .mdc-chip.label:not(.add) { + border: 1px solid var(--color, #e0e0e0); + background: var(--card-background-color); + } + .mdc-chip.label:not(.add) .mdc-chip__icon--leading, + .mdc-chip.label.add { + background: var(--background-color, #e0e0e0); + } + .mdc-chip:hover { + z-index: 5; + } + :host([disabled]) .mdc-chip { + opacity: var(--light-disabled-opacity); + pointer-events: none; + } + .tooltip-icon-img { + width: 24px; + height: 24px; + } + `; +} + +declare global { + interface HTMLElementTagNameMap { + "ha-target-picker-value-chip": HaTargetPickerValueChip; + } +} diff --git a/src/data/area_floor.ts b/src/data/area_floor.ts new file mode 100644 index 0000000000..c1c47e1e47 --- /dev/null +++ b/src/data/area_floor.ts @@ -0,0 +1,259 @@ +import { computeAreaName } from "../common/entity/compute_area_name"; +import { computeDomain } from "../common/entity/compute_domain"; +import { computeFloorName } from "../common/entity/compute_floor_name"; +import { stringCompare } from "../common/string/compare"; +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 { AreaRegistryEntry } from "./area_registry"; +import { + getDeviceEntityDisplayLookup, + type DeviceEntityDisplayLookup, + type DeviceRegistryEntry, +} from "./device_registry"; +import type { HaEntityPickerEntityFilterFunc } from "./entity"; +import type { EntityRegistryDisplayEntry } from "./entity_registry"; +import { getFloorAreaLookup, type FloorRegistryEntry } from "./floor_registry"; + +export interface FloorComboBoxItem extends PickerComboBoxItem { + type: "floor" | "area"; + floor?: FloorRegistryEntry; + area?: AreaRegistryEntry; +} + +export interface AreaFloorValue { + id: string; + type: "floor" | "area"; +} + +export const getAreasAndFloors = ( + 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[] +): FloorComboBoxItem[] => { + const floors = Object.values(haFloors); + const areas = Object.values(haAreas); + const devices = Object.values(haDevices); + const entities = Object.values(haEntities); + + let deviceEntityLookup: DeviceEntityDisplayLookup = {}; + let inputDevices: DeviceRegistryEntry[] | undefined; + let inputEntities: EntityRegistryDisplayEntry[] | undefined; + + if ( + includeDomains || + excludeDomains || + includeDeviceClasses || + deviceFilter || + entityFilter + ) { + deviceEntityLookup = getDeviceEntityDisplayLookup(entities); + inputDevices = devices; + inputEntities = entities.filter((entity) => entity.area_id); + + if (includeDomains) { + inputDevices = inputDevices!.filter((device) => { + const devEntities = deviceEntityLookup[device.id]; + if (!devEntities || !devEntities.length) { + return false; + } + return deviceEntityLookup[device.id].some((entity) => + includeDomains.includes(computeDomain(entity.entity_id)) + ); + }); + inputEntities = inputEntities!.filter((entity) => + includeDomains.includes(computeDomain(entity.entity_id)) + ); + } + + if (excludeDomains) { + inputDevices = inputDevices!.filter((device) => { + const devEntities = deviceEntityLookup[device.id]; + if (!devEntities || !devEntities.length) { + return true; + } + return entities.every( + (entity) => !excludeDomains.includes(computeDomain(entity.entity_id)) + ); + }); + inputEntities = inputEntities!.filter( + (entity) => !excludeDomains.includes(computeDomain(entity.entity_id)) + ); + } + + if (includeDeviceClasses) { + inputDevices = inputDevices!.filter((device) => { + const devEntities = deviceEntityLookup[device.id]; + if (!devEntities || !devEntities.length) { + return false; + } + return deviceEntityLookup[device.id].some((entity) => { + const stateObj = states[entity.entity_id]; + if (!stateObj) { + return false; + } + return ( + stateObj.attributes.device_class && + includeDeviceClasses.includes(stateObj.attributes.device_class) + ); + }); + }); + inputEntities = inputEntities!.filter((entity) => { + const stateObj = states[entity.entity_id]; + return ( + stateObj.attributes.device_class && + includeDeviceClasses.includes(stateObj.attributes.device_class) + ); + }); + } + + if (deviceFilter) { + inputDevices = inputDevices!.filter((device) => deviceFilter!(device)); + } + + if (entityFilter) { + inputDevices = inputDevices!.filter((device) => { + const devEntities = deviceEntityLookup[device.id]; + if (!devEntities || !devEntities.length) { + return false; + } + return deviceEntityLookup[device.id].some((entity) => { + const stateObj = states[entity.entity_id]; + if (!stateObj) { + return false; + } + return entityFilter(stateObj); + }); + }); + inputEntities = inputEntities!.filter((entity) => { + const stateObj = states[entity.entity_id]; + if (!stateObj) { + return false; + } + return entityFilter!(stateObj); + }); + } + } + + let outputAreas = areas; + + let areaIds: string[] | undefined; + + if (inputDevices) { + areaIds = inputDevices + .filter((device) => device.area_id) + .map((device) => device.area_id!); + } + + if (inputEntities) { + areaIds = (areaIds ?? []).concat( + inputEntities + .filter((entity) => entity.area_id) + .map((entity) => entity.area_id!) + ); + } + + if (areaIds) { + outputAreas = outputAreas.filter((area) => areaIds!.includes(area.area_id)); + } + + if (excludeAreas) { + outputAreas = outputAreas.filter( + (area) => !excludeAreas!.includes(area.area_id) + ); + } + + if (excludeFloors) { + outputAreas = outputAreas.filter( + (area) => !area.floor_id || !excludeFloors!.includes(area.floor_id) + ); + } + + const floorAreaLookup = getFloorAreaLookup(outputAreas); + const unassignedAreas = Object.values(outputAreas).filter( + (area) => !area.floor_id || !floorAreaLookup[area.floor_id] + ); + + // @ts-ignore + const floorAreaEntries: [ + FloorRegistryEntry | undefined, + AreaRegistryEntry[], + ][] = Object.entries(floorAreaLookup) + .map(([floorId, floorAreas]) => { + const floor = floors.find((fl) => fl.floor_id === floorId)!; + return [floor, floorAreas] as const; + }) + .sort(([floorA], [floorB]) => { + if (floorA.level !== floorB.level) { + return (floorA.level ?? 0) - (floorB.level ?? 0); + } + return stringCompare(floorA.name, floorB.name); + }); + + const items: FloorComboBoxItem[] = []; + + floorAreaEntries.forEach(([floor, floorAreas]) => { + if (floor) { + const floorName = computeFloorName(floor); + + const areaSearchLabels = floorAreas + .map((area) => { + const areaName = computeAreaName(area) || area.area_id; + return [area.area_id, areaName, ...area.aliases]; + }) + .flat(); + + items.push({ + id: formatId({ id: floor.floor_id, type: "floor" }), + type: "floor", + primary: floorName, + floor: floor, + search_labels: [ + floor.floor_id, + floorName, + ...floor.aliases, + ...areaSearchLabels, + ], + }); + } + items.push( + ...floorAreas.map((area) => { + 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], + }; + }) + ); + }); + + items.push( + ...unassignedAreas.map((area) => { + 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], + }; + }) + ); + + return items; +}; diff --git a/src/data/device_registry.ts b/src/data/device_registry.ts index eece4e9537..056bf9c3a9 100644 --- a/src/data/device_registry.ts +++ b/src/data/device_registry.ts @@ -1,12 +1,20 @@ +import { computeAreaName } from "../common/entity/compute_area_name"; +import { computeDeviceNameDisplay } from "../common/entity/compute_device_name"; +import { computeDomain } from "../common/entity/compute_domain"; import { computeStateName } from "../common/entity/compute_state_name"; +import { getDeviceContext } from "../common/entity/context/get_device_context"; import { caseInsensitiveStringCompare } from "../common/string/compare"; +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 { ConfigEntry } from "./config_entries"; +import type { HaEntityPickerEntityFilterFunc } from "./entity"; import type { EntityRegistryDisplayEntry, EntityRegistryEntry, } from "./entity_registry"; import type { EntitySources } from "./entity_sources"; +import { domainToName } from "./integration"; import type { RegistryEntry } from "./registry"; export { @@ -163,3 +171,147 @@ export const getDeviceIntegrationLookup = ( } return deviceIntegrations; }; + +export interface DevicePickerItem extends PickerComboBoxItem { + domain?: string; + domain_name?: string; +} + +export const getDevices = ( + hass: HomeAssistant, + configEntryLookup: Record, + includeDomains?: string[], + excludeDomains?: string[], + includeDeviceClasses?: string[], + deviceFilter?: HaDevicePickerDeviceFilterFunc, + entityFilter?: HaEntityPickerEntityFilterFunc, + excludeDevices?: string[], + value?: string +): DevicePickerItem[] => { + const devices = Object.values(hass.devices); + const entities = Object.values(hass.entities); + + let deviceEntityLookup: DeviceEntityDisplayLookup = {}; + + if ( + includeDomains || + excludeDomains || + includeDeviceClasses || + entityFilter + ) { + deviceEntityLookup = getDeviceEntityDisplayLookup(entities); + } + + let inputDevices = devices.filter( + (device) => device.id === value || !device.disabled_by + ); + + if (includeDomains) { + inputDevices = inputDevices.filter((device) => { + const devEntities = deviceEntityLookup[device.id]; + if (!devEntities || !devEntities.length) { + return false; + } + return deviceEntityLookup[device.id].some((entity) => + includeDomains.includes(computeDomain(entity.entity_id)) + ); + }); + } + + if (excludeDomains) { + inputDevices = inputDevices.filter((device) => { + const devEntities = deviceEntityLookup[device.id]; + if (!devEntities || !devEntities.length) { + return true; + } + return entities.every( + (entity) => !excludeDomains.includes(computeDomain(entity.entity_id)) + ); + }); + } + + if (excludeDevices) { + inputDevices = inputDevices.filter( + (device) => !excludeDevices!.includes(device.id) + ); + } + + if (includeDeviceClasses) { + inputDevices = inputDevices.filter((device) => { + const devEntities = deviceEntityLookup[device.id]; + if (!devEntities || !devEntities.length) { + return false; + } + return deviceEntityLookup[device.id].some((entity) => { + const stateObj = hass.states[entity.entity_id]; + if (!stateObj) { + return false; + } + return ( + stateObj.attributes.device_class && + includeDeviceClasses.includes(stateObj.attributes.device_class) + ); + }); + }); + } + + if (entityFilter) { + inputDevices = inputDevices.filter((device) => { + const devEntities = deviceEntityLookup[device.id]; + if (!devEntities || !devEntities.length) { + return false; + } + return devEntities.some((entity) => { + const stateObj = hass.states[entity.entity_id]; + if (!stateObj) { + return false; + } + return entityFilter(stateObj); + }); + }); + } + + if (deviceFilter) { + inputDevices = inputDevices.filter( + (device) => + // We always want to include the device of the current value + device.id === value || deviceFilter!(device) + ); + } + + const outputDevices = inputDevices.map((device) => { + const deviceName = computeDeviceNameDisplay( + device, + hass, + deviceEntityLookup[device.id] + ); + + const { area } = getDeviceContext(device, hass); + + const areaName = area ? computeAreaName(area) : undefined; + + const configEntry = device.primary_config_entry + ? configEntryLookup?.[device.primary_config_entry] + : undefined; + + const domain = configEntry?.domain; + const domainName = domain ? domainToName(hass.localize, domain) : undefined; + + return { + id: device.id, + label: "", + primary: + deviceName || + hass.localize("ui.components.device-picker.unnamed_device"), + secondary: areaName, + domain: configEntry?.domain, + domain_name: domainName, + search_labels: [deviceName, areaName, domain, domainName].filter( + Boolean + ) as string[], + sorting_label: deviceName || "zzz", + }; + }); + + return outputDevices; +}; diff --git a/src/data/entity.ts b/src/data/entity.ts index fe6ca3d6dc..9d54d7054a 100644 --- a/src/data/entity.ts +++ b/src/data/entity.ts @@ -1,3 +1,4 @@ +import type { HassEntity } from "home-assistant-js-websocket"; import { arrayLiteralIncludes } from "../common/array/literal-includes"; export const UNAVAILABLE = "unavailable"; @@ -10,3 +11,5 @@ export const OFF_STATES = [UNAVAILABLE, UNKNOWN, OFF] as const; export const isUnavailableState = arrayLiteralIncludes(UNAVAILABLE_STATES); export const isOffState = arrayLiteralIncludes(OFF_STATES); + +export type HaEntityPickerEntityFilterFunc = (entityId: HassEntity) => boolean; diff --git a/src/data/entity_registry.ts b/src/data/entity_registry.ts index 0b04097dae..a4b3279a68 100644 --- a/src/data/entity_registry.ts +++ b/src/data/entity_registry.ts @@ -1,12 +1,17 @@ -import type { Connection } from "home-assistant-js-websocket"; +import type { Connection, HassEntity } from "home-assistant-js-websocket"; import { createCollection } from "home-assistant-js-websocket"; import type { Store } from "home-assistant-js-websocket/dist/store"; import memoizeOne from "memoize-one"; import { computeDomain } from "../common/entity/compute_domain"; +import { computeEntityNameList } from "../common/entity/compute_entity_name_display"; import { computeStateName } from "../common/entity/compute_state_name"; import { caseInsensitiveStringCompare } from "../common/string/compare"; +import { computeRTL } from "../common/util/compute_rtl"; import { debounce } from "../common/util/debounce"; +import type { PickerComboBoxItem } from "../components/ha-picker-combo-box"; import type { HomeAssistant } from "../types"; +import type { HaEntityPickerEntityFilterFunc } from "./entity"; +import { domainToName } from "./integration"; import type { LightColor } from "./light"; import type { RegistryEntry } from "./registry"; @@ -324,3 +329,122 @@ export const getAutomaticEntityIds = ( type: "config/entity_registry/get_automatic_entity_ids", entity_ids, }); + +export interface EntityComboBoxItem extends PickerComboBoxItem { + domain_name?: string; + stateObj?: HassEntity; +} + +export const getEntities = ( + hass: HomeAssistant, + includeDomains?: string[], + excludeDomains?: string[], + entityFilter?: HaEntityPickerEntityFilterFunc, + includeDeviceClasses?: string[], + includeUnitOfMeasurement?: string[], + includeEntities?: string[], + excludeEntities?: string[], + value?: string +): EntityComboBoxItem[] => { + let items: EntityComboBoxItem[] = []; + + let entityIds = Object.keys(hass.states); + + if (includeEntities) { + entityIds = entityIds.filter((entityId) => + includeEntities.includes(entityId) + ); + } + + if (excludeEntities) { + entityIds = entityIds.filter( + (entityId) => !excludeEntities.includes(entityId) + ); + } + + if (includeDomains) { + entityIds = entityIds.filter((eid) => + includeDomains.includes(computeDomain(eid)) + ); + } + + if (excludeDomains) { + entityIds = entityIds.filter( + (eid) => !excludeDomains.includes(computeDomain(eid)) + ); + } + + items = entityIds.map((entityId) => { + const stateObj = hass.states[entityId]; + + const friendlyName = computeStateName(stateObj); // Keep this for search + const [entityName, deviceName, areaName] = computeEntityNameList( + stateObj, + [{ type: "entity" }, { type: "device" }, { type: "area" }], + hass.entities, + hass.devices, + hass.areas, + hass.floors + ); + + const domainName = domainToName(hass.localize, computeDomain(entityId)); + + const isRTL = computeRTL(hass); + + const primary = entityName || deviceName || entityId; + const secondary = [areaName, entityName ? deviceName : undefined] + .filter(Boolean) + .join(isRTL ? " ◂ " : " ▸ "); + const a11yLabel = [deviceName, entityName].filter(Boolean).join(" - "); + + return { + id: entityId, + primary: primary, + secondary: secondary, + domain_name: domainName, + sorting_label: [deviceName, entityName].filter(Boolean).join("_"), + search_labels: [ + entityName, + deviceName, + areaName, + domainName, + friendlyName, + entityId, + ].filter(Boolean) as string[], + a11y_label: a11yLabel, + stateObj: stateObj, + }; + }); + + if (includeDeviceClasses) { + items = items.filter( + (item) => + // We always want to include the entity of the current value + item.id === value || + (item.stateObj?.attributes.device_class && + includeDeviceClasses.includes(item.stateObj.attributes.device_class)) + ); + } + + if (includeUnitOfMeasurement) { + items = items.filter( + (item) => + // We always want to include the entity of the current value + item.id === value || + (item.stateObj?.attributes.unit_of_measurement && + includeUnitOfMeasurement.includes( + item.stateObj.attributes.unit_of_measurement + )) + ); + } + + if (entityFilter) { + items = items.filter( + (item) => + // We always want to include the entity of the current value + item.id === value || (item.stateObj && entityFilter!(item.stateObj)) + ); + } + + return items; +}; diff --git a/src/data/label_registry.ts b/src/data/label_registry.ts index 1ee1a9e9ca..569c3658cd 100644 --- a/src/data/label_registry.ts +++ b/src/data/label_registry.ts @@ -1,9 +1,20 @@ +import { mdiLabel } from "@mdi/js"; import type { Connection } from "home-assistant-js-websocket"; import { createCollection } from "home-assistant-js-websocket"; import type { Store } from "home-assistant-js-websocket/dist/store"; +import { computeDomain } from "../common/entity/compute_domain"; import { stringCompare } from "../common/string/compare"; import { debounce } from "../common/util/debounce"; +import type { HaDevicePickerDeviceFilterFunc } from "../components/device/ha-device-picker"; +import type { PickerComboBoxItem } from "../components/ha-picker-combo-box"; import type { HomeAssistant } from "../types"; +import { + getDeviceEntityDisplayLookup, + type DeviceEntityDisplayLookup, + type DeviceRegistryEntry, +} from "./device_registry"; +import type { HaEntityPickerEntityFilterFunc } from "./entity"; +import type { EntityRegistryDisplayEntry } from "./entity_registry"; import type { RegistryEntry } from "./registry"; export interface LabelRegistryEntry extends RegistryEntry { @@ -88,3 +99,178 @@ export const deleteLabelRegistryEntry = ( type: "config/label_registry/delete", label_id: labelId, }); + +export const getLabels = ( + hass: HomeAssistant, + labels?: LabelRegistryEntry[], + includeDomains?: string[], + excludeDomains?: string[], + includeDeviceClasses?: string[], + deviceFilter?: HaDevicePickerDeviceFilterFunc, + entityFilter?: HaEntityPickerEntityFilterFunc, + excludeLabels?: string[] +): PickerComboBoxItem[] => { + if (!labels || labels.length === 0) { + return []; + } + + const devices = Object.values(hass.devices); + const entities = Object.values(hass.entities); + + let deviceEntityLookup: DeviceEntityDisplayLookup = {}; + let inputDevices: DeviceRegistryEntry[] | undefined; + let inputEntities: EntityRegistryDisplayEntry[] | undefined; + + if ( + includeDomains || + excludeDomains || + includeDeviceClasses || + deviceFilter || + entityFilter + ) { + deviceEntityLookup = getDeviceEntityDisplayLookup(entities); + inputDevices = devices; + inputEntities = entities.filter((entity) => entity.labels.length > 0); + + if (includeDomains) { + inputDevices = inputDevices!.filter((device) => { + const devEntities = deviceEntityLookup[device.id]; + if (!devEntities || !devEntities.length) { + return false; + } + return deviceEntityLookup[device.id].some((entity) => + includeDomains.includes(computeDomain(entity.entity_id)) + ); + }); + inputEntities = inputEntities!.filter((entity) => + includeDomains.includes(computeDomain(entity.entity_id)) + ); + } + + if (excludeDomains) { + inputDevices = inputDevices!.filter((device) => { + const devEntities = deviceEntityLookup[device.id]; + if (!devEntities || !devEntities.length) { + return true; + } + return entities.every( + (entity) => !excludeDomains.includes(computeDomain(entity.entity_id)) + ); + }); + inputEntities = inputEntities!.filter( + (entity) => !excludeDomains.includes(computeDomain(entity.entity_id)) + ); + } + + if (includeDeviceClasses) { + inputDevices = inputDevices!.filter((device) => { + const devEntities = deviceEntityLookup[device.id]; + if (!devEntities || !devEntities.length) { + return false; + } + return deviceEntityLookup[device.id].some((entity) => { + const stateObj = hass.states[entity.entity_id]; + if (!stateObj) { + return false; + } + return ( + stateObj.attributes.device_class && + includeDeviceClasses.includes(stateObj.attributes.device_class) + ); + }); + }); + inputEntities = inputEntities!.filter((entity) => { + const stateObj = hass.states[entity.entity_id]; + return ( + stateObj.attributes.device_class && + includeDeviceClasses.includes(stateObj.attributes.device_class) + ); + }); + } + + if (deviceFilter) { + inputDevices = inputDevices!.filter((device) => deviceFilter!(device)); + } + + if (entityFilter) { + inputDevices = inputDevices!.filter((device) => { + const devEntities = deviceEntityLookup[device.id]; + if (!devEntities || !devEntities.length) { + return false; + } + return deviceEntityLookup[device.id].some((entity) => { + const stateObj = hass.states[entity.entity_id]; + if (!stateObj) { + return false; + } + return entityFilter(stateObj); + }); + }); + inputEntities = inputEntities!.filter((entity) => { + const stateObj = hass.states[entity.entity_id]; + if (!stateObj) { + return false; + } + return entityFilter!(stateObj); + }); + } + } + + let outputLabels = labels; + const usedLabels = new Set(); + + let areaIds: string[] | undefined; + + if (inputDevices) { + areaIds = inputDevices + .filter((device) => device.area_id) + .map((device) => device.area_id!); + + inputDevices.forEach((device) => { + device.labels.forEach((label) => usedLabels.add(label)); + }); + } + + if (inputEntities) { + areaIds = (areaIds ?? []).concat( + inputEntities + .filter((entity) => entity.area_id) + .map((entity) => entity.area_id!) + ); + inputEntities.forEach((entity) => { + entity.labels.forEach((label) => usedLabels.add(label)); + }); + } + + if (areaIds) { + areaIds.forEach((areaId) => { + const area = hass.areas[areaId]; + area.labels.forEach((label) => usedLabels.add(label)); + }); + } + + if (excludeLabels) { + outputLabels = outputLabels.filter( + (label) => !excludeLabels!.includes(label.label_id) + ); + } + + if (inputDevices || inputEntities) { + outputLabels = outputLabels.filter((label) => + usedLabels.has(label.label_id) + ); + } + + const items = outputLabels.map((label) => ({ + id: label.label_id, + primary: label.name, + icon: label.icon || undefined, + icon_path: label.icon ? undefined : mdiLabel, + sorting_label: label.name, + search_labels: [label.name, label.label_id, label.description].filter( + (v): v is string => Boolean(v) + ), + })); + + return items; +}; diff --git a/src/data/target.ts b/src/data/target.ts new file mode 100644 index 0000000000..b73c6255c9 --- /dev/null +++ b/src/data/target.ts @@ -0,0 +1,164 @@ +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 { HomeAssistant } from "../types"; +import type { AreaRegistryEntry } from "./area_registry"; +import type { DeviceRegistryEntry } from "./device_registry"; +import type { HaEntityPickerEntityFilterFunc } from "./entity"; +import type { EntityRegistryDisplayEntry } from "./entity_registry"; + +export type TargetType = "entity" | "device" | "area" | "label" | "floor"; +export type TargetTypeFloorless = Exclude; + +export interface ExtractFromTargetResult { + missing_areas: string[]; + missing_devices: string[]; + missing_floors: string[]; + missing_labels: string[]; + referenced_areas: string[]; + referenced_devices: string[]; + referenced_entities: string[]; +} + +export interface ExtractFromTargetResultReferenced { + referenced_areas: string[]; + referenced_devices: string[]; + referenced_entities: string[]; +} + +export const extractFromTarget = async ( + hass: HomeAssistant, + target: HassServiceTarget +) => + hass.callWS({ + type: "extract_from_target", + target, + }); + +export const areaMeetsFilter = ( + area: AreaRegistryEntry, + devices: HomeAssistant["devices"], + entities: HomeAssistant["entities"], + deviceFilter?: HaDevicePickerDeviceFilterFunc, + includeDomains?: string[], + includeDeviceClasses?: string[], + states?: HomeAssistant["states"], + entityFilter?: HaEntityPickerEntityFilterFunc +): boolean => { + const areaDevices = Object.values(devices).filter( + (device) => device.area_id === area.area_id + ); + + if ( + areaDevices.some((device) => + deviceMeetsFilter( + device, + entities, + deviceFilter, + includeDomains, + includeDeviceClasses, + states, + entityFilter + ) + ) + ) { + return true; + } + + const areaEntities = Object.values(entities).filter( + (entity) => entity.area_id === area.area_id + ); + + if ( + areaEntities.some((entity) => + entityRegMeetsFilter( + entity, + false, + includeDomains, + includeDeviceClasses, + states, + entityFilter + ) + ) + ) { + return true; + } + + return false; +}; + +export const deviceMeetsFilter = ( + device: DeviceRegistryEntry, + entities: HomeAssistant["entities"], + deviceFilter?: HaDevicePickerDeviceFilterFunc, + includeDomains?: string[], + includeDeviceClasses?: string[], + states?: HomeAssistant["states"], + entityFilter?: HaEntityPickerEntityFilterFunc +): boolean => { + const devEntities = Object.values(entities).filter( + (entity) => entity.device_id === device.id + ); + + if ( + !devEntities.some((entity) => + entityRegMeetsFilter( + entity, + false, + includeDomains, + includeDeviceClasses, + states, + entityFilter + ) + ) + ) { + return false; + } + + if (deviceFilter) { + return deviceFilter(device); + } + + return true; +}; + +export const entityRegMeetsFilter = ( + entity: EntityRegistryDisplayEntry, + includeSecondary = false, + includeDomains?: string[], + includeDeviceClasses?: string[], + states?: HomeAssistant["states"], + entityFilter?: HaEntityPickerEntityFilterFunc +): boolean => { + if (entity.hidden || (entity.entity_category && !includeSecondary)) { + return false; + } + + if ( + includeDomains && + !includeDomains.includes(computeDomain(entity.entity_id)) + ) { + return false; + } + if (includeDeviceClasses) { + const stateObj = states?.[entity.entity_id]; + if (!stateObj) { + return false; + } + if ( + !stateObj.attributes.device_class || + !includeDeviceClasses!.includes(stateObj.attributes.device_class) + ) { + return false; + } + } + + if (entityFilter) { + const stateObj = states?.[entity.entity_id]; + if (!stateObj) { + return false; + } + return entityFilter!(stateObj); + } + return true; +}; diff --git a/src/panels/history/ha-panel-history.ts b/src/panels/history/ha-panel-history.ts index 2e7c7c9350..170d3d2fa5 100644 --- a/src/panels/history/ha-panel-history.ts +++ b/src/panels/history/ha-panel-history.ts @@ -1,10 +1,10 @@ +import type { ActionDetail } from "@material/mwc-list"; import { mdiDotsVertical, mdiDownload, mdiFilterRemove, mdiImagePlus, } from "@mdi/js"; -import type { ActionDetail } from "@material/mwc-list"; import { differenceInHours } from "date-fns"; import type { HassServiceTarget, @@ -27,21 +27,21 @@ import { import { MIN_TIME_BETWEEN_UPDATES } from "../../components/chart/ha-chart-base"; import "../../components/chart/state-history-charts"; import type { StateHistoryCharts } from "../../components/chart/state-history-charts"; -import "../../components/ha-spinner"; +import "../../components/ha-button-menu"; import "../../components/ha-date-range-picker"; import "../../components/ha-icon-button"; -import "../../components/ha-button-menu"; -import "../../components/ha-list-item"; import "../../components/ha-icon-button-arrow-prev"; +import "../../components/ha-list-item"; import "../../components/ha-menu-button"; +import "../../components/ha-spinner"; import "../../components/ha-target-picker"; import "../../components/ha-top-app-bar-fixed"; import type { HistoryResult } from "../../data/history"; import { computeHistory, - subscribeHistory, - mergeHistoryResults, convertStatisticsToHistory, + mergeHistoryResults, + subscribeHistory, } from "../../data/history"; import { fetchStatistics } from "../../data/recorder"; import { resolveEntityIDs } from "../../data/selector"; @@ -182,6 +182,7 @@ class HaPanelHistory extends LitElement { .disabled=${this._isLoading} add-on-top @value-changed=${this._targetsChanged} + compact > ${this._isLoading @@ -649,6 +650,10 @@ class HaPanelHistory extends LitElement { direction: var(--direction); } + ha-target-picker { + flex: 1; + } + @media all and (max-width: 1025px) { .filters { flex-direction: column; diff --git a/src/panels/logbook/ha-panel-logbook.ts b/src/panels/logbook/ha-panel-logbook.ts index a5110c25c5..365731df37 100644 --- a/src/panels/logbook/ha-panel-logbook.ts +++ b/src/panels/logbook/ha-panel-logbook.ts @@ -1,9 +1,11 @@ import { mdiRefresh } from "@mdi/js"; +import type { HassServiceTarget } from "home-assistant-js-websocket"; import type { PropertyValues } from "lit"; import { css, html, LitElement } from "lit"; import { customElement, property, state } from "lit/decorators"; -import type { HassServiceTarget } from "home-assistant-js-websocket"; import memoizeOne from "memoize-one"; +import { ensureArray } from "../../common/array/ensure-array"; +import { storage } from "../../common/decorators/storage"; import { goBack, navigate } from "../../common/navigate"; import { constructUrlCurrentPath } from "../../common/url/construct-url"; import { @@ -16,17 +18,15 @@ import "../../components/ha-date-range-picker"; import "../../components/ha-icon-button"; import "../../components/ha-icon-button-arrow-prev"; import "../../components/ha-menu-button"; -import "../../components/ha-top-app-bar-fixed"; import "../../components/ha-target-picker"; +import "../../components/ha-top-app-bar-fixed"; +import type { HaEntityPickerEntityFilterFunc } from "../../data/entity"; import { filterLogbookCompatibleEntities } from "../../data/logbook"; +import { resolveEntityIDs } from "../../data/selector"; +import { getSensorNumericDeviceClasses } from "../../data/sensor"; import { haStyle } from "../../resources/styles"; import type { HomeAssistant } from "../../types"; import "./ha-logbook"; -import { storage } from "../../common/decorators/storage"; -import { ensureArray } from "../../common/array/ensure-array"; -import { resolveEntityIDs } from "../../data/selector"; -import { getSensorNumericDeviceClasses } from "../../data/sensor"; -import type { HaEntityPickerEntityFilterFunc } from "../../components/entity/ha-entity-picker"; @customElement("ha-panel-logbook") export class HaPanelLogbook extends LitElement { @@ -108,6 +108,7 @@ export class HaPanelLogbook extends LitElement { .value=${this._targetPickerValue} add-on-top @value-changed=${this._targetsChanged} + compact > @@ -363,6 +364,10 @@ export class HaPanelLogbook extends LitElement { max-width: 400px; } + ha-target-picker { + flex: 1; + } + :host([narrow]) ha-entity-picker { max-width: none; width: 100%; diff --git a/src/panels/lovelace/components/hui-entity-editor.ts b/src/panels/lovelace/components/hui-entity-editor.ts index 66d48fa863..8a5db67529 100644 --- a/src/panels/lovelace/components/hui-entity-editor.ts +++ b/src/panels/lovelace/components/hui-entity-editor.ts @@ -6,12 +6,10 @@ import { fireEvent } from "../../../common/dom/fire_event"; import { entityUseDeviceName } from "../../../common/entity/compute_entity_name"; import { computeRTL } from "../../../common/util/compute_rtl"; import "../../../components/entity/ha-entity-picker"; -import type { - HaEntityPicker, - HaEntityPickerEntityFilterFunc, -} from "../../../components/entity/ha-entity-picker"; +import type { HaEntityPicker } from "../../../components/entity/ha-entity-picker"; import "../../../components/ha-icon-button"; import "../../../components/ha-sortable"; +import type { HaEntityPickerEntityFilterFunc } from "../../../data/entity"; import type { HomeAssistant } from "../../../types"; import type { EntityConfig } from "../entity-rows/types"; diff --git a/src/panels/lovelace/editor/config-elements/hui-heading-badges-editor.ts b/src/panels/lovelace/editor/config-elements/hui-heading-badges-editor.ts index a5f3d61d75..5679640d18 100644 --- a/src/panels/lovelace/editor/config-elements/hui-heading-badges-editor.ts +++ b/src/panels/lovelace/editor/config-elements/hui-heading-badges-editor.ts @@ -148,10 +148,10 @@ export class HuiHeadingBadgesEditor extends LitElement { .hass=${this.hass} id="input" .placeholder=${this.hass.localize( - "ui.components.target-picker.add_entity_id" + "ui.components.entity.entity-picker.choose_entity" )} .searchLabel=${this.hass.localize( - "ui.components.target-picker.add_entity_id" + "ui.components.entity.entity-picker.choose_entity" )} @value-changed=${this._entityPicked} @click=${preventDefault} diff --git a/src/panels/lovelace/editor/config-elements/hui-logbook-card-editor.ts b/src/panels/lovelace/editor/config-elements/hui-logbook-card-editor.ts index 5fbfe41368..0dcae4b1dd 100644 --- a/src/panels/lovelace/editor/config-elements/hui-logbook-card-editor.ts +++ b/src/panels/lovelace/editor/config-elements/hui-logbook-card-editor.ts @@ -22,8 +22,8 @@ import type { LovelaceCardEditor } from "../../types"; import { baseLovelaceCardConfig } from "../structs/base-card-struct"; import { DEFAULT_HOURS_TO_SHOW } from "../../cards/hui-logbook-card"; import { targetStruct } from "../../../../data/script"; -import type { HaEntityPickerEntityFilterFunc } from "../../../../components/entity/ha-entity-picker"; import { getSensorNumericDeviceClasses } from "../../../../data/sensor"; +import type { HaEntityPickerEntityFilterFunc } from "../../../../data/entity"; const cardConfigStruct = assign( baseLovelaceCardConfig, diff --git a/src/resources/theme/color/wa.globals.ts b/src/resources/theme/color/wa.globals.ts index 634ead0752..0ace8cb1a5 100644 --- a/src/resources/theme/color/wa.globals.ts +++ b/src/resources/theme/color/wa.globals.ts @@ -52,6 +52,13 @@ 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-surface-default: var(--card-background-color); + --wa-panel-border-radius: var(--ha-border-radius-3xl); + --wa-panel-border-style: solid; + --wa-panel-border-width: 1px; + --wa-color-surface-border: var(--ha-color-border-neutral-quiet); + --wa-focus-ring-color: var(--ha-color-neutral-60); + --wa-shadow-l: 4px 8px 12px 0 rgba(0, 0, 0, 0.3); } `; diff --git a/src/resources/theme/wa.globals.ts b/src/resources/theme/wa.globals.ts index dd05b4e554..34ab8cca65 100644 --- a/src/resources/theme/wa.globals.ts +++ b/src/resources/theme/wa.globals.ts @@ -16,7 +16,7 @@ export const waMainStyles = css` --wa-font-weight-action: var(--ha-font-weight-medium); --wa-transition-fast: 75ms; --wa-transition-easing: ease; - --wa-border-width-l: var(--ha-border-radius-l); + --wa-border-width-l: var(--ha-border-radius-lg); --wa-space-xl: 32px; } diff --git a/src/state/context-mixin.ts b/src/state/context-mixin.ts index a2d1c44975..29094e98ed 100644 --- a/src/state/context-mixin.ts +++ b/src/state/context-mixin.ts @@ -1,4 +1,5 @@ import { ContextProvider } from "@lit/context"; +import type { UnsubscribeFunc } from "home-assistant-js-websocket"; import { areasContext, configContext, @@ -6,6 +7,7 @@ import { devicesContext, entitiesContext, floorsContext, + labelsContext, localeContext, localizeContext, panelsContext, @@ -15,6 +17,7 @@ import { userContext, userDataContext, } from "../data/context"; +import { subscribeLabelRegistry } from "../data/label_registry"; import type { Constructor, HomeAssistant } from "../types"; import type { HassBaseEl } from "./hass-base-mixin"; @@ -22,6 +25,8 @@ export const contextMixin = >( superClass: T ) => class extends superClass { + private _unsubscribeLabels?: UnsubscribeFunc; + private __contextProviders: Record< string, ContextProvider | undefined @@ -92,6 +97,10 @@ export const contextMixin = >( context: floorsContext, initialValue: this.hass ? this.hass.floors : this._pendingHass.floors, }), + labels: new ContextProvider(this, { + context: labelsContext, + initialValue: [], + }), }; protected hassConnected() { @@ -101,6 +110,13 @@ export const contextMixin = >( this.__contextProviders[key]!.setValue(value); } } + + this._unsubscribeLabels = subscribeLabelRegistry( + this.hass!.connection!, + (labels) => { + this.__contextProviders.labels!.setValue(labels); + } + ); } protected _updateHass(obj: Partial) { @@ -111,4 +127,9 @@ export const contextMixin = >( } } } + + public disconnectedCallback() { + super.disconnectedCallback(); + this._unsubscribeLabels?.(); + } }; diff --git a/src/translations/en.json b/src/translations/en.json index 5eb8181759..f060f73dc6 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -646,6 +646,7 @@ }, "entity": { "entity-picker": { + "choose_entity": "Choose entity", "entity": "Entity", "edit": "Edit", "clear": "Clear", @@ -682,16 +683,36 @@ "expand_area_id": "Split this area into separate devices and entities.", "expand_device_id": "Split this device into separate entities.", "expand_label_id": "Split this label into separate areas, devices and entities.", + "add_target": "Add target", "remove": "Remove", "remove_floor_id": "Remove floor", "remove_area_id": "Remove area", "remove_device_id": "Remove device", "remove_entity_id": "Remove entity", "remove_label_id": "Remove label", - "add_area_id": "Choose area", - "add_device_id": "Choose device", - "add_entity_id": "Choose entity", - "add_label_id": "Choose label" + "devices_count": "{count} {count, plural,\n one {device}\n other {devices}\n}", + "entities_count": "{count} {count, plural,\n one {entity}\n other {entities}\n}", + "target_details": "Target details", + "no_targets": "No targets", + "no_target_found": "No target found for {term}", + "selected": { + "entity": "Entities: {count}", + "device": "Devices: {count}", + "area": "Areas: {count}", + "label": "Labels: {count}", + "floor": "Floors: {count}" + }, + "type": { + "area": "Area", + "areas": "Areas", + "device": "Device", + "devices": "Devices", + "entity": "Entity", + "entities": "Entities", + "label": "Label", + "labels": "Labels", + "floor": "Floor" + } }, "subpage-data-table": { "filters": "Filters", diff --git a/test/common/entity/context/get_area_context.test.ts b/test/common/entity/context/get_area_context.test.ts index a35ea5b9e5..6837fac6f9 100644 --- a/test/common/entity/context/get_area_context.test.ts +++ b/test/common/entity/context/get_area_context.test.ts @@ -1,6 +1,5 @@ -import { describe, it, expect } from "vitest"; +import { describe, expect, it } from "vitest"; import { getAreaContext } from "../../../../src/common/entity/context/get_area_context"; -import type { HomeAssistant } from "../../../../src/types"; import { mockArea, mockFloor } from "./context-mock"; describe("getAreaContext", () => { @@ -9,14 +8,7 @@ describe("getAreaContext", () => { area_id: "area_1", }); - const hass = { - areas: { - area_1: area, - }, - floors: {}, - } as unknown as HomeAssistant; - - const result = getAreaContext(area, hass); + const result = getAreaContext(area, {}); expect(result).toEqual({ area, @@ -34,16 +26,9 @@ describe("getAreaContext", () => { floor_id: "floor_1", }); - const hass = { - areas: { - area_2: area, - }, - floors: { - floor_1: floor, - }, - } as unknown as HomeAssistant; - - const result = getAreaContext(area, hass); + const result = getAreaContext(area, { + floor_1: floor, + }); expect(result).toEqual({ area,