- <!--
- - @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>
- <NcHeaderMenu id="unified-search"
- class="unified-search"
- :exclude-click-outside-selectors="['.popover']"
- :open.sync="open"
- :aria-label="ariaLabel"
- @open="onOpen"
- @close="onClose">
- <!-- Header icon -->
- <template #trigger>
- <Magnify class="unified-search__trigger"
- :size="22/* fit better next to other 20px icons */" />
- </template>
-
- <!-- Search form & filters wrapper -->
- <div class="unified-search__input-wrapper">
- <div class="unified-search__input-row">
- <NcTextField ref="input"
- :value.sync="query"
- trailing-button-icon="close"
- :label="ariaLabel"
- :trailing-button-label="t('core','Reset search')"
- :show-trailing-button="query !== ''"
- aria-describedby="unified-search-desc"
- class="unified-search__form-input"
- :class="{'unified-search__form-input--with-reset': !!query}"
- :placeholder="t('core', 'Search {types} …', { types: typesNames.join(', ') })"
- @trailing-button-click="onReset"
- @input="onInputDebounced" />
- <p id="unified-search-desc" class="hidden-visually">
- {{ t('core', 'Search starts once you start typing and results may be reached with the arrow keys') }}
- </p>
-
- <!-- Search filters -->
- <NcActions v-if="availableFilters.length > 1"
- class="unified-search__filters"
- placement="bottom-end"
- container=".unified-search__input-wrapper">
- <!-- FIXME use element ref for container after https://github.com/nextcloud/nextcloud-vue/pull/3462 -->
- <NcActionButton v-for="filter in availableFilters"
- :key="filter"
- icon="icon-filter"
- :title="t('core', 'Search for {name} only', { name: typesMap[filter] })"
- @click.stop="onClickFilter(`in:${filter}`)">
- {{ `in:${filter}` }}
- </NcActionButton>
- </NcActions>
- </div>
- </div>
-
- <template v-if="!hasResults">
- <!-- Loading placeholders -->
- <SearchResultPlaceholders v-if="isLoading" />
-
- <NcEmptyContent v-else-if="isValidQuery"
- :title="validQueryTitle">
- <template #icon>
- <Magnify />
- </template>
- </NcEmptyContent>
-
- <NcEmptyContent v-else-if="!isLoading || isShortQuery"
- :title="t('core', 'Start typing to search')"
- :description="shortQueryDescription">
- <template #icon>
- <Magnify />
- </template>
- </NcEmptyContent>
- </template>
-
- <!-- Grouped search results -->
- <template v-for="({list, type}, typesIndex) in orderedResults" v-else>
- <h2 :key="type" class="unified-search__results-header">
- {{ typesMap[type] }}
- </h2>
- <ul :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.stop="loadMore(type)"
- @focus="setFocusedIndex" />
- </li>
- </ul>
- </template>
- </NcHeaderMenu>
- </template>
-
- <script>
- import debounce from 'debounce'
- import { emit, subscribe, unsubscribe } from '@nextcloud/event-bus'
- import { showError } from '@nextcloud/dialogs'
-
- import NcActionButton from '@nextcloud/vue/dist/Components/NcActionButton.js'
- import NcActions from '@nextcloud/vue/dist/Components/NcActions.js'
- import NcEmptyContent from '@nextcloud/vue/dist/Components/NcEmptyContent.js'
- import NcHeaderMenu from '@nextcloud/vue/dist/Components/NcHeaderMenu.js'
- import NcTextField from '@nextcloud/vue/dist/Components/NcTextField.js'
-
- import Magnify from 'vue-material-design-icons/Magnify.vue'
-
- import SearchResult from '../components/UnifiedSearch/SearchResult.vue'
- import SearchResultPlaceholders from '../components/UnifiedSearch/SearchResultPlaceholders.vue'
-
- import { minSearchLength, getTypes, search, defaultLimit, regexFilterIn, regexFilterNot, enableLiveSearch } from '../services/UnifiedSearchService.js'
-
- const REQUEST_FAILED = 0
- const REQUEST_OK = 1
- const REQUEST_CANCELED = 2
-
- export default {
- name: 'UnifiedSearch',
-
- components: {
- Magnify,
- NcActionButton,
- NcActions,
- NcEmptyContent,
- NcHeaderMenu,
- SearchResult,
- SearchResultPlaceholders,
- NcTextField,
- },
-
- 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,
- triggered: false,
-
- defaultLimit,
- minSearchLength,
- enableLiveSearch,
-
- 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
- *
- * @return {boolean}
- */
- hasResults() {
- return Object.keys(this.results).length !== 0
- },
-
- /**
- * Return ordered results
- *
- * @return {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
- *
- * @return {string[]}
- */
- availableFilters() {
- return Object.keys(this.results)
- },
-
- /**
- * Applied filters
- *
- * @return {string[]}
- */
- usedFiltersIn() {
- let match
- const filters = []
- while ((match = regexFilterIn.exec(this.query)) !== null) {
- filters.push(match[2])
- }
- return filters
- },
-
- /**
- * Applied anti filters
- *
- * @return {string[]}
- */
- usedFiltersNot() {
- let match
- const filters = []
- while ((match = regexFilterNot.exec(this.query)) !== null) {
- filters.push(match[2])
- }
- return filters
- },
-
- /**
- * Valid query empty content title
- *
- * @return {string}
- */
- validQueryTitle() {
- return this.triggered
- ? t('core', 'No results for {query}', { query: this.query })
- : t('core', 'Press Enter to start searching')
- },
-
- /**
- * Short query empty content description
- *
- * @return {string}
- */
- shortQueryDescription() {
- if (!this.isShortQuery) {
- return ''
- }
-
- return n('core',
- 'Please enter {minSearchLength} character or more to search',
- 'Please enter {minSearchLength} characters or more to search',
- this.minSearchLength,
- { minSearchLength: this.minSearchLength })
- },
-
- /**
- * Is the current search too short
- *
- * @return {boolean}
- */
- isShortQuery() {
- return this.query && this.query.trim().length < minSearchLength
- },
-
- /**
- * Is the current search valid
- *
- * @return {boolean}
- */
- isValidQuery() {
- return this.query && this.query.trim() !== '' && !this.isShortQuery
- },
-
- /**
- * Have we reached the end of all types searches
- *
- * @return {boolean}
- */
- isDoneSearching() {
- return Object.values(this.reached).every(state => state === false)
- },
-
- /**
- * Is there any search in progress
- *
- * @return {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)
- },
-
- beforeDestroy() {
- unsubscribe('files:navigation:changed', this.onNavigationChange)
- },
-
- mounted() {
- // subscribe in mounted, as onNavigationChange relys on $el
- subscribe('files:navigation:changed', this.onNavigationChange)
-
- if (OCP.Accessibility.disableKeyboardShortcuts()) {
- return
- }
-
- document.addEventListener('keydown', (event) => {
- // if not already opened, allows us to trigger default browser on second keydown
- if (event.ctrlKey && event.code === 'KeyF' && !this.open) {
- event.preventDefault()
- this.open = true
- } else if (event.ctrlKey && event.key === 'f' && this.open) {
- // User wants to use the native browser search, so we close ours again
- this.open = false
- }
-
- // 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() {
- // Update types list in the background
- this.types = await getTypes()
- },
- onClose() {
- emit('nextcloud:unified-search.close')
- },
-
- onNavigationChange() {
- this.$el?.querySelector?.('form[role="search"]')?.reset?.()
- },
-
- /**
- * 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
- this.triggered = false
- 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) {
- for (const type of this.typesIDs) {
- this.$delete(this.results, type)
- }
- 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.triggered = true
-
- if (!types.length) {
- // no results since no types were selected
- this.logger.error('No types to search in')
- return
- }
-
- 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: enableLiveSearch
- ? debounce(function(e) {
- this.onInput(e)
- }, 500)
- : function() {
- this.triggered = false
- },
-
- /**
- * 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
- * @return {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>
- @use "sass:math";
-
- $margin: 10px;
- $input-height: 34px;
- $input-padding: 10px;
-
- .unified-search {
- &__input-wrapper {
- position: sticky;
- // above search results
- z-index: 2;
- top: 0;
- display: inline-flex;
- flex-direction: column;
- align-items: center;
- width: 100%;
- background-color: var(--color-main-background);
-
- label[for="unified-search__input"] {
- align-self: flex-start;
- font-weight: bold;
- font-size: 19px;
- margin-left: 13px;
- }
- }
-
- &__form-input {
- margin: 0 !important;
- &:focus,
- &:focus-visible,
- &:active {
- border-color: 2px solid var(--color-main-text) !important;
- box-shadow: 0 0 0 2px var(--color-main-background) !important;
- }
- }
-
- &__input-row {
- display: flex;
- width: 100%;
- align-items: center;
- }
-
- &__filters {
- margin: $margin 0 $margin math.div($margin, 2);
- padding-top: 5px;
- ul {
- display: inline-flex;
- justify-content: space-between;
- }
- }
-
- &__form {
- position: relative;
- width: 100%;
- margin: $margin 0;
-
- // Loading spinner
- &::after {
- right: $input-padding;
- left: auto;
- }
-
- &-input,
- &-reset {
- margin: math.div($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;
- }
- }
-
- &-reset, &-submit {
- position: absolute;
- top: 0;
- right: 4px;
- width: $input-height - $input-padding;
- height: $input-height - $input-padding;
- min-height: 30px;
- padding: 0;
- opacity: .5;
- border: none;
- background-color: transparent;
- margin-right: 0;
-
- &:hover,
- &:focus,
- &:active {
- opacity: 1;
- }
- }
-
- &-submit {
- right: 28px;
- }
- }
-
- &__results {
- &-header {
- display: block;
- margin: $margin;
- margin-bottom: $margin - 4px;
- margin-left: 13px;
- color: var(--color-primary-element);
- font-size: 19px;
- font-weight: bold;
- }
- display: flex;
- flex-direction: column;
- gap: 4px;
- }
-
- .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);
- text-align: center;
- }
- }
- }
-
- </style>
|