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

View File

@ -280,12 +280,12 @@ export default {
}, },
mounted() { mounted() {
subscribe('nextcloud:unified-search:search', this.setSearch) subscribe('nextcloud:unified-search.search', this.setSearch)
subscribe('nextcloud:unified-search:reset', this.resetSearch) subscribe('nextcloud:unified-search.reset', this.resetSearch)
}, },
beforeDestroy() { beforeDestroy() {
unsubscribe('nextcloud:unified-search:search', this.setSearch) unsubscribe('nextcloud:unified-search.search', this.setSearch)
unsubscribe('nextcloud:unified-search:reset', this.resetSearch) unsubscribe('nextcloud:unified-search.reset', this.resetSearch)
}, },
methods: { 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> * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
* *
* @author 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> * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
* *
* @author 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> * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
* *
* @author 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> * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
* *
* @author 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 regexFilterIn = /[^-]in:([a-z_-]+)/ig
export const regexFilterNot = /-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 * Get the list of available search providers
* *
@ -54,13 +60,20 @@ export async function getTypes() {
/** /**
* Get the list of available search providers * Get the list of available search providers
* *
* @param {string} type the type to search * @param {Object} options destructuring object
* @param {string} query the search * @param {string} options.type the type to search
* @param {int|string|undefined} cursor the offset for paginated searches * @param {string} options.query the search
* @returns {Promise} * @param {int|string|undefined} options.cursor the offset for paginated searches
* @returns {Object} {request: Promise, cancel: Promise}
*/ */
export function search(type, query, cursor) { export function search({ type, query, cursor }) {
return axios.get(generateOcsUrl('search', 2) + `providers/${type}/search`, { /**
* Generate an axios cancel token
*/
const cancelToken = createCancelToken()
const request = async() => axios.get(generateOcsUrl('search', 2) + `providers/${type}/search`, {
cancelToken: cancelToken.token,
params: { params: {
term: query, term: query,
cursor, cursor,
@ -68,4 +81,9 @@ export function search(type, query, cursor) {
from: window.location.pathname.replace('/index.php', '') + window.location.search, 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> * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
* *
* @author 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/>. * along with this program. If not, see <http://www.gnu.org/licenses/>.
*/ */
import { getRequestToken } from '@nextcloud/auth'
import { generateFilePath } from '@nextcloud/router' 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 { translate as t, translatePlural as n } from '@nextcloud/l10n'
import Vue from 'vue' import Vue from 'vue'
@ -32,7 +33,17 @@ __webpack_nonce__ = btoa(getRequestToken())
// eslint-disable-next-line camelcase // eslint-disable-next-line camelcase
__webpack_public_path__ = generateFilePath('core', '', 'js/') __webpack_public_path__ = generateFilePath('core', '', 'js/')
const logger = getLoggerBuilder()
.setApp('unified-search')
.detectUser()
.build()
Vue.mixin({ Vue.mixin({
data() {
return {
logger,
}
},
methods: { methods: {
t, t,
n, n,

View File

@ -31,16 +31,31 @@
<Magnify class="unified-search__trigger" :size="20" fill-color="var(--color-primary-text)" /> <Magnify class="unified-search__trigger" :size="20" fill-color="var(--color-primary-text)" />
</template> </template>
<!-- Search input --> <!-- Search form & filters wrapper -->
<div class="unified-search__input-wrapper"> <div class="unified-search__input-wrapper">
<input ref="input" <form class="unified-search__form"
v-model="query" role="search"
class="unified-search__input" :class="{'icon-loading-small': isLoading}"
type="search" @submit.prevent.stop="onInputEnter"
:placeholder="t('core', 'Search {types} …', { types: typesNames.join(', ').toLowerCase() })" @reset.prevent.stop="onReset">
@input="onInputDebounced" <!-- Search input -->
@keypress.enter.prevent.stop="onInputEnter" <input ref="input"
@search="onSearch"> 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 --> <!-- Search filters -->
<Actions v-if="availableFilters.length > 1" class="unified-search__filters" placement="bottom"> <Actions v-if="availableFilters.length > 1" class="unified-search__filters" placement="bottom">
<ActionButton v-for="type in availableFilters" <ActionButton v-for="type in availableFilters"
@ -57,7 +72,7 @@
<!-- Loading placeholders --> <!-- Loading placeholders -->
<SearchResultPlaceholders v-if="isLoading" /> <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}) }} {{ t('core', 'No results for {query}', {query}) }}
</EmptyContent> </EmptyContent>
@ -107,6 +122,7 @@
<script> <script>
import { emit } from '@nextcloud/event-bus' import { emit } from '@nextcloud/event-bus'
import { minSearchLength, getTypes, search, defaultLimit, regexFilterIn, regexFilterNot } from '../services/UnifiedSearchService' import { minSearchLength, getTypes, search, defaultLimit, regexFilterIn, regexFilterNot } from '../services/UnifiedSearchService'
import { showError } from '@nextcloud/dialogs'
import ActionButton from '@nextcloud/vue/dist/Components/ActionButton' import ActionButton from '@nextcloud/vue/dist/Components/ActionButton'
import Actions from '@nextcloud/vue/dist/Components/Actions' import Actions from '@nextcloud/vue/dist/Components/Actions'
import debounce from 'debounce' import debounce from 'debounce'
@ -117,6 +133,10 @@ import HeaderMenu from '../components/HeaderMenu'
import SearchResult from '../components/UnifiedSearch/SearchResult' import SearchResult from '../components/UnifiedSearch/SearchResult'
import SearchResultPlaceholders from '../components/UnifiedSearch/SearchResultPlaceholders' import SearchResultPlaceholders from '../components/UnifiedSearch/SearchResultPlaceholders'
const REQUEST_FAILED = 0
const REQUEST_OK = 1
const REQUEST_CANCELED = 2
export default { export default {
name: 'UnifiedSearch', name: 'UnifiedSearch',
@ -134,10 +154,17 @@ export default {
return { return {
types: [], types: [],
// Cursors per types
cursors: {}, cursors: {},
// Various search limits per types
limits: {}, limits: {},
// Loading types
loading: {}, loading: {},
// Reached search types
reached: {}, reached: {},
// Pending cancellable requests
requests: [],
// List of all results
results: {}, results: {},
query: '', query: '',
@ -255,7 +282,7 @@ export default {
async created() { async created() {
this.types = await getTypes() 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() { mounted() {
@ -289,24 +316,38 @@ export default {
this.types = await getTypes() this.types = await getTypes()
}, },
onClose() { onClose() {
emit('nextcloud:unified-search:close') emit('nextcloud:unified-search.close')
}, },
/** /**
* Reset the search state * Reset the search state
*/ */
resetSearch() { onReset() {
emit('nextcloud:unified-search:reset') emit('nextcloud:unified-search.reset')
this.logger.debug('Search reset')
this.query = '' this.query = ''
this.resetState() this.resetState()
this.focusInput()
}, },
resetState() { async resetState() {
this.cursors = {} this.cursors = {}
this.limits = {} this.limits = {}
this.loading = {}
this.reached = {} this.reached = {}
this.results = {} this.results = {}
this.focused = null 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 we have results already, open first one
* If not, trigger the search again * If not, trigger the search again
@ -349,7 +378,7 @@ export default {
*/ */
async onInput() { async onInput() {
// emit the search query // 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 // Do not search if not long enough
if (this.query.trim() === '' || this.isShortQuery) { if (this.query.trim() === '' || this.isShortQuery) {
@ -372,42 +401,65 @@ export default {
// Remove any filters from the query // Remove any filters from the query
query = query.replace(regexFilterIn, '').replace(regexFilterNot, '') query = query.replace(regexFilterIn, '').replace(regexFilterNot, '')
console.debug('Searching', query, 'in', types)
// Reset search if the query changed // 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 => { Promise.all(types.map(async type => {
this.$set(this.loading, type, true) try {
const request = await search(type, query) // Init cancellable request
const { request, cancel } = search({ type, query })
this.requests.push(cancel)
// Process results // Fetch results
if (request.data.ocs.data.entries.length > 0) { const { data } = await request()
this.$set(this.results, type, request.data.ocs.data.entries)
} else {
this.$delete(this.results, type)
}
// Save cursor if any // Process results
if (request.data.ocs.data.cursor) { if (data.ocs.data.entries.length > 0) {
this.$set(this.cursors, type, request.data.ocs.data.cursor) this.$set(this.results, type, data.ocs.data.entries)
} else if (!request.data.ocs.data.isPaginated) { } 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 // If no cursor and no pagination, we save the default amount
// provided by server's initial state `defaultLimit` // 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 // 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) this.$set(this.reached, type, true)
} }
// If none already focused, focus the first rendered result // If none already focused, focus the first rendered result
if (this.focused === null) { if (this.focused === null) {
this.focused = 0 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) { onInputDebounced: debounce(function(e) {
@ -423,22 +475,27 @@ export default {
if (this.loading[type]) { if (this.loading[type]) {
return return
} }
this.$set(this.loading, type, true)
if (this.cursors[type]) { 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 // Save cursor if any
if (request.data.ocs.data.cursor) { if (data.ocs.data.cursor) {
this.$set(this.cursors, type, request.data.ocs.data.cursor) this.$set(this.cursors, type, data.ocs.data.cursor)
} }
if (request.data.ocs.data.entries.length > 0) { // Process results
this.results[type].push(...request.data.ocs.data.entries) if (data.ocs.data.entries.length > 0) {
this.results[type].push(...data.ocs.data.entries)
} }
// Check if we reached end of pagination // 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) this.$set(this.reached, type, true)
} }
} else } else
@ -460,8 +517,6 @@ export default {
this.focusIndex(this.focused) this.focusIndex(this.focused)
}) })
} }
this.$set(this.loading, type, false)
}, },
/** /**
@ -574,6 +629,7 @@ export default {
<style lang="scss" scoped> <style lang="scss" scoped>
$margin: 10px; $margin: 10px;
$input-height: 34px;
$input-padding: 6px; $input-padding: 6px;
.unified-search { .unified-search {
@ -583,13 +639,13 @@ $input-padding: 6px;
} }
&__input-wrapper { &__input-wrapper {
width: 100%;
position: sticky; position: sticky;
// above search results // above search results
z-index: 2; z-index: 2;
top: 0; top: 0;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
width: 100%;
background-color: var(--color-main-background); background-color: var(--color-main-background);
} }
@ -601,17 +657,67 @@ $input-padding: 6px;
} }
} }
&__input { &__form {
position: relative;
width: 100%; width: 100%;
height: 34px;
margin: $margin; margin: $margin;
padding: $input-padding;
&, // Loading spinner
&[placeholder], &::after {
&::placeholder { right: $input-padding;
overflow: hidden; left: auto;
white-space: nowrap; }
text-overflow: ellipsis;
&-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;
}
} }
} }