Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
228 changes: 100 additions & 128 deletions core/src/components/UnifiedSearch/UnifiedSearchModal.vue
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,7 @@
v-bind="result" />
</ul>
<div class="result-footer">
<NcButton v-if="providerResult.results.length === providerResult.limit" variant="tertiary-no-background" @click="loadMoreResultsForProvider(providerResult)">
<NcButton v-if="providerResult.hasMore" variant="tertiary-no-background" @click="loadMoreResultsForProvider(providerResult)">
{{ t('core', 'Load more results') }}
<template #icon>
<IconDotsHorizontal :size="20" />
Expand Down Expand Up @@ -183,7 +183,7 @@
v-bind="result" />
</ul>
<div class="result-footer">
<NcButton v-if="providerResult.results.length === providerResult.limit" variant="tertiary-no-background" @click="loadMoreResultsForProvider(providerResult)">
<NcButton v-if="providerResult.hasMore" variant="tertiary-no-background" @click="loadMoreResultsForProvider(providerResult)">
{{ t('core', 'Load more results') }}
<template #icon>
<IconDotsHorizontal :size="20" />
Expand All @@ -207,6 +207,7 @@

<script lang="ts">
import type { FocusTrap } from 'focus-trap'
import type { CategorySearchParams } from '../../services/UnifiedSearchController.ts'

import { subscribe } from '@nextcloud/event-bus'
import { loadState } from '@nextcloud/initial-state'
Expand Down Expand Up @@ -235,8 +236,9 @@ import CustomDateRangeModal from './CustomDateRangeModal.vue'
import SearchableList from './SearchableList.vue'
import FilterChip from './SearchFilterChip.vue'
import SearchResult from './SearchResult.vue'
import { useUnifiedSearch } from '../../composables/useUnifiedSearch.ts'
import { unifiedSearchLogger } from '../../logger.js'
import { getContacts, getProviders, search as unifiedSearch } from '../../services/UnifiedSearchService.js'
import { getContacts, getProviders } from '../../services/UnifiedSearchService.js'
import { useSearchStore } from '../../store/unified-search-external-filters.js'

export default defineComponent({
Expand Down Expand Up @@ -299,9 +301,14 @@ export default defineComponent({
const currentLocation = useBrowserLocation()
const searchStore = useSearchStore()
const isSmallMobile = useIsSmallMobile()

const { searchStates, search, loadMore } = useUnifiedSearch()

return {
t,

searchStates,
search,
loadMore,
currentLocation,
externalFilters: searchStore.externalFilters,
isSmallMobile,
Expand All @@ -313,7 +320,6 @@ export default defineComponent({
providers: [],
providerActionMenuIsOpen: false,
dateActionMenuIsOpen: false,
providerResultLimit: 5,
dateFilter: {
id: 'date',
type: 'date',
Expand All @@ -324,13 +330,10 @@ export default defineComponent({

personFilter: { id: 'person', type: 'person', name: '' },
filteredProviders: [],
searching: false,
searchQuery: '',
lastSearchQuery: '',
placessearchTerm: '',
dateTimeFilter: null,
filters: [],
results: [],
contacts: [],
showDateRangeModal: false,
initialized: false,
Expand All @@ -347,6 +350,11 @@ export default defineComponent({
return this.searchQuery.length === 0
},

// True while any category is still fetching.
searching() {
return Object.values(this.searchStates).some((state) => state.status === 'loading')
},

hasNoResults() {
return !this.isEmptySearch && this.results.length === 0
},
Expand All @@ -356,14 +364,12 @@ export default defineComponent({
},

showEmptyContentInfo() {
return this.isEmptySearch || this.hasNoResults
// A too-short query never searches, so any results still held are stale for it.
return this.isEmptySearch || this.isSearchQueryTooShort || this.hasNoResults
},

emptyContentMessage() {
if (this.searching && this.hasNoResults) {
return t('core', 'Searching …')
}

// Order matters: a query shrinking below the minimum mid-search shows the prompt, not "searching".
if (this.isSearchQueryTooShort) {
switch (this.minSearchLength) {
case 1:
Expand All @@ -373,6 +379,11 @@ export default defineComponent({
}
}

// Also "searching" before providers load: a query is pending but nothing is in flight yet.
if ((this.searching || !this.initialized) && this.hasNoResults) {
return t('core', 'Searching …')
}

return t('core', 'No matching results')
},

Expand Down Expand Up @@ -402,6 +413,25 @@ export default defineComponent({
return this.providerActionMenuIsOpen || this.dateActionMenuIsOpen || this.showDateRangeModal
},

results() {
const contentFilterTypes = this.filters
.filter((filter) => filter.type !== 'provider')
.map((filter) => filter.type)

return Object.entries(this.searchStates)
.filter(([, state]) => state.entries.length > 0 && (state.status === 'loaded' || state.status === 'loading'))
.map(([providerId, state]) => {
const provider = this.providers.find((p) => p.id === providerId)
const supportsActiveFilters = this.providerIsCompatibleWithFilters(provider, contentFilterTypes)
return {
...provider,
results: state.entries,
hasMore: state.hasMore,
supportsActiveFilters,
}
})
},

filteredResults() {
const isInFolderAtRoot = (result) => {
if (result.id !== 'in-folder') {
Expand Down Expand Up @@ -457,9 +487,15 @@ export default defineComponent({
this.contacts = this.mapContacts(contacts)
unifiedSearchLogger.debug('Search providers and contacts initialized:', { providers: this.providers, contacts: this.contacts })
this.initialized = true
// Run any query find() deferred while providers were still loading.
if (this.open && this.searchQuery) {
this.find(this.searchQuery)
}
})
.catch((error) => {
unifiedSearchLogger.error(error)
// Mark init done even on failure, so the empty state shows "no results" not a stuck "searching".
this.initialized = true
})
}
if (this.searchQuery) {
Expand Down Expand Up @@ -604,129 +640,65 @@ export default defineComponent({
this.$emit('update:open', false)
},

find(query: string, providersToSearchOverride = null) {
find(query: string) {
if (this.isSearchQueryTooShort) {
this.results = []
this.searching = false
return
}

// Reset the provider result limit when performing a new search
if (query !== this.lastSearchQuery) {
this.providerResultLimit = 5
// Providers load asynchronously on open. Searching before they arrive dispatches an
// empty list and lands on "no results", so defer; the init handler re-runs once ready.
if (!this.initialized) {
return
}
this.lastSearchQuery = query

this.searching = true
const newResults = []
const providersToSearch = providersToSearchOverride || (this.filteredProviders.length > 0 ? this.filteredProviders : this.providers)
const searchProvider = (provider) => {
const params = {
type: provider.searchFrom ?? provider.id,
query,
cursor: null,
extraQueries: provider.extraParams,
}

// This block of filter checks should be dynamic somehow and should be handled in
// nextcloud/search lib
const contentFilterTypes = this.filters
.filter((f) => f.type !== 'provider')
.map((f) => f.type)
const supportsActiveFilters = contentFilterTypes.length === 0
|| contentFilterTypes.every((type) => this.providerIsCompatibleWithFilters(provider, [type]))

const baseProvider = provider.searchFrom
? this.providers.find((p) => p.id === provider.searchFrom) ?? provider
: provider
// With provider filters, search exactly those (opted in even if external).
// Otherwise search all providers except external ones not switched on.
const searchable = this.filteredProviders.length > 0
? this.filteredProviders
: this.providers.filter((provider) => this.searchExternalResources || !provider.isExternalProvider)

// One param set per category, keyed by provider id, for the controller to
// dispatch. It reuses the same params on loadMore, so filters page correctly.
const params = {}
searchable.forEach((provider) => {
params[provider.id] = this.buildCategoryParams(provider)
})

const activeFilters = this.filters.filter((filter) => {
return filter.type !== 'provider' && this.providerIsCompatibleWithFilters(provider, [filter.type])
})
this.search(query, searchable.map((provider) => provider.id), params)
},

activeFilters.forEach((filter) => {
switch (filter.type) {
case 'date':
if (baseProvider.filters?.since && baseProvider.filters?.until) {
params.since = this.dateFilter.startFrom
params.until = this.dateFilter.endAt
}
break
case 'person':
if (baseProvider.filters?.person) {
params.person = this.personFilter.user
}
break
}
})
/**
* Translate a provider plus the active filters into controller search params.
*
* @param provider the provider to build params for
*/
buildCategoryParams(provider): CategorySearchParams {
const params: CategorySearchParams = {
extraQueries: provider.extraParams,
}

if (this.providerResultLimit > 5) {
params.limit = this.providerResultLimit
unifiedSearchLogger.debug('Limiting search to', params.limit)
}
// `searchFrom` aliases a provider onto another provider's backend. The
// controller dispatches on this `type` override; a plain provider sends none.
if (provider.searchFrom) {
params.type = provider.searchFrom
}

const shouldSkipSearch = !this.searchExternalResources && provider.isExternalProvider
const wasManuallySelected = this.filteredProviders.some((filteredProvider) => filteredProvider.id === provider.id)
// if the provider is an external resource and the user has not manually selected it, skip the search
if (shouldSkipSearch && !wasManuallySelected) {
this.searching = false
// Only attach a filter the provider supports. providerIsCompatibleWithFilters resolves
// the backing provider (via searchFrom) and checks its capabilities, so it's enough here.
this.filters.forEach((filter) => {
if (filter.type === 'provider' || !this.providerIsCompatibleWithFilters(provider, [filter.type])) {
return
}

const request = unifiedSearch(params).request

request().then((response) => {
newResults.push({
...provider,
results: response.data.ocs.data.entries,
limit: params.limit ?? 5,
supportsActiveFilters,
})

unifiedSearchLogger.debug('Unified search results:', { results: this.results, newResults })

this.updateResults(newResults)
this.searching = false
})
}

providersToSearch.forEach(searchProvider)
},

updateResults(newResults) {
let updatedResults = [...this.results]
// If filters are applied, remove any previous results for providers that are not in current filters
if (this.filters.length > 0) {
updatedResults = updatedResults.filter((result) => {
return this.filters.some((filter) => filter.id === result.id)
})
}
// Process the new results
newResults.forEach((newResult) => {
const existingResultIndex = updatedResults.findIndex((result) => result.id === newResult.id)
if (existingResultIndex !== -1) {
if (newResult.results.length === 0) {
// If the new results data has no matches for and existing result, remove the existing result
updatedResults.splice(existingResultIndex, 1)
} else {
// If input triggered a change in existing results, update existing result
updatedResults.splice(existingResultIndex, 1, newResult)
}
} else if (newResult.results.length > 0) {
// Push the new result to the array only if its results array is not empty
updatedResults.push(newResult)
if (filter.type === 'date') {
// The controller/API expect ISO strings, not Date objects.
params.since = this.dateFilter.startFrom?.toISOString()
params.until = this.dateFilter.endAt?.toISOString()
} else if (filter.type === 'person') {
params.person = this.personFilter.user
}
})
const sortedResults = updatedResults.slice(0)
// Order results according to provider preference
sortedResults.sort((a, b) => {
const aProvider = this.providers.find((provider) => provider.id === a.id)
const bProvider = this.providers.find((provider) => provider.id === b.id)
const aOrder = aProvider ? aProvider.order : 0
const bOrder = bProvider ? bProvider.order : 0
return aOrder - bOrder
})
this.results = sortedResults

return params
},

mapContacts(contacts) {
Expand Down Expand Up @@ -768,13 +740,14 @@ export default defineComponent({
unifiedSearchLogger.debug('Person filter applied', { person })
},

async loadMoreResultsForProvider(provider) {
this.providerResultLimit += 5
this.find(this.searchQuery, [provider])
loadMoreResultsForProvider(provider) {
// The controller pages from its stored cursor and reuses the original
// per-category params, so we only need to hand it the provider id.
this.loadMore(provider.id)
},

addProviderFilter(providerFilter, loadMoreResultsForProvider = false) {
unifiedSearchLogger.debug('Applying provider filter', { providerFilter, loadMoreResultsForProvider })
addProviderFilter(providerFilter) {
unifiedSearchLogger.debug('Applying provider filter', { providerFilter })
if (!providerFilter.id) {
return
}
Expand All @@ -786,7 +759,6 @@ export default defineComponent({
const isProviderFilterApplied = this.filteredProviders.some((provider) => provider.id === providerFilter.id)
providerFilter.callback(!isProviderFilterApplied)
}
this.providerResultLimit = loadMoreResultsForProvider ? this.providerResultLimit : 5
this.providerActionMenuIsOpen = false
// With the possibility for other apps to add new filters
// Resulting in a possible id/provider collision
Expand Down
30 changes: 30 additions & 0 deletions core/src/composables/useUnifiedSearch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
/*!
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

import type { CategorySearchState } from '../services/UnifiedSearchController.ts'

import { onUnmounted, shallowRef } from 'vue'
import { UnifiedSearchController } from '../services/UnifiedSearchController.ts'

/**
* Reactive adapter over UnifiedSearchController for use in an SFC.
*/
export function useUnifiedSearch() {
const searchStates = shallowRef<Record<string, CategorySearchState>>({})

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

onUnmounted(() => {
controller.dispose()
})

return {
searchStates,
search: controller.search.bind(controller),
loadMore: controller.loadMore.bind(controller),
}
}
Loading
Loading