diff --git a/src-ui/src/app/components/common/input/tags/tags.component.ts b/src-ui/src/app/components/common/input/tags/tags.component.ts index 6b7d3150f..e608ce56f 100644 --- a/src-ui/src/app/components/common/input/tags/tags.component.ts +++ b/src-ui/src/app/components/common/input/tags/tags.component.ts @@ -61,12 +61,15 @@ export class TagsComponent implements OnInit, ControlValueAccessor { writeValue(newValue: number[]): void { this.value = newValue } + registerOnChange(fn: any): void { this.onChange = fn } + registerOnTouched(fn: any): void { this.onTouched = fn } + setDisabledState?(isDisabled: boolean): void { this.disabled = isDisabled } @@ -74,6 +77,7 @@ export class TagsComponent implements OnInit, ControlValueAccessor { ngOnInit(): void { this.tagService.listAll().subscribe((result) => { this.tags = result.results + this.tagSearchTextCache.clear() }) } @@ -113,15 +117,12 @@ export class TagsComponent implements OnInit, ControlValueAccessor { tags: Tag[] = [] + private readonly tagSearchTextCache = new Map() + public createTagRef: (name) => void public searchFn = (term: string, tag: Tag): boolean => - matchesSearchText( - [this.getParentChain(tag?.id).map((parent) => parent.name), tag?.name] - .flat() - .join(' '), - term - ) + matchesSearchText(this.getTagSearchText(tag), term) getTag(id: number) { if (this.tags) { @@ -152,8 +153,8 @@ export class TagsComponent implements OnInit, ControlValueAccessor { private removeChildren(tagIDs: number[], tag: Tag) { if (tag.children?.length) { - const childIDs = tag.children.map((child) => child.id) - tagIDs = tagIDs.filter((id) => !childIDs.includes(id)) + const childIDs = new Set(tag.children.map((child) => child.id)) + tagIDs = tagIDs.filter((id) => !childIDs.has(id)) for (const child of tag.children) { tagIDs = this.removeChildren(tagIDs, child) } @@ -188,6 +189,7 @@ export class TagsComponent implements OnInit, ControlValueAccessor { tap((newTag) => { this.tagService.listAll().subscribe((tags) => { this.tags = tags.results + this.tagSearchTextCache.clear() add && this.addTag(newTag.id) }) }) @@ -238,4 +240,22 @@ export class TagsComponent implements OnInit, ControlValueAccessor { } return chain } + + private getTagSearchText(tag: Tag): string { + if (!tag) { + return '' + } + + const cachedSearchText = this.tagSearchTextCache.get(tag.id) + if (cachedSearchText !== undefined) { + return cachedSearchText + } + + const searchText = [ + ...this.getParentChain(tag.id).map((parent) => parent.name), + tag.name, + ].join(' ') + this.tagSearchTextCache.set(tag.id, searchText) + return searchText + } } diff --git a/src-ui/src/app/pipes/filter.pipe.spec.ts b/src-ui/src/app/pipes/filter.pipe.spec.ts index 08b14ccf9..5b665234b 100644 --- a/src-ui/src/app/pipes/filter.pipe.spec.ts +++ b/src-ui/src/app/pipes/filter.pipe.spec.ts @@ -46,4 +46,23 @@ describe('FilterPipe', () => { itemsReturned = pipe.transform(items, 'slug', 'slug') expect(itemsReturned).toEqual([items[0]]) }) + + it('should filter matchingmodel items by normalized independent terms', () => { + const pipe = new FilterPipe() + const items: MatchingModel[] = [ + { + id: 1, + name: 'Financ\u00e9 Taxes 2026', + slug: 'finance-taxes', + }, + { + id: 2, + name: 'Receipt Archive 2026', + slug: 'receipt-archive', + }, + ] + + expect(pipe.transform(items, 'finance 26')).toEqual([items[0]]) + expect(pipe.transform(items, 'finance receipt')).toEqual([]) + }) }) diff --git a/src-ui/src/app/utils/text-search.ts b/src-ui/src/app/utils/text-search.ts index d64ecdec9..52fa020e6 100644 --- a/src-ui/src/app/utils/text-search.ts +++ b/src-ui/src/app/utils/text-search.ts @@ -8,8 +8,39 @@ export type SearchTextValue = | null | undefined +const normalizedStringCache = new Map() +const searchTermsCache = new Map() + +function normalizeString(value: string): string { + const cachedValue = normalizedStringCache.get(value) + if (cachedValue !== undefined) { + return cachedValue + } + + const normalizedValue = normalizeSync(value).toLocaleLowerCase() + normalizedStringCache.set(value, normalizedValue) + return normalizedValue +} + export function normalizeSearchText(value: SearchTextValue): string { - return normalizeSync(String(value ?? '')).toLocaleLowerCase() + return normalizeString(String(value ?? '')) +} + +function getSearchTerms(searchText: SearchTextValue): string[] { + const normalizedSearchText = normalizeSearchText(searchText).trim() + + if (!normalizedSearchText) { + return [] + } + + const cachedTerms = searchTermsCache.get(normalizedSearchText) + if (cachedTerms !== undefined) { + return cachedTerms + } + + const searchTerms = normalizedSearchText.split(/\s+/) + searchTermsCache.set(normalizedSearchText, searchTerms) + return searchTerms } export function matchesSearchText( @@ -17,7 +48,7 @@ export function matchesSearchText( searchText: SearchTextValue ): boolean { const normalizedValue = normalizeSearchText(value) - const searchTerms = normalizeSearchText(searchText).trim().split(/\s+/) + const searchTerms = getSearchTerms(searchText) return searchTerms.every((term) => normalizedValue.includes(term)) }