diff --git a/src/components/entity/ha-statistic-picker.ts b/src/components/entity/ha-statistic-picker.ts index 586c26b52b..37b7712299 100644 --- a/src/components/entity/ha-statistic-picker.ts +++ b/src/components/entity/ha-statistic-picker.ts @@ -1,30 +1,56 @@ +import { mdiChartLine } from "@mdi/js"; import type { ComboBoxLitRenderer } from "@vaadin/combo-box/lit"; +import Fuse, { type IFuseOptions } from "fuse.js"; import type { HassEntity } from "home-assistant-js-websocket"; import type { PropertyValues, TemplateResult } from "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 { ensureArray } from "../../common/array/ensure-array"; import { fireEvent } from "../../common/dom/fire_event"; -import { stringCompare } from "../../common/string/compare"; -import type { ScorableTextItem } from "../../common/string/filter/sequence-matching"; -import { fuzzyFilterSort } from "../../common/string/filter/sequence-matching"; +import { computeAreaName } from "../../common/entity/compute_area_name"; +import { computeDeviceName } from "../../common/entity/compute_device_name"; +import { computeEntityName } from "../../common/entity/compute_entity_name"; +import { computeStateName } from "../../common/entity/compute_state_name"; +import { getEntityContext } from "../../common/entity/get_entity_context"; +import { caseInsensitiveStringCompare } from "../../common/string/compare"; +import { computeRTL } from "../../common/util/compute_rtl"; import type { StatisticsMetaData } from "../../data/recorder"; import { getStatisticIds, getStatisticLabel } from "../../data/recorder"; import type { HomeAssistant, ValueChangedEvent } from "../../types"; -import { documentationUrl } from "../../util/documentation-url"; import "../ha-combo-box"; import type { HaComboBox } from "../ha-combo-box"; import "../ha-combo-box-item"; import "../ha-svg-icon"; import "./state-badge"; +import { domainToName } from "../../data/integration"; -interface StatisticItem extends ScorableTextItem { +type StatisticItemType = "entity" | "external" | "no_state"; + +interface StatisticItem { id: string; - name: string; + label: string; + primary: string; + secondary?: string; + show_entity_id?: boolean; + entity_name?: string; + area_name?: string; + device_name?: string; + friendly_name?: string; + sorting_label?: string; state?: HassEntity; + type?: StatisticItemType; + iconPath?: string; } +const TYPE_ORDER = ["entity", "external", "no_state"] as StatisticItemType[]; + +const ENTITY_ID_STYLE = styleMap({ + fontFamily: "var(--code-font-family, monospace)", + fontSize: "11px", +}); + @customElement("ha-statistic-picker") export class HaStatisticPicker extends LitElement { @property({ attribute: false }) public hass!: HomeAssistant; @@ -88,44 +114,56 @@ export class HaStatisticPicker extends LitElement { @property({ attribute: false }) public helpMissingEntityUrl = "/more-info/statistics/"; - @state() private _opened?: boolean; + @state() private _opened = false; @query("ha-combo-box", true) public comboBox!: HaComboBox; - private _init = false; + private _initialItems = false; - private _statistics: StatisticItem[] = []; + private _items: StatisticItem[] = []; - @state() private _filteredItems?: StatisticItem[] = undefined; + protected firstUpdated(changedProperties: PropertyValues): void { + super.firstUpdated(changedProperties); + this.hass.loadBackendTranslation("title"); + } - private _rowRenderer: ComboBoxLitRenderer = (item) => - html` - ${item.state - ? html` + private _rowRenderer: ComboBoxLitRenderer = ( + item, + { index } + ) => html` + + ${!item.state + ? html`` + : html` - ` - : html``} - ${item.name} - ${item.id === "" || item.id === "__missing" - ? html`${this.hass.localize( - "ui.components.statistic-picker.learn_more" - )}` - : item.id} - `; + `} - private _getStatistics = memoizeOne( + ${item.primary} + ${item.secondary + ? html`${item.secondary}` + : nothing} + ${item.id && item.show_entity_id + ? html` + + ${item.id} + + ` + : nothing} + + `; + + private _getItems = memoizeOne( ( + _opened: boolean, + hass: this["hass"], statisticIds: StatisticsMetaData[], includeStatisticsUnitOfMeasurement?: string | string[], includeUnitClass?: string | string[], @@ -138,10 +176,12 @@ export class HaStatisticPicker extends LitElement { return [ { id: "", - name: this.hass.localize( + label: this.hass.localize( + "ui.components.statistic-picker.no_statistics" + ), + primary: this.hass.localize( "ui.components.statistic-picker.no_statistics" ), - strings: [], }, ]; } @@ -175,6 +215,8 @@ export class HaStatisticPicker extends LitElement { }); } + const isRTL = computeRTL(this.hass); + const output: StatisticItem[] = []; statisticIds.forEach((meta) => { if ( @@ -188,22 +230,67 @@ export class HaStatisticPicker extends LitElement { if (!entityState) { if (!entitiesOnly) { const id = meta.statistic_id; - const name = getStatisticLabel(this.hass, meta.statistic_id, meta); - output.push({ - id, - name, - strings: [id, name], - }); + const label = getStatisticLabel(this.hass, meta.statistic_id, meta); + const type = + meta.statistic_id.includes(":") && + !meta.statistic_id.includes(".") + ? "external" + : "no_state"; + + if (type === "no_state") { + output.push({ + id, + primary: label, + secondary: this.hass.localize( + "ui.components.statistic-picker.no_state" + ), + label, + type, + sorting_label: label, + }); + } else if (type === "external") { + const domain = id.split(":")[0]; + const domainName = domainToName(this.hass.localize, domain); + output.push({ + id, + primary: label, + secondary: domainName, + label, + type, + sorting_label: label, + iconPath: mdiChartLine, + }); + } } return; } const id = meta.statistic_id; - const name = getStatisticLabel(this.hass, meta.statistic_id, meta); + + const { area, device } = getEntityContext(entityState, hass); + + const friendlyName = computeStateName(entityState); // Keep this for search + const entityName = computeEntityName(entityState, hass); + const deviceName = device ? computeDeviceName(device) : undefined; + const areaName = area ? computeAreaName(area) : undefined; + + const primary = entityName || deviceName || id; + const secondary = [areaName, entityName ? deviceName : undefined] + .filter(Boolean) + .join(isRTL ? " ◂ " : " ▸ "); + output.push({ id, - name, + primary, + secondary, + label: friendlyName, state: entityState, - strings: [id, name], + type: "entity", + sorting_label: [deviceName, entityName].join("_"), + entity_name: entityName || deviceName, + area_name: areaName, + device_name: deviceName, + friendly_name: friendlyName, + show_entity_id: hass.userData?.showEntityIdPicker, }); }); @@ -211,36 +298,62 @@ export class HaStatisticPicker extends LitElement { return [ { id: "", - name: this.hass.localize("ui.components.statistic-picker.no_match"), - strings: [], + primary: this.hass.localize( + "ui.components.statistic-picker.no_match" + ), + label: this.hass.localize( + "ui.components.statistic-picker.no_match" + ), }, ]; } if (output.length > 1) { - output.sort((a, b) => - stringCompare(a.name || "", b.name || "", this.hass.locale.language) - ); + output.sort((a, b) => { + const aPrefix = TYPE_ORDER.indexOf(a.type || "no_state"); + const bPrefix = TYPE_ORDER.indexOf(b.type || "no_state"); + + return caseInsensitiveStringCompare( + `${aPrefix}_${a.sorting_label || ""}`, + `${bPrefix}_${b.sorting_label || ""}`, + this.hass.locale.language + ); + }); } output.push({ id: "__missing", - name: this.hass.localize( + primary: this.hass.localize( + "ui.components.statistic-picker.missing_entity" + ), + label: this.hass.localize( "ui.components.statistic-picker.missing_entity" ), - strings: [], }); return output; } ); - public open() { - this.comboBox?.open(); + public async open() { + await this.updateComplete; + await this.comboBox?.open(); } - public focus() { - this.comboBox?.focus(); + public async focus() { + await this.updateComplete; + await this.comboBox?.focus(); + } + + protected shouldUpdate(changedProps: PropertyValues) { + if ( + changedProps.has("value") || + changedProps.has("label") || + changedProps.has("disabled") + ) { + return true; + } + return !(!changedProps.has("_opened") && this._opened); } public willUpdate(changedProps: PropertyValues) { @@ -250,39 +363,28 @@ export class HaStatisticPicker extends LitElement { ) { this._getStatisticIds(); } - if ( - (!this._init && this.statisticIds) || - (changedProps.has("_opened") && this._opened) - ) { - this._init = true; - if (this.hasUpdated) { - this._statistics = this._getStatistics( - this.statisticIds!, - this.includeStatisticsUnitOfMeasurement, - this.includeUnitClass, - this.includeDeviceClass, - this.entitiesOnly, - this.excludeStatistics, - this.value - ); - } else { - this.updateComplete.then(() => { - this._statistics = this._getStatistics( - this.statisticIds!, - this.includeStatisticsUnitOfMeasurement, - this.includeUnitClass, - this.includeDeviceClass, - this.entitiesOnly, - this.excludeStatistics, - this.value - ); - }); + + if (!this._initialItems || (changedProps.has("_opened") && this._opened)) { + this._items = this._getItems( + this._opened, + this.hass, + this.statisticIds!, + this.includeStatisticsUnitOfMeasurement, + this.includeUnitClass, + this.includeDeviceClass, + this.entitiesOnly, + this.excludeStatistics, + this.value + ); + if (this._initialItems) { + this.comboBox.filteredItems = this._items; } + this._initialItems = true; } } protected render(): TemplateResult | typeof nothing { - if (this._statistics.length === 0) { + if (this._items.length === 0) { return nothing; } @@ -296,11 +398,10 @@ export class HaStatisticPicker extends LitElement { .renderer=${this._rowRenderer} .disabled=${this.disabled} .allowCustomValue=${this.allowCustomEntity} - .items=${this._statistics} - .filteredItems=${this._filteredItems ?? this._statistics} + .filteredItems=${this._items} item-value-path="id" item-id-path="id" - item-label-path="name" + item-label-path="label" @opened-changed=${this._openedChanged} @value-changed=${this._statisticChanged} @filter-changed=${this._filterChanged} @@ -332,11 +433,49 @@ export class HaStatisticPicker extends LitElement { this._opened = ev.detail.value; } + private _fuseKeys = [ + "label", + "entity_name", + "device_name", + "area_name", + "friendly_name", // for backwards compatibility + "id", // for technical search + ]; + + private _fuseIndex = memoizeOne((states: StatisticItem[]) => + Fuse.createIndex(this._fuseKeys, states) + ); + private _filterChanged(ev: CustomEvent): void { - const filterString = ev.detail.value.toLowerCase(); - this._filteredItems = filterString.length - ? fuzzyFilterSort(filterString, this._statistics) - : undefined; + const target = ev.target as HaComboBox; + 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) { diff --git a/src/translations/en.json b/src/translations/en.json index 2e4330ad0c..c55f4b569b 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -722,6 +722,7 @@ "statistic": "Statistic", "no_statistics": "You don't have any statistics", "no_match": "No matching statistics found", + "no_state": "Entity without state", "missing_entity": "Why is my entity not listed?", "learn_more": "Learn more about statistics" },