diff options
Diffstat (limited to 'core/src/components/UnifiedSearch')
8 files changed, 1105 insertions, 125 deletions
diff --git a/core/src/components/UnifiedSearch/CustomDateRangeModal.vue b/core/src/components/UnifiedSearch/CustomDateRangeModal.vue index ec592732a8d..d86192d156e 100644 --- a/core/src/components/UnifiedSearch/CustomDateRangeModal.vue +++ b/core/src/components/UnifiedSearch/CustomDateRangeModal.vue @@ -1,3 +1,7 @@ +<!-- + - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> <template> <NcModal v-if="isModalOpen" id="unified-search" @@ -33,9 +37,9 @@ </template> <script> -import NcButton from '@nextcloud/vue/dist/Components/NcButton.js' -import NcDateTimePicker from '@nextcloud/vue/dist/Components/NcDateTimePickerNative.js' -import NcModal from '@nextcloud/vue/dist/Components/NcModal.js' +import NcButton from '@nextcloud/vue/components/NcButton' +import NcDateTimePicker from '@nextcloud/vue/components/NcDateTimePickerNative' +import NcModal from '@nextcloud/vue/components/NcModal' import CalendarRangeIcon from 'vue-material-design-icons/CalendarRange.vue' export default { diff --git a/core/src/components/UnifiedSearch/LegacySearchResult.vue b/core/src/components/UnifiedSearch/LegacySearchResult.vue index 01f48a36709..4592adf08c9 100644 --- a/core/src/components/UnifiedSearch/LegacySearchResult.vue +++ b/core/src/components/UnifiedSearch/LegacySearchResult.vue @@ -1,24 +1,7 @@ - <!-- - - @copyright Copyright (c) 2020 John Molakvoæ <skjnldsv@protonmail.com> - - - - @author John Molakvoæ <skjnldsv@protonmail.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/>. - - - --> +<!-- + - SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> <template> <a :href="resourceUrl || '#'" class="unified-search__result" @@ -59,7 +42,7 @@ </template> <script> -import NcHighlight from '@nextcloud/vue/dist/Components/NcHighlight.js' +import NcHighlight from '@nextcloud/vue/components/NcHighlight' export default { name: 'LegacySearchResult', @@ -236,7 +219,7 @@ $margin: 10px; flex-wrap: wrap; // Set to minimum and gro from it min-width: 0; - padding-left: $margin; + padding-inline-start: $margin; } &-line-one, diff --git a/core/src/components/UnifiedSearch/SearchFilterChip.vue b/core/src/components/UnifiedSearch/SearchFilterChip.vue index 8342e9e256d..e08ddd58a4b 100644 --- a/core/src/components/UnifiedSearch/SearchFilterChip.vue +++ b/core/src/components/UnifiedSearch/SearchFilterChip.vue @@ -1,3 +1,7 @@ +<!-- + - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> <template> <div class="chip"> <span class="icon"> @@ -50,7 +54,7 @@ export default { .icon { display: flex; align-items: center; - padding-right: 5px; + padding-inline-end: 5px; img { width: 20px; diff --git a/core/src/components/UnifiedSearch/SearchResult.vue b/core/src/components/UnifiedSearch/SearchResult.vue index a746a5751b7..4f33fbd54cc 100644 --- a/core/src/components/UnifiedSearch/SearchResult.vue +++ b/core/src/components/UnifiedSearch/SearchResult.vue @@ -1,16 +1,20 @@ +<!-- + - SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> <template> - <NcListItem class="result-items__item" + <NcListItem class="result-item" :name="title" :bold="false" :href="resourceUrl" target="_self"> <template #icon> <div aria-hidden="true" - class="result-items__item-icon" + class="result-item__icon" :class="{ - 'result-items__item-icon--rounded': rounded, - 'result-items__item-icon--no-preview': !isValidIconOrPreviewUrl(thumbnailUrl), - 'result-items__item-icon--with-thumbnail': isValidIconOrPreviewUrl(thumbnailUrl), + 'result-item__icon--rounded': rounded, + 'result-item__icon--no-preview': !isValidIconOrPreviewUrl(thumbnailUrl), + 'result-item__icon--with-thumbnail': isValidIconOrPreviewUrl(thumbnailUrl), [icon]: !isValidIconOrPreviewUrl(icon), }" :style="{ @@ -28,7 +32,7 @@ </template> <script> -import NcListItem from '@nextcloud/vue/dist/Components/NcListItem.js' +import NcListItem from '@nextcloud/vue/components/NcListItem' export default { name: 'SearchResult', @@ -97,73 +101,59 @@ export default { </script> <style lang="scss" scoped> -@use "sass:math"; -$clickable-area: 44px; -$margin: 10px; - -.result-items { - &__item { - - ::v-deep a { - border-radius: 12px; - border: 2px solid transparent; - border-radius: var(--border-radius-large) !important; - - &--focused { - background-color: var(--color-background-hover); - } - - &:active, - &:hover, - &:focus { - background-color: var(--color-background-hover); - border: 2px solid var(--color-border-maxcontrast); - } - - * { - cursor: pointer; - } - - } - - &-icon { - overflow: hidden; - width: $clickable-area; - height: $clickable-area; - border-radius: var(--border-radius); - background-repeat: no-repeat; - background-position: center center; - background-size: 32px; - - &--rounded { - border-radius: math.div($clickable-area, 2); - } +.result-item { + :deep(a) { + border: 2px solid transparent; + border-radius: var(--border-radius-large) !important; + + &:active, + &:hover, + &:focus { + background-color: var(--color-background-hover); + border: 2px solid var(--color-border-maxcontrast); + } - &--no-preview { - background-size: 32px; - } + * { + cursor: pointer; + } + } + + &__icon { + overflow: hidden; + width: var(--default-clickable-area); + height: var(--default-clickable-area); + border-radius: var(--border-radius); + background-repeat: no-repeat; + background-position: center center; + background-size: 32px; + + &--rounded { + border-radius: calc(var(--default-clickable-area) / 2); + } - &--with-thumbnail { - background-size: cover; - } + &--no-preview { + background-size: 32px; + } - &--with-thumbnail:not(&--rounded) { - // compensate for border - max-width: $clickable-area - 2px; - max-height: $clickable-area - 2px; - border: 1px solid var(--color-border); - } + &--with-thumbnail { + background-size: cover; + } - img { - // Make sure to keep ratio - width: 100%; - height: 100%; + &--with-thumbnail:not(#{&}--rounded) { + border: 1px solid var(--color-border); + // compensate for border + max-height: calc(var(--default-clickable-area) - 2px); + max-width: calc(var(--default-clickable-area) - 2px); + } - object-fit: cover; - object-position: center; - } - } + img { + // Make sure to keep ratio + width: 100%; + height: 100%; - } + object-fit: cover; + object-position: center; + } + } } </style> diff --git a/core/src/components/UnifiedSearch/SearchResultPlaceholders.vue b/core/src/components/UnifiedSearch/SearchResultPlaceholders.vue index d2a297a0a37..aec2791d8e4 100644 --- a/core/src/components/UnifiedSearch/SearchResultPlaceholders.vue +++ b/core/src/components/UnifiedSearch/SearchResultPlaceholders.vue @@ -1,3 +1,7 @@ +<!-- + - SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> <template> <ul> <!-- Placeholder animation --> diff --git a/core/src/components/UnifiedSearch/SearchableList.vue b/core/src/components/UnifiedSearch/SearchableList.vue index 33f45d06266..d7abb6ffdbb 100644 --- a/core/src/components/UnifiedSearch/SearchableList.vue +++ b/core/src/components/UnifiedSearch/SearchableList.vue @@ -1,23 +1,6 @@ <!-- - - @copyright 2023 Marco Ambrosini <marcoambrosini@proton.me> - - - - @author Marco Ambrosini <marcoambrosini@proton.me> - - - - @license AGPL-3.0-or-later - - - - 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/>. - - + - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later --> <template> @@ -34,7 +17,7 @@ :show-trailing-button="searchTerm !== ''" @update:value="searchTermChanged" @trailing-button-click="clearSearch"> - <Magnify :size="20" /> + <IconMagnify :size="20" /> </NcTextField> <ul v-if="filteredList.length > 0" class="searchable-list__list"> <li v-for="element in filteredList" @@ -46,7 +29,11 @@ :wide="true" @click="itemSelected(element)"> <template #icon> - <NcAvatar :user="element.user" :show-user-status="false" :hide-favorite="false" /> + <NcAvatar v-if="element.isUser" :user="element.user" :show-user-status="false" /> + <NcAvatar v-else + :is-no-user="true" + :display-name="element.displayName" + :show-user-status="false" /> </template> {{ element.displayName }} </NcButton> @@ -55,7 +42,7 @@ <div v-else class="searchable-list__empty-content"> <NcEmptyContent :name="emptyContentText"> <template #icon> - <AlertCircleOutline /> + <IconAlertCircleOutline /> </template> </NcEmptyContent> </div> @@ -64,22 +51,26 @@ </template> <script> -import { NcPopover, NcTextField, NcAvatar, NcEmptyContent, NcButton } from '@nextcloud/vue' +import NcAvatar from '@nextcloud/vue/components/NcAvatar' +import NcButton from '@nextcloud/vue/components/NcButton' +import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent' +import NcPopover from '@nextcloud/vue/components/NcPopover' +import NcTextField from '@nextcloud/vue/components/NcTextField' -import AlertCircleOutline from 'vue-material-design-icons/AlertCircleOutline.vue' -import Magnify from 'vue-material-design-icons/Magnify.vue' +import IconAlertCircleOutline from 'vue-material-design-icons/AlertCircleOutline.vue' +import IconMagnify from 'vue-material-design-icons/Magnify.vue' export default { name: 'SearchableList', components: { - NcPopover, - NcTextField, - Magnify, - AlertCircleOutline, + IconMagnify, + IconAlertCircleOutline, NcAvatar, - NcEmptyContent, NcButton, + NcEmptyContent, + NcPopover, + NcTextField, }, props: { diff --git a/core/src/components/UnifiedSearch/UnifiedSearchLocalSearchBar.vue b/core/src/components/UnifiedSearch/UnifiedSearchLocalSearchBar.vue new file mode 100644 index 00000000000..171eada8a06 --- /dev/null +++ b/core/src/components/UnifiedSearch/UnifiedSearchLocalSearchBar.vue @@ -0,0 +1,166 @@ +<!-- + - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> +<template> + <Transition> + <div v-if="open" + class="local-unified-search animated-width" + :class="{ 'local-unified-search--open': open }"> + <!-- We can not use labels as it breaks the header layout so only aria-label and placeholder --> + <NcInputField ref="searchInput" + class="local-unified-search__input animated-width" + :aria-label="t('core', 'Search in current app')" + :placeholder="t('core', 'Search in current app')" + show-trailing-button + :trailing-button-label="t('core', 'Clear search')" + :value="query" + @update:value="$emit('update:query', $event)" + @trailing-button-click="clearAndCloseSearch"> + <template #trailing-button-icon> + <NcIconSvgWrapper :path="mdiClose" /> + </template> + </NcInputField> + + <NcButton ref="searchGlobalButton" + class="local-unified-search__global-search" + :aria-label="t('core', 'Search everywhere')" + :title="t('core', 'Search everywhere')" + type="tertiary-no-background" + @click="$emit('global-search')"> + <template v-if="!isMobile" #default> + {{ t('core', 'Search everywhere') }} + </template> + <template #icon> + <NcIconSvgWrapper :path="mdiCloudSearchOutline" /> + </template> + </NcButton> + </div> + </Transition> +</template> + +<script lang="ts" setup> +import type { ComponentPublicInstance } from 'vue' +import { mdiCloudSearchOutline, mdiClose } from '@mdi/js' +import { translate as t } from '@nextcloud/l10n' +import { useIsMobile } from '@nextcloud/vue/composables/useIsMobile' +import { useElementSize } from '@vueuse/core' +import { computed, ref, watchEffect } from 'vue' + +import NcButton from '@nextcloud/vue/components/NcButton' +import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper' +import NcInputField from '@nextcloud/vue/components/NcInputField' + +const props = defineProps<{ + query: string, + open: boolean +}>() + +const emit = defineEmits<{ + (e: 'update:open', open: boolean): void + (e: 'update:query', query: string): void + (e: 'global-search'): void +}>() + +// Hacky type until the library provides real Types +type FocusableComponent = ComponentPublicInstance<object, object, object, Record<string, never>, { focus: () => void }> +/** The input field component */ +const searchInput = ref<FocusableComponent>() +/** When the search bar is opened we focus the input */ +watchEffect(() => { + if (props.open && searchInput.value) { + searchInput.value.focus() + } +}) + +/** Current window size is below the "mobile" breakpoint (currently 1024px) */ +const isMobile = useIsMobile() + +const searchGlobalButton = ref<ComponentPublicInstance>() +/** Width of the search global button, used to resize the input field */ +const { width: searchGlobalButtonWidth } = useElementSize(searchGlobalButton) +const searchGlobalButtonCSSWidth = computed(() => searchGlobalButtonWidth.value ? `${searchGlobalButtonWidth.value}px` : 'var(--default-clickable-area)') + +/** + * Clear the search query and close the search bar + */ +function clearAndCloseSearch() { + emit('update:query', '') + emit('update:open', false) +} +</script> + +<style scoped lang="scss"> +.local-unified-search { + --local-search-width: min(calc(250px + v-bind('searchGlobalButtonCSSWidth')), 95vw); + box-sizing: border-box; + position: relative; + height: var(--header-height); + width: var(--local-search-width); + display: flex; + align-items: center; + // Ensure it overlays the other entries + z-index: 10; + // add some padding for the focus visible outline + padding-inline: var(--border-width-input-focused); + // hide the overflow - needed for the transition + overflow: hidden; + // Ensure the position is fixed also during "position: absolut" (transition) + inset-inline-end: 0; + + #{&} &__global-search { + position: absolute; + inset-inline-end: var(--default-clickable-area); + } + + #{&} &__input { + box-sizing: border-box; + // override some nextcloud-vue styles + margin: 0; + width: var(--local-search-width); + + // Fixup the spacing so we can fit in the "search globally" button + // this can break at any time the component library changes + :deep(input) { + // search global width + close button width + padding-inline-end: calc(v-bind('searchGlobalButtonCSSWidth') + var(--default-clickable-area)); + } + } +} + +.animated-width { + transition: width var(--animation-quick) linear; +} + +// Make the position absolute during the transition +// this is needed to "hide" the button behind it +.v-leave-active { + position: absolute !important; +} + +.v-enter, +.v-leave-to { + &.local-unified-search { + // Start with only the overlay button + --local-search-width: var(--clickable-area-large); + } +} + +@media screen and (max-width: 500px) { + .local-unified-search.local-unified-search--open { + // 100% but still show the menu toggle on the very right + --local-search-width: 100vw; + padding-inline: var(--default-grid-baseline); + } + + // when open we need to position it absolute to allow overlay the full bar + :global(.unified-search-menu:has(.local-unified-search--open)) { + position: absolute !important; + inset-inline: 0; + } + // Hide all other entries, especially the user menu as it might leak pixels + :global(.header-end:has(.local-unified-search--open) > :not(.unified-search-menu)) { + display: none; + } +} +</style> diff --git a/core/src/components/UnifiedSearch/UnifiedSearchModal.vue b/core/src/components/UnifiedSearch/UnifiedSearchModal.vue new file mode 100644 index 00000000000..e59058bc0f0 --- /dev/null +++ b/core/src/components/UnifiedSearch/UnifiedSearchModal.vue @@ -0,0 +1,838 @@ +<!-- + - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> +<template> + <NcDialog id="unified-search" + ref="unifiedSearchModal" + content-classes="unified-search-modal__content" + dialog-classes="unified-search-modal" + :name="t('core', 'Unified search')" + :open="open" + size="normal" + @update:open="onUpdateOpen"> + <!-- Modal for picking custom time range --> + <CustomDateRangeModal :is-open="showDateRangeModal" + class="unified-search__date-range" + @set:custom-date-range="setCustomDateRange" + @update:is-open="showDateRangeModal = $event" /> + + <!-- Unified search form --> + <div class="unified-search-modal__header"> + <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> + <IconListBox :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, '')}`" + :disabled="provider.disabled" + @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> + <IconCalendarRange :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> + <IconAccountGroup :size="20" /> + </template> + {{ t('core', 'People') }} + </NcButton> + </template> + </SearchableList> + <NcButton v-if="localSearch" data-cy-unified-search-filter="current-view" @click="searchLocally"> + {{ t('core', 'Filter in current view') }} + <template #icon> + <IconFilter :size="20" /> + </template> + </NcButton> + <NcCheckboxRadioSwitch v-if="hasExternalResources" + v-model="searchExternalResources" + type="switch" + class="unified-search-modal__search-external-resources" + :class="{'unified-search-modal__search-external-resources--aligned': localSearch}"> + {{ t('core', 'Search connected services') }} + </NcCheckboxRadioSwitch> + </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" /> + <IconCalendarRange v-else-if="filter.type === 'date'" /> + <img v-else :src="filter.icon" alt=""> + </template> + </FilterChip> + </div> + </div> + + <div v-if="showEmptyContentInfo" class="unified-search-modal__no-content"> + <NcEmptyContent :name="emptyContentMessage"> + <template #icon> + <IconMagnify :size="64" /> + </template> + </NcEmptyContent> + </div> + + <div v-else class="unified-search-modal__results"> + <h3 class="hidden-visually"> + {{ t('core', 'Results') }} + </h3> + <div v-for="providerResult in results" :key="providerResult.id" class="result"> + <h4 :id="`unified-search-result-${providerResult.id}`" class="result-title"> + {{ providerResult.name }} + </h4> + <ul class="result-items" :aria-labelledby="`unified-search-result-${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" type="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" type="tertiary-no-background"> + {{ t('core', 'Search in') }} {{ providerResult.name }} + <template #icon> + <IconArrowRight :size="20" /> + </template> + </NcButton> + </div> + </div> + </div> + </NcDialog> +</template> + +<script lang="ts"> +import { subscribe } from '@nextcloud/event-bus' +import { translate as t } from '@nextcloud/l10n' +import { useBrowserLocation } from '@vueuse/core' +import { defineComponent } from 'vue' +import { getProviders, search as unifiedSearch, getContacts } from '../../services/UnifiedSearchService.js' +import { useSearchStore } from '../../store/unified-search-external-filters.js' + +import debounce from 'debounce' +import { unifiedSearchLogger } from '../../logger' + +import IconArrowRight from 'vue-material-design-icons/ArrowRight.vue' +import IconAccountGroup from 'vue-material-design-icons/AccountGroupOutline.vue' +import IconCalendarRange from 'vue-material-design-icons/CalendarRangeOutline.vue' +import IconDotsHorizontal from 'vue-material-design-icons/DotsHorizontal.vue' +import IconFilter from 'vue-material-design-icons/Filter.vue' +import IconListBox from 'vue-material-design-icons/ListBox.vue' +import IconMagnify from 'vue-material-design-icons/Magnify.vue' +import NcActions from '@nextcloud/vue/components/NcActions' +import NcActionButton from '@nextcloud/vue/components/NcActionButton' +import NcAvatar from '@nextcloud/vue/components/NcAvatar' +import NcButton from '@nextcloud/vue/components/NcButton' +import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent' +import NcInputField from '@nextcloud/vue/components/NcInputField' +import NcDialog from '@nextcloud/vue/components/NcDialog' +import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch' + +import CustomDateRangeModal from './CustomDateRangeModal.vue' +import FilterChip from './SearchFilterChip.vue' +import SearchableList from './SearchableList.vue' +import SearchResult from './SearchResult.vue' + +export default defineComponent({ + name: 'UnifiedSearchModal', + components: { + IconArrowRight, + IconAccountGroup, + IconCalendarRange, + IconDotsHorizontal, + IconFilter, + IconListBox, + IconMagnify, + + CustomDateRangeModal, + FilterChip, + NcActions, + NcActionButton, + NcAvatar, + NcButton, + NcEmptyContent, + NcDialog, + NcInputField, + NcCheckboxRadioSwitch, + SearchableList, + SearchResult, + }, + + props: { + /** + * Open state of the modal + */ + open: { + type: Boolean, + required: true, + }, + + /** + * The current query string + */ + query: { + type: String, + default: '', + }, + + /** + * If the current page / app supports local search + */ + localSearch: { + type: Boolean, + default: false, + }, + }, + + emits: ['update:open', 'update:query'], + + setup() { + /** + * Reactive version of window.location + */ + const currentLocation = useBrowserLocation() + const searchStore = useSearchStore() + return { + t, + + 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: '' }, + filteredProviders: [], + searching: false, + searchQuery: '', + lastSearchQuery: '', + placessearchTerm: '', + dateTimeFilter: null, + filters: [], + results: [], + contacts: [], + showDateRangeModal: false, + internalIsVisible: this.open, + initialized: false, + searchExternalResources: false, + } + }, + + computed: { + isEmptySearch() { + return this.searchQuery.length === 0 + }, + + hasNoResults() { + return !this.isEmptySearch && this.results.length === 0 + }, + + showEmptyContentInfo() { + return this.isEmptySearch || this.hasNoResults + }, + + emptyContentMessage() { + if (this.searching && this.hasNoResults) { + return t('core', 'Searching …') + } + if (this.isEmptySearch) { + return t('core', 'Start typing to search') + } + return t('core', 'No matching results') + }, + + userContacts() { + return this.contacts + }, + + debouncedFind() { + return debounce(this.find, 300) + }, + + debouncedFilterContacts() { + return debounce(this.filterContacts, 300) + }, + + hasExternalResources() { + return this.providers.some(provider => provider.isExternalProvider) + }, + }, + + watch: { + open() { + // Load results when opened with already filled query + if (this.open) { + this.focusInput() + if (!this.initialized) { + Promise.all([getProviders(), getContacts({ searchTerm: '' })]) + .then(([providers, contacts]) => { + this.providers = this.groupProvidersByApp([...providers, ...this.externalFilters]) + this.contacts = this.mapContacts(contacts) + unifiedSearchLogger.debug('Search providers and contacts initialized:', { providers: this.providers, contacts: this.contacts }) + this.initialized = true + }) + .catch((error) => { + unifiedSearchLogger.error(error) + }) + } + if (this.searchQuery) { + this.find(this.searchQuery) + } + } + }, + + query: { + immediate: true, + handler() { + this.searchQuery = this.query + }, + }, + + searchQuery: { + handler() { + this.$emit('update:query', this.searchQuery) + }, + }, + + searchExternalResources() { + if (this.searchQuery) { + this.find(this.searchQuery) + } + }, + }, + + mounted() { + subscribe('nextcloud:unified-search:add-filter', this.handlePluginFilter) + }, + methods: { + /** + * On close the modal is closed and the query is reset + * @param open The new open state + */ + onUpdateOpen(open: boolean) { + if (!open) { + this.$emit('update:open', false) + this.$emit('update:query', '') + } + }, + + /** + * Only close the modal but keep the query for in-app search + */ + searchLocally() { + this.$emit('update:query', this.searchQuery) + this.$emit('update:open', false) + }, + focusInput() { + this.$nextTick(() => { + this.$refs.searchInput?.focus() + }) + }, + find(query: string, providersToSearchOverride = null) { + if (query.length === 0) { + this.results = [] + this.searching = false + return + } + + // Reset the provider result limit when performing a new search + if (query !== this.lastSearchQuery) { + this.providerResultLimit = 5 + } + 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 activeFilters = this.filters.filter(filter => { + return filter.type !== 'provider' && this.providerIsCompatibleWithFilters(provider, [filter.type]) + }) + + activeFilters.forEach(filter => { + switch (filter.type) { + case 'date': + if (provider.filters?.since && provider.filters?.until) { + params.since = this.dateFilter.startFrom + params.until = this.dateFilter.endAt + } + break + case 'person': + if (provider.filters?.person) { + params.person = this.personFilter.user + } + break + } + }) + + if (this.providerResultLimit > 5) { + params.limit = this.providerResultLimit + unifiedSearchLogger.debug('Limiting search to', params.limit) + } + + 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 + return + } + + const request = unifiedSearch(params).request + + request().then((response) => { + newResults.push({ + ...provider, + results: response.data.ocs.data.entries, + limit: params.limit ?? 5, + }) + + 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) + } + }) + 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) + unifiedSearchLogger.debug(`Contacts filtered by ${query}`, { contacts: this.contacts }) + }) + }, + applyPersonFilter(person) { + + 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.providers.forEach(async (provider, index) => { + this.providers[index].disabled = !(await this.providerIsCompatibleWithFilters(provider, ['person'])) + }) + + this.debouncedFind(this.searchQuery) + unifiedSearchLogger.debug('Person filter applied', { person }) + }, + async loadMoreResultsForProvider(provider) { + this.providerResultLimit += 5 + this.find(this.searchQuery, [provider]) + }, + addProviderFilter(providerFilter, loadMoreResultsForProvider = false) { + unifiedSearchLogger.debug('Applying provider filter', { providerFilter, loadMoreResultsForProvider }) + if (!providerFilter.id) return + if (providerFilter.isPluginFilter) { + // There is no way to know what should go into the callback currently + // Here we are passing isProviderFilterApplied (boolean) which is a flag sent to the plugin + // This is sent to the plugin so that depending on whether the filter is applied or not, the plugin can decide what to do + // TODO : In nextcloud/search, this should be a proper interface that the plugin can implement + 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 + // 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({ + ...providerFilter, + type: providerFilter.type || 'provider', + isPluginFilter: providerFilter.isPluginFilter || false, + }) + this.filters = this.syncProviderFilters(this.filters, this.filteredProviders) + unifiedSearchLogger.debug('Search filters (newly added)', { filters: 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) + unifiedSearchLogger.debug('Search filters (recently removed)', { filters: this.filters }) + + } else { + // Remove non provider filters such as date and person filters + for (let i = 0; i < this.filters.length; i++) { + if (this.filters[i].id === filter.id) { + this.filters.splice(i, 1) + this.enableAllProviders() + 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.providers.forEach(async (provider, index) => { + this.providers[index].disabled = !(await this.providerIsCompatibleWithFilters(provider, ['since', 'until'])) + }) + 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) { + unifiedSearchLogger.debug('Custom date range', { 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) { + unifiedSearchLogger.debug('Handling plugin filter', { 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 + }, + async providerIsCompatibleWithFilters(provider, filterIds) { + return filterIds.every(filterId => provider.filters?.[filterId] !== undefined) + }, + async enableAllProviders() { + this.providers.forEach(async (_, index) => { + this.providers[index].disabled = false + }) + }, + }, +}) +</script> + +<style lang="scss" scoped> +:deep(.unified-search-modal .unified-search-modal__content) { + --dialog-height: min(80vh, 800px); + box-sizing: border-box; + height: var(--dialog-height); + max-height: var(--dialog-height); + min-height: var(--dialog-height); + + display: flex; + flex-direction: column; + // No padding to prevent scrollbar misplacement + padding-inline: 0; +} + +.unified-search-modal { + &__header { + // Add background to prevent leaking scrolled content (because of sticky position) + background-color: var(--color-main-background); + // Fix padding to have the input centered + padding-inline-end: 12px; + // Some padding to make elements scrolled under sticky position look nicer + padding-block-end: 12px; + // Make it sticky with the input margin for the label + position: sticky; + top: 6px; + } + + &__filters { + display: flex; + flex-wrap: wrap; + gap: 4px; + justify-content: start; + padding-top: 4px; + } + + &__search-external-resources { + :deep(span.checkbox-content) { + padding-top: 0; + padding-bottom: 0; + } + + :deep(.checkbox-content__icon) { + margin: auto !important; + } + + &--aligned { + margin-inline-start: auto; + } + } + + &__filters-applied { + padding-top: 4px; + display: flex; + flex-wrap: wrap; + } + + &__no-content { + display: flex; + align-items: center; + margin-top: 0.5em; + height: 70%; + } + + &__results { + overflow: hidden scroll; + // Adjust padding to match container but keep the scrollbar on the very end + padding-inline: 0 12px; + padding-block: 0 12px; + + .result { + &-title { + color: var(--color-primary-element); + font-size: 16px; + margin-block: 8px 4px; + } + + &-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> |