Skip to content

Commit 8ca7596

Browse files
committed
fix(search): properly clear keyword from input when toggled
1 parent ad2f0b4 commit 8ca7596

2 files changed

Lines changed: 74 additions & 2 deletions

File tree

app/composables/useStructuredFilters.ts

Lines changed: 52 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,17 @@ export function parseSearchOperators(input: string): ParsedSearchOperators {
7575
result.text = cleanedText
7676
}
7777

78+
// Deduplicate keywords (case-insensitive)
79+
if (result.keywords) {
80+
const seen = new Set<string>()
81+
result.keywords = result.keywords.filter(kw => {
82+
const lower = kw.toLowerCase()
83+
if (seen.has(lower)) return false
84+
seen.add(lower)
85+
return true
86+
})
87+
}
88+
7889
return result
7990
}
8091

@@ -85,6 +96,13 @@ export function hasSearchOperators(parsed: ParsedSearchOperators): boolean {
8596
return !!(parsed.name?.length || parsed.description?.length || parsed.keywords?.length)
8697
}
8798

99+
/**
100+
* Escape special regex characters in a string
101+
*/
102+
function escapeRegExp(str: string): string {
103+
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
104+
}
105+
88106
interface UseStructuredFiltersOptions {
89107
packages: Ref<NpmSearchResult[]>
90108
initialFilters?: Partial<StructuredFilters>
@@ -140,7 +158,8 @@ export function useStructuredFilters(options: UseStructuredFiltersOptions) {
140158
const parsed = parseSearchOperators(value)
141159

142160
filters.value.text = parsed.text ?? ''
143-
filters.value.keywords = [...(parsed.keywords ?? [])]
161+
// Deduplicate keywords (in case of both kw: and keyword: for same value)
162+
filters.value.keywords = parsed.keywords ?? []
144163

145164
// Note: We intentionally don't reset other filters (security, downloadRange, etc.)
146165
// as those are not typically driven by the search query string structure
@@ -423,7 +442,38 @@ export function useStructuredFilters(options: UseStructuredFiltersOptions) {
423442

424443
function removeKeyword(keyword: string) {
425444
filters.value.keywords = filters.value.keywords.filter(k => k !== keyword)
426-
const newQ = searchQuery.value.replace(new RegExp(`keyword:${keyword}($| )`, 'g'), '').trim()
445+
446+
// Need to handle both kw:xxx and keyword:xxx formats
447+
// Also handle comma-separated values like kw:foo,bar,baz
448+
let newQ = searchQuery.value
449+
450+
// First, try to remove standalone keyword:xxx or kw:xxx
451+
// Match: (kw|keyword):value followed by space or end of string
452+
newQ = newQ.replace(new RegExp(`\\b(?:kw|keyword):${escapeRegExp(keyword)}(?=\\s|$)`, 'gi'), '')
453+
454+
// Handle comma-separated values: remove the keyword from within a list
455+
// e.g., "kw:foo,bar,baz" should become "kw:foo,baz" if removing "bar"
456+
newQ = newQ.replace(
457+
new RegExp(
458+
`\\b((?:kw|keyword):)([^\\s]*,)?${escapeRegExp(keyword)}(,[^\\s]*)?(?=\\s|$)`,
459+
'gi',
460+
),
461+
(match, prefix, before, after) => {
462+
const beforePart = before?.replace(/,$/, '') ?? ''
463+
const afterPart = after?.replace(/^,/, '') ?? ''
464+
if (!beforePart && !afterPart) {
465+
// This was the only keyword in the operator
466+
return ''
467+
}
468+
// Reconstruct with remaining keywords
469+
const separator = beforePart && afterPart ? ',' : ''
470+
return `${prefix}${beforePart}${separator}${afterPart}`
471+
},
472+
)
473+
474+
// Clean up any double spaces and trim
475+
newQ = newQ.replace(/\s+/g, ' ').trim()
476+
427477
router.replace({ query: { ...route.query, q: newQ || undefined } })
428478
}
429479

test/nuxt/composables/useStructuredFilters.spec.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,28 @@ describe('hasSearchOperators', () => {
186186
})
187187
})
188188

189+
describe('keyword deduplication', () => {
190+
it('deduplicates same keyword from kw: and keyword: operators', () => {
191+
const result = parseSearchOperators('kw:react keyword:react')
192+
expect(result.keywords).toEqual(['react'])
193+
})
194+
195+
it('deduplicates case-insensitively', () => {
196+
const result = parseSearchOperators('kw:React keyword:REACT kw:react')
197+
expect(result.keywords).toEqual(['React'])
198+
})
199+
200+
it('preserves different keywords', () => {
201+
const result = parseSearchOperators('kw:react keyword:vue')
202+
expect(result.keywords).toEqual(['react', 'vue'])
203+
})
204+
205+
it('deduplicates within comma-separated values', () => {
206+
const result = parseSearchOperators('kw:react,vue keyword:react,angular')
207+
expect(result.keywords).toEqual(['react', 'vue', 'angular'])
208+
})
209+
})
210+
189211
describe('keyword clearing scenarios', () => {
190212
it('returns keywords when kw: operator is present', () => {
191213
const result = parseSearchOperators('test kw:react')

0 commit comments

Comments
 (0)