diff --git a/src-ui/src/app/components/common/custom-fields-dropdown/custom-fields-dropdown.component.ts b/src-ui/src/app/components/common/custom-fields-dropdown/custom-fields-dropdown.component.ts index b0fcd48b8..ac7b3a541 100644 --- a/src-ui/src/app/components/common/custom-fields-dropdown/custom-fields-dropdown.component.ts +++ b/src-ui/src/app/components/common/custom-fields-dropdown/custom-fields-dropdown.component.ts @@ -23,6 +23,7 @@ import { import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service' import { ToastService } from 'src/app/services/toast.service' import { pngxPopperOptions } from 'src/app/utils/popper-options' +import { matchesSearchText } from 'src/app/utils/text-search' import { LoadingComponentWithPermissions } from '../../loading-component/loading.component' import { CustomFieldEditDialogComponent } from '../edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component' @@ -69,9 +70,7 @@ export class CustomFieldsDropdownComponent extends LoadingComponentWithPermissio public get filteredFields(): CustomField[] { return this.unusedFields.filter( - (f) => - !this.filterText || - f.name.toLowerCase().includes(this.filterText.toLowerCase()) + (f) => !this.filterText || matchesSearchText(f.name, this.filterText) ) } diff --git a/src-ui/src/app/pipes/filter.pipe.ts b/src-ui/src/app/pipes/filter.pipe.ts index 90ac3da1c..a70556f10 100644 --- a/src-ui/src/app/pipes/filter.pipe.ts +++ b/src-ui/src/app/pipes/filter.pipe.ts @@ -1,5 +1,6 @@ import { Pipe, PipeTransform } from '@angular/core' import { MatchingModel } from '../data/matching-model' +import { matchesSearchText } from '../utils/text-search' @Pipe({ name: 'filter', @@ -21,9 +22,7 @@ export class FilterPipe implements PipeTransform { typeof item[key] === 'string' || typeof item[key] === 'number' ) return keys.some((key) => { - return String(item[key]) - .toLowerCase() - .includes(searchText.toLowerCase()) + return matchesSearchText(item[key], searchText) }) }) } diff --git a/src-ui/src/app/utils/text-search.spec.ts b/src-ui/src/app/utils/text-search.spec.ts new file mode 100644 index 000000000..fdf6e55c5 --- /dev/null +++ b/src-ui/src/app/utils/text-search.spec.ts @@ -0,0 +1,10 @@ +import { matchesSearchText } from './text-search' + +describe('text search utilities', () => { + it('matches text accent-insensitively', () => { + expect(matchesSearchText('R\u00e9sum\u00e9', 'resume')).toBeTruthy() + expect(matchesSearchText('S\u00f8ren', 'soren')).toBeTruthy() + expect(matchesSearchText('\u0152uvre', 'oeuvre')).toBeTruthy() + expect(matchesSearchText('Invoice', 'receipt')).toBeFalsy() + }) +}) diff --git a/src-ui/src/app/utils/text-search.ts b/src-ui/src/app/utils/text-search.ts new file mode 100644 index 000000000..d8d8dd414 --- /dev/null +++ b/src-ui/src/app/utils/text-search.ts @@ -0,0 +1,42 @@ +const EXTRA_DIACRITIC_REPLACEMENTS: Record = { + '\u00c6': 'AE', + '\u00e6': 'ae', + '\u00d0': 'D', + '\u00f0': 'd', + '\u00d8': 'O', + '\u00f8': 'o', + '\u00de': 'Th', + '\u00fe': 'th', + '\u0110': 'D', + '\u0111': 'd', + '\u0131': 'i', + '\u0141': 'L', + '\u0142': 'l', + '\u0152': 'OE', + '\u0153': 'oe', + '\u00df': 'ss', +} + +// ng-select has a similar private helper stripSpecialChars, but we can't use it +const EXTRA_DIACRITICS_PATTERN = new RegExp( + `[${Object.keys(EXTRA_DIACRITIC_REPLACEMENTS).join('')}]`, + 'g' +) + +function normalizeSearchText(value: unknown): string { + return String(value ?? '') + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') + .replace( + EXTRA_DIACRITICS_PATTERN, + (char) => EXTRA_DIACRITIC_REPLACEMENTS[char] ?? char + ) + .toLocaleLowerCase() +} + +export function matchesSearchText( + value: unknown, + searchText: unknown +): boolean { + return normalizeSearchText(value).includes(normalizeSearchText(searchText)) +}