Refactor multi term fuse search to reuse it (#25143)

* Refactor multi term fuse search to re-use

* Do not create filter when not open

* Update fuse options

* Use fuse options
This commit is contained in:
Paul Bottein 2025-04-24 07:31:02 +02:00 committed by GitHub
parent 11b8f6210f
commit 94b5ed97c6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 120 additions and 65 deletions

View File

@ -1,6 +1,5 @@
import { mdiMagnify, mdiPlus } from "@mdi/js"; import { mdiMagnify, mdiPlus } from "@mdi/js";
import type { ComboBoxLitRenderer } from "@vaadin/combo-box/lit"; import type { ComboBoxLitRenderer } from "@vaadin/combo-box/lit";
import type { IFuseOptions } from "fuse.js";
import Fuse from "fuse.js"; import Fuse from "fuse.js";
import type { HassEntity } from "home-assistant-js-websocket"; import type { HassEntity } from "home-assistant-js-websocket";
import type { PropertyValues, TemplateResult } from "lit"; import type { PropertyValues, TemplateResult } from "lit";
@ -21,6 +20,7 @@ import { domainToName } from "../../data/integration";
import type { HelperDomain } from "../../panels/config/helpers/const"; import type { HelperDomain } from "../../panels/config/helpers/const";
import { isHelperDomain } from "../../panels/config/helpers/const"; import { isHelperDomain } from "../../panels/config/helpers/const";
import { showHelperDetailDialog } from "../../panels/config/helpers/show-dialog-helper-detail"; import { showHelperDetailDialog } from "../../panels/config/helpers/show-dialog-helper-detail";
import { HaFuse } from "../../resources/fuse";
import type { HomeAssistant, ValueChangedEvent } from "../../types"; import type { HomeAssistant, ValueChangedEvent } from "../../types";
import "../ha-combo-box"; import "../ha-combo-box";
import type { HaComboBox } from "../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[]) => 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 { private _filterChanged(ev: CustomEvent): void {
if (!this._opened) return;
const target = ev.target as HaComboBox; const target = ev.target as HaComboBox;
const filterString = ev.detail.value.trim().toLowerCase() as string; 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( const results = fuse.multiTermsSearch(filterString);
(term) => term.length >= minLength if (results) {
);
if (searchTerms.length > 0) {
const index = this._fuseIndex(this._items);
const options: IFuseOptions<EntityPickerItem> = {
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); target.filteredItems = results.map((result) => result.item);
} else { } else {
target.filteredItems = this._items; target.filteredItems = this._items;

View File

@ -1,6 +1,6 @@
import { mdiChartLine } from "@mdi/js"; import { mdiChartLine } from "@mdi/js";
import type { ComboBoxLitRenderer } from "@vaadin/combo-box/lit"; 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 { HassEntity } from "home-assistant-js-websocket";
import type { PropertyValues, TemplateResult } from "lit"; import type { PropertyValues, TemplateResult } from "lit";
import { html, LitElement, nothing } 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 { getEntityContext } from "../../common/entity/get_entity_context";
import { caseInsensitiveStringCompare } from "../../common/string/compare"; import { caseInsensitiveStringCompare } from "../../common/string/compare";
import { computeRTL } from "../../common/util/compute_rtl"; import { computeRTL } from "../../common/util/compute_rtl";
import { domainToName } from "../../data/integration";
import type { StatisticsMetaData } from "../../data/recorder"; import type { StatisticsMetaData } from "../../data/recorder";
import { getStatisticIds, getStatisticLabel } from "../../data/recorder"; import { getStatisticIds, getStatisticLabel } from "../../data/recorder";
import { HaFuse } from "../../resources/fuse";
import type { HomeAssistant, ValueChangedEvent } from "../../types"; import type { HomeAssistant, ValueChangedEvent } from "../../types";
import "../ha-combo-box"; import "../ha-combo-box";
import type { HaComboBox } from "../ha-combo-box"; import type { HaComboBox } from "../ha-combo-box";
import "../ha-combo-box-item"; import "../ha-combo-box-item";
import "../ha-svg-icon"; import "../ha-svg-icon";
import "./state-badge"; import "./state-badge";
import { domainToName } from "../../data/integration";
type StatisticItemType = "entity" | "external" | "no_state"; type StatisticItemType = "entity" | "external" | "no_state";
@ -364,7 +365,10 @@ export class HaStatisticPicker extends LitElement {
this._getStatisticIds(); 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._items = this._getItems(
this._opened, this._opened,
this.hass, this.hass,
@ -433,45 +437,32 @@ export class HaStatisticPicker extends LitElement {
this._opened = ev.detail.value; 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[]) => 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 { private _filterChanged(ev: CustomEvent): void {
if (!this._opened) return;
const target = ev.target as HaComboBox; const target = ev.target as HaComboBox;
const filterString = ev.detail.value.trim().toLowerCase() as string; 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( const results = fuse.multiTermsSearch(filterString);
(term) => term.length >= minLength
);
if (searchTerms.length > 0) { if (results) {
const index = this._fuseIndex(this._items);
const options: IFuseOptions<StatisticItem> = {
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); target.filteredItems = results.map((result) => result.item);
} else { } else {
target.filteredItems = this._items; target.filteredItems = this._items;

78
src/resources/fuse.ts Normal file
View File

@ -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<any> = {
ignoreDiacritics: true,
isCaseSensitive: false,
threshold: 0.3,
minMatchCharLength: 2,
};
export class HaFuse<T> extends Fuse<T> {
public constructor(
list: readonly T[],
options?: IFuseOptions<T>,
index?: FuseIndex<T>
) {
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<T>[] | null {
const terms = search.split(" ");
// @ts-expect-error options is not part of the Fuse type
const { minMatchCharLength } = this.options as IFuseOptions<T>;
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);
}
}