From ad2f0b47341e17ecb381f5a849b5ce6fbfadf843 Mon Sep 17 00:00:00 2001 From: rygrit Date: Sun, 8 Feb 2026 19:25:08 +0800 Subject: [PATCH 1/2] 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/2] 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')