diff --git a/app/composables/useStructuredFilters.ts b/app/composables/useStructuredFilters.ts index a33d4d150..7b0af8cc0 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,34 @@ export function hasSearchOperators(parsed: ParsedSearchOperators): boolean { return !!(parsed.name?.length || parsed.description?.length || parsed.keywords?.length) } +/** + * Remove a keyword from a search query string. + * Handles kw:xxx and keyword:xxx formats, including comma-separated values. + */ +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 { packages: Ref searchQueryModel?: Ref @@ -119,6 +158,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 => { @@ -126,15 +173,22 @@ 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 ?? '' + // 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 }, + { immediate: true }, ) - // Filter state - const filters = ref({ - ...DEFAULT_FILTERS, - ...initialFilters, - }) - // Sort state const sortOption = shallowRef(initialSort ?? 'updated-desc') @@ -399,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}` @@ -411,14 +467,21 @@ 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() + const lowerKeyword = keyword.toLowerCase() + filters.value.keywords = filters.value.keywords.filter(k => k.toLowerCase() !== lowerKeyword) + + // 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 aa37df432..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', () => { @@ -185,3 +189,166 @@ describe('hasSearchOperators', () => { expect(hasSearchOperators({ name: [], keywords: [] })).toBe(false) }) }) + +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') + 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([]) + } + }) +}) + +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('') + }) + }) +})