diff options
Diffstat (limited to 'core/src/views/UnifiedSearch.vue')
-rw-r--r-- | core/src/views/UnifiedSearch.vue | 761 |
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> |