nextcloud-server/core/src/views/LegacyUnifiedSearch.vue

861 lines
20 KiB
Vue

This file contains invisible Unicode characters!

This file contains invisible Unicode characters that may be processed differently from what appears below. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to reveal hidden characters.

<!--
- SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<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-icon" :size="20" />
</template>
<!-- Search form & filters wrapper -->
<div class="unified-search__input-wrapper">
<div class="unified-search__input-row">
<NcTextField
ref="input"
v-model="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"
@click.stop="onClickFilter(`in:${filter}`)">
{{ t('core', 'Search for {name} only', { name: typesMap[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 { showError } from '@nextcloud/dialogs'
import { emit, subscribe, unsubscribe } from '@nextcloud/event-bus'
import debounce from 'debounce'
import NcActionButton from '@nextcloud/vue/components/NcActionButton'
import NcActions from '@nextcloud/vue/components/NcActions'
import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent'
import NcHeaderMenu from '@nextcloud/vue/components/NcHeaderMenu'
import NcTextField from '@nextcloud/vue/components/NcTextField'
import Magnify from 'vue-material-design-icons/Magnify.vue'
import SearchResult from '../components/UnifiedSearch/LegacySearchResult.vue'
import SearchResultPlaceholders from '../components/UnifiedSearch/SearchResultPlaceholders.vue'
import logger from '../logger.js'
import { defaultLimit, enableLiveSearch, getTypes, minSearchLength, regexFilterIn, regexFilterNot, search } from '../services/LegacyUnifiedSearchService.js'
const REQUEST_FAILED = 0
const REQUEST_OK = 1
const REQUEST_CANCELED = 2
export default {
name: 'LegacyUnifiedSearch',
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()
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 {
&__trigger-icon {
color: var(--color-background-plain-text) !important;
}
&__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-inline-start: 13px;
}
}
&__input-row {
display: flex;
width: 100%;
align-items: center;
}
&__filters {
margin-block: $margin;
margin-inline: math.div($margin, 2) 0;
padding-top: 5px;
ul {
display: inline-flex;
justify-content: space-between;
}
}
&__form {
position: relative;
width: 100%;
margin: $margin 0;
// Loading spinner
&::after {
inset-inline-start: auto $input-padding;
}
&-input,
&-reset {
margin: math.div($input-padding, 2);
}
&-input {
width: 100%;
height: $input-height;
padding: $input-padding;
&:focus,
&:focus-visible,
&:active {
border-color: 2px solid var(--color-main-text) !important;
box-shadow: 0 0 0 2px var(--color-main-background) !important;
}
&,
&[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;
inset-inline-end: 4px;
width: $input-height - $input-padding;
height: $input-height - $input-padding;
min-height: 30px;
padding: 0;
opacity: .5;
border: none;
background-color: transparent;
margin-inline-end: 0;
&:hover,
&:focus,
&:active {
opacity: 1;
}
}
&-submit {
inset-inline-end: 28px;
}
}
&__results {
display: flex;
flex-direction: column;
gap: 4px;
&-header {
display: block;
margin: $margin;
margin-bottom: $margin - 4px;
margin-inline-start: 13px;
color: var(--color-primary-element);
font-size: 19px;
font-weight: bold;
}
}
:deep(.unified-search__result-more) {
color: var(--color-text-maxcontrast);
}
.empty-content {
margin: 10vh 0;
:deep(.empty-content__title) {
font-weight: normal;
font-size: var(--default-font-size);
text-align: center;
}
}
}
</style>