diff --git a/src-ui/src/app/utils/text-search.spec.ts b/src-ui/src/app/utils/text-search.spec.ts index a1225c1d0..7fe684042 100644 --- a/src-ui/src/app/utils/text-search.spec.ts +++ b/src-ui/src/app/utils/text-search.spec.ts @@ -15,6 +15,35 @@ describe('text search utilities', () => { expect(matchesSearchText('taxes 2026', 'tax receipt')).toBeFalsy() }) + it('does not let two terms match the same word', () => { + expect(matchesSearchText('Another Tag', 'another tag th')).toBeFalsy() + expect(matchesSearchText('Another Tag', 'another tag ag')).toBeFalsy() + expect(matchesSearchText('Another Tag', 'another tag e')).toBeFalsy() + expect(matchesSearchText('Another Tag', 'another tag')).toBeTruthy() + expect(matchesSearchText('Another Tag', 'tag another')).toBeTruthy() + }) + + it('matches a single term anywhere in the value', () => { + expect(matchesSearchText('Another Tag', 'anoth')).toBeTruthy() + expect(matchesSearchText('Another Tag', 'th')).toBeTruthy() + }) + + it('treats punctuation as a separator on both sides', () => { + expect(matchesSearchText('medical-history', 'medical history')).toBeTruthy() + expect(matchesSearchText('medical history', 'medical-history')).toBeTruthy() + expect(matchesSearchText('medical-history', 'medical dental')).toBeFalsy() + }) + + it('matches longer terms first so they cannot be starved', () => { + expect(matchesSearchText('tagger tag', 'tag tagger')).toBeTruthy() + }) + + it('handles a query with no usable terms', () => { + expect(matchesSearchText('Another Tag', '')).toBeTruthy() + // Still filters, so the dropdown can offer to create a tag named "---" + expect(matchesSearchText('Another Tag', '---')).toBeFalsy() + }) + it('matches a large set of tag names without blocking input', () => { const tagNames = Array.from( { length: 1280 }, diff --git a/src-ui/src/app/utils/text-search.ts b/src-ui/src/app/utils/text-search.ts index f7f552408..82e29eead 100644 --- a/src-ui/src/app/utils/text-search.ts +++ b/src-ui/src/app/utils/text-search.ts @@ -3,13 +3,18 @@ import { diacritics } from 'normalize-diacritics/diacritics' export type SearchTextValue = string | number | boolean | bigint | null | undefined +const NON_ASCII = /[^\x00-\x7F]/ +const SEPARATORS = /[^\p{L}\p{N}]+/u + export function normalizeSearchText(value: SearchTextValue): string { - const normalized = diacritics.reduce( - (text, replacement) => { - return text.replace(replacement.diacritics, replacement.letter) - }, - String(value ?? '') - ) + const text = String(value ?? '') + + // Nothing in the table matches ASCII, so skip normaliation + if (!NON_ASCII.test(text)) return text.toLocaleLowerCase() + + const normalized = diacritics.reduce((text, replacement) => { + return text.replace(replacement.diacritics, replacement.letter) + }, text) return normalized.toLocaleLowerCase() } @@ -18,8 +23,28 @@ export function matchesSearchText( value: SearchTextValue, searchText: SearchTextValue ): boolean { - const normalizedValue = normalizeSearchText(value) - const searchTerms = normalizeSearchText(searchText).trim().split(/\s+/) + const query = normalizeSearchText(searchText) + const terms = query.split(SEPARATORS).filter(Boolean) - return searchTerms.every((term) => normalizedValue.includes(term)) + // Empty or punctuation-only query, nothing to split into terms + if (terms.length === 0) { + return normalizeSearchText(value).includes(query.trim()) + } + + const words = normalizeSearchText(value).split(SEPARATORS).filter(Boolean) + const claimed = new Array(words.length).fill(false) + + // Each term takes a word of its own, longest first, so that "another tag th" + // doesn't match "Another Tag" by finding the "th" inside "another" + return terms + .sort((a, b) => b.length - a.length) + .every((term) => { + for (let i = 0; i < words.length; i++) { + if (!claimed[i] && words[i].includes(term)) { + claimed[i] = true + return true + } + } + return false + }) }