Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat: Create filter-plugin architecture for unified search #43189

Merged
merged 3 commits into from
Mar 6, 2024
Merged
Show file tree
Hide file tree
Changes from 2 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
4 changes: 3 additions & 1 deletion core/src/services/UnifiedSearchService.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand All @@ -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,
},
})

Expand Down
36 changes: 36 additions & 0 deletions core/src/store/unified-search-external-filters.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
/*
* @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/>.
*
*/
import { defineStore } from 'pinia'

export const useSearchStore = defineStore({
id: 'search',

state: () => ({
externalFilters: [],
}),

actions: {
registerExternalFilter({ id, appId, label, callback, icon }) {
emoral435 marked this conversation as resolved.
Show resolved Hide resolved
this.externalFilters.push({ id, appId, name: label, callback, icon, isPluginFilter: true })
nfebe marked this conversation as resolved.
Show resolved Hide resolved
},
},
})
19 changes: 17 additions & 2 deletions core/src/unified-search.js
Original file line number Diff line number Diff line change
@@ -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
*
Expand All @@ -23,9 +23,11 @@
import { getLoggerBuilder } from '@nextcloud/logger'
import { getRequestToken } from '@nextcloud/auth'
import { translate as t, translatePlural as n } from '@nextcloud/l10n'
import { createPinia, PiniaVuePlugin } from 'pinia'
import Vue from 'vue'

import UnifiedSearch from './views/UnifiedSearch.vue'
import { useSearchStore } from '../src/store/unified-search-external-filters.js'
nfebe marked this conversation as resolved.
Show resolved Hide resolved

// eslint-disable-next-line camelcase
__webpack_nonce__ = btoa(getRequestToken())
Expand All @@ -47,8 +49,21 @@ Vue.mixin({
},
})

// Register the add/register filter action API globally
window.OCA = window.OCA || {}
window.OCA.UnifiedSearch = {
registerFilterAction: ({ id, appId, label, callback, icon }) => {
const searchStore = useSearchStore()
searchStore.registerExternalFilter({ id, appId, label, callback, icon })
},
}

Vue.use(PiniaVuePlugin)
const pinia = createPinia()

export default new Vue({
el: '#unified-search',
pinia,
// eslint-disable-next-line vue/match-component-file-name
name: 'UnifiedSearchRoot',
render: h => h(UnifiedSearch),
Expand Down
75 changes: 68 additions & 7 deletions core/src/views/UnifiedSearchModal.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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="">
Expand Down Expand Up @@ -150,9 +152,10 @@ 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 { getProviders, search as unifiedSearch, getContacts } from '../services/UnifiedSearchService.js'
import { useSearchStore } from '../store/unified-search-external-filters.js'

export default {
name: 'UnifiedSearchModal',
Expand Down Expand Up @@ -187,8 +190,10 @@ export default {
* Reactive version of window.location
*/
const currentLocation = useBrowserLocation()
const searchStore = useSearchStore()
return {
currentLocation,
externalFilters: searchStore.externalFilters,
}
},
data() {
Expand Down Expand Up @@ -258,8 +263,13 @@ export default {

},
mounted() {
subscribe('nextcloud:unified-search:add-filter', this.handlePluginFilter)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's a no from me, using an event to actually handle this registrations are not a stable way to do so.

I'd suggest using a proper registration like the event bus or the file actions is doing.

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) => {
Expand All @@ -284,6 +294,7 @@ export default {
type: provider.id,
query,
cursor: null,
extraQueries: provider.extraParams,
}

if (filters.dateFilterIsApplied) {
Expand Down Expand Up @@ -412,12 +423,27 @@ export default {
},
addProviderFilter(providerFilter, loadMoreResultsForProvider = false) {
if (!providerFilter.id) return
if (providerFilter.isPluginFilter) {
providerFilter.callback()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is a callback? What does it do?
A callback that is called without any parameters is odd to me.

}
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)
nfebe marked this conversation as resolved.
Show resolved Hide resolved
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)
Expand Down Expand Up @@ -535,6 +561,41 @@ 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
}
}
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()
},
Expand All @@ -557,7 +618,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;
}

Expand Down
Loading