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,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
0 commit comments