Skip to content

Commit f374326

Browse files
RYGRITdanielroe
andauthored
fix: sync keyword highlights and deduplicate filters (#1214)
Co-authored-by: Daniel Roe <daniel@roe.dev>
1 parent 76909e5 commit f374326

File tree

4 files changed

+241
-17
lines changed

4 files changed

+241
-17
lines changed

app/composables/useStructuredFilters.ts

Lines changed: 73 additions & 10 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,34 @@ export function hasSearchOperators(parsed: ParsedSearchOperators): boolean {
8596
return !!(parsed.name?.length || parsed.description?.length || parsed.keywords?.length)
8697
}
8798

99+
/**
100+
* Remove a keyword from a search query string.
101+
* Handles kw:xxx and keyword:xxx formats, including comma-separated values.
102+
*/
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
125+
}
126+
88127
interface UseStructuredFiltersOptions {
89128
packages: Ref<NpmSearchResult[]>
90129
searchQueryModel?: Ref<string>
@@ -119,22 +158,37 @@ export function useStructuredFilters(options: UseStructuredFiltersOptions) {
119158
const { t } = useI18n()
120159

121160
const searchQuery = shallowRef(normalizeSearchParam(route.query.q))
161+
162+
// Filter state - must be declared before the watcher that uses it
163+
const filters = ref<StructuredFilters>({
164+
...DEFAULT_FILTERS,
165+
...initialFilters,
166+
})
167+
168+
// Watch route query changes and sync filter state
122169
watch(
123170
() => route.query.q,
124171
urlQuery => {
125172
const value = normalizeSearchParam(urlQuery)
126173
if (searchQuery.value !== value) {
127174
searchQuery.value = value
128175
}
176+
177+
// Sync filters with URL
178+
// When URL changes (e.g. from search input or navigation),
179+
// we need to update our local filter state to match
180+
const parsed = parseSearchOperators(value)
181+
182+
filters.value.text = parsed.text ?? ''
183+
// Deduplicate keywords (in case of both kw: and keyword: for same value)
184+
filters.value.keywords = parsed.keywords ?? []
185+
186+
// Note: We intentionally don't reset other filters (security, downloadRange, etc.)
187+
// as those are not typically driven by the search query string structure
129188
},
189+
{ immediate: true },
130190
)
131191

132-
// Filter state
133-
const filters = ref<StructuredFilters>({
134-
...DEFAULT_FILTERS,
135-
...initialFilters,
136-
})
137-
138192
// Sort state
139193
const sortOption = shallowRef<SortOption>(initialSort ?? 'updated-desc')
140194

@@ -399,7 +453,9 @@ export function useStructuredFilters(options: UseStructuredFiltersOptions) {
399453
}
400454

401455
function addKeyword(keyword: string) {
402-
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) {
403459
filters.value.keywords = [...filters.value.keywords, keyword]
404460
const newQ = searchQuery.value
405461
? `${searchQuery.value.trim()} keyword:${keyword}`
@@ -411,14 +467,21 @@ export function useStructuredFilters(options: UseStructuredFiltersOptions) {
411467
}
412468

413469
function removeKeyword(keyword: string) {
414-
filters.value.keywords = filters.value.keywords.filter(k => k !== keyword)
415-
const newQ = searchQuery.value.replace(new RegExp(`keyword:${keyword}($| )`, 'g'), '').trim()
470+
const lowerKeyword = keyword.toLowerCase()
471+
filters.value.keywords = filters.value.keywords.filter(k => k.toLowerCase() !== lowerKeyword)
472+
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)
476+
416477
router.replace({ query: { ...route.query, q: newQ || undefined } })
417478
if (searchQueryModel) searchQueryModel.value = newQ
418479
}
419480

420481
function toggleKeyword(keyword: string) {
421-
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) {
422485
removeKeyword(keyword)
423486
} else {
424487
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: 168 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', () => {
@@ -185,3 +189,166 @@ describe('hasSearchOperators', () => {
185189
expect(hasSearchOperators({ name: [], keywords: [] })).toBe(false)
186190
})
187191
})
192+
193+
describe('keyword deduplication', () => {
194+
it('deduplicates same keyword from kw: and keyword: operators', () => {
195+
const result = parseSearchOperators('kw:react keyword:react')
196+
expect(result.keywords).toEqual(['react'])
197+
})
198+
199+
it('deduplicates case-insensitively', () => {
200+
const result = parseSearchOperators('kw:React keyword:REACT kw:react')
201+
expect(result.keywords).toEqual(['React'])
202+
})
203+
204+
it('preserves different keywords', () => {
205+
const result = parseSearchOperators('kw:react keyword:vue')
206+
expect(result.keywords).toEqual(['react', 'vue'])
207+
})
208+
209+
it('deduplicates within comma-separated values', () => {
210+
const result = parseSearchOperators('kw:react,vue keyword:react,angular')
211+
expect(result.keywords).toEqual(['react', 'vue', 'angular'])
212+
})
213+
})
214+
215+
describe('keyword clearing scenarios', () => {
216+
it('returns keywords when kw: operator is present', () => {
217+
const result = parseSearchOperators('test kw:react')
218+
expect(result.keywords).toEqual(['react'])
219+
expect(result.text).toBe('test')
220+
})
221+
222+
it('returns undefined keywords when kw: operator is removed', () => {
223+
const result = parseSearchOperators('test')
224+
expect(result.keywords).toBeUndefined()
225+
expect(result.text).toBe('test')
226+
})
227+
228+
it('handles transition from keyword to no keyword', () => {
229+
// Simulate the state transition when user removes keyword from search
230+
const withKeyword = parseSearchOperators('test kw:react')
231+
expect(withKeyword.keywords).toEqual(['react'])
232+
233+
const withoutKeyword = parseSearchOperators('test')
234+
expect(withoutKeyword.keywords).toBeUndefined()
235+
236+
// This is what useStructuredFilters does in the watcher:
237+
// filters.value.keywords = [...(parsed.keywords ?? [])]
238+
const updatedKeywords = [...(withoutKeyword.keywords ?? [])]
239+
expect(updatedKeywords).toEqual([])
240+
})
241+
242+
it('returns empty keywords array after nullish coalescing', () => {
243+
// Verify the exact logic used in useStructuredFilters watcher
244+
const testCases = ['', 'test', 'some search query', 'name:package', 'desc:something']
245+
246+
for (const query of testCases) {
247+
const parsed = parseSearchOperators(query)
248+
// This is the exact line from useStructuredFilters.ts:
249+
const keywords = [...(parsed.keywords ?? [])]
250+
expect(keywords).toEqual([])
251+
}
252+
})
253+
})
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)