From ad2f0b47341e17ecb381f5a849b5ce6fbfadf843 Mon Sep 17 00:00:00 2001 From: rygrit Date: Sun, 8 Feb 2026 19:25:08 +0800 Subject: [PATCH 1/3] fix(search): resolve persistent keyword highlight in card list --- app/composables/useStructuredFilters.ts | 26 +++++++++--- .../composables/useStructuredFilters.spec.ts | 40 +++++++++++++++++++ 2 files changed, 60 insertions(+), 6 deletions(-) diff --git a/app/composables/useStructuredFilters.ts b/app/composables/useStructuredFilters.ts index 19266e1ee..c3ebb86be 100644 --- a/app/composables/useStructuredFilters.ts +++ b/app/composables/useStructuredFilters.ts @@ -118,6 +118,14 @@ export function useStructuredFilters(options: UseStructuredFiltersOptions) { const { t } = useI18n() const searchQuery = shallowRef(normalizeSearchParam(route.query.q)) + + // Filter state - must be declared before the watcher that uses it + const filters = ref({ + ...DEFAULT_FILTERS, + ...initialFilters, + }) + + // Watch route query changes and sync filter state watch( () => route.query.q, urlQuery => { @@ -125,15 +133,21 @@ export function useStructuredFilters(options: UseStructuredFiltersOptions) { if (searchQuery.value !== value) { searchQuery.value = value } + + // Sync filters with URL + // When URL changes (e.g. from search input or navigation), + // we need to update our local filter state to match + const parsed = parseSearchOperators(value) + + filters.value.text = parsed.text ?? '' + filters.value.keywords = [...(parsed.keywords ?? [])] + + // Note: We intentionally don't reset other filters (security, downloadRange, etc.) + // as those are not typically driven by the search query string structure }, + { immediate: true }, ) - // Filter state - const filters = ref({ - ...DEFAULT_FILTERS, - ...initialFilters, - }) - // Sort state const sortOption = shallowRef(initialSort ?? 'updated-desc') diff --git a/test/nuxt/composables/useStructuredFilters.spec.ts b/test/nuxt/composables/useStructuredFilters.spec.ts index aa37df432..f5223751d 100644 --- a/test/nuxt/composables/useStructuredFilters.spec.ts +++ b/test/nuxt/composables/useStructuredFilters.spec.ts @@ -185,3 +185,43 @@ describe('hasSearchOperators', () => { expect(hasSearchOperators({ name: [], keywords: [] })).toBe(false) }) }) + +describe('keyword clearing scenarios', () => { + it('returns keywords when kw: operator is present', () => { + const result = parseSearchOperators('test kw:react') + expect(result.keywords).toEqual(['react']) + expect(result.text).toBe('test') + }) + + it('returns undefined keywords when kw: operator is removed', () => { + const result = parseSearchOperators('test') + expect(result.keywords).toBeUndefined() + expect(result.text).toBe('test') + }) + + it('handles transition from keyword to no keyword', () => { + // Simulate the state transition when user removes keyword from search + const withKeyword = parseSearchOperators('test kw:react') + expect(withKeyword.keywords).toEqual(['react']) + + const withoutKeyword = parseSearchOperators('test') + expect(withoutKeyword.keywords).toBeUndefined() + + // This is what useStructuredFilters does in the watcher: + // filters.value.keywords = [...(parsed.keywords ?? [])] + const updatedKeywords = [...(withoutKeyword.keywords ?? [])] + expect(updatedKeywords).toEqual([]) + }) + + it('returns empty keywords array after nullish coalescing', () => { + // Verify the exact logic used in useStructuredFilters watcher + const testCases = ['', 'test', 'some search query', 'name:package', 'desc:something'] + + for (const query of testCases) { + const parsed = parseSearchOperators(query) + // This is the exact line from useStructuredFilters.ts: + const keywords = [...(parsed.keywords ?? [])] + expect(keywords).toEqual([]) + } + }) +}) From 8ca7596ffebf965f1122f6917fc68c39875115a7 Mon Sep 17 00:00:00 2001 From: rygrit Date: Sun, 8 Feb 2026 22:16:32 +0800 Subject: [PATCH 2/3] fix(search): properly clear keyword from input when toggled --- app/composables/useStructuredFilters.ts | 54 ++++++++++++++++++- .../composables/useStructuredFilters.spec.ts | 22 ++++++++ 2 files changed, 74 insertions(+), 2 deletions(-) diff --git a/app/composables/useStructuredFilters.ts b/app/composables/useStructuredFilters.ts index c3ebb86be..c8b2f5338 100644 --- a/app/composables/useStructuredFilters.ts +++ b/app/composables/useStructuredFilters.ts @@ -75,6 +75,17 @@ export function parseSearchOperators(input: string): ParsedSearchOperators { result.text = cleanedText } + // Deduplicate keywords (case-insensitive) + if (result.keywords) { + const seen = new Set() + result.keywords = result.keywords.filter(kw => { + const lower = kw.toLowerCase() + if (seen.has(lower)) return false + seen.add(lower) + return true + }) + } + return result } @@ -85,6 +96,13 @@ export function hasSearchOperators(parsed: ParsedSearchOperators): boolean { return !!(parsed.name?.length || parsed.description?.length || parsed.keywords?.length) } +/** + * Escape special regex characters in a string + */ +function escapeRegExp(str: string): string { + return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +} + interface UseStructuredFiltersOptions { packages: Ref initialFilters?: Partial @@ -140,7 +158,8 @@ export function useStructuredFilters(options: UseStructuredFiltersOptions) { const parsed = parseSearchOperators(value) filters.value.text = parsed.text ?? '' - filters.value.keywords = [...(parsed.keywords ?? [])] + // Deduplicate keywords (in case of both kw: and keyword: for same value) + filters.value.keywords = parsed.keywords ?? [] // Note: We intentionally don't reset other filters (security, downloadRange, etc.) // as those are not typically driven by the search query string structure @@ -423,7 +442,38 @@ export function useStructuredFilters(options: UseStructuredFiltersOptions) { function removeKeyword(keyword: string) { filters.value.keywords = filters.value.keywords.filter(k => k !== keyword) - const newQ = searchQuery.value.replace(new RegExp(`keyword:${keyword}($| )`, 'g'), '').trim() + + // Need to handle both kw:xxx and keyword:xxx formats + // Also handle comma-separated values like kw:foo,bar,baz + let newQ = searchQuery.value + + // First, try to remove standalone keyword:xxx or kw:xxx + // Match: (kw|keyword):value followed by space or end of string + newQ = newQ.replace(new RegExp(`\\b(?:kw|keyword):${escapeRegExp(keyword)}(?=\\s|$)`, 'gi'), '') + + // Handle comma-separated values: remove the keyword from within a list + // e.g., "kw:foo,bar,baz" should become "kw:foo,baz" if removing "bar" + newQ = newQ.replace( + new RegExp( + `\\b((?:kw|keyword):)([^\\s]*,)?${escapeRegExp(keyword)}(,[^\\s]*)?(?=\\s|$)`, + 'gi', + ), + (match, prefix, before, after) => { + const beforePart = before?.replace(/,$/, '') ?? '' + const afterPart = after?.replace(/^,/, '') ?? '' + if (!beforePart && !afterPart) { + // This was the only keyword in the operator + return '' + } + // Reconstruct with remaining keywords + const separator = beforePart && afterPart ? ',' : '' + return `${prefix}${beforePart}${separator}${afterPart}` + }, + ) + + // Clean up any double spaces and trim + newQ = newQ.replace(/\s+/g, ' ').trim() + router.replace({ query: { ...route.query, q: newQ || undefined } }) } diff --git a/test/nuxt/composables/useStructuredFilters.spec.ts b/test/nuxt/composables/useStructuredFilters.spec.ts index f5223751d..3de2d69ac 100644 --- a/test/nuxt/composables/useStructuredFilters.spec.ts +++ b/test/nuxt/composables/useStructuredFilters.spec.ts @@ -186,6 +186,28 @@ describe('hasSearchOperators', () => { }) }) +describe('keyword deduplication', () => { + it('deduplicates same keyword from kw: and keyword: operators', () => { + const result = parseSearchOperators('kw:react keyword:react') + expect(result.keywords).toEqual(['react']) + }) + + it('deduplicates case-insensitively', () => { + const result = parseSearchOperators('kw:React keyword:REACT kw:react') + expect(result.keywords).toEqual(['React']) + }) + + it('preserves different keywords', () => { + const result = parseSearchOperators('kw:react keyword:vue') + expect(result.keywords).toEqual(['react', 'vue']) + }) + + it('deduplicates within comma-separated values', () => { + const result = parseSearchOperators('kw:react,vue keyword:react,angular') + expect(result.keywords).toEqual(['react', 'vue', 'angular']) + }) +}) + describe('keyword clearing scenarios', () => { it('returns keywords when kw: operator is present', () => { const result = parseSearchOperators('test kw:react') From 472bf361229277fa5cf0a6008432f95bf2d7aed6 Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Thu, 26 Feb 2026 11:10:51 +0000 Subject: [PATCH 3/3] fix: add removeKeywordFromQuery (vs backtracking) + remove initialFilters --- app/composables/useStructuredFilters.ts | 71 ++++++------ app/pages/org/[org].vue | 3 - app/pages/search.vue | 3 - .../composables/useStructuredFilters.spec.ts | 107 +++++++++++++++++- 4 files changed, 141 insertions(+), 43 deletions(-) diff --git a/app/composables/useStructuredFilters.ts b/app/composables/useStructuredFilters.ts index bedf64cae..7b0af8cc0 100644 --- a/app/composables/useStructuredFilters.ts +++ b/app/composables/useStructuredFilters.ts @@ -97,10 +97,31 @@ export function hasSearchOperators(parsed: ParsedSearchOperators): boolean { } /** - * Escape special regex characters in a string + * Remove a keyword from a search query string. + * Handles kw:xxx and keyword:xxx formats, including comma-separated values. */ -function escapeRegExp(str: string): string { - return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +export function removeKeywordFromQuery(query: string, keyword: string): string { + const operatorRegex = /\b((?:kw|keyword):)(\S+)/gi + const lowerKeyword = keyword.toLowerCase() + + let result = query.replace(operatorRegex, (match, prefix: string, value: string) => { + const values = value.split(',').filter(Boolean) + const filtered = values.filter(v => v.toLowerCase() !== lowerKeyword) + + if (filtered.length === 0) { + // All values removed — drop the entire operator + return '' + } + if (filtered.length === values.length) { + // Nothing was removed — keep original + return match + } + return `${prefix}${filtered.join(',')}` + }) + + // Clean up double spaces and trim + result = result.replace(/\s+/g, ' ').trim() + return result } interface UseStructuredFiltersOptions { @@ -432,7 +453,9 @@ export function useStructuredFilters(options: UseStructuredFiltersOptions) { } function addKeyword(keyword: string) { - if (!filters.value.keywords.includes(keyword)) { + const lowerKeyword = keyword.toLowerCase() + const alreadyExists = filters.value.keywords.some(k => k.toLowerCase() === lowerKeyword) + if (!alreadyExists) { filters.value.keywords = [...filters.value.keywords, keyword] const newQ = searchQuery.value ? `${searchQuery.value.trim()} keyword:${keyword}` @@ -444,45 +467,21 @@ export function useStructuredFilters(options: UseStructuredFiltersOptions) { } function removeKeyword(keyword: string) { - filters.value.keywords = filters.value.keywords.filter(k => k !== keyword) - - // Need to handle both kw:xxx and keyword:xxx formats - // Also handle comma-separated values like kw:foo,bar,baz - let newQ = searchQuery.value - - // First, try to remove standalone keyword:xxx or kw:xxx - // Match: (kw|keyword):value followed by space or end of string - newQ = newQ.replace(new RegExp(`\\b(?:kw|keyword):${escapeRegExp(keyword)}(?=\\s|$)`, 'gi'), '') - - // Handle comma-separated values: remove the keyword from within a list - // e.g., "kw:foo,bar,baz" should become "kw:foo,baz" if removing "bar" - newQ = newQ.replace( - new RegExp( - `\\b((?:kw|keyword):)([^\\s]*,)?${escapeRegExp(keyword)}(,[^\\s]*)?(?=\\s|$)`, - 'gi', - ), - (match, prefix, before, after) => { - const beforePart = before?.replace(/,$/, '') ?? '' - const afterPart = after?.replace(/^,/, '') ?? '' - if (!beforePart && !afterPart) { - // This was the only keyword in the operator - return '' - } - // Reconstruct with remaining keywords - const separator = beforePart && afterPart ? ',' : '' - return `${prefix}${beforePart}${separator}${afterPart}` - }, - ) + const lowerKeyword = keyword.toLowerCase() + filters.value.keywords = filters.value.keywords.filter(k => k.toLowerCase() !== lowerKeyword) - // Clean up any double spaces and trim - newQ = newQ.replace(/\s+/g, ' ').trim() + // Remove the keyword from the search query string. + // Handles both kw:xxx and keyword:xxx formats, including comma-separated values. + const newQ = removeKeywordFromQuery(searchQuery.value, keyword) router.replace({ query: { ...route.query, q: newQ || undefined } }) if (searchQueryModel) searchQueryModel.value = newQ } function toggleKeyword(keyword: string) { - if (filters.value.keywords.includes(keyword)) { + const lowerKeyword = keyword.toLowerCase() + const exists = filters.value.keywords.some(k => k.toLowerCase() === lowerKeyword) + if (exists) { removeKeyword(keyword) } else { addKeyword(keyword) diff --git a/app/pages/org/[org].vue b/app/pages/org/[org].vue index e7ec4a649..b513e5e9e 100644 --- a/app/pages/org/[org].vue +++ b/app/pages/org/[org].vue @@ -57,9 +57,6 @@ const { setSort, } = useStructuredFilters({ packages, - initialFilters: { - ...parseSearchOperators(normalizeSearchParam(route.query.q)), - }, initialSort: (normalizeSearchParam(route.query.sort) as SortOption) ?? 'updated-desc', }) diff --git a/app/pages/search.vue b/app/pages/search.vue index dcb285382..21cd2c3bd 100644 --- a/app/pages/search.vue +++ b/app/pages/search.vue @@ -143,9 +143,6 @@ const { clearAllFilters, } = useStructuredFilters({ packages: resultsArray, - initialFilters: { - ...parseSearchOperators(normalizeSearchParam(route.query.q)), - }, initialSort: 'relevance-desc', // Default to search relevance searchQueryModel: searchQuery, }) diff --git a/test/nuxt/composables/useStructuredFilters.spec.ts b/test/nuxt/composables/useStructuredFilters.spec.ts index 3de2d69ac..7018e1516 100644 --- a/test/nuxt/composables/useStructuredFilters.spec.ts +++ b/test/nuxt/composables/useStructuredFilters.spec.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from 'vitest' -import { hasSearchOperators, parseSearchOperators } from '~/composables/useStructuredFilters' +import { + hasSearchOperators, + parseSearchOperators, + removeKeywordFromQuery, +} from '~/composables/useStructuredFilters' describe('parseSearchOperators', () => { describe('basic operator parsing', () => { @@ -247,3 +251,104 @@ describe('keyword clearing scenarios', () => { } }) }) + +describe('removeKeywordFromQuery', () => { + describe('standalone keyword removal', () => { + it('removes standalone kw:value', () => { + expect(removeKeywordFromQuery('test kw:react', 'react')).toBe('test') + }) + + it('removes standalone keyword:value', () => { + expect(removeKeywordFromQuery('test keyword:react', 'react')).toBe('test') + }) + + it('removes keyword at start of query', () => { + expect(removeKeywordFromQuery('kw:react test', 'react')).toBe('test') + }) + + it('removes keyword when it is the entire query', () => { + expect(removeKeywordFromQuery('kw:react', 'react')).toBe('') + }) + + it('is case-insensitive', () => { + expect(removeKeywordFromQuery('kw:React', 'react')).toBe('') + expect(removeKeywordFromQuery('kw:react', 'React')).toBe('') + expect(removeKeywordFromQuery('kw:REACT', 'react')).toBe('') + }) + }) + + describe('comma-separated keyword removal', () => { + it('removes keyword from middle of comma list', () => { + expect(removeKeywordFromQuery('kw:foo,bar,baz', 'bar')).toBe('kw:foo,baz') + }) + + it('removes keyword from start of comma list', () => { + expect(removeKeywordFromQuery('kw:foo,bar,baz', 'foo')).toBe('kw:bar,baz') + }) + + it('removes keyword from end of comma list', () => { + expect(removeKeywordFromQuery('kw:foo,bar,baz', 'baz')).toBe('kw:foo,bar') + }) + + it('removes only keyword in comma list (drops operator)', () => { + expect(removeKeywordFromQuery('test kw:react', 'react')).toBe('test') + }) + + it('removes keyword from two-item list', () => { + expect(removeKeywordFromQuery('kw:foo,bar', 'foo')).toBe('kw:bar') + expect(removeKeywordFromQuery('kw:foo,bar', 'bar')).toBe('kw:foo') + }) + + it('is case-insensitive within comma list', () => { + expect(removeKeywordFromQuery('kw:Foo,Bar,Baz', 'bar')).toBe('kw:Foo,Baz') + }) + }) + + describe('duplicate keyword removal', () => { + it('removes all occurrences across multiple operators', () => { + expect(removeKeywordFromQuery('kw:react keyword:react', 'react')).toBe('') + }) + + it('removes from both standalone and comma-separated', () => { + expect(removeKeywordFromQuery('kw:react,vue keyword:react', 'react')).toBe('kw:vue') + }) + + it('removes duplicate within same comma list', () => { + expect(removeKeywordFromQuery('kw:react,vue,react', 'react')).toBe('kw:vue') + }) + }) + + describe('preserves unrelated content', () => { + it('preserves other operators', () => { + expect(removeKeywordFromQuery('name:foo kw:react desc:bar', 'react')).toBe( + 'name:foo desc:bar', + ) + }) + + it('preserves free text', () => { + expect(removeKeywordFromQuery('hello world kw:react', 'react')).toBe('hello world') + }) + + it('does not remove substring matches', () => { + expect(removeKeywordFromQuery('kw:react-hooks', 'react')).toBe('kw:react-hooks') + }) + + it('does not remove keyword that is a prefix of another in comma list', () => { + expect(removeKeywordFromQuery('kw:react,react-hooks', 'react')).toBe('kw:react-hooks') + }) + + it('does not modify query when keyword is not present', () => { + expect(removeKeywordFromQuery('kw:vue,angular test', 'react')).toBe('kw:vue,angular test') + }) + }) + + describe('whitespace handling', () => { + it('collapses multiple spaces after removal', () => { + expect(removeKeywordFromQuery('test kw:react more', 'react')).toBe('test more') + }) + + it('trims leading and trailing spaces', () => { + expect(removeKeywordFromQuery(' kw:react ', 'react')).toBe('') + }) + }) +})