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 " />
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 " />
207207
208208<script lang="ts">
209209import type { FocusTrap } from ' focus-trap'
210+ import type { CategorySearchParams } from ' ../../services/UnifiedSearchController.ts'
210211
211212import { subscribe } from ' @nextcloud/event-bus'
212213import { loadState } from ' @nextcloud/initial-state'
@@ -235,8 +236,9 @@ import CustomDateRangeModal from './CustomDateRangeModal.vue'
235236import SearchableList from ' ./SearchableList.vue'
236237import FilterChip from ' ./SearchFilterChip.vue'
237238import SearchResult from ' ./SearchResult.vue'
239+ import { useUnifiedSearch } from ' ../../composables/useUnifiedSearch.ts'
238240import { unifiedSearchLogger } from ' ../../logger.js'
239- import { getContacts , getProviders , search as unifiedSearch } from ' ../../services/UnifiedSearchService.js'
241+ import { getContacts , getProviders } from ' ../../services/UnifiedSearchService.js'
240242import { useSearchStore } from ' ../../store/unified-search-external-filters.js'
241243
242244export 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
0 commit comments