From d56c7c41e2e54bb94779958851809fc17dd57629 Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Thu, 17 Apr 2025 16:43:47 +0200 Subject: [PATCH] Update entity naming in entity picker (#24971) --- src/components/entity/ha-entity-picker.ts | 295 ++++++++++++------ src/components/ha-combo-box-item.ts | 44 +++ src/data/frontend.ts | 1 + src/panels/profile/ha-entity-id-picker-row.ts | 55 ++++ .../profile/ha-profile-section-general.ts | 10 + src/translations/en.json | 4 + 6 files changed, 317 insertions(+), 92 deletions(-) create mode 100644 src/components/ha-combo-box-item.ts create mode 100644 src/panels/profile/ha-entity-id-picker-row.ts diff --git a/src/components/entity/ha-entity-picker.ts b/src/components/entity/ha-entity-picker.ts index 900b72bddb..1f2d67252b 100644 --- a/src/components/entity/ha-entity-picker.ts +++ b/src/components/entity/ha-entity-picker.ts @@ -1,35 +1,78 @@ -import "../ha-list-item"; +import { mdiMagnify, mdiPlus } from "@mdi/js"; +import type { ComboBoxLitRenderer } from "@vaadin/combo-box/lit"; +import type { IFuseOptions } from "fuse.js"; +import Fuse from "fuse.js"; import type { HassEntity } from "home-assistant-js-websocket"; import type { PropertyValues, TemplateResult } from "lit"; -import { html, LitElement } from "lit"; -import type { ComboBoxLitRenderer } from "@vaadin/combo-box/lit"; +import { html, LitElement, nothing } from "lit"; import { customElement, property, query, state } from "lit/decorators"; +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 { computeDeviceName } from "../../common/entity/compute_device_name"; import { computeDomain } from "../../common/entity/compute_domain"; +import { computeEntityName } from "../../common/entity/compute_entity_name"; import { computeStateName } from "../../common/entity/compute_state_name"; -import type { ScorableTextItem } from "../../common/string/filter/sequence-matching"; -import { fuzzyFilterSort } from "../../common/string/filter/sequence-matching"; -import type { ValueChangedEvent, HomeAssistant } from "../../types"; -import "../ha-combo-box"; -import type { HaComboBox } from "../ha-combo-box"; -import "../ha-icon-button"; -import "../ha-svg-icon"; -import "./state-badge"; +import { getEntityContext } from "../../common/entity/get_entity_context"; import { caseInsensitiveStringCompare } from "../../common/string/compare"; -import { showHelperDetailDialog } from "../../panels/config/helpers/show-dialog-helper-detail"; +import { computeRTL } from "../../common/util/compute_rtl"; import { domainToName } from "../../data/integration"; import type { HelperDomain } from "../../panels/config/helpers/const"; import { isHelperDomain } from "../../panels/config/helpers/const"; +import { showHelperDetailDialog } from "../../panels/config/helpers/show-dialog-helper-detail"; +import type { HomeAssistant, ValueChangedEvent } from "../../types"; +import "../ha-combo-box"; +import type { HaComboBox } from "../ha-combo-box"; +import "../ha-combo-box-item"; +import "../ha-icon-button"; +import "../ha-list-item"; +import "../ha-svg-icon"; +import "./state-badge"; -interface HassEntityWithCachedName extends HassEntity, ScorableTextItem { - friendly_name: string; +const FAKE_ENTITY: HassEntity = { + entity_id: "", + state: "", + last_changed: "", + last_updated: "", + context: { id: "", user_id: null, parent_id: null }, + attributes: {}, +}; + +interface EntityPickerItem extends HassEntity { + label: string; + primary: string; + secondary?: string; + translated_domain?: string; + show_entity_id?: boolean; + entity_name?: string; + area_name?: string; + device_name?: string; + friendly_name?: string; + sorting_label?: string; + icon_path?: string; } export type HaEntityPickerEntityFilterFunc = (entity: HassEntity) => boolean; const CREATE_ID = "___create-new-entity___"; +const DOMAIN_STYLE = styleMap({ + fontSize: "12px", + fontWeight: "400", + lineHeight: "18px", + alignSelf: "flex-end", + maxWidth: "30%", + textOverflow: "ellipsis", + overflow: "hidden", + whiteSpace: "nowrap", +}); + +const ENTITY_ID_STYLE = styleMap({ + fontFamily: "var(--code-font-family, monospace)", + fontSize: "11px", +}); + @customElement("ha-entity-picker") export class HaEntityPicker extends LitElement { @property({ attribute: false }) public hass!: HomeAssistant; @@ -106,8 +149,7 @@ export class HaEntityPicker extends LitElement { @property({ attribute: "hide-clear-icon", type: Boolean }) public hideClearIcon = false; - @property({ attribute: "item-label-path" }) public itemLabelPath = - "friendly_name"; + @property({ attribute: "item-label-path" }) public itemLabelPath = "label"; @state() private _opened = false; @@ -123,30 +165,48 @@ export class HaEntityPicker extends LitElement { await this.comboBox?.focus(); } - private _initedStates = false; + private _initialItems = false; - private _states: HassEntityWithCachedName[] = []; + private _items: EntityPickerItem[] = []; - private _rowRenderer: ComboBoxLitRenderer = ( - item - ) => - html` - ${item.state - ? html`` - : ""} - ${item.friendly_name} - ${item.entity_id.startsWith(CREATE_ID) - ? this.hass.localize("ui.components.entity.entity-picker.new_entity") - : item.entity_id} - `; + protected firstUpdated(changedProperties: PropertyValues): void { + super.firstUpdated(changedProperties); + this.hass.loadBackendTranslation("title"); + } - private _getStates = memoizeOne( + private _rowRenderer: ComboBoxLitRenderer = ( + item, + { index } + ) => html` + + ${item.icon_path + ? html`` + : html` + + `} + + ${item.primary} + ${item.secondary + ? html`${item.secondary}` + : nothing} + ${item.entity_id && item.show_entity_id + ? html`${item.entity_id}` + : nothing} + ${item.translated_domain && !item.show_entity_id + ? html`
+ ${item.translated_domain} +
` + : nothing} +
+ `; + + private _getItems = memoizeOne( ( _opened: boolean, hass: this["hass"], @@ -158,8 +218,8 @@ export class HaEntityPicker extends LitElement { includeEntities: this["includeEntities"], excludeEntities: this["excludeEntities"], createDomains: this["createDomains"] - ): HassEntityWithCachedName[] => { - let states: HassEntityWithCachedName[] = []; + ): EntityPickerItem[] => { + let states: EntityPickerItem[] = []; if (!hass) { return []; @@ -168,7 +228,7 @@ export class HaEntityPicker extends LitElement { const createItems = createDomains?.length ? createDomains.map((domain) => { - const newFriendlyName = hass.localize( + const primary = hass.localize( "ui.components.entity.entity-picker.create_helper", { domain: isHelperDomain(domain) @@ -180,16 +240,14 @@ export class HaEntityPicker extends LitElement { ); return { + ...FAKE_ENTITY, entity_id: CREATE_ID + domain, - state: "on", - last_changed: "", - last_updated: "", - context: { id: "", user_id: null, parent_id: null }, - friendly_name: newFriendlyName, - attributes: { - icon: "mdi:plus", - }, - strings: [domain, newFriendlyName], + primary: primary, + label: primary, + secondary: this.hass.localize( + "ui.components.entity.entity-picker.new_entity" + ), + icon_path: mdiPlus, }; }) : []; @@ -197,21 +255,14 @@ export class HaEntityPicker extends LitElement { if (!entityIds.length) { return [ { - entity_id: "", - state: "", - last_changed: "", - last_updated: "", - context: { id: "", user_id: null, parent_id: null }, - friendly_name: this.hass!.localize( + ...FAKE_ENTITY, + primary: this.hass!.localize( "ui.components.entity.entity-picker.no_entities" ), - attributes: { - friendly_name: this.hass!.localize( - "ui.components.entity.entity-picker.no_entities" - ), - icon: "mdi:magnify", - }, - strings: [], + label: this.hass!.localize( + "ui.components.entity.entity-picker.no_entities" + ), + icon_path: mdiMagnify, }, ...createItems, ]; @@ -241,19 +292,49 @@ export class HaEntityPicker extends LitElement { ); } + const isRTL = computeRTL(this.hass); + states = entityIds - .map((key) => { - const friendly_name = computeStateName(hass!.states[key]) || key; + .map((entityId) => { + const stateObj = hass!.states[entityId]; + + const { area, device } = getEntityContext(stateObj, hass); + + const friendlyName = computeStateName(stateObj); // Keep this for search + const entityName = computeEntityName(stateObj, hass); + const deviceName = device ? computeDeviceName(device) : undefined; + const areaName = area ? computeAreaName(area) : undefined; + + const primary = entityName || deviceName || entityId; + const secondary = [areaName, entityName ? deviceName : undefined] + .filter(Boolean) + .join(isRTL ? " ◂ " : " ▸ "); + + const translatedDomain = domainToName( + this.hass.localize, + computeDomain(entityId) + ); + return { - ...hass!.states[key], - friendly_name, - strings: [key, friendly_name], + ...hass!.states[entityId], + primary: primary, + secondary: + secondary || + this.hass.localize("ui.components.device-picker.no_area"), + label: friendlyName, + translated_domain: translatedDomain, + sorting_label: [deviceName, entityName].filter(Boolean).join("-"), + entity_name: entityName || deviceName, + area_name: areaName, + device_name: deviceName, + friendly_name: friendlyName, + show_entity_id: hass.userData?.showEntityIdPicker, }; }) .sort((entityA, entityB) => caseInsensitiveStringCompare( - entityA.friendly_name, - entityB.friendly_name, + entityA.sorting_label!, + entityB.sorting_label!, this.hass.locale.language ) ); @@ -291,21 +372,14 @@ export class HaEntityPicker extends LitElement { if (!states.length) { return [ { - entity_id: "", - state: "", - last_changed: "", - last_updated: "", - context: { id: "", user_id: null, parent_id: null }, - friendly_name: this.hass!.localize( + ...FAKE_ENTITY, + primary: this.hass!.localize( "ui.components.entity.entity-picker.no_match" ), - attributes: { - friendly_name: this.hass!.localize( - "ui.components.entity.entity-picker.no_match" - ), - icon: "mdi:magnify", - }, - strings: [], + label: this.hass!.localize( + "ui.components.entity.entity-picker.no_match" + ), + icon_path: mdiMagnify, }, ...createItems, ]; @@ -331,8 +405,8 @@ export class HaEntityPicker extends LitElement { } public willUpdate(changedProps: PropertyValues) { - if (!this._initedStates || (changedProps.has("_opened") && this._opened)) { - this._states = this._getStates( + if (!this._initialItems || (changedProps.has("_opened") && this._opened)) { + this._items = this._getItems( this._opened, this.hass, this.includeDomains, @@ -344,10 +418,10 @@ export class HaEntityPicker extends LitElement { this.excludeEntities, this.createDomains ); - if (this._initedStates) { - this.comboBox.filteredItems = this._states; + if (this._initialItems) { + this.comboBox.filteredItems = this._items; } - this._initedStates = true; + this._initialItems = true; } if (changedProps.has("createDomains") && this.createDomains?.length) { @@ -367,7 +441,7 @@ export class HaEntityPicker extends LitElement { : this.label} .helper=${this.helper} .allowCustomValue=${this.allowCustomEntity} - .filteredItems=${this._states} + .filteredItems=${this._items} .renderer=${this._rowRenderer} .required=${this.required} .disabled=${this.disabled} @@ -408,12 +482,49 @@ export class HaEntityPicker extends LitElement { } } + private _fuseKeys = [ + "entity_name", + "device_name", + "area_name", + "translated_domain", + "friendly_name", // for backwards compatibility + "entity_id", // for technical search + ]; + + private _fuseIndex = memoizeOne((states: EntityPickerItem[]) => + Fuse.createIndex(this._fuseKeys, states) + ); + private _filterChanged(ev: CustomEvent): void { const target = ev.target as HaComboBox; - const filterString = ev.detail.value.trim().toLowerCase(); - target.filteredItems = filterString.length - ? fuzzyFilterSort(filterString, this._states) - : this._states; + const filterString = ev.detail.value.trim().toLowerCase() as string; + + const minLength = 2; + + const searchTerms = (filterString.split(" ") ?? []).filter( + (term) => term.length >= minLength + ); + + if (searchTerms.length > 0) { + const index = this._fuseIndex(this._items); + + const options: IFuseOptions = { + isCaseSensitive: false, + threshold: 0.3, + ignoreDiacritics: true, + minMatchCharLength: minLength, + }; + + const fuse = new Fuse(this._items, options, index); + const results = fuse.search({ + $and: searchTerms.map((term) => ({ + $or: this._fuseKeys.map((key) => ({ [key]: term })), + })), + }); + target.filteredItems = results.map((result) => result.item); + } else { + target.filteredItems = this._items; + } } private _setValue(value: string | undefined) { diff --git a/src/components/ha-combo-box-item.ts b/src/components/ha-combo-box-item.ts new file mode 100644 index 0000000000..d42db9bd8d --- /dev/null +++ b/src/components/ha-combo-box-item.ts @@ -0,0 +1,44 @@ +import { css } from "lit"; +import { customElement, property } from "lit/decorators"; +import { HaMdListItem } from "./ha-md-list-item"; + +@customElement("ha-combo-box-item") +export class HaComboBoxItem extends HaMdListItem { + @property({ type: Boolean, reflect: true, attribute: "border-top" }) + public borderTop = false; + + static override styles = [ + ...super.styles, + css` + :host { + --md-list-item-two-line-container-height: 64px; + } + :host([border-top]) md-item { + border-top: 1px solid var(--divider-color); + } + [slot="start"] { + --paper-item-icon-color: var(--secondary-text-color); + } + [slot="headline"] { + line-height: 22px; + font-size: 14px; + white-space: nowrap; + } + [slot="supporting-text"] { + line-height: 18px; + font-size: 12px; + white-space: nowrap; + } + ::slotted(state-badge) { + width: 32px; + height: 32px; + } + `, + ]; +} + +declare global { + interface HTMLElementTagNameMap { + "ha-combo-box-item": HaComboBoxItem; + } +} diff --git a/src/data/frontend.ts b/src/data/frontend.ts index d0217993d5..555919c0ed 100644 --- a/src/data/frontend.ts +++ b/src/data/frontend.ts @@ -3,6 +3,7 @@ import { getOptimisticCollection } from "./collection"; export interface CoreFrontendUserData { showAdvanced?: boolean; + showEntityIdPicker?: boolean; } declare global { diff --git a/src/panels/profile/ha-entity-id-picker-row.ts b/src/panels/profile/ha-entity-id-picker-row.ts new file mode 100644 index 0000000000..a6b5567f18 --- /dev/null +++ b/src/panels/profile/ha-entity-id-picker-row.ts @@ -0,0 +1,55 @@ +import type { TemplateResult } from "lit"; +import { css, html, LitElement } from "lit"; +import { customElement, property } from "lit/decorators"; +import "../../components/ha-card"; +import "../../components/ha-settings-row"; +import "../../components/ha-switch"; +import type { CoreFrontendUserData } from "../../data/frontend"; +import { getOptimisticFrontendUserDataCollection } from "../../data/frontend"; +import type { HomeAssistant } from "../../types"; + +@customElement("ha-entity-id-picker-row") +class EntityIdPickerRow extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @property({ type: Boolean }) public narrow = false; + + @property({ attribute: false }) public coreUserData?: CoreFrontendUserData; + + protected render(): TemplateResult { + return html` + + + ${this.hass.localize("ui.panel.profile.entity_id_picker.title")} + + ${this.hass.localize("ui.panel.profile.entity_id_picker.description")} + + + + `; + } + + private async _toggled(ev) { + getOptimisticFrontendUserDataCollection(this.hass.connection, "core").save({ + ...this.coreUserData, + showEntityIdPicker: ev.currentTarget.checked, + }); + } + + static styles = css` + a { + color: var(--primary-color); + } + `; +} + +declare global { + interface HTMLElementTagNameMap { + "ha-entity-id-picker-row": EntityIdPickerRow; + } +} diff --git a/src/panels/profile/ha-profile-section-general.ts b/src/panels/profile/ha-profile-section-general.ts index c2d1439fac..caa4c403e7 100644 --- a/src/panels/profile/ha-profile-section-general.ts +++ b/src/panels/profile/ha-profile-section-general.ts @@ -16,6 +16,7 @@ import { haStyle } from "../../resources/styles"; import type { HomeAssistant, Route } from "../../types"; import "./ha-advanced-mode-row"; import "./ha-enable-shortcuts-row"; +import "./ha-entity-id-picker-row"; import "./ha-force-narrow-row"; import "./ha-pick-dashboard-row"; import "./ha-pick-first-weekday-row"; @@ -156,6 +157,15 @@ class HaProfileSectionGeneral extends LitElement { > ` : ""} + ${this.hass.user!.is_admin + ? html` + + ` + : ""}