Merge pull request #22935 from nextcloud/backport/22868/stable20

[stable20] Fix/unified search papercuts
This commit is contained in:
Morris Jobke 2020-09-18 18:14:17 +02:00 committed by GitHub
commit 03d00afe31
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
34 changed files with 257 additions and 122 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -426,8 +426,8 @@ export default {
/**
* Register search
*/
subscribe('nextcloud:unified-search:search', this.search)
subscribe('nextcloud:unified-search:reset', this.resetSearch)
subscribe('nextcloud:unified-search.search', this.search)
subscribe('nextcloud:unified-search.reset', this.resetSearch)
/**
* If disabled group but empty, redirect
@ -435,8 +435,8 @@ export default {
this.redirectIfDisabled()
},
beforeDestroy() {
unsubscribe('nextcloud:unified-search:search', this.search)
unsubscribe('nextcloud:unified-search:reset', this.resetSearch)
unsubscribe('nextcloud:unified-search.search', this.search)
unsubscribe('nextcloud:unified-search.reset', this.resetSearch)
},
methods: {

View File

@ -280,12 +280,12 @@ export default {
},
mounted() {
subscribe('nextcloud:unified-search:search', this.setSearch)
subscribe('nextcloud:unified-search:reset', this.resetSearch)
subscribe('nextcloud:unified-search.search', this.setSearch)
subscribe('nextcloud:unified-search.reset', this.resetSearch)
},
beforeDestroy() {
unsubscribe('nextcloud:unified-search:search', this.setSearch)
unsubscribe('nextcloud:unified-search:reset', this.resetSearch)
unsubscribe('nextcloud:unified-search.search', this.setSearch)
unsubscribe('nextcloud:unified-search.reset', this.resetSearch)
},
methods: {

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1,4 +1,4 @@
/*
/**
* @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
*
* @author 2019 Christoph Wurst <christoph@winzerhof-wurst.at>

View File

@ -1,4 +1,4 @@
/*
/**
* @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
*
* @author 2019 Christoph Wurst <christoph@winzerhof-wurst.at>

View File

@ -1,4 +1,4 @@
/*
/**
* @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
*
* @author 2019 Christoph Wurst <christoph@winzerhof-wurst.at>

View File

@ -1,4 +1,4 @@
/*
/**
* @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
*
* @author 2019 Christoph Wurst <christoph@winzerhof-wurst.at>

View File

@ -28,6 +28,12 @@ export const minSearchLength = 2
export const regexFilterIn = /[^-]in:([a-z_-]+)/ig
export const regexFilterNot = /-in:([a-z_-]+)/ig
/**
* Create a cancel token
* @returns {CancelTokenSource}
*/
const createCancelToken = () => axios.CancelToken.source()
/**
* Get the list of available search providers
*
@ -54,13 +60,20 @@ export async function getTypes() {
/**
* Get the list of available search providers
*
* @param {string} type the type to search
* @param {string} query the search
* @param {int|string|undefined} cursor the offset for paginated searches
* @returns {Promise}
* @param {Object} options destructuring object
* @param {string} options.type the type to search
* @param {string} options.query the search
* @param {int|string|undefined} options.cursor the offset for paginated searches
* @returns {Object} {request: Promise, cancel: Promise}
*/
export function search(type, query, cursor) {
return axios.get(generateOcsUrl('search', 2) + `providers/${type}/search`, {
export function search({ type, query, cursor }) {
/**
* Generate an axios cancel token
*/
const cancelToken = createCancelToken()
const request = async() => axios.get(generateOcsUrl('search', 2) + `providers/${type}/search`, {
cancelToken: cancelToken.token,
params: {
term: query,
cursor,
@ -68,4 +81,9 @@ export function search(type, query, cursor) {
from: window.location.pathname.replace('/index.php', '') + window.location.search,
},
})
return {
request,
cancel: cancelToken.cancel,
}
}

View File

@ -1,4 +1,4 @@
/*
/**
* @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
*
* @author 2019 Christoph Wurst <christoph@winzerhof-wurst.at>

View File

@ -19,8 +19,9 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { getRequestToken } from '@nextcloud/auth'
import { generateFilePath } from '@nextcloud/router'
import { getLoggerBuilder } from '@nextcloud/logger'
import { getRequestToken } from '@nextcloud/auth'
import { translate as t, translatePlural as n } from '@nextcloud/l10n'
import Vue from 'vue'
@ -32,7 +33,17 @@ __webpack_nonce__ = btoa(getRequestToken())
// eslint-disable-next-line camelcase
__webpack_public_path__ = generateFilePath('core', '', 'js/')
const logger = getLoggerBuilder()
.setApp('unified-search')
.detectUser()
.build()
Vue.mixin({
data() {
return {
logger,
}
},
methods: {
t,
n,

View File

@ -31,16 +31,31 @@
<Magnify class="unified-search__trigger" :size="20" fill-color="var(--color-primary-text)" />
</template>
<!-- Search input -->
<!-- Search form & filters wrapper -->
<div class="unified-search__input-wrapper">
<input ref="input"
v-model="query"
class="unified-search__input"
type="search"
:placeholder="t('core', 'Search {types} …', { types: typesNames.join(', ').toLowerCase() })"
@input="onInputDebounced"
@keypress.enter.prevent.stop="onInputEnter"
@search="onSearch">
<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(', ').toLowerCase() })"
@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"
@ -57,7 +72,7 @@
<!-- Loading placeholders -->
<SearchResultPlaceholders v-if="isLoading" />
<EmptyContent v-else-if="isValidQuery && isDoneSearching" icon="icon-search">
<EmptyContent v-else-if="isValidQuery" icon="icon-search">
{{ t('core', 'No results for {query}', {query}) }}
</EmptyContent>
@ -107,6 +122,7 @@
<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'
@ -117,6 +133,10 @@ 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',
@ -134,10 +154,17 @@ export default {
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: '',
@ -255,7 +282,7 @@ export default {
async created() {
this.types = await getTypes()
console.debug('Unified Search initialized with the following providers', this.types)
this.logger.debug('Unified Search initialized with the following providers', this.types)
},
mounted() {
@ -289,24 +316,38 @@ export default {
this.types = await getTypes()
},
onClose() {
emit('nextcloud:unified-search:close')
emit('nextcloud:unified-search.close')
},
/**
* Reset the search state
*/
resetSearch() {
emit('nextcloud:unified-search:reset')
onReset() {
emit('nextcloud:unified-search.reset')
this.logger.debug('Search reset')
this.query = ''
this.resetState()
this.focusInput()
},
resetState() {
async resetState() {
this.cursors = {}
this.limits = {}
this.loading = {}
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()))
},
/**
@ -319,18 +360,6 @@ export default {
})
},
/**
* Watch the search event on the input
* Used to detect the reset button press
* @param {Event} event the search event
*/
onSearch(event) {
// If value is empty, the reset button has been pressed
if (event.target.value === '') {
this.resetSearch()
}
},
/**
* If we have results already, open first one
* If not, trigger the search again
@ -349,7 +378,7 @@ export default {
*/
async onInput() {
// emit the search query
emit('nextcloud:unified-search:search', { query: this.query })
emit('nextcloud:unified-search.search', { query: this.query })
// Do not search if not long enough
if (this.query.trim() === '' || this.isShortQuery) {
@ -372,42 +401,65 @@ export default {
// Remove any filters from the query
query = query.replace(regexFilterIn, '').replace(regexFilterNot, '')
console.debug('Searching', query, 'in', types)
// Reset search if the query changed
this.resetState()
await this.resetState()
this.$set(this.loading, 'all', true)
this.logger.debug(`Searching ${query} in`, types)
types.forEach(async type => {
this.$set(this.loading, type, true)
const request = await search(type, query)
Promise.all(types.map(async type => {
try {
// Init cancellable request
const { request, cancel } = search({ type, query })
this.requests.push(cancel)
// Process results
if (request.data.ocs.data.entries.length > 0) {
this.$set(this.results, type, request.data.ocs.data.entries)
} else {
this.$delete(this.results, type)
}
// Fetch results
const { data } = await request()
// Save cursor if any
if (request.data.ocs.data.cursor) {
this.$set(this.cursors, type, request.data.ocs.data.cursor)
} else if (!request.data.ocs.data.isPaginated) {
// 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)
}
this.$set(this.limits, type, this.defaultLimit)
}
// Check if we reached end of pagination
if (request.data.ocs.data.entries.length < this.defaultLimit) {
this.$set(this.reached, type, true)
}
// 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
}
// 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)
this.$set(this.loading, type, false)
// 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) {
@ -423,22 +475,27 @@ export default {
if (this.loading[type]) {
return
}
this.$set(this.loading, type, true)
if (this.cursors[type]) {
const request = await search(type, this.query, 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 (request.data.ocs.data.cursor) {
this.$set(this.cursors, type, request.data.ocs.data.cursor)
if (data.ocs.data.cursor) {
this.$set(this.cursors, type, data.ocs.data.cursor)
}
if (request.data.ocs.data.entries.length > 0) {
this.results[type].push(...request.data.ocs.data.entries)
// 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 (request.data.ocs.data.entries.length < this.defaultLimit) {
if (data.ocs.data.entries.length < this.defaultLimit) {
this.$set(this.reached, type, true)
}
} else
@ -460,8 +517,6 @@ export default {
this.focusIndex(this.focused)
})
}
this.$set(this.loading, type, false)
},
/**
@ -574,6 +629,7 @@ export default {
<style lang="scss" scoped>
$margin: 10px;
$input-height: 34px;
$input-padding: 6px;
.unified-search {
@ -583,13 +639,13 @@ $input-padding: 6px;
}
&__input-wrapper {
width: 100%;
position: sticky;
// above search results
z-index: 2;
top: 0;
display: inline-flex;
align-items: center;
width: 100%;
background-color: var(--color-main-background);
}
@ -601,17 +657,67 @@ $input-padding: 6px;
}
}
&__input {
&__form {
position: relative;
width: 100%;
height: 34px;
margin: $margin;
padding: $input-padding;
&,
&[placeholder],
&::placeholder {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
// 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;
}
}
}