Skip to content

Commit 357c3e4

Browse files
committed
fix: support sorting algolia search results
1 parent 62841f9 commit 357c3e4

File tree

5 files changed

+138
-53
lines changed

5 files changed

+138
-53
lines changed

app/components/Package/ListToolbar.vue

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ const props = defineProps<{
3030
activeFilters: FilterChip[]
3131
/** When true, shows search-specific UI (relevance sort, no filters) */
3232
searchContext?: boolean
33+
/** Sort keys to force-disable (e.g. when the current provider doesn't support them) */
34+
disabledSortKeys?: SortKey[]
3335
}>()
3436
3537
const { t } = useI18n()
@@ -58,17 +60,20 @@ const showingFiltered = computed(() => props.filteredCount !== props.totalCount)
5860
const currentSort = computed(() => parseSortOption(sortOption.value))
5961
6062
// Get available sort keys based on context
63+
const disabledSet = computed(() => new Set(props.disabledSortKeys ?? []))
64+
6165
const availableSortKeys = computed(() => {
66+
const applyDisabled = (k: (typeof SORT_KEYS)[number]) => ({
67+
...k,
68+
disabled: k.disabled || disabledSet.value.has(k.key),
69+
})
70+
6271
if (props.searchContext) {
63-
// In search context: show relevance (enabled) and others (disabled)
64-
return SORT_KEYS.filter(k => !k.searchOnly || k.key === 'relevance').map(k =>
65-
Object.assign({}, k, {
66-
disabled: k.key !== 'relevance',
67-
}),
68-
)
72+
// In search context: show relevance + non-disabled sorts (downloads, updated, name)
73+
return SORT_KEYS.filter(k => !k.searchOnly || k.key === 'relevance').map(applyDisabled)
6974
}
7075
// In org/user context: hide search-only sorts
71-
return SORT_KEYS.filter(k => !k.searchOnly)
76+
return SORT_KEYS.filter(k => !k.searchOnly).map(applyDisabled)
7277
})
7378
7479
// Handle sort key change from dropdown
@@ -182,9 +187,9 @@ function getSortKeyLabelKey(key: SortKey): string {
182187
</div>
183188
</div>
184189

185-
<!-- Sort direction toggle (hidden in search context) -->
190+
<!-- Sort direction toggle -->
186191
<button
187-
v-if="!searchContext"
192+
v-if="!searchContext || currentSort.key !== 'relevance'"
188193
type="button"
189194
class="p-1.5 rounded border border-border bg-bg-subtle text-fg-muted hover:text-fg hover:border-border-hover transition-colors duration-200 focus-visible:ring-2 focus-visible:ring-fg focus-visible:ring-offset-2 focus-visible:ring-offset-bg"
190195
:aria-label="$t('filters.sort.toggle_direction')"

app/pages/search.vue

Lines changed: 121 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
<script setup lang="ts">
2-
import type { FilterChip } from '#shared/types/preferences'
2+
import type { FilterChip, SortKey } from '#shared/types/preferences'
3+
import { parseSortOption } from '#shared/types/preferences'
34
import { onKeyDown } from '@vueuse/core'
45
import { debounce } from 'perfect-debounce'
56
import { isValidNewPackageName, checkPackageExists } from '~/utils/package-name'
@@ -9,6 +10,10 @@ import { normalizeSearchParam } from '#shared/utils/url'
910
const route = useRoute()
1011
const router = useRouter()
1112
13+
// Search provider
14+
const { search: algoliaSearch } = useAlgoliaSearch()
15+
const { isAlgolia } = useSearchProvider()
16+
1217
// Preferences (persisted to localStorage)
1318
const {
1419
viewMode,
@@ -45,13 +50,6 @@ onMounted(() => {
4550
const pageSize = 25
4651
const currentPage = shallowRef(1)
4752
48-
// Calculate how many results we need based on current page and preferred page size
49-
const requestedSize = computed(() => {
50-
const numericPrefSize = preferredPageSize.value === 'all' ? 250 : preferredPageSize.value
51-
// Always fetch at least enough for the current page
52-
return Math.max(pageSize, currentPage.value * numericPrefSize)
53-
})
54-
5553
// Get initial page from URL (for scroll restoration on reload)
5654
const initialPage = computed(() => {
5755
const p = Number.parseInt(normalizeSearchParam(route.query.page), 10)
@@ -65,18 +63,6 @@ onMounted(() => {
6563
}
6664
})
6765
68-
// Use incremental search with client-side caching
69-
const {
70-
data: results,
71-
status,
72-
isLoadingMore,
73-
hasMore,
74-
fetchMore,
75-
isRateLimited,
76-
} = useNpmSearch(query, () => ({
77-
size: requestedSize.value,
78-
}))
79-
8066
// Results to display (directly from incremental search)
8167
const rawVisibleResults = computed(() => results.value)
8268
@@ -125,11 +111,27 @@ const visibleResults = computed(() => {
125111
// Use structured filters for client-side refinement of search results
126112
const resultsArray = computed(() => visibleResults.value?.objects ?? [])
127113
114+
// Sort keys that the npm registry path doesn't support (only relevance works server-side)
115+
const NON_RELEVANCE_SORT_KEYS: SortKey[] = [
116+
'downloads-week',
117+
'downloads-day',
118+
'downloads-month',
119+
'downloads-year',
120+
'updated',
121+
'name',
122+
'quality',
123+
'popularity',
124+
'maintenance',
125+
'score',
126+
]
127+
128+
// Disable non-relevance sorts when using npm provider (results are relevance-only from server)
129+
const disabledSortKeys = computed<SortKey[]>(() => (isAlgolia.value ? [] : NON_RELEVANCE_SORT_KEYS))
130+
128131
// Minimal structured filters usage for search context (no client-side filtering)
129132
const {
130133
filters,
131134
sortOption,
132-
sortedPackages,
133135
availableKeywords,
134136
activeFilters,
135137
setTextFilter,
@@ -148,19 +150,90 @@ const {
148150
initialSort: 'relevance-desc', // Default to search relevance
149151
})
150152
151-
// Client-side filtered/sorted results for display
152-
// In search context, we always use server order (relevance) - no client-side filtering
153+
const isRelevanceSort = computed(
154+
() => sortOption.value === 'relevance-desc' || sortOption.value === 'relevance-asc',
155+
)
156+
157+
// Calculate how many results we need based on current page and preferred page size
158+
const requestedSize = computed(() => {
159+
const numericPrefSize = preferredPageSize.value === 'all' ? 250 : preferredPageSize.value
160+
const base = Math.max(pageSize, currentPage.value * numericPrefSize)
161+
// When sorting by something other than relevance, fetch a large batch from Algolia
162+
// so client-side sorting operates on a meaningful pool of matching results
163+
if (!isRelevanceSort.value) {
164+
return Math.max(base, 250)
165+
}
166+
return base
167+
})
168+
169+
// Reset to relevance sort when switching to npm (which only supports relevance)
170+
watch(isAlgolia, algolia => {
171+
if (!algolia && !isRelevanceSort.value) {
172+
sortOption.value = 'relevance-desc'
173+
}
174+
})
175+
176+
// Use incremental search with client-side caching
177+
const {
178+
data: results,
179+
status,
180+
isLoadingMore,
181+
hasMore,
182+
fetchMore,
183+
isRateLimited,
184+
} = useNpmSearch(query, () => ({
185+
size: requestedSize.value,
186+
}))
187+
188+
// Client-side sorted results for display
189+
// The search API already handles text filtering, so we only need to sort.
153190
const displayResults = computed(() => {
154-
// When using relevance sort, return original server-sorted results
155-
if (sortOption.value === 'relevance-desc' || sortOption.value === 'relevance-asc') {
191+
if (isRelevanceSort.value) {
156192
return resultsArray.value
157193
}
158194
159-
return sortedPackages.value
195+
// Sort the fetched results client-side (Algolia doesn't support arbitrary
196+
// sort orders without replica indices, so we fetch a large batch and sort here)
197+
const { key, direction } = parseSortOption(sortOption.value)
198+
const multiplier = direction === 'asc' ? 1 : -1
199+
200+
return [...resultsArray.value].sort((a, b) => {
201+
let diff: number
202+
switch (key) {
203+
case 'downloads-week':
204+
case 'downloads-day':
205+
case 'downloads-month':
206+
case 'downloads-year':
207+
diff = (a.downloads?.weekly ?? 0) - (b.downloads?.weekly ?? 0)
208+
break
209+
case 'updated':
210+
diff = new Date(a.package.date).getTime() - new Date(b.package.date).getTime()
211+
break
212+
case 'name':
213+
diff = a.package.name.localeCompare(b.package.name)
214+
break
215+
default:
216+
diff = 0
217+
}
218+
return diff * multiplier
219+
})
160220
})
161221
162222
const resultCount = computed(() => displayResults.value.length)
163223
224+
/**
225+
* The effective total for display and pagination purposes.
226+
* When sorting by non-relevance, we're working with a fetched subset (e.g. 250),
227+
* not the full Algolia total (e.g. 92,324). Show the actual working set size.
228+
*/
229+
const effectiveTotal = computed(() => {
230+
if (isRelevanceSort.value) {
231+
return visibleResults.value?.total ?? 0
232+
}
233+
// When sorting, the total is the number of results we actually fetched and sorted
234+
return displayResults.value.length
235+
})
236+
164237
// Handle filter chip removal
165238
function handleClearFilter(chip: FilterChip) {
166239
clearFilter(chip)
@@ -316,9 +389,6 @@ interface ValidatedSuggestion {
316389
/** Cache for existence checks to avoid repeated API calls */
317390
const existenceCache = ref<Record<string, boolean | 'pending'>>({})
318391
319-
const { search: algoliaSearch } = useAlgoliaSearch()
320-
const { isAlgolia } = useSearchProvider()
321-
322392
/**
323393
* Check if an org exists by searching for scoped packages (@orgname/...).
324394
* When Algolia is active, searches for `@name/` scoped packages via text query.
@@ -773,10 +843,11 @@ defineOgImageComponent('Default', {
773843
:columns="columns"
774844
v-model:pagination-mode="paginationMode"
775845
v-model:page-size="preferredPageSize"
776-
:total-count="visibleResults.total"
846+
:total-count="effectiveTotal"
777847
:filtered-count="displayResults.length"
778848
:available-keywords="availableKeywords"
779849
:active-filters="activeFilters"
850+
:disabled-sort-keys="disabledSortKeys"
780851
search-context
781852
@toggle-column="toggleColumn"
782853
@reset-columns="resetColumns"
@@ -789,24 +860,31 @@ defineOgImageComponent('Default', {
789860
@update:updated-within="setUpdatedWithin"
790861
@toggle-keyword="toggleKeyword"
791862
/>
792-
<!-- Show "Found X packages" (infinite scroll mode only) -->
863+
<!-- Show count status (infinite scroll mode only) -->
793864
<p
794865
v-if="viewMode === 'cards' && paginationMode === 'infinite'"
795866
role="status"
796867
class="text-fg-muted text-sm mt-4 font-mono"
797868
>
798-
{{
799-
$t(
800-
'search.found_packages',
801-
{ count: $n(visibleResults.total) },
802-
visibleResults.total,
803-
)
804-
}}
869+
<template v-if="isRelevanceSort">
870+
{{
871+
$t(
872+
'search.found_packages',
873+
{ count: $n(visibleResults.total) },
874+
visibleResults.total,
875+
)
876+
}}
877+
</template>
878+
<template v-else>
879+
{{
880+
$t('search.found_packages_sorted', { count: $n(effectiveTotal) }, effectiveTotal)
881+
}}
882+
</template>
805883
<span v-if="status === 'pending'" class="text-fg-subtle">{{
806884
$t('search.updating')
807885
}}</span>
808886
</p>
809-
<!-- Show "x of y packages" (paginated/table mode only) -->
887+
<!-- Show "x of y" (paginated/table mode only) -->
810888
<p
811889
v-if="viewMode === 'table' || paginationMode === 'paginated'"
812890
role="status"
@@ -816,11 +894,10 @@ defineOgImageComponent('Default', {
816894
$t(
817895
'filters.count.showing_paginated',
818896
{
819-
pageSize:
820-
preferredPageSize === 'all' ? $n(visibleResults.total) : preferredPageSize,
821-
count: $n(visibleResults.total),
897+
pageSize: preferredPageSize === 'all' ? $n(effectiveTotal) : preferredPageSize,
898+
count: $n(effectiveTotal),
822899
},
823-
visibleResults.total,
900+
effectiveTotal,
824901
)
825902
}}
826903
</p>
@@ -890,7 +967,7 @@ defineOgImageComponent('Default', {
890967
v-model:mode="paginationMode"
891968
v-model:page-size="preferredPageSize"
892969
v-model:current-page="currentPage"
893-
:total-items="visibleResults?.total ?? displayResults.length"
970+
:total-items="effectiveTotal"
894971
:view-mode="viewMode"
895972
/>
896973
</div>

i18n/locales/en.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
"button": "search",
2424
"searching": "Searching...",
2525
"found_packages": "No packages found | Found 1 package | Found {count} packages",
26+
"found_packages_sorted": "Sorting top {count} result | Sorting top {count} results",
2627
"updating": "(updating...)",
2728
"no_results": "No packages found for \"{query}\"",
2829
"rate_limited": "Hit npm rate limit, try again in a moment",

lunaria/files/en-GB.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
"button": "search",
2424
"searching": "Searching...",
2525
"found_packages": "No packages found | Found 1 package | Found {count} packages",
26+
"found_packages_sorted": "Sorting top {count} result | Sorting top {count} results",
2627
"updating": "(updating...)",
2728
"no_results": "No packages found for \"{query}\"",
2829
"rate_limited": "Hit npm rate limit, try again in a moment",

lunaria/files/en-US.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
"button": "search",
2424
"searching": "Searching...",
2525
"found_packages": "No packages found | Found 1 package | Found {count} packages",
26+
"found_packages_sorted": "Sorting top {count} result | Sorting top {count} results",
2627
"updating": "(updating...)",
2728
"no_results": "No packages found for \"{query}\"",
2829
"rate_limited": "Hit npm rate limit, try again in a moment",

0 commit comments

Comments
 (0)