Skip to content
Open
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
78 changes: 71 additions & 7 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,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<NpmSearchResult[]>
initialFilters?: Partial<StructuredFilters>
Expand Down Expand Up @@ -118,22 +136,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 @@ -409,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 } })
}

Expand Down
62 changes: 62 additions & 0 deletions test/nuxt/composables/useStructuredFilters.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,3 +185,65 @@ 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([])
}
})
})
Loading