diff options
author | Marco Ambrosini <marcoambrosini@icloud.com> | 2023-11-20 15:45:45 +0900 |
---|---|---|
committer | Ferdinand Thiessen <opensource@fthiessen.de> | 2024-06-27 12:13:14 +0200 |
commit | 6ee965f0d92f3538ae5b44249d929fe741a371c0 (patch) | |
tree | cad7006d17922618f07a33ff1d920a8843135957 /core/src | |
parent | 2482688fa09d7dc477ad0d23049d55b62d5b3d6c (diff) | |
download | nextcloud-server-6ee965f0d92f3538ae5b44249d929fe741a371c0.tar.gz nextcloud-server-6ee965f0d92f3538ae5b44249d929fe741a371c0.zip |
feat: Add in-app search
Co-authored-by: Ferdinand Thiessen <opensource@fthiessen.de>
Signed-off-by: Marco Ambrosini <marcoambrosini@icloud.com>
Diffstat (limited to 'core/src')
-rw-r--r-- | core/src/components/UnifiedSearch/UnifiedSearchLocalSearchBar.vue | 131 | ||||
-rw-r--r-- | core/src/components/UnifiedSearch/UnifiedSearchModal.vue (renamed from core/src/views/UnifiedSearchModal.vue) | 498 | ||||
-rw-r--r-- | core/src/eventbus.d.ts | 14 | ||||
-rw-r--r-- | core/src/logger.js | 5 | ||||
-rw-r--r-- | core/src/views/UnifiedSearch.vue | 114 |
5 files changed, 532 insertions, 230 deletions
diff --git a/core/src/components/UnifiedSearch/UnifiedSearchLocalSearchBar.vue b/core/src/components/UnifiedSearch/UnifiedSearchLocalSearchBar.vue new file mode 100644 index 00000000000..ed9a9951297 --- /dev/null +++ b/core/src/components/UnifiedSearch/UnifiedSearchLocalSearchBar.vue @@ -0,0 +1,131 @@ +<!-- + - 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 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 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 #icon> + <NcIconSvgWrapper :path="mdiEarth" /> + </template> + </NcButton> + </div> + </Transition> +</template> + +<script lang="ts" setup> +import { mdiEarth, mdiClose } from '@mdi/js' +import { translate as t } from '@nextcloud/l10n' + +import NcButton from '@nextcloud/vue/dist/Components/NcButton.js' +import NcIconSvgWrapper from '@nextcloud/vue/dist/Components/NcIconSvgWrapper.js' +import NcInputField from '@nextcloud/vue/dist/Components/NcInputField.js' + +defineProps<{ + query: string, + open: boolean +}>() + +const emit = defineEmits<{ + (e: 'update:open', open: boolean): void + (e: 'update:query', query: string): void + (e: 'global-search'): void +}>() + +function clearAndCloseSearch() { + emit('update:query', '') + emit('update:open', false) +} +</script> + +<style scoped lang="scss"> +.local-unified-search { + --width: min(250px, 95vw); + position: relative; + height: var(--header-height); + width: var(--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: 0; + } + + #{&} &__input { + // override some nextcloud-vue styles + margin: 0; + width: var(--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) { + padding-inline-end: calc(2 * var(--default-clickable-area) + var(--default-grid-baseline)); + } + :deep(button) { + inset-inline-end: var(--default-clickable-area); + } + } +} + +.animated-width { + transition: width var(--animation-quick) linear; +} + +// Make the position absolut during the transition +// this is needed to "hide" the button begind it +.v-leave-active { + position: absolute !important; +} + +.v-enter, +.v-leave-to { + &.local-unified-search { + // Start with only those two buttons + a little bit of the input element + --width: calc(3 * var(--default-clickable-area)); + } +} + +@media screen and (max-width: 500px) { + .local-unified-search.local-unified-search--open { + // 100% but still show the menu toggle on the very right + --width: calc(100vw - (var(--clickable-area-large) + 5 * var(--default-grid-baseline))); + } + + // when open we need to position it absolut to allow overlay the full bar + :global(.unified-search-menu:has(.local-unified-search--open)) { + position: absolute !important; + // Keep showing the menu toggle + inset-inline-end: calc(var(--clickable-area-large) + 4 * var(--default-grid-baseline)); + } +} +</style> diff --git a/core/src/views/UnifiedSearchModal.vue b/core/src/components/UnifiedSearch/UnifiedSearchModal.vue index a925ea4bf65..75009ee9be5 100644 --- a/core/src/views/UnifiedSearchModal.vue +++ b/core/src/components/UnifiedSearch/UnifiedSearchModal.vue @@ -3,194 +3,229 @@ - SPDX-License-Identifier: AGPL-3.0-or-later --> <template> - <NcModal id="unified-search" + <NcDialog id="unified-search" ref="unifiedSearchModal" - :show.sync="internalIsVisible" - :clear-view-delay="0" - @close="closeModal"> + content-classes="unified-search-modal__content" + dialog-classes="unified-search-modal" + :name="t('core', 'Unified search')" + :open="open" + @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 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"> + <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, '')}`" + @click="addProviderFilter(provider)"> <template #icon> - <ListBox :size="20" /> + <img :src="provider.icon" class="filter-button__icon" alt=""> </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)"> + {{ 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> - <img :src="provider.icon" class="filter-button__icon" alt=""> + <IconAccountGroup :size="20" /> </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') }} + {{ 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> + </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.provider }} + </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 type="tertiary-no-background" @click="loadMoreResultsForProvider(providerResult.id)"> + {{ t('core', 'Load more results') }} <template #icon> - <FilterIcon :size="20" /> + <IconDotsHorizontal :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)"> + <NcButton v-if="providerResult.inAppSearch" alignment="end-reverse" type="tertiary-no-background"> + {{ t('core', 'Search in') }} {{ providerResult.provider }} <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=""> + <IconArrowRight :size="20" /> </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> + </NcButton> </div> </div> </div> - </NcModal> + </NcDialog> </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' +<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/AccountGroup.vue' +import IconCalendarRange from 'vue-material-design-icons/CalendarRange.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/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 NcDialog from '@nextcloud/vue/dist/Components/NcDialog.js' -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' +import CustomDateRangeModal from './CustomDateRangeModal.vue' +import FilterChip from './SearchFilterChip.vue' +import SearchableList from './SearchableList.vue' +import SearchResult from './SearchResult.vue' -export default { +export default defineComponent({ name: 'UnifiedSearchModal', components: { - ArrowRight, - AccountGroup, - CalendarRangeIcon, + IconArrowRight, + IconAccountGroup, + IconCalendarRange, + IconDotsHorizontal, + IconFilter, + IconListBox, + IconMagnify, + CustomDateRangeModal, - DotsHorizontalIcon, - FilterIcon, FilterChip, - ListBox, NcActions, NcActionButton, NcAvatar, NcButton, NcEmptyContent, - NcModal, + NcDialog, NcInputField, - MagnifyIcon, SearchableList, SearchResult, }, + props: { - isVisible: { + /** + * 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 @@ -198,10 +233,13 @@ export default { const currentLocation = useBrowserLocation() const searchStore = useSearchStore() return { + t, + currentLocation, externalFilters: searchStore.externalFilters, } }, + data() { return { providers: [], @@ -220,54 +258,63 @@ export default { filters: [], results: [], contacts: [], - debouncedFind: debounce(this.find, 300), - debouncedFilterContacts: debounce(this.filterContacts, 300), showDateRangeModal: false, - internalIsVisible: false, + internalIsVisible: this.open, } }, 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 }, - 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, - } + + debouncedFind() { + return debounce(this.find, 300) }, - 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)) + + debouncedFilterContacts() { + return debounce(this.filterContacts, 300) }, }, + 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: '' }) + open() { + // Load results when opened with already filled query + if (this.open && this.searchQuery) { + this.find(this.searchQuery) } - this.internalIsVisible = value - }, - internalIsVisible(value) { - this.$emit('update:isVisible', value) - this.$nextTick(() => { - if (value) { - this.focusInput() - } - }) }, + query: { + immediate: true, + handler() { + this.searchQuery = this.query.trim() + } + }, }, + mounted() { subscribe('nextcloud:unified-search:add-filter', this.handlePluginFilter) getProviders().then((providers) => { @@ -276,23 +323,41 @@ export default { this.providers.push(filter) }) this.providers = this.groupProvidersByApp(this.providers) - console.debug('Search providers', this.providers) + unifiedSearchLogger.debug('Search providers', { providers: this.providers }) }) getContacts({ searchTerm: '' }).then((contacts) => { this.contacts = this.mapContacts(contacts) - console.debug('Contacts', this.contacts) + unifiedSearchLogger.debug('Contacts', { contacts: this.contacts }) }) }, methods: { - find(query) { - this.searching = true + /** + * 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) + }, + + find(query: string) { if (query.length === 0) { this.results = [] this.searching = false - emit('nextcloud:unified-search.reset', { query }) return } - emit('nextcloud:unified-search.search', { query }) + + this.searching = true const newResults = [] const providersToSearch = this.filteredProviders.length > 0 ? this.filteredProviders : this.providers const searchProvider = (provider, filters) => { @@ -304,7 +369,7 @@ export default { } if (filters.dateFilterIsApplied) { - if (provider.filters.since && provider.filters.until) { + if (provider.filters?.since && provider.filters?.until) { params.since = this.dateFilter.startFrom params.until = this.dateFilter.endAt } else { @@ -314,7 +379,7 @@ export default { } if (filters.personFilterIsApplied) { - if (provider.filters.person) { + 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 @@ -336,8 +401,7 @@ export default { results: response.data.ocs.data.entries, }) - console.debug('New results', newResults) - console.debug('Unified search results:', this.results) + unifiedSearchLogger.debug('Unified search results:', { results: this.results, newResults }) this.updateResults(newResults) this.searching = false @@ -402,7 +466,7 @@ export default { filterContacts(query) { getContacts({ searchTerm: query }).then((contacts) => { this.contacts = this.mapContacts(contacts) - console.debug(`Contacts filtered by ${query}`, this.contacts) + unifiedSearchLogger.debug(`Contacts filtered by ${query}`, { contacts: this.contacts }) }) }, applyPersonFilter(person) { @@ -420,7 +484,7 @@ export default { } this.debouncedFind(this.searchQuery) - console.debug('Person filter applied', person) + unifiedSearchLogger.debug('Person filter applied', { person }) }, loadMoreResultsForProvider(providerId) { this.providerResultLimit += 5 @@ -452,7 +516,7 @@ export default { isPluginFilter: providerFilter.isPluginFilter || false, }) this.filters = this.syncProviderFilters(this.filters, this.filteredProviders) - console.debug('Search filters (newly added)', this.filters) + unifiedSearchLogger.debug('Search filters (newly added)', { filters: this.filters }) this.debouncedFind(this.searchQuery) }, removeFilter(filter) { @@ -464,7 +528,7 @@ export default { } } this.filters = this.syncProviderFilters(this.filters, this.filteredProviders) - console.debug('Search filters (recently removed)', this.filters) + unifiedSearchLogger.debug('Search filters (recently removed)', { filters: this.filters }) } else { for (let i = 0; i < this.filters.length; i++) { @@ -562,7 +626,7 @@ export default { }, setCustomDateRange(event) { - console.debug('Custom date range', 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()}`) @@ -603,41 +667,35 @@ export default { 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 { +:deep(.unified-search-modal .unified-search-modal__content) { + --dialog-height: min(80vh, 800px); box-sizing: border-box; - height: 100%; - min-height: 80vh; + height: var(--dialog-height); + max-height: var(--dialog-height); + min-height: var(--dialog-height); 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; - } + // No padding to prevent scrollbar misplacement + padding-inline: 0; +} +.unified-search-modal { &__header { - padding-block-end: 8px; - } - - &__heading { - font-size: 16px; - font-weight: bolder; - line-height: 2em; - margin-bottom: 0; + // 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 { @@ -662,15 +720,15 @@ export default { &__results { overflow: hidden scroll; - padding-block: 0 10px; + // Adjust padding to match container but keep the scrollbar on the very end + padding-inline: 0 12px; + padding-block: 0 12px; .result { &-title { - span { - color: var(--color-primary-element); - font-weight: bolder; - font-size: 16px; - } + color: var(--color-primary-element); + font-size: 16px; + margin-block: 8px 4px; } &-footer { diff --git a/core/src/eventbus.d.ts b/core/src/eventbus.d.ts new file mode 100644 index 00000000000..4fac9bc7841 --- /dev/null +++ b/core/src/eventbus.d.ts @@ -0,0 +1,14 @@ +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +declare module '@nextcloud/event-bus' { + export interface NextcloudEvents { + // mapping of 'event name' => 'event type' + 'nextcloud:unified-search:reset': undefined + 'nextcloud:unified-search:search': { query: string } + } +} + +export {} diff --git a/core/src/logger.js b/core/src/logger.js index d0f7a7682cd..78d51a798e4 100644 --- a/core/src/logger.js +++ b/core/src/logger.js @@ -19,3 +19,8 @@ const getLogger = user => { } export default getLogger(getCurrentUser()) + +export const unifiedSearchLogger = getLoggerBuilder() + .setApp('unified-search') + .detectUser() + .build() diff --git a/core/src/views/UnifiedSearch.vue b/core/src/views/UnifiedSearch.vue index 2adf818b181..4aca60c3a80 100644 --- a/core/src/views/UnifiedSearch.vue +++ b/core/src/views/UnifiedSearch.vue @@ -4,7 +4,8 @@ --> <template> <div class="header-menu unified-search-menu"> - <NcButton class="header-menu__trigger" + <NcButton v-show="!showLocalSearch" + class="header-menu__trigger" :aria-label="t('core', 'Unified search')" type="tertiary-no-background" @click="toggleUnifiedSearch"> @@ -12,39 +13,132 @@ <Magnify class="header-menu__trigger-icon" :size="20" /> </template> </NcButton> - <UnifiedSearchModal :is-visible="showUnifiedSearch" @update:isVisible="handleModalVisibilityChange" /> + <UnifiedSearchLocalSearchBar v-if="supportsLocalSearch" + :open.sync="showLocalSearch" + :query.sync="queryText" + @global-search="openModal" /> + <UnifiedSearchModal :local-search="supportsLocalSearch" + :query.sync="queryText" + :open.sync="showUnifiedSearch" /> </div> </template> -<script> +<script lang="ts"> +import { emit, subscribe } from '@nextcloud/event-bus' +import { translate } from '@nextcloud/l10n' +import { useBrowserLocation } from '@vueuse/core' +import { defineComponent } from 'vue' + import NcButton from '@nextcloud/vue/dist/Components/NcButton.js' import Magnify from 'vue-material-design-icons/Magnify.vue' -import UnifiedSearchModal from './UnifiedSearchModal.vue' +import UnifiedSearchModal from '../components/UnifiedSearch/UnifiedSearchModal.vue' +import UnifiedSearchLocalSearchBar from '../components/UnifiedSearch/UnifiedSearchLocalSearchBar.vue' + +import debounce from 'debounce' +import logger from '../logger' -export default { +export default defineComponent({ name: 'UnifiedSearch', + components: { NcButton, Magnify, UnifiedSearchModal, + UnifiedSearchLocalSearchBar, }, + + setup() { + const currentLocation = useBrowserLocation() + + return { + currentLocation, + t: translate, + } + }, + data() { return { + /** The current search query */ + queryText: '', + /** Open state of the modal */ showUnifiedSearch: false, + /** Open state of the local search bar */ + showLocalSearch: false, } }, + + computed: { + /** + * Debounce emitting the search query by 250ms + */ + debouncedQueryUpdate() { + return debounce(this.emitUpdatedQuery, 250) + }, + + /** + * Current page (app) supports local in-app search + */ + supportsLocalSearch() { + // TODO: Make this an API + const providerPaths = ['/settings/users', '/apps/files', '/apps/deck'] + return providerPaths.some((path) => this.currentLocation.pathname?.includes?.(path)) + }, + }, + + watch: { + /** + * Emit the updated query as eventbus events + * (This is debounced) + */ + queryText() { + this.debouncedQueryUpdate() + }, + }, + mounted() { - console.debug('Unified search initialized!') + logger.debug('Unified search initialized!') + + // Deprecated events to be removed + subscribe('nextcloud:unified-search:reset', () => { + emit('nextcloud:unified-search.reset', { query: '' }) + }) + subscribe('nextcloud:unified-search:search', ({ query }) => { + emit('nextcloud:unified-search.search', { query }) + }) }, + methods: { + /** + * Toggle the local search if available - otherwise open the unified search modal + */ toggleUnifiedSearch() { - this.showUnifiedSearch = !this.showUnifiedSearch + if (this.supportsLocalSearch) { + this.showLocalSearch = true + } else { + this.openModal() + } + }, + + /** + * Open the unified search modal + */ + openModal() { + this.showUnifiedSearch = true + this.showLocalSearch = false }, - handleModalVisibilityChange(newVisibilityVal) { - this.showUnifiedSearch = newVisibilityVal + + /** + * Emit the updated search query as eventbus events + */ + emitUpdatedQuery() { + if (this.queryText === '') { + emit('nextcloud:unified-search:reset') + } else { + emit('nextcloud:unified-search:search', { query: this.queryText }) + } }, }, -} +}) </script> <style lang="scss" scoped> |