Merge pull request #22868 from nextcloud/fix/unified-search-papercuts
Fix/unified search papercuts
This commit is contained in:
commit
bd9af41808
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
|
@ -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: {
|
||||
|
|
|
@ -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
|
@ -1,4 +1,4 @@
|
|||
/*
|
||||
/**
|
||||
* @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
|
||||
*
|
||||
* @author 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
/*
|
||||
/**
|
||||
* @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
|
||||
*
|
||||
* @author 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
/*
|
||||
/**
|
||||
* @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
|
||||
*
|
||||
* @author 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
/*
|
||||
/**
|
||||
* @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
|
||||
*
|
||||
* @author 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
/*
|
||||
/**
|
||||
* @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
|
||||
*
|
||||
* @author 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
Loading…
Reference in New Issue