Welcome to mirror list, hosted at ThFree Co, Russian Federation.

github.com/nextcloud/server.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
Diffstat (limited to 'core/src/views/UnifiedSearch.vue')
-rw-r--r--core/src/views/UnifiedSearch.vue761
1 files changed, 761 insertions, 0 deletions
diff --git a/core/src/views/UnifiedSearch.vue b/core/src/views/UnifiedSearch.vue
new file mode 100644
index 00000000000..80ba7c7c7b9
--- /dev/null
+++ b/core/src/views/UnifiedSearch.vue
@@ -0,0 +1,761 @@
+ <!--
+ - @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/>.
+ -
+ -->
+<template>
+ <HeaderMenu id="unified-search"
+ class="unified-search"
+ exclude-click-outside-classes="popover"
+ :open.sync="open"
+ @open="onOpen"
+ @close="onClose">
+ <!-- Header icon -->
+ <template #trigger>
+ <Magnify class="unified-search__trigger"
+ :size="20"
+ :title="ariaLabel"
+ fill-color="var(--color-primary-text)" />
+ </template>
+
+ <!-- Search form & filters wrapper -->
+ <div class="unified-search__input-wrapper">
+ <form class="unified-search__form"
+ role="search"
+ :class="{'icon-loading-small': isLoading}"
+ @submit.prevent.stop="onInputEnter"
+ @reset.prevent.stop="onReset">
+ <!-- Search input -->
+ <input ref="input"
+ v-model="query"
+ class="unified-search__form-input"
+ type="search"
+ :class="{'unified-search__form-input--with-reset': !!query}"
+ :placeholder="t('core', 'Search {types} …', { types: typesNames.join(', ') })"
+ @input="onInputDebounced"
+ @keypress.enter.prevent.stop="onInputEnter">
+
+ <!-- Reset search button -->
+ <input v-if="!!query && !isLoading"
+ type="reset"
+ class="unified-search__form-reset icon-close"
+ :aria-label="t('core','Reset search')"
+ value="">
+ </form>
+
+ <!-- Search filters -->
+ <Actions v-if="availableFilters.length > 1" class="unified-search__filters" placement="bottom">
+ <ActionButton v-for="type in availableFilters"
+ :key="type"
+ icon="icon-filter"
+ :title="t('core', 'Search for {name} only', { name: typesMap[type] })"
+ @click="onClickFilter(`in:${type}`)">
+ {{ `in:${type}` }}
+ </ActionButton>
+ </Actions>
+ </div>
+
+ <template v-if="!hasResults">
+ <!-- Loading placeholders -->
+ <SearchResultPlaceholders v-if="isLoading" />
+
+ <EmptyContent v-else-if="isValidQuery" icon="icon-search">
+ {{ t('core', 'No results for {query}', {query}) }}
+ </EmptyContent>
+
+ <EmptyContent v-else-if="!isLoading || isShortQuery" icon="icon-search">
+ {{ t('core', 'Start typing to search') }}
+ <template v-if="isShortQuery" #desc>
+ {{ n('core',
+ 'Please enter {minSearchLength} character or more to search',
+ 'Please enter {minSearchLength} characters or more to search',
+ minSearchLength,
+ {minSearchLength}) }}
+ </template>
+ </EmptyContent>
+ </template>
+
+ <!-- Grouped search results -->
+ <template v-else>
+ <ul v-for="({list, type}, typesIndex) in orderedResults"
+ :key="type"
+ class="unified-search__results"
+ :class="`unified-search__results-${type}`"
+ :aria-label="typesMap[type]">
+ <!-- Search results -->
+ <li v-for="(result, index) in limitIfAny(list, type)" :key="result.resourceUrl">
+ <SearchResult v-bind="result"
+ :query="query"
+ :focused="focused === 0 && typesIndex === 0 && index === 0"
+ @focus="setFocusedIndex" />
+ </li>
+
+ <!-- Load more button -->
+ <li>
+ <SearchResult v-if="!reached[type]"
+ class="unified-search__result-more"
+ :title="loading[type]
+ ? t('core', 'Loading more results …')
+ : t('core', 'Load more results')"
+ :icon-class="loading[type] ? 'icon-loading-small' : ''"
+ @click.prevent="loadMore(type)"
+ @focus="setFocusedIndex" />
+ </li>
+ </ul>
+ </template>
+ </HeaderMenu>
+</template>
+
+<script>
+import { emit } from '@nextcloud/event-bus'
+import { minSearchLength, getTypes, search, defaultLimit, regexFilterIn, regexFilterNot } from '../services/UnifiedSearchService'
+import { showError } from '@nextcloud/dialogs'
+import ActionButton from '@nextcloud/vue/dist/Components/ActionButton'
+import Actions from '@nextcloud/vue/dist/Components/Actions'
+import debounce from 'debounce'
+import EmptyContent from '@nextcloud/vue/dist/Components/EmptyContent'
+import Magnify from 'vue-material-design-icons/Magnify'
+
+import HeaderMenu from '../components/HeaderMenu'
+import SearchResult from '../components/UnifiedSearch/SearchResult'
+import SearchResultPlaceholders from '../components/UnifiedSearch/SearchResultPlaceholders'
+
+const REQUEST_FAILED = 0
+const REQUEST_OK = 1
+const REQUEST_CANCELED = 2
+
+export default {
+ name: 'UnifiedSearch',
+
+ components: {
+ ActionButton,
+ Actions,
+ EmptyContent,
+ HeaderMenu,
+ Magnify,
+ SearchResult,
+ SearchResultPlaceholders,
+ },
+
+ data() {
+ return {
+ types: [],
+
+ // Cursors per types
+ cursors: {},
+ // Various search limits per types
+ limits: {},
+ // Loading types
+ loading: {},
+ // Reached search types
+ reached: {},
+ // Pending cancellable requests
+ requests: [],
+ // List of all results
+ results: {},
+
+ query: '',
+ focused: null,
+
+ defaultLimit,
+ minSearchLength,
+
+ open: false,
+ }
+ },
+
+ computed: {
+ typesIDs() {
+ return this.types.map(type => type.id)
+ },
+ typesNames() {
+ return this.types.map(type => type.name)
+ },
+ typesMap() {
+ return this.types.reduce((prev, curr) => {
+ prev[curr.id] = curr.name
+ return prev
+ }, {})
+ },
+
+ ariaLabel() {
+ return t('core', 'Search')
+ },
+
+ /**
+ * Is there any result to display
+ * @returns {boolean}
+ */
+ hasResults() {
+ return Object.keys(this.results).length !== 0
+ },
+
+ /**
+ * Return ordered results
+ * @returns {Array}
+ */
+ orderedResults() {
+ return this.typesIDs
+ .filter(type => type in this.results)
+ .map(type => ({
+ type,
+ list: this.results[type],
+ }))
+ },
+
+ /**
+ * Available filters
+ * We only show filters that are available on the results
+ * @returns {string[]}
+ */
+ availableFilters() {
+ return Object.keys(this.results)
+ },
+
+ /**
+ * Applied filters
+ * @returns {string[]}
+ */
+ usedFiltersIn() {
+ let match
+ const filters = []
+ while ((match = regexFilterIn.exec(this.query)) !== null) {
+ filters.push(match[1])
+ }
+ return filters
+ },
+
+ /**
+ * Applied anti filters
+ * @returns {string[]}
+ */
+ usedFiltersNot() {
+ let match
+ const filters = []
+ while ((match = regexFilterNot.exec(this.query)) !== null) {
+ filters.push(match[1])
+ }
+ return filters
+ },
+
+ /**
+ * Is the current search too short
+ * @returns {boolean}
+ */
+ isShortQuery() {
+ return this.query && this.query.trim().length < minSearchLength
+ },
+
+ /**
+ * Is the current search valid
+ * @returns {boolean}
+ */
+ isValidQuery() {
+ return this.query && this.query.trim() !== '' && !this.isShortQuery
+ },
+
+ /**
+ * Have we reached the end of all types searches
+ * @returns {boolean}
+ */
+ isDoneSearching() {
+ return Object.values(this.reached).every(state => state === false)
+ },
+
+ /**
+ * Is there any search in progress
+ * @returns {boolean}
+ */
+ isLoading() {
+ return Object.values(this.loading).some(state => state === true)
+ },
+ },
+
+ async created() {
+ this.types = await getTypes()
+ this.logger.debug('Unified Search initialized with the following providers', this.types)
+ },
+
+ mounted() {
+ document.addEventListener('keydown', (event) => {
+ // if not already opened, allows us to trigger default browser on second keydown
+ if (event.ctrlKey && event.key === 'f' && !this.open) {
+ event.preventDefault()
+ this.open = true
+ this.focusInput()
+ }
+
+ // https://www.w3.org/WAI/GL/wiki/Using_ARIA_menus
+ if (this.open) {
+ // If arrow down, focus next result
+ if (event.key === 'ArrowDown') {
+ this.focusNext(event)
+ }
+
+ // If arrow up, focus prev result
+ if (event.key === 'ArrowUp') {
+ this.focusPrev(event)
+ }
+ }
+ })
+ },
+
+ methods: {
+ async onOpen() {
+ this.focusInput()
+ // Update types list in the background
+ this.types = await getTypes()
+ },
+ onClose() {
+ emit('nextcloud:unified-search.close')
+ },
+
+ /**
+ * Reset the search state
+ */
+ onReset() {
+ emit('nextcloud:unified-search.reset')
+ this.logger.debug('Search reset')
+ this.query = ''
+ this.resetState()
+ this.focusInput()
+ },
+ async resetState() {
+ this.cursors = {}
+ this.limits = {}
+ this.reached = {}
+ this.results = {}
+ this.focused = null
+ await this.cancelPendingRequests()
+ },
+
+ /**
+ * Cancel any ongoing searches
+ */
+ async cancelPendingRequests() {
+ // Cloning so we can keep processing other requests
+ const requests = this.requests.slice(0)
+ this.requests = []
+
+ // Cancel all pending requests
+ await Promise.all(requests.map(cancel => cancel()))
+ },
+
+ /**
+ * Focus the search input on next tick
+ */
+ focusInput() {
+ this.$nextTick(() => {
+ this.$refs.input.focus()
+ this.$refs.input.select()
+ })
+ },
+
+ /**
+ * If we have results already, open first one
+ * If not, trigger the search again
+ */
+ onInputEnter() {
+ if (this.hasResults) {
+ const results = this.getResultsList()
+ results[0].click()
+ return
+ }
+ this.onInput()
+ },
+
+ /**
+ * Start searching on input
+ */
+ async onInput() {
+ // emit the search query
+ emit('nextcloud:unified-search.search', { query: this.query })
+
+ // Do not search if not long enough
+ if (this.query.trim() === '' || this.isShortQuery) {
+ return
+ }
+
+ let types = this.typesIDs
+ let query = this.query
+
+ // Filter out types
+ if (this.usedFiltersNot.length > 0) {
+ types = this.typesIDs.filter(type => this.usedFiltersNot.indexOf(type) === -1)
+ }
+
+ // Only use those filters if any and check if they are valid
+ if (this.usedFiltersIn.length > 0) {
+ types = this.typesIDs.filter(type => this.usedFiltersIn.indexOf(type) > -1)
+ }
+
+ // Remove any filters from the query
+ query = query.replace(regexFilterIn, '').replace(regexFilterNot, '')
+
+ // Reset search if the query changed
+ await this.resetState()
+ this.$set(this.loading, 'all', true)
+ this.logger.debug(`Searching ${query} in`, types)
+
+ Promise.all(types.map(async type => {
+ try {
+ // Init cancellable request
+ const { request, cancel } = search({ type, query })
+ this.requests.push(cancel)
+
+ // Fetch results
+ const { data } = await request()
+
+ // Process results
+ if (data.ocs.data.entries.length > 0) {
+ this.$set(this.results, type, data.ocs.data.entries)
+ } else {
+ this.$delete(this.results, type)
+ }
+
+ // Save cursor if any
+ if (data.ocs.data.cursor) {
+ this.$set(this.cursors, type, data.ocs.data.cursor)
+ } else if (!data.ocs.data.isPaginated) {
+ // If no cursor and no pagination, we save the default amount
+ // provided by server's initial state `defaultLimit`
+ this.$set(this.limits, type, this.defaultLimit)
+ }
+
+ // Check if we reached end of pagination
+ if (data.ocs.data.entries.length < this.defaultLimit) {
+ this.$set(this.reached, type, true)
+ }
+
+ // If none already focused, focus the first rendered result
+ if (this.focused === null) {
+ this.focused = 0
+ }
+ return REQUEST_OK
+ } catch (error) {
+ this.$delete(this.results, type)
+
+ // If this is not a cancelled throw
+ if (error.response && error.response.status) {
+ this.logger.error(`Error searching for ${this.typesMap[type]}`, error)
+ showError(this.t('core', 'An error occurred while searching for {type}', { type: this.typesMap[type] }))
+ return REQUEST_FAILED
+ }
+ return REQUEST_CANCELED
+ }
+ })).then(results => {
+ // Do not declare loading finished if the request have been cancelled
+ // This means another search was triggered and we're therefore still loading
+ if (results.some(result => result === REQUEST_CANCELED)) {
+ return
+ }
+ // We finished all searches
+ this.loading = {}
+ })
+ },
+ onInputDebounced: debounce(function(e) {
+ this.onInput(e)
+ }, 200),
+
+ /**
+ * Load more results for the provided type
+ * @param {String} type type
+ */
+ async loadMore(type) {
+ // If already loading, ignore
+ if (this.loading[type]) {
+ return
+ }
+
+ if (this.cursors[type]) {
+ // Init cancellable request
+ const { request, cancel } = search({ type, query: this.query, cursor: this.cursors[type] })
+ this.requests.push(cancel)
+
+ // Fetch results
+ const { data } = await request()
+
+ // Save cursor if any
+ if (data.ocs.data.cursor) {
+ this.$set(this.cursors, type, data.ocs.data.cursor)
+ }
+
+ // Process results
+ if (data.ocs.data.entries.length > 0) {
+ this.results[type].push(...data.ocs.data.entries)
+ }
+
+ // Check if we reached end of pagination
+ if (data.ocs.data.entries.length < this.defaultLimit) {
+ this.$set(this.reached, type, true)
+ }
+ } else
+
+ // If no cursor, we might have all the results already,
+ // let's fake pagination and show the next xxx entries
+ if (this.limits[type] && this.limits[type] >= 0) {
+ this.limits[type] += this.defaultLimit
+
+ // Check if we reached end of pagination
+ if (this.limits[type] >= this.results[type].length) {
+ this.$set(this.reached, type, true)
+ }
+ }
+
+ // Focus result after render
+ if (this.focused !== null) {
+ this.$nextTick(() => {
+ this.focusIndex(this.focused)
+ })
+ }
+ },
+
+ /**
+ * Return a subset of the array if the search provider
+ * doesn't supports pagination
+ *
+ * @param {Array} list the results
+ * @param {string} type the type
+ * @returns {Array}
+ */
+ limitIfAny(list, type) {
+ if (type in this.limits) {
+ return list.slice(0, this.limits[type])
+ }
+ return list
+ },
+
+ getResultsList() {
+ return this.$el.querySelectorAll('.unified-search__results .unified-search__result')
+ },
+
+ /**
+ * Focus the first result if any
+ * @param {Event} event the keydown event
+ */
+ focusFirst(event) {
+ const results = this.getResultsList()
+ if (results && results.length > 0) {
+ if (event) {
+ event.preventDefault()
+ }
+ this.focused = 0
+ this.focusIndex(this.focused)
+ }
+ },
+
+ /**
+ * Focus the next result if any
+ * @param {Event} event the keydown event
+ */
+ focusNext(event) {
+ if (this.focused === null) {
+ this.focusFirst(event)
+ return
+ }
+
+ const results = this.getResultsList()
+ // If we're not focusing the last, focus the next one
+ if (results && results.length > 0 && this.focused + 1 < results.length) {
+ event.preventDefault()
+ this.focused++
+ this.focusIndex(this.focused)
+ }
+ },
+
+ /**
+ * Focus the previous result if any
+ * @param {Event} event the keydown event
+ */
+ focusPrev(event) {
+ if (this.focused === null) {
+ this.focusFirst(event)
+ return
+ }
+
+ const results = this.getResultsList()
+ // If we're not focusing the first, focus the previous one
+ if (results && results.length > 0 && this.focused > 0) {
+ event.preventDefault()
+ this.focused--
+ this.focusIndex(this.focused)
+ }
+
+ },
+
+ /**
+ * Focus the specified result index if it exists
+ * @param {number} index the result index
+ */
+ focusIndex(index) {
+ const results = this.getResultsList()
+ if (results && results[index]) {
+ results[index].focus()
+ }
+ },
+
+ /**
+ * Set the current focused element based on the target
+ * @param {Event} event the focus event
+ */
+ setFocusedIndex(event) {
+ const entry = event.target
+ const results = this.getResultsList()
+ const index = [...results].findIndex(search => search === entry)
+ if (index > -1) {
+ // let's not use focusIndex as the entry is already focused
+ this.focused = index
+ }
+ },
+
+ onClickFilter(filter) {
+ this.query = `${this.query} ${filter}`
+ .replace(/ {2}/g, ' ')
+ .trim()
+ this.onInput()
+ },
+ },
+}
+</script>
+
+<style lang="scss" scoped>
+$margin: 10px;
+$input-height: 34px;
+$input-padding: 6px;
+
+.unified-search {
+ &__trigger {
+ width: 20px;
+ height: 20px;
+ }
+
+ &__input-wrapper {
+ position: sticky;
+ // above search results
+ z-index: 2;
+ top: 0;
+ display: inline-flex;
+ align-items: center;
+ width: 100%;
+ background-color: var(--color-main-background);
+ }
+
+ &__filters {
+ margin: $margin / 2 $margin;
+ ul {
+ display: inline-flex;
+ justify-content: space-between;
+ }
+ }
+
+ &__form {
+ position: relative;
+ width: 100%;
+ margin: $margin;
+
+ // Loading spinner
+ &::after {
+ right: $input-padding;
+ left: auto;
+ }
+
+ &-input,
+ &-reset {
+ margin: $input-padding / 2;
+ }
+
+ &-input {
+ width: 100%;
+ height: $input-height;
+ padding: $input-padding;
+
+ &,
+ &[placeholder],
+ &::placeholder {
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ }
+
+ // Hide webkit clear search
+ &::-webkit-search-decoration,
+ &::-webkit-search-cancel-button,
+ &::-webkit-search-results-button,
+ &::-webkit-search-results-decoration {
+ -webkit-appearance: none;
+ }
+
+ // Ellipsis earlier if reset button is here
+ .icon-loading-small &,
+ &--with-reset {
+ padding-right: $input-height;
+ }
+ }
+
+ &-reset {
+ position: absolute;
+ top: 0;
+ right: 0;
+ width: $input-height - $input-padding;
+ height: $input-height - $input-padding;
+ padding: 0;
+ opacity: .5;
+ border: none;
+ background-color: transparent;
+ margin-right: 0;
+
+ &:hover,
+ &:focus,
+ &:active {
+ opacity: 1;
+ }
+ }
+ }
+
+ &__filters {
+ margin-right: $margin / 2;
+ }
+
+ &__results {
+ &::before {
+ display: block;
+ margin: $margin;
+ margin-left: $margin + $input-padding;
+ content: attr(aria-label);
+ color: var(--color-primary-element);
+ }
+ }
+
+ .unified-search__result-more::v-deep {
+ color: var(--color-text-maxcontrast);
+ }
+
+ .empty-content {
+ margin: 10vh 0;
+
+ ::v-deep .empty-content__title {
+ font-weight: normal;
+ font-size: var(--default-font-size);
+ padding: 0 15px;
+ text-align: center;
+ }
+ }
+}
+
+</style>