fix(unified-search): Separate filtered and unfiltered results

Show results from providers that don't support active content filters
(date/person) in a separate "Additional results" section with a note
explaining that some filters may have been ignored.

Changes:
- Add computed properties to separate filtered/unfiltered results
- Track filter compatibility using baseProvider for searchFrom providers
- Deduplicate results by resourceUrl across sections
- Skip in-folder results when at root to avoid duplicating Files results
- Fix providerIsCompatibleWithFilters to check correct filter properties
- Add styling for the unfiltered results section

Signed-off-by: nfebe <fenn25.fn@gmail.com>
pull/56620/head
nfebe 2025-12-01 21:27:09 +07:00 committed by F. E Noel Nfebe
parent 61ebc6e251
commit f035ff3d3a
1 changed files with 125 additions and 8 deletions

@ -24,13 +24,13 @@
<div class="unified-search-modal__header">
<NcInputField
ref="searchInput"
v-model:value="searchQuery"
v-model="searchQuery"
data-cy-unified-search-input
type="text"
:label="t('core', 'Search apps, files, tags, messages') + '...'"
@update:value="debouncedFind" />
<div class="unified-search-modal__filters" data-cy-unified-search-filters>
<NcActions v-model:open="providerActionMenuIsOpen" :menu-name="t('core', 'Places')" data-cy-unified-search-filter="places">
<NcActions :open.sync="providerActionMenuIsOpen" :menu-name="t('core', 'Places')" data-cy-unified-search-filter="places">
<template #icon>
<IconListBox :size="20" />
</template>
@ -47,7 +47,7 @@
{{ provider.name }}
</NcActionButton>
</NcActions>
<NcActions v-model:open="dateActionMenuIsOpen" :menu-name="t('core', 'Date')" data-cy-unified-search-filter="date">
<NcActions :open.sync="dateActionMenuIsOpen" :menu-name="t('core', 'Date')" data-cy-unified-search-filter="date">
<template #icon>
<IconCalendarRange :size="20" />
</template>
@ -135,7 +135,8 @@
<h3 class="hidden-visually">
{{ t('core', 'Results') }}
</h3>
<div v-for="providerResult in results" :key="providerResult.id" class="result">
<!-- Filtered results section -->
<div v-for="providerResult in filteredResults" :key="providerResult.id" class="result">
<h4 :id="`unified-search-result-${providerResult.id}`" class="result-title">
{{ providerResult.name }}
</h4>
@ -160,6 +161,37 @@
</NcButton>
</div>
</div>
<!-- Unfiltered results section -->
<template v-if="unfilteredResults.length > 0">
<div class="unified-search-modal__unfiltered-header">
<span class="unified-search-modal__unfiltered-label">{{ t('core', 'Partial matches') }}</span>
</div>
<div v-for="providerResult in unfilteredResults" :key="`unfiltered-${providerResult.id}`" class="result result--unfiltered">
<h4 :id="`unified-search-result-unfiltered-${providerResult.id}`" class="result-title">
{{ providerResult.name }}
</h4>
<ul class="result-items" :aria-labelledby="`unified-search-result-unfiltered-${providerResult.id}`">
<SearchResult
v-for="(result, index) in providerResult.results"
:key="index"
v-bind="result" />
</ul>
<div class="result-footer">
<NcButton v-if="providerResult.results.length === providerResult.limit" variant="tertiary-no-background" @click="loadMoreResultsForProvider(providerResult)">
{{ t('core', 'Load more results') }}
<template #icon>
<IconDotsHorizontal :size="20" />
</template>
</NcButton>
<NcButton v-if="providerResult.inAppSearch" alignment="end-reverse" variant="tertiary-no-background">
{{ t('core', 'Search in') }} {{ providerResult.name }}
<template #icon>
<IconArrowRight :size="20" />
</template>
</NcButton>
</div>
</div>
</template>
</div>
</NcDialog>
</template>
@ -342,6 +374,50 @@ export default defineComponent({
hasExternalResources() {
return this.providers.some((provider) => provider.isExternalProvider)
},
hasContentFilters() {
return this.filters.some((filter) => filter.type === 'date' || filter.type === 'person')
},
filteredResults() {
const isInFolderAtRoot = (result) => {
if (result.id !== 'in-folder') {
return false
}
const path = result.extraParams?.path
return !path || path === '/' || path === ''
}
if (!this.hasContentFilters) {
return this.results.filter((result) => !isInFolderAtRoot(result))
}
return this.results.filter((result) => result.supportsActiveFilters === true && !isInFolderAtRoot(result))
},
filteredResultUrls() {
const urls = new Set()
this.filteredResults.forEach((provider) => {
provider.results.forEach((entry) => {
if (entry.resourceUrl) {
urls.add(entry.resourceUrl)
}
})
})
return urls
},
unfilteredResults() {
if (!this.hasContentFilters) {
return []
}
return this.results
.filter((result) => result.supportsActiveFilters === false)
.map((provider) => ({
...provider,
results: provider.results.filter((entry) => !this.filteredResultUrls.has(entry.resourceUrl)),
}))
.filter((provider) => provider.results.length > 0)
},
},
watch: {
@ -444,6 +520,16 @@ export default defineComponent({
// 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
const activeFilters = this.filters.filter((filter) => {
return filter.type !== 'provider' && this.providerIsCompatibleWithFilters(provider, [filter.type])
})
@ -451,13 +537,13 @@ export default defineComponent({
activeFilters.forEach((filter) => {
switch (filter.type) {
case 'date':
if (provider.filters?.since && provider.filters?.until) {
if (baseProvider.filters?.since && baseProvider.filters?.until) {
params.since = this.dateFilter.startFrom
params.until = this.dateFilter.endAt
}
break
case 'person':
if (provider.filters?.person) {
if (baseProvider.filters?.person) {
params.person = this.personFilter.user
}
break
@ -484,6 +570,7 @@ export default defineComponent({
...provider,
results: response.data.ocs.data.entries,
limit: params.limit ?? 5,
supportsActiveFilters,
})
unifiedSearchLogger.debug('Unified search results:', { results: this.results, newResults })
@ -766,8 +853,20 @@ export default defineComponent({
return flattenedArray
},
async providerIsCompatibleWithFilters(provider, filterIds) {
return filterIds.every((filterId) => provider.filters?.[filterId] !== undefined)
providerIsCompatibleWithFilters(provider, filterIds) {
const baseProvider = provider.searchFrom
? this.providers.find((p) => p.id === provider.searchFrom) ?? provider
: provider
return filterIds.every((filterId) => {
switch (filterId) {
case 'date':
return baseProvider.filters?.since !== undefined && baseProvider.filters?.until !== undefined
case 'person':
return baseProvider.filters?.person !== undefined
default:
return baseProvider.filters?.[filterId] !== undefined
}
})
},
async enableAllProviders() {
@ -859,9 +958,27 @@ export default defineComponent({
align-items: center;
display: flex;
}
&--unfiltered {
opacity: 0.7;
}
}
}
&__unfiltered-header {
display: flex;
flex-direction: column;
gap: 2px;
margin-block: 16px 8px;
padding-block: 12px 0;
border-top: 1px solid var(--color-border);
}
&__unfiltered-label {
font-weight: bold;
color: var(--color-text-maxcontrast);
}
}
.filter-button__icon {