diff --git a/src/components/device/ha-device-picker.ts b/src/components/device/ha-device-picker.ts index d5b304fe2a..38431a19e3 100644 --- a/src/components/device/ha-device-picker.ts +++ b/src/components/device/ha-device-picker.ts @@ -1,33 +1,28 @@ import type { ComboBoxLitRenderer } from "@vaadin/combo-box/lit"; import type { HassEntity } from "home-assistant-js-websocket"; -import type { PropertyValues, TemplateResult } from "lit"; -import { LitElement, html, nothing } from "lit"; +import { 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 { computeDeviceNameDisplay } from "../../common/entity/compute_device_name"; +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 { stringCompare } from "../../common/string/compare"; -import type { ScorableTextItem } from "../../common/string/filter/sequence-matching"; -import { fuzzyFilterSort } from "../../common/string/filter/sequence-matching"; -import type { - DeviceEntityDisplayLookup, - DeviceRegistryEntry, +import { getDeviceContext } from "../../common/entity/context/get_device_context"; +import { getConfigEntries, type ConfigEntry } from "../../data/config_entries"; +import { + getDeviceEntityDisplayLookup, + type DeviceEntityDisplayLookup, + type DeviceRegistryEntry, } from "../../data/device_registry"; -import { getDeviceEntityDisplayLookup } from "../../data/device_registry"; -import type { EntityRegistryDisplayEntry } from "../../data/entity_registry"; -import type { HomeAssistant, ValueChangedEvent } from "../../types"; -import "../ha-combo-box"; -import type { HaComboBox } from "../ha-combo-box"; -import "../ha-combo-box-item"; - -interface Device { - name: string; - area: string; - id: string; -} - -type ScorableDevice = ScorableTextItem & Device; +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 @@ -35,25 +30,35 @@ export type HaDevicePickerDeviceFilterFunc = ( export type HaDevicePickerEntityFilterFunc = (entity: HassEntity) => boolean; -const rowRenderer: ComboBoxLitRenderer = (item) => html` - - ${item.name} - ${item.area - ? html`${item.area}` - : nothing} - -`; +interface DevicePickerItem extends PickerComboBoxItem { + domain?: string; + domain_name?: string; +} @customElement("ha-device-picker") export class HaDevicePicker extends LitElement { @property({ attribute: false }) public hass!: HomeAssistant; + // eslint-disable-next-line lit/no-native-attributes + @property({ type: Boolean }) public autofocus = false; + + @property({ type: Boolean }) public disabled = false; + + @property({ type: Boolean }) public required = false; + @property() public label?: string; @property() public value?: string; @property() public helper?: string; + @property() public placeholder?: string; + + @property({ type: String, attribute: "search-label" }) + public searchLabel?: string; + + @property({ attribute: false, type: Array }) public createDomains?: string[]; + /** * Show only devices with entities from specific domains. * @type {Array} @@ -92,38 +97,52 @@ export class HaDevicePicker extends LitElement { @property({ attribute: false }) public entityFilter?: HaDevicePickerEntityFilterFunc; - @property({ type: Boolean }) public disabled = false; + @property({ attribute: "hide-clear-icon", type: Boolean }) + public hideClearIcon = false; - @property({ type: Boolean }) public required = false; + @query("ha-generic-picker") private _picker?: HaGenericPicker; - @state() private _opened?: boolean; + @state() private _configEntryLookup: Record = {}; - @query("ha-combo-box", true) public comboBox!: HaComboBox; + protected firstUpdated(_changedProperties: PropertyValues): void { + super.firstUpdated(_changedProperties); + this._loadConfigEntries(); + } - private _init = false; + private async _loadConfigEntries() { + const configEntries = await getConfigEntries(this.hass); + this._configEntryLookup = Object.fromEntries( + configEntries.map((entry) => [entry.entry_id, entry]) + ); + } + + private _getItems = () => + this._getDevices( + this.hass.devices, + this.hass.entities, + this._configEntryLookup, + this.includeDomains, + this.excludeDomains, + this.includeDeviceClasses, + this.deviceFilter, + this.entityFilter, + this.excludeDevices + ); private _getDevices = memoizeOne( ( - devices: DeviceRegistryEntry[], - areas: HomeAssistant["areas"], - entities: EntityRegistryDisplayEntry[], + 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"] - ): ScorableDevice[] => { - if (!devices.length) { - return [ - { - id: "no_devices", - area: "", - name: this.hass.localize("ui.components.device-picker.no_devices"), - strings: [], - }, - ]; - } + ): DevicePickerItem[] => { + const devices = Object.values(haDevices); + const entities = Object.values(haEntities); let deviceEntityLookup: DeviceEntityDisplayLookup = {}; @@ -214,133 +233,158 @@ export class HaDevicePicker extends LitElement { ); } - const outputDevices = inputDevices.map((device) => { - const name = computeDeviceNameDisplay( + 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, - name: - name || + label: "", + primary: + deviceName || this.hass.localize("ui.components.device-picker.unnamed_device"), - area: - device.area_id && areas[device.area_id] - ? areas[device.area_id].name - : this.hass.localize("ui.components.device-picker.no_area"), - strings: [name || ""], + secondary: areaName, + domain: configEntry?.domain, + domain_name: domainName, + search_labels: [deviceName, areaName, domain, domainName].filter( + Boolean + ) as string[], + sorting_label: deviceName || "zzz", }; }); - if (!outputDevices.length) { - return [ - { - id: "no_devices", - area: "", - name: this.hass.localize("ui.components.device-picker.no_match"), - strings: [], - }, - ]; - } - if (outputDevices.length === 1) { - return outputDevices; - } - return outputDevices.sort((a, b) => - stringCompare(a.name || "", b.name || "", this.hass.locale.language) - ); + + return outputDevices; } ); - public async open() { - await this.updateComplete; - await this.comboBox?.open(); - } + private _valueRenderer = memoizeOne( + (configEntriesLookup: Record) => (value: string) => { + const deviceId = value; + const device = this.hass.devices[deviceId]; - public async focus() { - await this.updateComplete; - await this.comboBox?.focus(); - } + if (!device) { + return html`${deviceId}`; + } - protected updated(changedProps: PropertyValues) { - if ( - (!this._init && this.hass) || - (this._init && changedProps.has("_opened") && this._opened) - ) { - this._init = true; - const devices = this._getDevices( - Object.values(this.hass.devices), - this.hass.areas, - Object.values(this.hass.entities), - this.includeDomains, - this.excludeDomains, - this.includeDeviceClasses, - this.deviceFilter, - this.entityFilter, - this.excludeDevices - ); - this.comboBox.items = devices; - this.comboBox.filteredItems = devices; + const { area } = getDeviceContext(device, this.hass); + + const deviceName = device ? computeDeviceName(device) : undefined; + const areaName = area ? computeAreaName(area) : undefined; + + const primary = deviceName; + const secondary = areaName; + + const configEntry = device.primary_config_entry + ? configEntriesLookup[device.primary_config_entry] + : undefined; + + return html` + ${configEntry + ? html`` + : nothing} + ${primary} + ${secondary} + `; } - } + ); + + private _rowRenderer: ComboBoxLitRenderer = (item) => html` + + ${item.domain + ? html` + + ` + : nothing} + + ${item.primary} + ${item.secondary + ? html`${item.secondary}` + : nothing} + ${item.domain_name + ? html` +
+ ${item.domain_name} +
+ ` + : nothing} +
+ `; + + protected render() { + const placeholder = + this.placeholder ?? + this.hass.localize("ui.components.device-picker.placeholder"); + const notFoundLabel = this.hass.localize( + "ui.components.device-picker.no_match" + ); + + const valueRenderer = this._valueRenderer(this._configEntryLookup); - protected render(): TemplateResult { return html` - + .autofocus=${this.autofocus} + .label=${this.label} + .searchLabel=${this.searchLabel} + .notFoundLabel=${notFoundLabel} + .placeholder=${placeholder} + .value=${this.value} + .rowRenderer=${this._rowRenderer} + .getItems=${this._getItems} + .hideClearIcon=${this.hideClearIcon} + .valueRenderer=${valueRenderer} + @value-changed=${this._valueChanged} + > + `; } - private get _value() { - return this.value || ""; + public async open() { + await this.updateComplete; + await this._picker?.open(); } - private _filterChanged(ev: CustomEvent): void { - const target = ev.target as HaComboBox; - const filterString = ev.detail.value.toLowerCase(); - target.filteredItems = filterString.length - ? fuzzyFilterSort(filterString, target.items || []) - : target.items; - } - - private _deviceChanged(ev: ValueChangedEvent) { + private _valueChanged(ev) { ev.stopPropagation(); - let newValue = ev.detail.value; - - if (newValue === "no_devices") { - newValue = ""; - } - - if (newValue !== this._value) { - this._setValue(newValue); - } - } - - private _openedChanged(ev: ValueChangedEvent) { - this._opened = ev.detail.value; - } - - private _setValue(value: string) { + const value = ev.detail.value; this.value = value; - setTimeout(() => { - fireEvent(this, "value-changed", { value }); - fireEvent(this, "change"); - }, 0); + fireEvent(this, "value-changed", { value }); } } diff --git a/src/components/device/ha-devices-picker.ts b/src/components/device/ha-devices-picker.ts index 8c6a2fb570..3223ce4a37 100644 --- a/src/components/device/ha-devices-picker.ts +++ b/src/components/device/ha-devices-picker.ts @@ -1,7 +1,7 @@ import { css, html, LitElement, nothing } from "lit"; import { customElement, property } from "lit/decorators"; import { fireEvent } from "../../common/dom/fire_event"; -import type { ValueChangedEvent, HomeAssistant } from "../../types"; +import type { HomeAssistant, ValueChangedEvent } from "../../types"; import "./ha-device-picker"; import type { HaDevicePickerDeviceFilterFunc, diff --git a/src/components/ha-picker-combo-box.ts b/src/components/ha-picker-combo-box.ts index 95ab8fcbc6..029dfd7f31 100644 --- a/src/components/ha-picker-combo-box.ts +++ b/src/components/ha-picker-combo-box.ts @@ -212,6 +212,10 @@ export class HaPickerComboBox extends LitElement { this.comboBox.setTextFieldValue(""); const newValue = ev.detail.value?.trim(); + if (newValue === NO_MATCHING_ITEMS_FOUND_ID) { + return; + } + if (newValue !== this._value) { this._setValue(newValue); } diff --git a/src/components/ha-target-picker.ts b/src/components/ha-target-picker.ts index 20fd210484..9d3652f78a 100644 --- a/src/components/ha-target-picker.ts +++ b/src/components/ha-target-picker.ts @@ -419,7 +419,10 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) { .hass=${this.hass} id="input" .type=${"device_id"} - .label=${this.hass.localize( + .placeholder=${this.hass.localize( + "ui.components.target-picker.add_device_id" + )} + .searchLabel=${this.hass.localize( "ui.components.target-picker.add_device_id" )} .deviceFilter=${this.deviceFilter} diff --git a/src/dialogs/quick-bar/ha-quick-bar.ts b/src/dialogs/quick-bar/ha-quick-bar.ts index f27ff5b428..68b2fa2a5f 100644 --- a/src/dialogs/quick-bar/ha-quick-bar.ts +++ b/src/dialogs/quick-bar/ha-quick-bar.ts @@ -28,6 +28,7 @@ import { import { computeDomain } from "../../common/entity/compute_domain"; import { computeEntityName } from "../../common/entity/compute_entity_name"; import { computeStateName } from "../../common/entity/compute_state_name"; +import { getDeviceContext } from "../../common/entity/context/get_device_context"; import { getEntityContext } from "../../common/entity/context/get_entity_context"; import { navigate } from "../../common/navigate"; import { caseInsensitiveStringCompare } from "../../common/string/compare"; @@ -41,6 +42,7 @@ import "../../components/ha-md-list-item"; import "../../components/ha-spinner"; import "../../components/ha-textfield"; import "../../components/ha-tip"; +import { getConfigEntries } from "../../data/config_entries"; import { fetchHassioAddonsInfo } from "../../data/hassio/addon"; import { domainToName } from "../../data/integration"; import { getPanelNameTranslationKey } from "../../data/panel"; @@ -50,6 +52,7 @@ import { HaFuse } from "../../resources/fuse"; import { haStyleDialog, haStyleScrollbar } from "../../resources/styles"; import { loadVirtualizer } from "../../resources/virtualizer"; import type { HomeAssistant } from "../../types"; +import { brandsUrl } from "../../util/brands-url"; import { showConfirmationDialog } from "../generic/show-dialog-box"; import { showShortcutsDialog } from "../shortcuts/show-shortcuts-dialog"; import { QuickBarMode, type QuickBarParams } from "./show-dialog-quick-bar"; @@ -75,6 +78,8 @@ interface EntityItem extends QuickBarItem { interface DeviceItem extends QuickBarItem { deviceId: string; + domain?: string; + translatedDomain?: string; area?: string; } @@ -297,7 +302,8 @@ export class QuickBar extends LitElement { this._commandItems = this._commandItems || (await this._generateCommandItems()); } else if (this._mode === QuickBarMode.Device) { - this._deviceItems = this._deviceItems || this._generateDeviceItems(); + this._deviceItems = + this._deviceItems || (await this._generateDeviceItems()); } else { this._entityItems = this._entityItems || (await this._generateEntityItems()); @@ -344,10 +350,28 @@ export class QuickBar extends LitElement { tabindex="0" type="button" > + ${item.domain + ? html`` + : nothing} ${item.primaryText} ${item.area ? html` ${item.area} ` : nothing} + ${item.translatedDomain + ? html`
+ ${item.translatedDomain} +
` + : nothing} `; } @@ -549,23 +573,44 @@ export class QuickBar extends LitElement { ); } - private _generateDeviceItems(): DeviceItem[] { + private async _generateDeviceItems(): Promise { + const configEntries = await getConfigEntries(this.hass); + const configEntryLookup = Object.fromEntries( + configEntries.map((entry) => [entry.entry_id, entry]) + ); + return Object.values(this.hass.devices) .filter((device) => !device.disabled_by) .map((device) => { - const area = device.area_id - ? this.hass.areas[device.area_id] - : undefined; + const deviceName = computeDeviceNameDisplay(device, this.hass); + + const { area } = getDeviceContext(device, this.hass); + + const areaName = area ? computeAreaName(area) : undefined; + const deviceItem = { - primaryText: computeDeviceNameDisplay(device, this.hass), + primaryText: deviceName, deviceId: device.id, - area: area?.name, + area: areaName, action: () => navigate(`/config/devices/device/${device.id}`), }; + const configEntry = device.primary_config_entry + ? configEntryLookup[device.primary_config_entry] + : undefined; + + const domain = configEntry?.domain; + const translatedDomain = domain + ? domainToName(this.hass.localize, domain) + : undefined; + return { ...deviceItem, - strings: [deviceItem.primaryText], + domain, + translatedDomain, + strings: [deviceName, areaName, domain, domainToName].filter( + Boolean + ) as string[], }; }) .sort((a, b) => @@ -1036,6 +1081,11 @@ export class QuickBar extends LitElement { white-space: nowrap; } + ha-md-list-item img { + width: 32px; + height: 32px; + } + ha-tip { padding: 20px; } diff --git a/src/translations/en.json b/src/translations/en.json index ba9c4490b3..ca739096b1 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -662,7 +662,8 @@ "no_match": "No matching devices found", "device": "Device", "unnamed_device": "Unnamed device", - "no_area": "No area" + "no_area": "No area", + "placeholder": "Select a device" }, "category-picker": { "clear": "Clear",