feat(core): create filter-plugin architecture for unified search

This commit introduces the mechanism for apps out of the call,
to add search filters to the unified search "Places" filter selector.

Signed-off-by: fenn-cs <fenn25.fn@gmail.com>
pull/43189/head
fenn-cs 2024-01-29 12:20:05 +07:00
parent 9f1f123990
commit d35a49caba
5 changed files with 144 additions and 10 deletions

@ -65,9 +65,10 @@ export async function getProviders() {
* @param {string} options.until the search
* @param {string} options.limit the search
* @param {string} options.person the search
* @param {object} options.extraQueries additional queries to filter search results
* @return {object} {request: Promise, cancel: Promise}
*/
export function search({ type, query, cursor, since, until, limit, person }) {
export function search({ type, query, cursor, since, until, limit, person, extraQueries = {} }) {
/**
* Generate an axios cancel token
*/
@ -84,6 +85,7 @@ export function search({ type, query, cursor, since, until, limit, person }) {
person,
// Sending which location we're currently at
from: window.location.pathname.replace('/index.php', '') + window.location.search,
...extraQueries,
},
})

@ -0,0 +1,11 @@
import Vue from 'vue';
import Vuex from 'vuex';
import search from './unified-search-external-filters';
Vue.use(Vuex);
export default new Vuex.Store({
modules: {
search,
},
});

@ -0,0 +1,42 @@
/*
* @copyright Copyright (c) 2024 Fon E. Noel NFEBE <opensource@nfebe.com>
*
* @author Fon E. Noel NFEBE <opensource@nfebe.com>
*
* @license GNU AGPL version 3 or any later version
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
const state = {
externalFilters: [],
}
const mutations = {
registerExternalFilter(state, { id, label, callback, icon }) {
state.externalFilters.push({ id, name: label, callback, icon, isPluginFilter: true })
},
}
const actions = {
registerExternalFilter({ commit }, { id, label, callback, icon }) {
commit('registerExternalFilter', { id, label, callback, icon })
},
}
export default {
state,
mutations,
actions,
}

@ -1,7 +1,7 @@
/**
* @copyright Copyright (c) 2020 Fon E. Noel NFEBE <fenn25.fn@gmail.com>
* @copyright Copyright (c) 2024 Fon E. Noel NFEBE <opensource@nfebe.com>
*
* @author Fon E. Noel NFEBE <fenn25.fn@gmail.com>
* @author Fon E. Noel NFEBE <opensource@nfebe.com>
*
* @license AGPL-3.0-or-later
*
@ -26,6 +26,7 @@ import { translate as t, translatePlural as n } from '@nextcloud/l10n'
import Vue from 'vue'
import UnifiedSearch from './views/UnifiedSearch.vue'
import store from '../src/store/index.js'
// eslint-disable-next-line camelcase
__webpack_nonce__ = btoa(getRequestToken())
@ -47,9 +48,24 @@ Vue.mixin({
},
})
// Register the add/register filter action API globally
window.OCA = window.OCA || {}
window.OCA.UnifiedSearch = {
registerFilterAction: ({ id, name, label, callback, icon }) => {
store.dispatch('registerExternalFilter', {
id,
name,
label,
icon,
callback,
})
},
}
export default new Vue({
el: '#unified-search',
// eslint-disable-next-line vue/match-component-file-name
name: 'UnifiedSearchRoot',
store,
render: h => h(UnifiedSearch),
})

@ -18,12 +18,14 @@
:label="t('core', 'Search apps, files, tags, messages') + '...'"
@update:value="debouncedFind" />
<div class="unified-search-modal__filters">
<NcActions :menu-name="t('core', 'Apps and Settings')" :open.sync="providerActionMenuIsOpen">
<NcActions :menu-name="t('core', 'Places')" :open.sync="providerActionMenuIsOpen">
<template #icon>
<ListBox :size="20" />
</template>
<!-- Provider id's may be duplicated since, plugin filters could depend on a provider that is already in the defaults.
provider.id concatenated to provider.name is used to create the item id, if same then, there should be an issue. -->
<NcActionButton v-for="provider in providers"
:key="provider.id"
:key="`${provider.id}-${provider.name.replace(/\s/g, '')}`"
@click="addProviderFilter(provider)">
<template #icon>
<img :src="provider.icon" class="filter-button__icon" alt="">
@ -150,8 +152,9 @@ import SearchableList from '../components/UnifiedSearch/SearchableList.vue'
import SearchResult from '../components/UnifiedSearch/SearchResult.vue'
import debounce from 'debounce'
import { emit } from '@nextcloud/event-bus'
import { emit, subscribe } from '@nextcloud/event-bus'
import { useBrowserLocation } from '@vueuse/core'
import { mapState } from 'vuex'
import { getProviders, search as unifiedSearch, getContacts } from '../services/UnifiedSearchService.js'
export default {
@ -217,6 +220,9 @@ export default {
},
computed: {
...mapState({
externalFilters: state => state.search.externalFilters,
}),
userContacts() {
return this.contacts
},
@ -258,8 +264,13 @@ export default {
},
mounted() {
subscribe('nextcloud:unified-search:add-filter', this.handlePluginFilter)
getProviders().then((providers) => {
this.providers = providers
this.externalFilters.forEach(filter => {
this.providers.push(filter)
})
this.providers = this.groupProvidersByApp(this.providers)
console.debug('Search providers', this.providers)
})
getContacts({ searchTerm: '' }).then((contacts) => {
@ -284,6 +295,7 @@ export default {
type: provider.id,
query,
cursor: null,
extraQueries: provider.extraParams,
}
if (filters.dateFilterIsApplied) {
@ -412,12 +424,27 @@ export default {
},
addProviderFilter(providerFilter, loadMoreResultsForProvider = false) {
if (!providerFilter.id) return
if (providerFilter.isPluginFilter) {
providerFilter.callback()
}
this.providerResultLimit = loadMoreResultsForProvider ? this.providerResultLimit : 5
this.providerActionMenuIsOpen = false
const existingFilter = this.filteredProviders.find(existing => existing.id === providerFilter.id)
if (!existingFilter) {
this.filteredProviders.push({ id: providerFilter.id, name: providerFilter.name, icon: providerFilter.icon, type: 'provider', filters: providerFilter.filters })
// With the possibility for other apps to add new filters
// Resulting in a possible id/provider collision
// If a user tries to apply a filter that seems to already exist, we remove the current one and add the new one.
const existingFilterIndex = this.filteredProviders.findIndex(existing => existing.id === providerFilter.id)
if (existingFilterIndex > -1) {
this.filteredProviders.splice(existingFilterIndex, 1)
this.filters = this.syncProviderFilters(this.filters, this.filteredProviders)
}
this.filteredProviders.push({
id: providerFilter.id,
name: providerFilter.name,
icon: providerFilter.icon,
type: providerFilter.type || 'provider',
filters: providerFilter.filters,
isPluginFilter: providerFilter.isPluginFilter || false,
})
this.filters = this.syncProviderFilters(this.filters, this.filteredProviders)
console.debug('Search filters (newly added)', this.filters)
this.debouncedFind(this.searchQuery)
@ -535,6 +562,42 @@ export default {
this.dateFilter.text = t('core', `Between ${this.dateFilter.startFrom.toLocaleDateString()} and ${this.dateFilter.endAt.toLocaleDateString()}`)
this.updateDateFilter()
},
handlePluginFilter(addFilterEvent) {
for (let i = 0; i < this.filteredProviders.length; i++) {
const provider = this.filteredProviders[i]
if (provider.id === addFilterEvent.id) {
provider.name = addFilterEvent.filterUpdateText
// Filters attached may only make sense with certain providers,
// So, find the provider attached, add apply the extra parameters to those providers only
const compatibleProviderIndex = this.providers.findIndex(provider => provider.id === addFilterEvent.id)
if (compatibleProviderIndex > -1) {
provider.extraParams = addFilterEvent.filterParams
this.filteredProviders[i] = provider
}
break
}
}
console.debug('Search scope set to conversation', addFilterEvent)
this.debouncedFind(this.searchQuery)
},
groupProvidersByApp(filters) {
const groupedByProviderApp = {}
filters.forEach(filter => {
const provider = filter.appId ? filter.appId : 'general'
if (!groupedByProviderApp[provider]) {
groupedByProviderApp[provider] = []
}
groupedByProviderApp[provider].push(filter)
})
const flattenedArray = []
Object.values(groupedByProviderApp).forEach(group => {
flattenedArray.push(...group)
})
return flattenedArray
},
focusInput() {
this.$refs.searchInput.$el.children[0].children[0].focus()
},
@ -557,7 +620,7 @@ export default {
padding-block: 10px 0;
// inline padding on direct children to make sure the scrollbar is on the modal container
> * {
>* {
padding-inline: 20px;
}