Skip to content

Commit 472bf36

Browse files
committed
fix: add removeKeywordFromQuery (vs backtracking) + remove initialFilters
1 parent d03afe1 commit 472bf36

4 files changed

Lines changed: 141 additions & 43 deletions

File tree

app/composables/useStructuredFilters.ts

Lines changed: 35 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -97,10 +97,31 @@ export function hasSearchOperators(parsed: ParsedSearchOperators): boolean {
9797
}
9898

9999
/**
100-
* Escape special regex characters in a string
100+
* Remove a keyword from a search query string.
101+
* Handles kw:xxx and keyword:xxx formats, including comma-separated values.
101102
*/
102-
function escapeRegExp(str: string): string {
103-
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
103+
export function removeKeywordFromQuery(query: string, keyword: string): string {
104+
const operatorRegex = /\b((?:kw|keyword):)(\S+)/gi
105+
const lowerKeyword = keyword.toLowerCase()
106+
107+
let result = query.replace(operatorRegex, (match, prefix: string, value: string) => {
108+
const values = value.split(',').filter(Boolean)
109+
const filtered = values.filter(v => v.toLowerCase() !== lowerKeyword)
110+
111+
if (filtered.length === 0) {
112+
// All values removed — drop the entire operator
113+
return ''
114+
}
115+
if (filtered.length === values.length) {
116+
// Nothing was removed — keep original
117+
return match
118+
}
119+
return `${prefix}${filtered.join(',')}`
120+
})
121+
122+
// Clean up double spaces and trim
123+
result = result.replace(/\s+/g, ' ').trim()
124+
return result
104125
}
105126

106127
interface UseStructuredFiltersOptions {
@@ -432,7 +453,9 @@ export function useStructuredFilters(options: UseStructuredFiltersOptions) {
432453
}
433454

434455
function addKeyword(keyword: string) {
435-
if (!filters.value.keywords.includes(keyword)) {
456+
const lowerKeyword = keyword.toLowerCase()
457+
const alreadyExists = filters.value.keywords.some(k => k.toLowerCase() === lowerKeyword)
458+
if (!alreadyExists) {
436459
filters.value.keywords = [...filters.value.keywords, keyword]
437460
const newQ = searchQuery.value
438461
? `${searchQuery.value.trim()} keyword:${keyword}`
@@ -444,45 +467,21 @@ export function useStructuredFilters(options: UseStructuredFiltersOptions) {
444467
}
445468

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

477-
// Clean up any double spaces and trim
478-
newQ = newQ.replace(/\s+/g, ' ').trim()
473+
// Remove the keyword from the search query string.
474+
// Handles both kw:xxx and keyword:xxx formats, including comma-separated values.
475+
const newQ = removeKeywordFromQuery(searchQuery.value, keyword)
479476

480477
router.replace({ query: { ...route.query, q: newQ || undefined } })
481478
if (searchQueryModel) searchQueryModel.value = newQ
482479
}
483480

484481
function toggleKeyword(keyword: string) {
485-
if (filters.value.keywords.includes(keyword)) {
482+
const lowerKeyword = keyword.toLowerCase()
483+
const exists = filters.value.keywords.some(k => k.toLowerCase() === lowerKeyword)
484+
if (exists) {
486485
removeKeyword(keyword)
487486
} else {
488487
addKeyword(keyword)

app/pages/org/[org].vue

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -57,9 +57,6 @@ const {
5757
setSort,
5858
} = useStructuredFilters({
5959
packages,
60-
initialFilters: {
61-
...parseSearchOperators(normalizeSearchParam(route.query.q)),
62-
},
6360
initialSort: (normalizeSearchParam(route.query.sort) as SortOption) ?? 'updated-desc',
6461
})
6562

app/pages/search.vue

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -143,9 +143,6 @@ const {
143143
clearAllFilters,
144144
} = useStructuredFilters({
145145
packages: resultsArray,
146-
initialFilters: {
147-
...parseSearchOperators(normalizeSearchParam(route.query.q)),
148-
},
149146
initialSort: 'relevance-desc', // Default to search relevance
150147
searchQueryModel: searchQuery,
151148
})

test/nuxt/composables/useStructuredFilters.spec.ts

Lines changed: 106 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
import { describe, expect, it } from 'vitest'
2-
import { hasSearchOperators, parseSearchOperators } from '~/composables/useStructuredFilters'
2+
import {
3+
hasSearchOperators,
4+
parseSearchOperators,
5+
removeKeywordFromQuery,
6+
} from '~/composables/useStructuredFilters'
37

48
describe('parseSearchOperators', () => {
59
describe('basic operator parsing', () => {
@@ -247,3 +251,104 @@ describe('keyword clearing scenarios', () => {
247251
}
248252
})
249253
})
254+
255+
describe('removeKeywordFromQuery', () => {
256+
describe('standalone keyword removal', () => {
257+
it('removes standalone kw:value', () => {
258+
expect(removeKeywordFromQuery('test kw:react', 'react')).toBe('test')
259+
})
260+
261+
it('removes standalone keyword:value', () => {
262+
expect(removeKeywordFromQuery('test keyword:react', 'react')).toBe('test')
263+
})
264+
265+
it('removes keyword at start of query', () => {
266+
expect(removeKeywordFromQuery('kw:react test', 'react')).toBe('test')
267+
})
268+
269+
it('removes keyword when it is the entire query', () => {
270+
expect(removeKeywordFromQuery('kw:react', 'react')).toBe('')
271+
})
272+
273+
it('is case-insensitive', () => {
274+
expect(removeKeywordFromQuery('kw:React', 'react')).toBe('')
275+
expect(removeKeywordFromQuery('kw:react', 'React')).toBe('')
276+
expect(removeKeywordFromQuery('kw:REACT', 'react')).toBe('')
277+
})
278+
})
279+
280+
describe('comma-separated keyword removal', () => {
281+
it('removes keyword from middle of comma list', () => {
282+
expect(removeKeywordFromQuery('kw:foo,bar,baz', 'bar')).toBe('kw:foo,baz')
283+
})
284+
285+
it('removes keyword from start of comma list', () => {
286+
expect(removeKeywordFromQuery('kw:foo,bar,baz', 'foo')).toBe('kw:bar,baz')
287+
})
288+
289+
it('removes keyword from end of comma list', () => {
290+
expect(removeKeywordFromQuery('kw:foo,bar,baz', 'baz')).toBe('kw:foo,bar')
291+
})
292+
293+
it('removes only keyword in comma list (drops operator)', () => {
294+
expect(removeKeywordFromQuery('test kw:react', 'react')).toBe('test')
295+
})
296+
297+
it('removes keyword from two-item list', () => {
298+
expect(removeKeywordFromQuery('kw:foo,bar', 'foo')).toBe('kw:bar')
299+
expect(removeKeywordFromQuery('kw:foo,bar', 'bar')).toBe('kw:foo')
300+
})
301+
302+
it('is case-insensitive within comma list', () => {
303+
expect(removeKeywordFromQuery('kw:Foo,Bar,Baz', 'bar')).toBe('kw:Foo,Baz')
304+
})
305+
})
306+
307+
describe('duplicate keyword removal', () => {
308+
it('removes all occurrences across multiple operators', () => {
309+
expect(removeKeywordFromQuery('kw:react keyword:react', 'react')).toBe('')
310+
})
311+
312+
it('removes from both standalone and comma-separated', () => {
313+
expect(removeKeywordFromQuery('kw:react,vue keyword:react', 'react')).toBe('kw:vue')
314+
})
315+
316+
it('removes duplicate within same comma list', () => {
317+
expect(removeKeywordFromQuery('kw:react,vue,react', 'react')).toBe('kw:vue')
318+
})
319+
})
320+
321+
describe('preserves unrelated content', () => {
322+
it('preserves other operators', () => {
323+
expect(removeKeywordFromQuery('name:foo kw:react desc:bar', 'react')).toBe(
324+
'name:foo desc:bar',
325+
)
326+
})
327+
328+
it('preserves free text', () => {
329+
expect(removeKeywordFromQuery('hello world kw:react', 'react')).toBe('hello world')
330+
})
331+
332+
it('does not remove substring matches', () => {
333+
expect(removeKeywordFromQuery('kw:react-hooks', 'react')).toBe('kw:react-hooks')
334+
})
335+
336+
it('does not remove keyword that is a prefix of another in comma list', () => {
337+
expect(removeKeywordFromQuery('kw:react,react-hooks', 'react')).toBe('kw:react-hooks')
338+
})
339+
340+
it('does not modify query when keyword is not present', () => {
341+
expect(removeKeywordFromQuery('kw:vue,angular test', 'react')).toBe('kw:vue,angular test')
342+
})
343+
})
344+
345+
describe('whitespace handling', () => {
346+
it('collapses multiple spaces after removal', () => {
347+
expect(removeKeywordFromQuery('test kw:react more', 'react')).toBe('test more')
348+
})
349+
350+
it('trims leading and trailing spaces', () => {
351+
expect(removeKeywordFromQuery(' kw:react ', 'react')).toBe('')
352+
})
353+
})
354+
})

0 commit comments

Comments
 (0)