Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 73 additions & 10 deletions app/composables/useStructuredFilters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,17 @@ export function parseSearchOperators(input: string): ParsedSearchOperators {
result.text = cleanedText
}

// Deduplicate keywords (case-insensitive)
if (result.keywords) {
const seen = new Set<string>()
result.keywords = result.keywords.filter(kw => {
const lower = kw.toLowerCase()
if (seen.has(lower)) return false
seen.add(lower)
return true
})
}

return result
}

Expand All @@ -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<NpmSearchResult[]>
searchQueryModel?: Ref<string>
Expand Down Expand Up @@ -119,22 +158,37 @@ 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<StructuredFilters>({
...DEFAULT_FILTERS,
...initialFilters,
})

// Watch route query changes and sync filter state
watch(
() => route.query.q,
urlQuery => {
const value = normalizeSearchParam(urlQuery)
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<StructuredFilters>({
...DEFAULT_FILTERS,
...initialFilters,
})

// Sort state
const sortOption = shallowRef<SortOption>(initialSort ?? 'updated-desc')

Expand Down Expand Up @@ -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}`
Expand All @@ -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)
Expand Down
3 changes: 0 additions & 3 deletions app/pages/org/[org].vue
Original file line number Diff line number Diff line change
Expand Up @@ -57,9 +57,6 @@ const {
setSort,
} = useStructuredFilters({
packages,
initialFilters: {
...parseSearchOperators(normalizeSearchParam(route.query.q)),
},
initialSort: (normalizeSearchParam(route.query.sort) as SortOption) ?? 'updated-desc',
})

Expand Down
3 changes: 0 additions & 3 deletions app/pages/search.vue
Original file line number Diff line number Diff line change
Expand Up @@ -143,9 +143,6 @@ const {
clearAllFilters,
} = useStructuredFilters({
packages: resultsArray,
initialFilters: {
...parseSearchOperators(normalizeSearchParam(route.query.q)),
},
initialSort: 'relevance-desc', // Default to search relevance
searchQueryModel: searchQuery,
})
Expand Down
169 changes: 168 additions & 1 deletion test/nuxt/composables/useStructuredFilters.spec.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -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('')
})
})
})
Loading