Skip to content

Commit 769015e

Browse files
authored
Merge pull request #62093 from nextcloud/feat/search-controller-adapter
feat(core): drive the unified search modal from the search controller
2 parents 9b70a65 + 469a12a commit 769015e

8 files changed

Lines changed: 870 additions & 184 deletions

File tree

core/src/components/UnifiedSearch/UnifiedSearchModal.vue

Lines changed: 100 additions & 128 deletions
Original file line numberDiff line numberDiff line change
@@ -153,7 +153,7 @@
153153
v-bind="result" />
154154
</ul>
155155
<div class="result-footer">
156-
<NcButton v-if="providerResult.results.length === providerResult.limit" variant="tertiary-no-background" @click="loadMoreResultsForProvider(providerResult)">
156+
<NcButton v-if="providerResult.hasMore" variant="tertiary-no-background" @click="loadMoreResultsForProvider(providerResult)">
157157
{{ t('core', 'Load more results') }}
158158
<template #icon>
159159
<IconDotsHorizontal :size="20" />
@@ -183,7 +183,7 @@
183183
v-bind="result" />
184184
</ul>
185185
<div class="result-footer">
186-
<NcButton v-if="providerResult.results.length === providerResult.limit" variant="tertiary-no-background" @click="loadMoreResultsForProvider(providerResult)">
186+
<NcButton v-if="providerResult.hasMore" variant="tertiary-no-background" @click="loadMoreResultsForProvider(providerResult)">
187187
{{ t('core', 'Load more results') }}
188188
<template #icon>
189189
<IconDotsHorizontal :size="20" />
@@ -207,6 +207,7 @@
207207

208208
<script lang="ts">
209209
import type { FocusTrap } from 'focus-trap'
210+
import type { CategorySearchParams } from '../../services/UnifiedSearchController.ts'
210211
211212
import { subscribe } from '@nextcloud/event-bus'
212213
import { loadState } from '@nextcloud/initial-state'
@@ -235,8 +236,9 @@ import CustomDateRangeModal from './CustomDateRangeModal.vue'
235236
import SearchableList from './SearchableList.vue'
236237
import FilterChip from './SearchFilterChip.vue'
237238
import SearchResult from './SearchResult.vue'
239+
import { useUnifiedSearch } from '../../composables/useUnifiedSearch.ts'
238240
import { unifiedSearchLogger } from '../../logger.js'
239-
import { getContacts, getProviders, search as unifiedSearch } from '../../services/UnifiedSearchService.js'
241+
import { getContacts, getProviders } from '../../services/UnifiedSearchService.js'
240242
import { useSearchStore } from '../../store/unified-search-external-filters.js'
241243
242244
export default defineComponent({
@@ -299,9 +301,14 @@ export default defineComponent({
299301
const currentLocation = useBrowserLocation()
300302
const searchStore = useSearchStore()
301303
const isSmallMobile = useIsSmallMobile()
304+
305+
const { searchStates, search, loadMore } = useUnifiedSearch()
306+
302307
return {
303308
t,
304-
309+
searchStates,
310+
search,
311+
loadMore,
305312
currentLocation,
306313
externalFilters: searchStore.externalFilters,
307314
isSmallMobile,
@@ -313,7 +320,6 @@ export default defineComponent({
313320
providers: [],
314321
providerActionMenuIsOpen: false,
315322
dateActionMenuIsOpen: false,
316-
providerResultLimit: 5,
317323
dateFilter: {
318324
id: 'date',
319325
type: 'date',
@@ -324,13 +330,10 @@ export default defineComponent({
324330
325331
personFilter: { id: 'person', type: 'person', name: '' },
326332
filteredProviders: [],
327-
searching: false,
328333
searchQuery: '',
329-
lastSearchQuery: '',
330334
placessearchTerm: '',
331335
dateTimeFilter: null,
332336
filters: [],
333-
results: [],
334337
contacts: [],
335338
showDateRangeModal: false,
336339
initialized: false,
@@ -347,6 +350,11 @@ export default defineComponent({
347350
return this.searchQuery.length === 0
348351
},
349352
353+
// True while any category is still fetching.
354+
searching() {
355+
return Object.values(this.searchStates).some((state) => state.status === 'loading')
356+
},
357+
350358
hasNoResults() {
351359
return !this.isEmptySearch && this.results.length === 0
352360
},
@@ -356,14 +364,12 @@ export default defineComponent({
356364
},
357365
358366
showEmptyContentInfo() {
359-
return this.isEmptySearch || this.hasNoResults
367+
// A too-short query never searches, so any results still held are stale for it.
368+
return this.isEmptySearch || this.isSearchQueryTooShort || this.hasNoResults
360369
},
361370
362371
emptyContentMessage() {
363-
if (this.searching && this.hasNoResults) {
364-
return t('core', 'Searching …')
365-
}
366-
372+
// Order matters: a query shrinking below the minimum mid-search shows the prompt, not "searching".
367373
if (this.isSearchQueryTooShort) {
368374
switch (this.minSearchLength) {
369375
case 1:
@@ -373,6 +379,11 @@ export default defineComponent({
373379
}
374380
}
375381
382+
// Also "searching" before providers load: a query is pending but nothing is in flight yet.
383+
if ((this.searching || !this.initialized) && this.hasNoResults) {
384+
return t('core', 'Searching …')
385+
}
386+
376387
return t('core', 'No matching results')
377388
},
378389
@@ -402,6 +413,25 @@ export default defineComponent({
402413
return this.providerActionMenuIsOpen || this.dateActionMenuIsOpen || this.showDateRangeModal
403414
},
404415
416+
results() {
417+
const contentFilterTypes = this.filters
418+
.filter((filter) => filter.type !== 'provider')
419+
.map((filter) => filter.type)
420+
421+
return Object.entries(this.searchStates)
422+
.filter(([, state]) => state.entries.length > 0 && (state.status === 'loaded' || state.status === 'loading'))
423+
.map(([providerId, state]) => {
424+
const provider = this.providers.find((p) => p.id === providerId)
425+
const supportsActiveFilters = this.providerIsCompatibleWithFilters(provider, contentFilterTypes)
426+
return {
427+
...provider,
428+
results: state.entries,
429+
hasMore: state.hasMore,
430+
supportsActiveFilters,
431+
}
432+
})
433+
},
434+
405435
filteredResults() {
406436
const isInFolderAtRoot = (result) => {
407437
if (result.id !== 'in-folder') {
@@ -457,9 +487,15 @@ export default defineComponent({
457487
this.contacts = this.mapContacts(contacts)
458488
unifiedSearchLogger.debug('Search providers and contacts initialized:', { providers: this.providers, contacts: this.contacts })
459489
this.initialized = true
490+
// Run any query find() deferred while providers were still loading.
491+
if (this.open && this.searchQuery) {
492+
this.find(this.searchQuery)
493+
}
460494
})
461495
.catch((error) => {
462496
unifiedSearchLogger.error(error)
497+
// Mark init done even on failure, so the empty state shows "no results" not a stuck "searching".
498+
this.initialized = true
463499
})
464500
}
465501
if (this.searchQuery) {
@@ -604,129 +640,65 @@ export default defineComponent({
604640
this.$emit('update:open', false)
605641
},
606642
607-
find(query: string, providersToSearchOverride = null) {
643+
find(query: string) {
608644
if (this.isSearchQueryTooShort) {
609-
this.results = []
610-
this.searching = false
611645
return
612646
}
613647
614-
// Reset the provider result limit when performing a new search
615-
if (query !== this.lastSearchQuery) {
616-
this.providerResultLimit = 5
648+
// Providers load asynchronously on open. Searching before they arrive dispatches an
649+
// empty list and lands on "no results", so defer; the init handler re-runs once ready.
650+
if (!this.initialized) {
651+
return
617652
}
618-
this.lastSearchQuery = query
619-
620-
this.searching = true
621-
const newResults = []
622-
const providersToSearch = providersToSearchOverride || (this.filteredProviders.length > 0 ? this.filteredProviders : this.providers)
623-
const searchProvider = (provider) => {
624-
const params = {
625-
type: provider.searchFrom ?? provider.id,
626-
query,
627-
cursor: null,
628-
extraQueries: provider.extraParams,
629-
}
630-
631-
// This block of filter checks should be dynamic somehow and should be handled in
632-
// nextcloud/search lib
633-
const contentFilterTypes = this.filters
634-
.filter((f) => f.type !== 'provider')
635-
.map((f) => f.type)
636-
const supportsActiveFilters = contentFilterTypes.length === 0
637-
|| contentFilterTypes.every((type) => this.providerIsCompatibleWithFilters(provider, [type]))
638653
639-
const baseProvider = provider.searchFrom
640-
? this.providers.find((p) => p.id === provider.searchFrom) ?? provider
641-
: provider
654+
// With provider filters, search exactly those (opted in even if external).
655+
// Otherwise search all providers except external ones not switched on.
656+
const searchable = this.filteredProviders.length > 0
657+
? this.filteredProviders
658+
: this.providers.filter((provider) => this.searchExternalResources || !provider.isExternalProvider)
659+
660+
// One param set per category, keyed by provider id, for the controller to
661+
// dispatch. It reuses the same params on loadMore, so filters page correctly.
662+
const params = {}
663+
searchable.forEach((provider) => {
664+
params[provider.id] = this.buildCategoryParams(provider)
665+
})
642666
643-
const activeFilters = this.filters.filter((filter) => {
644-
return filter.type !== 'provider' && this.providerIsCompatibleWithFilters(provider, [filter.type])
645-
})
667+
this.search(query, searchable.map((provider) => provider.id), params)
668+
},
646669
647-
activeFilters.forEach((filter) => {
648-
switch (filter.type) {
649-
case 'date':
650-
if (baseProvider.filters?.since && baseProvider.filters?.until) {
651-
params.since = this.dateFilter.startFrom
652-
params.until = this.dateFilter.endAt
653-
}
654-
break
655-
case 'person':
656-
if (baseProvider.filters?.person) {
657-
params.person = this.personFilter.user
658-
}
659-
break
660-
}
661-
})
670+
/**
671+
* Translate a provider plus the active filters into controller search params.
672+
*
673+
* @param provider the provider to build params for
674+
*/
675+
buildCategoryParams(provider): CategorySearchParams {
676+
const params: CategorySearchParams = {
677+
extraQueries: provider.extraParams,
678+
}
662679
663-
if (this.providerResultLimit > 5) {
664-
params.limit = this.providerResultLimit
665-
unifiedSearchLogger.debug('Limiting search to', params.limit)
666-
}
680+
// `searchFrom` aliases a provider onto another provider's backend. The
681+
// controller dispatches on this `type` override; a plain provider sends none.
682+
if (provider.searchFrom) {
683+
params.type = provider.searchFrom
684+
}
667685
668-
const shouldSkipSearch = !this.searchExternalResources && provider.isExternalProvider
669-
const wasManuallySelected = this.filteredProviders.some((filteredProvider) => filteredProvider.id === provider.id)
670-
// if the provider is an external resource and the user has not manually selected it, skip the search
671-
if (shouldSkipSearch && !wasManuallySelected) {
672-
this.searching = false
686+
// Only attach a filter the provider supports. providerIsCompatibleWithFilters resolves
687+
// the backing provider (via searchFrom) and checks its capabilities, so it's enough here.
688+
this.filters.forEach((filter) => {
689+
if (filter.type === 'provider' || !this.providerIsCompatibleWithFilters(provider, [filter.type])) {
673690
return
674691
}
675-
676-
const request = unifiedSearch(params).request
677-
678-
request().then((response) => {
679-
newResults.push({
680-
...provider,
681-
results: response.data.ocs.data.entries,
682-
limit: params.limit ?? 5,
683-
supportsActiveFilters,
684-
})
685-
686-
unifiedSearchLogger.debug('Unified search results:', { results: this.results, newResults })
687-
688-
this.updateResults(newResults)
689-
this.searching = false
690-
})
691-
}
692-
693-
providersToSearch.forEach(searchProvider)
694-
},
695-
696-
updateResults(newResults) {
697-
let updatedResults = [...this.results]
698-
// If filters are applied, remove any previous results for providers that are not in current filters
699-
if (this.filters.length > 0) {
700-
updatedResults = updatedResults.filter((result) => {
701-
return this.filters.some((filter) => filter.id === result.id)
702-
})
703-
}
704-
// Process the new results
705-
newResults.forEach((newResult) => {
706-
const existingResultIndex = updatedResults.findIndex((result) => result.id === newResult.id)
707-
if (existingResultIndex !== -1) {
708-
if (newResult.results.length === 0) {
709-
// If the new results data has no matches for and existing result, remove the existing result
710-
updatedResults.splice(existingResultIndex, 1)
711-
} else {
712-
// If input triggered a change in existing results, update existing result
713-
updatedResults.splice(existingResultIndex, 1, newResult)
714-
}
715-
} else if (newResult.results.length > 0) {
716-
// Push the new result to the array only if its results array is not empty
717-
updatedResults.push(newResult)
692+
if (filter.type === 'date') {
693+
// The controller/API expect ISO strings, not Date objects.
694+
params.since = this.dateFilter.startFrom?.toISOString()
695+
params.until = this.dateFilter.endAt?.toISOString()
696+
} else if (filter.type === 'person') {
697+
params.person = this.personFilter.user
718698
}
719699
})
720-
const sortedResults = updatedResults.slice(0)
721-
// Order results according to provider preference
722-
sortedResults.sort((a, b) => {
723-
const aProvider = this.providers.find((provider) => provider.id === a.id)
724-
const bProvider = this.providers.find((provider) => provider.id === b.id)
725-
const aOrder = aProvider ? aProvider.order : 0
726-
const bOrder = bProvider ? bProvider.order : 0
727-
return aOrder - bOrder
728-
})
729-
this.results = sortedResults
700+
701+
return params
730702
},
731703
732704
mapContacts(contacts) {
@@ -768,13 +740,14 @@ export default defineComponent({
768740
unifiedSearchLogger.debug('Person filter applied', { person })
769741
},
770742
771-
async loadMoreResultsForProvider(provider) {
772-
this.providerResultLimit += 5
773-
this.find(this.searchQuery, [provider])
743+
loadMoreResultsForProvider(provider) {
744+
// The controller pages from its stored cursor and reuses the original
745+
// per-category params, so we only need to hand it the provider id.
746+
this.loadMore(provider.id)
774747
},
775748
776-
addProviderFilter(providerFilter, loadMoreResultsForProvider = false) {
777-
unifiedSearchLogger.debug('Applying provider filter', { providerFilter, loadMoreResultsForProvider })
749+
addProviderFilter(providerFilter) {
750+
unifiedSearchLogger.debug('Applying provider filter', { providerFilter })
778751
if (!providerFilter.id) {
779752
return
780753
}
@@ -786,7 +759,6 @@ export default defineComponent({
786759
const isProviderFilterApplied = this.filteredProviders.some((provider) => provider.id === providerFilter.id)
787760
providerFilter.callback(!isProviderFilterApplied)
788761
}
789-
this.providerResultLimit = loadMoreResultsForProvider ? this.providerResultLimit : 5
790762
this.providerActionMenuIsOpen = false
791763
// With the possibility for other apps to add new filters
792764
// Resulting in a possible id/provider collision
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
/*!
2+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
3+
* SPDX-License-Identifier: AGPL-3.0-or-later
4+
*/
5+
6+
import type { CategorySearchState } from '../services/UnifiedSearchController.ts'
7+
8+
import { onUnmounted, shallowRef } from 'vue'
9+
import { UnifiedSearchController } from '../services/UnifiedSearchController.ts'
10+
11+
/**
12+
* Reactive adapter over UnifiedSearchController for use in an SFC.
13+
*/
14+
export function useUnifiedSearch() {
15+
const searchStates = shallowRef<Record<string, CategorySearchState>>({})
16+
17+
const controller = new UnifiedSearchController((states) => {
18+
searchStates.value = states
19+
})
20+
21+
onUnmounted(() => {
22+
controller.dispose()
23+
})
24+
25+
return {
26+
searchStates,
27+
search: controller.search.bind(controller),
28+
loadMore: controller.loadMore.bind(controller),
29+
}
30+
}

0 commit comments

Comments
 (0)