Skip to content

Commit e85573a

Browse files
committed
feat(core): drive the unified search modal from the search controller
Signed-off-by: Peter Ringelmann <peter.ringelmann@nextcloud.com>
1 parent 41168e1 commit e85573a

5 files changed

Lines changed: 423 additions & 134 deletions

File tree

core/src/components/UnifiedSearch/UnifiedSearchModal.vue

Lines changed: 109 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,12 @@ export default defineComponent({
347350
return this.searchQuery.length === 0
348351
},
349352
353+
// Coarse in-flight flag: true while any category is still fetching. Derived
354+
// from the controller snapshot rather than tracked by hand, so it can't drift.
355+
searching() {
356+
return Object.values(this.searchStates).some((state) => state.status === 'loading')
357+
},
358+
350359
hasNoResults() {
351360
return !this.isEmptySearch && this.results.length === 0
352361
},
@@ -356,14 +365,14 @@ export default defineComponent({
356365
},
357366
358367
showEmptyContentInfo() {
359-
return this.isEmptySearch || this.hasNoResults
368+
// A too-short query never triggers a search, so any snapshot still held by
369+
// the controller is stale for the current query: show the prompt, not results.
370+
return this.isEmptySearch || this.isSearchQueryTooShort || this.hasNoResults
360371
},
361372
362373
emptyContentMessage() {
363-
if (this.searching && this.hasNoResults) {
364-
return t('core', 'Searching …')
365-
}
366-
374+
// Check "too short" before "searching": a search left in flight from a longer
375+
// query must not surface the searching state once the query drops below the minimum.
367376
if (this.isSearchQueryTooShort) {
368377
switch (this.minSearchLength) {
369378
case 1:
@@ -373,6 +382,12 @@ export default defineComponent({
373382
}
374383
}
375384
385+
// `!initialized` covers the window before providers have loaded: a query is
386+
// pending but no category is in flight yet, so treat it as searching too.
387+
if ((this.searching || !this.initialized) && this.hasNoResults) {
388+
return t('core', 'Searching …')
389+
}
390+
376391
return t('core', 'No matching results')
377392
},
378393
@@ -402,6 +417,25 @@ export default defineComponent({
402417
return this.providerActionMenuIsOpen || this.dateActionMenuIsOpen || this.showDateRangeModal
403418
},
404419
420+
results() {
421+
const contentFilterTypes = this.filters
422+
.filter((filter) => filter.type !== 'provider')
423+
.map((filter) => filter.type)
424+
425+
return Object.entries(this.searchStates)
426+
.filter(([, state]) => state.entries.length > 0 && (state.status === 'loaded' || state.status === 'loading'))
427+
.map(([providerId, state]) => {
428+
const provider = this.providers.find((p) => p.id === providerId)
429+
const supportsActiveFilters = this.providerIsCompatibleWithFilters(provider, contentFilterTypes)
430+
return {
431+
...provider,
432+
results: state.entries,
433+
hasMore: state.hasMore,
434+
supportsActiveFilters,
435+
}
436+
})
437+
},
438+
405439
filteredResults() {
406440
const isInFolderAtRoot = (result) => {
407441
if (result.id !== 'in-folder') {
@@ -457,9 +491,17 @@ export default defineComponent({
457491
this.contacts = this.mapContacts(contacts)
458492
unifiedSearchLogger.debug('Search providers and contacts initialized:', { providers: this.providers, contacts: this.contacts })
459493
this.initialized = true
494+
// A query typed while providers were still loading was deferred by
495+
// find(); run it now that we have a provider list to search.
496+
if (this.open && this.searchQuery) {
497+
this.find(this.searchQuery)
498+
}
460499
})
461500
.catch((error) => {
462501
unifiedSearchLogger.error(error)
502+
// Mark init done even on failure so the empty state settles on
503+
// "no results" instead of an indefinite "searching" message.
504+
this.initialized = true
463505
})
464506
}
465507
if (this.searchQuery) {
@@ -604,129 +646,68 @@ export default defineComponent({
604646
this.$emit('update:open', false)
605647
},
606648
607-
find(query: string, providersToSearchOverride = null) {
649+
find(query: string) {
608650
if (this.isSearchQueryTooShort) {
609-
this.results = []
610-
this.searching = false
611651
return
612652
}
613653
614-
// Reset the provider result limit when performing a new search
615-
if (query !== this.lastSearchQuery) {
616-
this.providerResultLimit = 5
654+
// Providers are fetched asynchronously when the modal opens. Searching before
655+
// they arrive would dispatch against an empty provider list and settle instantly
656+
// into "no results"; defer instead. The init handler re-runs this query once ready.
657+
if (!this.initialized) {
658+
return
617659
}
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]))
638660
639-
const baseProvider = provider.searchFrom
640-
? this.providers.find((p) => p.id === provider.searchFrom) ?? provider
641-
: provider
661+
// Active provider filters narrow the search to exactly those providers
662+
// (a selected provider is opted-in even if it's external). With no filters,
663+
// search every provider except external ones the user hasn't switched on.
664+
const searchable = this.filteredProviders.length > 0
665+
? this.filteredProviders
666+
: this.providers.filter((provider) => this.searchExternalResources || !provider.isExternalProvider)
667+
668+
// One param set per category, keyed by provider id, for the controller to
669+
// dispatch. It reuses the same params on loadMore, so filters page correctly.
670+
const params = {}
671+
searchable.forEach((provider) => {
672+
params[provider.id] = this.buildCategoryParams(provider)
673+
})
642674
643-
const activeFilters = this.filters.filter((filter) => {
644-
return filter.type !== 'provider' && this.providerIsCompatibleWithFilters(provider, [filter.type])
645-
})
675+
this.search(query, searchable.map((provider) => provider.id), params)
676+
},
646677
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-
})
678+
/**
679+
* Translate a provider plus the active filters into controller search params.
680+
*
681+
* @param provider the provider to build params for
682+
*/
683+
buildCategoryParams(provider): CategorySearchParams {
684+
const params: CategorySearchParams = {
685+
extraQueries: provider.extraParams,
686+
}
662687
663-
if (this.providerResultLimit > 5) {
664-
params.limit = this.providerResultLimit
665-
unifiedSearchLogger.debug('Limiting search to', params.limit)
666-
}
688+
// `searchFrom` aliases a provider onto another provider's backend. The
689+
// controller dispatches on this `type` override; a plain provider sends none.
690+
if (provider.searchFrom) {
691+
params.type = provider.searchFrom
692+
}
667693
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
694+
// Only attach a filter the provider actually supports. providerIsCompatibleWithFilters
695+
// resolves the backing provider (via searchFrom) and checks its declared capabilities,
696+
// so passing that guard is enough; no need to re-check the capability here.
697+
this.filters.forEach((filter) => {
698+
if (filter.type === 'provider' || !this.providerIsCompatibleWithFilters(provider, [filter.type])) {
673699
return
674700
}
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)
701+
if (filter.type === 'date') {
702+
// The controller/API expect ISO strings, not Date objects.
703+
params.since = this.dateFilter.startFrom?.toISOString()
704+
params.until = this.dateFilter.endAt?.toISOString()
705+
} else if (filter.type === 'person') {
706+
params.person = this.personFilter.user
718707
}
719708
})
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
709+
710+
return params
730711
},
731712
732713
mapContacts(contacts) {
@@ -768,13 +749,14 @@ export default defineComponent({
768749
unifiedSearchLogger.debug('Person filter applied', { person })
769750
},
770751
771-
async loadMoreResultsForProvider(provider) {
772-
this.providerResultLimit += 5
773-
this.find(this.searchQuery, [provider])
752+
loadMoreResultsForProvider(provider) {
753+
// The controller pages from its stored cursor and reuses the original
754+
// per-category params, so we only need to hand it the provider id.
755+
this.loadMore(provider.id)
774756
},
775757
776-
addProviderFilter(providerFilter, loadMoreResultsForProvider = false) {
777-
unifiedSearchLogger.debug('Applying provider filter', { providerFilter, loadMoreResultsForProvider })
758+
addProviderFilter(providerFilter) {
759+
unifiedSearchLogger.debug('Applying provider filter', { providerFilter })
778760
if (!providerFilter.id) {
779761
return
780762
}
@@ -786,7 +768,6 @@ export default defineComponent({
786768
const isProviderFilterApplied = this.filteredProviders.some((provider) => provider.id === providerFilter.id)
787769
providerFilter.callback(!isProviderFilterApplied)
788770
}
789-
this.providerResultLimit = loadMoreResultsForProvider ? this.providerResultLimit : 5
790771
this.providerActionMenuIsOpen = false
791772
// With the possibility for other apps to add new filters
792773
// Resulting in a possible id/provider collision

core/src/composables/useUnifiedSearch.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,16 @@
33
* SPDX-License-Identifier: AGPL-3.0-or-later
44
*/
55

6+
import type { CategorySearchState } from '../services/UnifiedSearchController.ts'
7+
68
import { onUnmounted, shallowRef } from 'vue'
79
import { UnifiedSearchController } from '../services/UnifiedSearchController.ts'
810

911
/**
1012
* Reactive adapter over UnifiedSearchController for use in an SFC.
1113
*/
1214
export function useUnifiedSearch() {
13-
const searchStates = shallowRef({})
15+
const searchStates = shallowRef<Record<string, CategorySearchState>>({})
1416

1517
const controller = new UnifiedSearchController((states) => {
1618
searchStates.value = states

0 commit comments

Comments
 (0)