diff --git a/src/components/entity/ha-entity-picker.ts b/src/components/entity/ha-entity-picker.ts index 1f2d67252b..b7b3e9390b 100644 --- a/src/components/entity/ha-entity-picker.ts +++ b/src/components/entity/ha-entity-picker.ts @@ -1,6 +1,5 @@ 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"; @@ -21,6 +20,7 @@ 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 { HaFuse } from "../../resources/fuse"; import type { HomeAssistant, ValueChangedEvent } from "../../types"; import "../ha-combo-box"; import type { HaComboBox } from "../ha-combo-box"; @@ -482,45 +482,31 @@ 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) + Fuse.createIndex( + [ + "entity_name", + "device_name", + "area_name", + "translated_domain", + "friendly_name", // for backwards compatibility + "entity_id", // for technical search + ], + states + ) ); private _filterChanged(ev: CustomEvent): void { + if (!this._opened) return; + const target = ev.target as HaComboBox; const filterString = ev.detail.value.trim().toLowerCase() as string; - const minLength = 2; + const index = this._fuseIndex(this._items); + const fuse = new HaFuse(this._items, {}, index); - 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 })), - })), - }); + const results = fuse.multiTermsSearch(filterString); + if (results) { target.filteredItems = results.map((result) => result.item); } else { target.filteredItems = this._items; diff --git a/src/components/entity/ha-statistic-picker.ts b/src/components/entity/ha-statistic-picker.ts index 37b7712299..165ca1ffe9 100644 --- a/src/components/entity/ha-statistic-picker.ts +++ b/src/components/entity/ha-statistic-picker.ts @@ -1,6 +1,6 @@ import { mdiChartLine } from "@mdi/js"; import type { ComboBoxLitRenderer } from "@vaadin/combo-box/lit"; -import Fuse, { 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, nothing } from "lit"; @@ -16,15 +16,16 @@ 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 { domainToName } from "../../data/integration"; import type { StatisticsMetaData } from "../../data/recorder"; import { getStatisticIds, getStatisticLabel } from "../../data/recorder"; +import { HaFuse } from "../../resources/fuse"; import type { HomeAssistant, ValueChangedEvent } from "../../types"; 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"; type StatisticItemType = "entity" | "external" | "no_state"; @@ -364,7 +365,10 @@ export class HaStatisticPicker extends LitElement { this._getStatisticIds(); } - if (!this._initialItems || (changedProps.has("_opened") && this._opened)) { + if ( + this.statisticIds && + (!this._initialItems || (changedProps.has("_opened") && this._opened)) + ) { this._items = this._getItems( this._opened, this.hass, @@ -433,45 +437,32 @@ 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) + Fuse.createIndex( + [ + "label", + "entity_name", + "device_name", + "area_name", + "friendly_name", // for backwards compatibility + "id", // for technical search + ], + states + ) ); private _filterChanged(ev: CustomEvent): void { + if (!this._opened) return; + const target = ev.target as HaComboBox; const filterString = ev.detail.value.trim().toLowerCase() as string; - const minLength = 2; + const index = this._fuseIndex(this._items); + const fuse = new HaFuse(this._items, {}, index); - const searchTerms = (filterString.split(" ") ?? []).filter( - (term) => term.length >= minLength - ); + const results = fuse.multiTermsSearch(filterString); - 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 })), - })), - }); + if (results) { target.filteredItems = results.map((result) => result.item); } else { target.filteredItems = this._items; diff --git a/src/resources/fuse.ts b/src/resources/fuse.ts new file mode 100644 index 0000000000..b27b5b4ab6 --- /dev/null +++ b/src/resources/fuse.ts @@ -0,0 +1,78 @@ +import Fuse, { + type Expression, + type FuseIndex, + type FuseResult, + type FuseSearchOptions, + type IFuseOptions, +} from "fuse.js"; + +export interface FuseKey { + getFn: null; + id: string; + path: string[]; + src: string; + weight: number; +} + +const DEFAULT_OPTIONS: IFuseOptions = { + ignoreDiacritics: true, + isCaseSensitive: false, + threshold: 0.3, + minMatchCharLength: 2, +}; + +export class HaFuse extends Fuse { + public constructor( + list: readonly T[], + options?: IFuseOptions, + index?: FuseIndex + ) { + const mergedOptions = { + ...DEFAULT_OPTIONS, + ...options, + }; + super(list, mergedOptions, index); + } + + /** + * Performs a multi-term search across the indexed data. + * Splits the search string into individual terms and performs an AND operation between terms, + * where each term is searched across all indexed keys with an OR operation. words with less than + * 2 characters are ignored. If no valid terms are found, the search will return null. + * + * @param search - The search string to split into terms. Terms are space-separated. + * @param options - Optional Fuse.js search options to customize the search behavior. + * @typeParam R - The type of the result items. Defaults to T (the type of the indexed items). + * @returns An array of FuseResult objects containing matched items and their matching information. + * If no valid terms are found (after filtering by minimum length), returns all items with empty matches. + */ + public multiTermsSearch( + search: string, + options?: FuseSearchOptions + ): FuseResult[] | null { + const terms = search.split(" "); + + // @ts-expect-error options is not part of the Fuse type + const { minMatchCharLength } = this.options as IFuseOptions; + + const filteredTerms = minMatchCharLength + ? terms.filter((term) => term.length >= minMatchCharLength) + : terms; + + if (filteredTerms.length === 0) { + // If no valid terms are found, return null to indicate no search was performed + return null; + } + + const index = this.getIndex().toJSON(); + const keys = index.keys as unknown as FuseKey[]; // Fuse type for key is not correct + + const expression: Expression = { + $and: filteredTerms.map((term) => ({ + $or: keys.map((key) => ({ $path: key.path, $val: term })), + })), + }; + + return this.search(expression, options); + } +}