aboutsummaryrefslogtreecommitdiffstats
path: root/core/src
diff options
context:
space:
mode:
authorMarco Ambrosini <marcoambrosini@icloud.com>2023-11-20 15:45:45 +0900
committerFerdinand Thiessen <opensource@fthiessen.de>2024-06-27 12:13:14 +0200
commit6ee965f0d92f3538ae5b44249d929fe741a371c0 (patch)
treecad7006d17922618f07a33ff1d920a8843135957 /core/src
parent2482688fa09d7dc477ad0d23049d55b62d5b3d6c (diff)
downloadnextcloud-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.vue131
-rw-r--r--core/src/components/UnifiedSearch/UnifiedSearchModal.vue (renamed from core/src/views/UnifiedSearchModal.vue)498
-rw-r--r--core/src/eventbus.d.ts14
-rw-r--r--core/src/logger.js5
-rw-r--r--core/src/views/UnifiedSearch.vue114
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>