diff options
Diffstat (limited to 'core/src/views/UnifiedSearchModal.vue')
-rw-r--r-- | core/src/views/UnifiedSearchModal.vue | 700 |
1 files changed, 0 insertions, 700 deletions
diff --git a/core/src/views/UnifiedSearchModal.vue b/core/src/views/UnifiedSearchModal.vue deleted file mode 100644 index a925ea4bf65..00000000000 --- a/core/src/views/UnifiedSearchModal.vue +++ /dev/null @@ -1,700 +0,0 @@ -<!-- - - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors - - SPDX-License-Identifier: AGPL-3.0-or-later ---> -<template> - <NcModal id="unified-search" - ref="unifiedSearchModal" - :show.sync="internalIsVisible" - :clear-view-delay="0" - @close="closeModal"> - <CustomDateRangeModal :is-open="showDateRangeModal" - class="unified-search__date-range" - @set:custom-date-range="setCustomDateRange" - @update:is-open="showDateRangeModal = $event" /> - <!-- Unified search form --> - <div ref="unifiedSearch" class="unified-search-modal"> - <div class="unified-search-modal__header"> - <h2>{{ t('core', 'Unified search') }}</h2> - <NcInputField ref="searchInput" - data-cy-unified-search-input - :value.sync="searchQuery" - 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 :menu-name="t('core', 'Places')" :open.sync="providerActionMenuIsOpen" data-cy-unified-search-filter="places"> - <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}-${provider.name.replace(/\s/g, '')}`" - @click="addProviderFilter(provider)"> - <template #icon> - <img :src="provider.icon" class="filter-button__icon" alt=""> - </template> - {{ provider.name }} - </NcActionButton> - </NcActions> - <NcActions :menu-name="t('core', 'Date')" :open.sync="dateActionMenuIsOpen" data-cy-unified-search-filter="date"> - <template #icon> - <CalendarRangeIcon :size="20" /> - </template> - <NcActionButton :close-after-click="true" @click="applyQuickDateRange('today')"> - {{ t('core', 'Today') }} - </NcActionButton> - <NcActionButton :close-after-click="true" @click="applyQuickDateRange('7days')"> - {{ t('core', 'Last 7 days') }} - </NcActionButton> - <NcActionButton :close-after-click="true" @click="applyQuickDateRange('30days')"> - {{ t('core', 'Last 30 days') }} - </NcActionButton> - <NcActionButton :close-after-click="true" @click="applyQuickDateRange('thisyear')"> - {{ t('core', 'This year') }} - </NcActionButton> - <NcActionButton :close-after-click="true" @click="applyQuickDateRange('lastyear')"> - {{ t('core', 'Last year') }} - </NcActionButton> - <NcActionButton :close-after-click="true" @click="applyQuickDateRange('custom')"> - {{ t('core', 'Custom date range') }} - </NcActionButton> - </NcActions> - <SearchableList :label-text="t('core', 'Search people')" - :search-list="userContacts" - :empty-content-text="t('core', 'Not found')" - data-cy-unified-search-filter="people" - @search-term-change="debouncedFilterContacts" - @item-selected="applyPersonFilter"> - <template #trigger> - <NcButton> - <template #icon> - <AccountGroup :size="20" /> - </template> - {{ t('core', 'People') }} - </NcButton> - </template> - </SearchableList> - <NcButton v-if="supportFiltering" data-cy-unified-search-filter="current-view" @click="closeModal"> - {{ t('core', 'Filter in current view') }} - <template #icon> - <FilterIcon :size="20" /> - </template> - </NcButton> - </div> - <div class="unified-search-modal__filters-applied"> - <FilterChip v-for="filter in filters" - :key="filter.id" - :text="filter.name ?? filter.text" - :pretext="''" - @delete="removeFilter(filter)"> - <template #icon> - <NcAvatar v-if="filter.type === 'person'" - :user="filter.user" - :size="24" - :disable-menu="true" - :show-user-status="false" - :hide-favorite="false" /> - <CalendarRangeIcon v-else-if="filter.type === 'date'" /> - <img v-else :src="filter.icon" alt=""> - </template> - </FilterChip> - </div> - </div> - <div v-if="noContentInfo.show" class="unified-search-modal__no-content"> - <NcEmptyContent :name="noContentInfo.text"> - <template #icon> - <component :is="noContentInfo.icon" /> - </template> - </NcEmptyContent> - </div> - <div v-else class="unified-search-modal__results"> - <div v-for="providerResult in results" :key="providerResult.id" class="result"> - <div class="result-title"> - <span>{{ providerResult.provider }}</span> - </div> - <ul class="result-items"> - <SearchResult v-for="(result, index) in providerResult.results" :key="index" v-bind="result" /> - </ul> - <div class="result-footer"> - <NcButton type="tertiary-no-background" @click="loadMoreResultsForProvider(providerResult.id)"> - {{ t('core', 'Load more results') }} - <template #icon> - <DotsHorizontalIcon :size="20" /> - </template> - </NcButton> - <NcButton v-if="providerResult.inAppSearch" alignment="end-reverse" type="tertiary-no-background"> - {{ t('core', 'Search in') }} {{ providerResult.provider }} - <template #icon> - <ArrowRight :size="20" /> - </template> - </NcButton> - </div> - </div> - </div> - </div> - </NcModal> -</template> - -<script> -import ArrowRight from 'vue-material-design-icons/ArrowRight.vue' -import AccountGroup from 'vue-material-design-icons/AccountGroup.vue' -import CalendarRangeIcon from 'vue-material-design-icons/CalendarRange.vue' -import CustomDateRangeModal from '../components/UnifiedSearch/CustomDateRangeModal.vue' -import DotsHorizontalIcon from 'vue-material-design-icons/DotsHorizontal.vue' -import FilterIcon from 'vue-material-design-icons/Filter.vue' -import FilterChip from '../components/UnifiedSearch/SearchFilterChip.vue' -import ListBox from 'vue-material-design-icons/ListBox.vue' -import NcActions from '@nextcloud/vue/dist/Components/NcActions.js' -import NcActionButton from '@nextcloud/vue/dist/Components/NcActionButton.js' -import NcAvatar from '@nextcloud/vue/dist/Components/NcAvatar.js' -import NcButton from '@nextcloud/vue/dist/Components/NcButton.js' -import NcEmptyContent from '@nextcloud/vue/dist/Components/NcEmptyContent.js' -import NcInputField from '@nextcloud/vue/dist/Components/NcInputField.js' -import NcModal from '@nextcloud/vue/dist/Components/NcModal.js' -import MagnifyIcon from 'vue-material-design-icons/Magnify.vue' -import SearchableList from '../components/UnifiedSearch/SearchableList.vue' -import SearchResult from '../components/UnifiedSearch/SearchResult.vue' - -import debounce from 'debounce' -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', - components: { - ArrowRight, - AccountGroup, - CalendarRangeIcon, - CustomDateRangeModal, - DotsHorizontalIcon, - FilterIcon, - FilterChip, - ListBox, - NcActions, - NcActionButton, - NcAvatar, - NcButton, - NcEmptyContent, - NcModal, - NcInputField, - MagnifyIcon, - SearchableList, - SearchResult, - }, - props: { - isVisible: { - type: Boolean, - required: true, - }, - }, - setup() { - /** - * Reactive version of window.location - */ - const currentLocation = useBrowserLocation() - const searchStore = useSearchStore() - return { - currentLocation, - externalFilters: searchStore.externalFilters, - } - }, - data() { - return { - providers: [], - providerActionMenuIsOpen: false, - dateActionMenuIsOpen: false, - providerResultLimit: 5, - dateFilter: { id: 'date', type: 'date', text: '', startFrom: null, endAt: null }, - personFilter: { id: 'person', type: 'person', name: '' }, - dateFilterIsApplied: false, - personFilterIsApplied: false, - filteredProviders: [], - searching: false, - searchQuery: '', - placessearchTerm: '', - dateTimeFilter: null, - filters: [], - results: [], - contacts: [], - debouncedFind: debounce(this.find, 300), - debouncedFilterContacts: debounce(this.filterContacts, 300), - showDateRangeModal: false, - internalIsVisible: false, - } - }, - - computed: { - userContacts() { - return this.contacts - }, - noContentInfo() { - const isEmptySearch = this.searchQuery.length === 0 - const hasNoResults = this.searchQuery.length > 0 && this.results.length === 0 - return { - show: isEmptySearch || hasNoResults, - text: this.searching && hasNoResults ? t('core', 'Searching …') : (isEmptySearch ? t('core', 'Start typing to search') : t('core', 'No matching results')), - icon: MagnifyIcon, - } - }, - supportFiltering() { - /* Hard coded apps for the moment this would be improved in coming updates. */ - const providerPaths = ['/settings/users', '/apps/files', '/apps/deck'] - return providerPaths.some((path) => this.currentLocation.pathname?.includes?.(path)) - }, - }, - watch: { - isVisible(value) { - if (value) { - /* - * Before setting the search UI to visible, reset previous search event emissions. - * This allows apps to restore defaults after "Filter in current view" if the user opens the search interface once more. - * Additionally, it's a new search, so it's better to reset all previous events emitted. - */ - emit('nextcloud:unified-search.reset', { query: '' }) - } - this.internalIsVisible = value - }, - internalIsVisible(value) { - this.$emit('update:isVisible', value) - this.$nextTick(() => { - if (value) { - this.focusInput() - } - }) - }, - - }, - 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) => { - this.contacts = this.mapContacts(contacts) - console.debug('Contacts', this.contacts) - }) - }, - methods: { - find(query) { - this.searching = true - if (query.length === 0) { - this.results = [] - this.searching = false - emit('nextcloud:unified-search.reset', { query }) - return - } - emit('nextcloud:unified-search.search', { query }) - const newResults = [] - const providersToSearch = this.filteredProviders.length > 0 ? this.filteredProviders : this.providers - const searchProvider = (provider, filters) => { - const params = { - type: provider.id, - query, - cursor: null, - extraQueries: provider.extraParams, - } - - if (filters.dateFilterIsApplied) { - if (provider.filters.since && provider.filters.until) { - params.since = this.dateFilter.startFrom - params.until = this.dateFilter.endAt - } else { - // Date filter is applied but provider does not support it, no need to search provider - return - } - } - - if (filters.personFilterIsApplied) { - if (provider.filters.person) { - params.person = this.personFilter.user - } else { - // Person filter is applied but provider does not support it, no need to search provider - return - } - } - - if (this.providerResultLimit > 5) { - params.limit = this.providerResultLimit - } - - const request = unifiedSearch(params).request - - request().then((response) => { - newResults.push({ - id: provider.id, - provider: provider.name, - inAppSearch: provider.inAppSearch, - results: response.data.ocs.data.entries, - }) - - console.debug('New results', newResults) - console.debug('Unified search results:', this.results) - - this.updateResults(newResults) - this.searching = false - }) - } - providersToSearch.forEach(provider => { - const dateFilterIsApplied = this.dateFilterIsApplied - const personFilterIsApplied = this.personFilterIsApplied - searchProvider(provider, { dateFilterIsApplied, personFilterIsApplied }) - }) - - }, - 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) - } - }) - 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 - }, - mapContacts(contacts) { - return contacts.map(contact => { - return { - // id: contact.id, - // name: '', - displayName: contact.fullName, - isNoUser: false, - subname: contact.emailAddresses[0] ? contact.emailAddresses[0] : '', - icon: '', - user: contact.id, - isUser: contact.isUser, - } - }) - }, - filterContacts(query) { - getContacts({ searchTerm: query }).then((contacts) => { - this.contacts = this.mapContacts(contacts) - console.debug(`Contacts filtered by ${query}`, this.contacts) - }) - }, - applyPersonFilter(person) { - this.personFilterIsApplied = true - const existingPersonFilter = this.filters.findIndex(filter => filter.id === person.id) - if (existingPersonFilter === -1) { - this.personFilter.id = person.id - this.personFilter.user = person.user - this.personFilter.name = person.displayName - this.filters.push(this.personFilter) - } else { - this.filters[existingPersonFilter].id = person.id - this.filters[existingPersonFilter].user = person.user - this.filters[existingPersonFilter].name = person.displayName - } - - this.debouncedFind(this.searchQuery) - console.debug('Person filter applied', person) - }, - loadMoreResultsForProvider(providerId) { - this.providerResultLimit += 5 - this.filters = this.filters.filter(filter => filter.type !== 'provider') - const provider = this.providers.find(provider => provider.id === providerId) - this.addProviderFilter(provider, true) - }, - addProviderFilter(providerFilter, loadMoreResultsForProvider = false) { - if (!providerFilter.id) return - if (providerFilter.isPluginFilter) { - providerFilter.callback() - } - 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 - // 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) - }, - removeFilter(filter) { - if (filter.type === 'provider') { - for (let i = 0; i < this.filteredProviders.length; i++) { - if (this.filteredProviders[i].id === filter.id) { - this.filteredProviders.splice(i, 1) - break - } - } - this.filters = this.syncProviderFilters(this.filters, this.filteredProviders) - console.debug('Search filters (recently removed)', this.filters) - - } else { - for (let i = 0; i < this.filters.length; i++) { - // Remove date and person filter - if (this.filters[i].id === 'date' || this.filters[i].id === filter.id) { - this.dateFilterIsApplied = false - this.filters.splice(i, 1) - if (filter.type === 'person') { - this.personFilterIsApplied = false - } - break - } - } - } - this.debouncedFind(this.searchQuery) - }, - syncProviderFilters(firstArray, secondArray) { - // Create a copy of the first array to avoid modifying it directly. - const synchronizedArray = firstArray.slice() - // Remove items from the synchronizedArray that are not in the secondArray. - synchronizedArray.forEach((item, index) => { - const itemId = item.id - if (item.type === 'provider') { - if (!secondArray.some(secondItem => secondItem.id === itemId)) { - synchronizedArray.splice(index, 1) - } - } - }) - // Add items to the synchronizedArray that are in the secondArray but not in the firstArray. - secondArray.forEach(secondItem => { - const itemId = secondItem.id - if (secondItem.type === 'provider') { - if (!synchronizedArray.some(item => item.id === itemId)) { - synchronizedArray.push(secondItem) - } - } - }) - - return synchronizedArray - }, - updateDateFilter() { - const currFilterIndex = this.filters.findIndex(filter => filter.id === 'date') - if (currFilterIndex !== -1) { - this.filters[currFilterIndex] = this.dateFilter - } else { - this.filters.push(this.dateFilter) - } - this.dateFilterIsApplied = true - this.debouncedFind(this.searchQuery) - }, - applyQuickDateRange(range) { - this.dateActionMenuIsOpen = false - const today = new Date() - let startDate - let endDate - - switch (range) { - case 'today': - // For 'Today', both start and end are set to today - startDate = new Date(today.getFullYear(), today.getMonth(), today.getDate(), 0, 0, 0, 0) - endDate = new Date(today.getFullYear(), today.getMonth(), today.getDate(), 23, 59, 59, 999) - this.dateFilter.text = t('core', 'Today') - break - case '7days': - // For 'Last 7 days', start date is 7 days ago, end is today - startDate = new Date(today.getFullYear(), today.getMonth(), today.getDate() - 6, 0, 0, 0, 0) - this.dateFilter.text = t('core', 'Last 7 days') - break - case '30days': - // For 'Last 30 days', start date is 30 days ago, end is today - startDate = new Date(today.getFullYear(), today.getMonth(), today.getDate() - 29, 0, 0, 0, 0) - this.dateFilter.text = t('core', 'Last 30 days') - break - case 'thisyear': - // For 'This year', start date is the first day of the year, end is the last day of the year - startDate = new Date(today.getFullYear(), 0, 1, 0, 0, 0, 0) - endDate = new Date(today.getFullYear(), 11, 31, 23, 59, 59, 999) - this.dateFilter.text = t('core', 'This year') - break - case 'lastyear': - // For 'Last year', start date is the first day of the previous year, end is the last day of the previous year - startDate = new Date(today.getFullYear() - 1, 0, 1, 0, 0, 0, 0) - endDate = new Date(today.getFullYear() - 1, 11, 31, 23, 59, 59, 999) - this.dateFilter.text = t('core', 'Last year') - break - case 'custom': - this.showDateRangeModal = true - return - default: - return - } - this.dateFilter.startFrom = startDate - this.dateFilter.endAt = endDate - this.updateDateFilter() - - }, - setCustomDateRange(event) { - console.debug('Custom date range', event) - this.dateFilter.startFrom = event.startFrom - this.dateFilter.endAt = event.endAt - 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() - }, - closeModal() { - this.internalIsVisible = false - this.searchQuery = '' - }, - }, -} -</script> - -<style lang="scss" scoped> -.unified-search-modal { - box-sizing: border-box; - height: 100%; - min-height: 80vh; - - display: flex; - flex-direction: column; - padding-block: 10px 0; - - // inline padding on direct children to make sure the scrollbar is on the modal container - >* { - padding-inline: 20px; - } - - &__header { - padding-block-end: 8px; - } - - &__heading { - font-size: 16px; - font-weight: bolder; - line-height: 2em; - margin-bottom: 0; - } - - &__filters { - display: flex; - flex-wrap: wrap; - gap: 4px; - justify-content: start; - padding-top: 4px; - } - - &__filters-applied { - padding-top: 4px; - display: flex; - flex-wrap: wrap; - } - - &__no-content { - display: flex; - align-items: center; - height: 100%; - } - - &__results { - overflow: hidden scroll; - padding-block: 0 10px; - - .result { - &-title { - span { - color: var(--color-primary-element); - font-weight: bolder; - font-size: 16px; - } - } - - &-footer { - justify-content: space-between; - align-items: center; - display: flex; - } - } - - } -} - -.filter-button__icon { - height: 20px; - width: 20px; - object-fit: contain; - filter: var(--background-invert-if-bright); - padding: 11px; // align with text to fit at least 44px -} - -// Ensure modal is accessible on small devices -@media only screen and (max-height: 400px) { - .unified-search-modal__results { - overflow: unset; - } -} -</style> |