SearchForm: fix inconsistent/mixed search results
- SearchForm.vue: route Enter key through submit() (native form submit interception) so it behaves like the button; fix autocomplete array mutation during iteration in the typed watcher. - Base.vue: parse terms consistently in beforeRouteUpdate; render items via displayedItems (drops sparse holes); only mount infinite-loading after the first page and reset it per search via :identifier. - search store: guard against stale async responses (searchId); place each page at its offset instead of concatenating in arrival order, which mixed pages when a later page resolved first.
This commit is contained in:
@@ -135,16 +135,17 @@ export default {
|
|||||||
// let tag_list = n
|
// let tag_list = n
|
||||||
// }
|
// }
|
||||||
// remove terms from autocomplete if removed from typed
|
// remove terms from autocomplete if removed from typed
|
||||||
|
if (typeof n !== 'string') {
|
||||||
|
return
|
||||||
|
}
|
||||||
const r = /,\s?$/ // remove last comma space
|
const r = /,\s?$/ // remove last comma space
|
||||||
let tag_list = n.replace(r,'').split(', ')
|
let tag_list = n.replace(r,'').split(', ')
|
||||||
console.log('watch typed tag_list', tag_list)
|
console.log('watch typed tag_list', tag_list)
|
||||||
console.log('watch typed autocomplete before', this.autocomplete)
|
console.log('watch typed autocomplete before', this.autocomplete)
|
||||||
this.autocomplete.forEach( (term,index,array) => {
|
// Keep only the terms whose label is still present in the typed text.
|
||||||
console.log("watch typed autocomplete term", term, index, array)
|
// Filtering into a new array avoids skipping items when splicing during
|
||||||
if (tag_list.indexOf(term.label) < 0) {
|
// iteration (which left stale terms in autocomplete, corrupting `terms`).
|
||||||
this.autocomplete.splice(index,1)
|
this.autocomplete = this.autocomplete.filter(term => tag_list.indexOf(term.label) >= 0)
|
||||||
}
|
|
||||||
});
|
|
||||||
console.log('watch typed autocomplete after', this.autocomplete)
|
console.log('watch typed autocomplete after', this.autocomplete)
|
||||||
},
|
},
|
||||||
keys(n, o){
|
keys(n, o){
|
||||||
@@ -166,6 +167,20 @@ export default {
|
|||||||
mounted(){
|
mounted(){
|
||||||
// console.log('SearchForm mounted')
|
// console.log('SearchForm mounted')
|
||||||
Drupal.attachBehaviors(this.$el);
|
Drupal.attachBehaviors(this.$el);
|
||||||
|
|
||||||
|
// Route the native form submission (Enter key) through the same submit()
|
||||||
|
// method as the button (@click.prevent="submit"). Without this, pressing
|
||||||
|
// Enter either triggers a native POST reload or only fills the jQuery UI
|
||||||
|
// autocomplete selection, so keys/terms sent to the backend differ from a
|
||||||
|
// button click and the search results are inconsistent.
|
||||||
|
// Note: when the autocomplete menu is open, jQuery UI handles Enter itself
|
||||||
|
// (selects the highlighted suggestion) and prevents this submit event, which
|
||||||
|
// is the intended behaviour.
|
||||||
|
this.$el.addEventListener('submit', (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
this.submit()
|
||||||
|
})
|
||||||
|
|
||||||
// get the search input
|
// get the search input
|
||||||
this.$input = this.$el.querySelector('#edit-search')
|
this.$input = this.$el.querySelector('#edit-search')
|
||||||
// // focus on input
|
// // focus on input
|
||||||
|
|||||||
@@ -11,13 +11,14 @@
|
|||||||
<span>{{ $t('default.Loading…') }}</span>
|
<span>{{ $t('default.Loading…') }}</span>
|
||||||
</div>
|
</div>
|
||||||
<ul v-else>
|
<ul v-else>
|
||||||
<li v-for="item in items" v-bind:key="item.id">
|
<li v-for="item in displayedItems" v-bind:key="item.id">
|
||||||
<Card v-if="item.bundle == 'materiau'" :item="item"/>
|
<Card v-if="item.bundle == 'materiau'" :item="item"/>
|
||||||
<CardThematique v-if="item.bundle == 'thematique'" :item="item"/>
|
<CardThematique v-if="item.bundle == 'thematique'" :item="item"/>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<infinite-loading
|
<infinite-loading
|
||||||
v-if="count > limit"
|
v-if="count > limit && displayedItems.length >= limit"
|
||||||
|
:identifier="searchId"
|
||||||
@infinite="nextPage"
|
@infinite="nextPage"
|
||||||
>
|
>
|
||||||
<div slot="no-more">No more results</div>
|
<div slot="no-more">No more results</div>
|
||||||
@@ -48,8 +49,14 @@ export default {
|
|||||||
searchinfos: state => state.Search.infos,
|
searchinfos: state => state.Search.infos,
|
||||||
count: state => state.Search.count,
|
count: state => state.Search.count,
|
||||||
noresults: state => state.Search.noresults,
|
noresults: state => state.Search.noresults,
|
||||||
limit: state => state.Search.limit
|
limit: state => state.Search.limit,
|
||||||
})
|
searchId: state => state.Search.searchId
|
||||||
|
}),
|
||||||
|
// items is a sparse array (pages placed by offset); drop the holes left by
|
||||||
|
// pages that returned fewer results than the limit before rendering.
|
||||||
|
displayedItems () {
|
||||||
|
return this.items.filter(Boolean)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
...mapActions({
|
...mapActions({
|
||||||
@@ -98,7 +105,10 @@ export default {
|
|||||||
// todo text field of search form is not emptying on clicking on base after a search
|
// todo text field of search form is not emptying on clicking on base after a search
|
||||||
// this.$store.commit('Search/setKeys', to.query.keys)
|
// this.$store.commit('Search/setKeys', to.query.keys)
|
||||||
if (to.query.hasOwnProperty('terms')) {
|
if (to.query.hasOwnProperty('terms')) {
|
||||||
this.$store.commit('Search/setTerms', to.query.terms)
|
// to.query.terms is a raw JSON string (see SearchForm.submit) and must be
|
||||||
|
// parsed like in created(), otherwise getResults() double-encodes it and
|
||||||
|
// the backend silently drops the terms (less relevant results).
|
||||||
|
this.$store.commit('Search/setTerms', JSON.parse(to.query.terms))
|
||||||
}else{
|
}else{
|
||||||
this.$store.commit('Search/reSetTerms')
|
this.$store.commit('Search/reSetTerms')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,9 @@ export default {
|
|||||||
infos: null,
|
infos: null,
|
||||||
count: 0,
|
count: 0,
|
||||||
noresults: false,
|
noresults: false,
|
||||||
|
// Incremented on every new search. Async responses (MA + GraphQL) carrying an
|
||||||
|
// outdated id are discarded to avoid mixing results from concurrent searches.
|
||||||
|
searchId: 0,
|
||||||
// infinteState will come from vue-infinite-loading plugin
|
// infinteState will come from vue-infinite-loading plugin
|
||||||
// implemented in vuejs/components/Content/Base.vue
|
// implemented in vuejs/components/Content/Base.vue
|
||||||
infiniteLoadingState: null
|
infiniteLoadingState: null
|
||||||
@@ -35,23 +38,38 @@ export default {
|
|||||||
|
|
||||||
// mutations
|
// mutations
|
||||||
mutations: {
|
mutations: {
|
||||||
setUuids (state, uuids) {
|
setUuids (state, { uuids, offset }) {
|
||||||
state.uuids = state.uuids.concat(uuids)
|
// Place the page at its offset position instead of concatenating in
|
||||||
|
// response-arrival order. With infinite-loading firing several pages
|
||||||
|
// concurrently, concat mixed pages when a later page resolved first.
|
||||||
|
if (!uuids) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const newUuids = state.uuids.slice()
|
||||||
|
uuids.forEach((uuid, i) => {
|
||||||
|
newUuids[offset + i] = uuid
|
||||||
|
})
|
||||||
|
state.uuids = newUuids
|
||||||
},
|
},
|
||||||
resetUuids (state) {
|
resetUuids (state) {
|
||||||
state.uuids = []
|
state.uuids = []
|
||||||
},
|
},
|
||||||
setResults (state, items) {
|
setResults (state, { items, offset }) {
|
||||||
// console.log('setResults, items', items)
|
// Same as setUuids: position each page by its offset so the display order
|
||||||
if (items) { // avoid bug like items = [null]
|
// always matches the backend relevance order, whatever the network race.
|
||||||
state.items = state.items.concat(items)
|
if (!items) { // avoid bug like items = [null]
|
||||||
|
return
|
||||||
}
|
}
|
||||||
// console.log('setResults, state.items', state.items)
|
const newItems = state.items.slice()
|
||||||
|
items.forEach((item, i) => {
|
||||||
|
newItems[offset + i] = item
|
||||||
|
})
|
||||||
|
state.items = newItems
|
||||||
},
|
},
|
||||||
updateItem (state, item) {
|
updateItem (state, item) {
|
||||||
// state.items[data.index] = data.item // this is not triggering vuejs refresh
|
// state.items[data.index] = data.item // this is not triggering vuejs refresh
|
||||||
// find the right index
|
// find the right index
|
||||||
const index = state.items.findIndex(i => i.id === item.id)
|
const index = state.items.findIndex(i => i && i.id === item.id)
|
||||||
// update the array
|
// update the array
|
||||||
if (index !== -1) {
|
if (index !== -1) {
|
||||||
Vue.set(state.items, index, item)
|
Vue.set(state.items, index, item)
|
||||||
@@ -107,6 +125,9 @@ export default {
|
|||||||
},
|
},
|
||||||
setInfiniteState (state, infiniteLoadingstate) {
|
setInfiniteState (state, infiniteLoadingstate) {
|
||||||
state.infiniteLoadingState = infiniteLoadingstate
|
state.infiniteLoadingState = infiniteLoadingstate
|
||||||
|
},
|
||||||
|
incrementSearchId (state) {
|
||||||
|
state.searchId++
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -120,6 +141,8 @@ export default {
|
|||||||
commit('resetNoresults')
|
commit('resetNoresults')
|
||||||
commit('resetOffset')
|
commit('resetOffset')
|
||||||
commit('resetInfos')
|
commit('resetInfos')
|
||||||
|
// Invalidate any in-flight response from a previous search.
|
||||||
|
commit('incrementSearchId')
|
||||||
if (state.keys || state.terms.length) {
|
if (state.keys || state.terms.length) {
|
||||||
this.commit('Common/setPagetitle', state.keys.join(', '))
|
this.commit('Common/setPagetitle', state.keys.join(', '))
|
||||||
} else {
|
} else {
|
||||||
@@ -134,10 +157,15 @@ export default {
|
|||||||
dispatch('getResults')
|
dispatch('getResults')
|
||||||
},
|
},
|
||||||
getResults ({ dispatch, commit, state }) {
|
getResults ({ dispatch, commit, state }) {
|
||||||
|
// Capture the search token; a newSearch bumps it and makes this run stale.
|
||||||
|
const searchId = state.searchId
|
||||||
|
// Capture the offset of THIS request so the response is placed correctly
|
||||||
|
// even if a later page (higher offset) resolves before it.
|
||||||
|
const offset = state.offset
|
||||||
const params = {
|
const params = {
|
||||||
keys: state.keys.join(', '),
|
keys: state.keys.join(', '),
|
||||||
terms: JSON.stringify(state.terms),
|
terms: JSON.stringify(state.terms),
|
||||||
offset: state.offset,
|
offset: offset,
|
||||||
limit: state.limit
|
limit: state.limit
|
||||||
}
|
}
|
||||||
console.log('search store getResults, params', params)
|
console.log('search store getResults, params', params)
|
||||||
@@ -153,11 +181,15 @@ export default {
|
|||||||
return MA.get('/materio_sapi/getresults?' + q)
|
return MA.get('/materio_sapi/getresults?' + q)
|
||||||
.then(({ data }) => {
|
.then(({ data }) => {
|
||||||
console.log('search MA getresults data', data, state.terms)
|
console.log('search MA getresults data', data, state.terms)
|
||||||
|
// Discard the response if another search was started meanwhile.
|
||||||
|
if (searchId !== state.searchId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
// commit('setItems', data.items)
|
// commit('setItems', data.items)
|
||||||
commit('setInfos', data.infos)
|
commit('setInfos', data.infos)
|
||||||
commit('setCount', data.count)
|
commit('setCount', data.count)
|
||||||
if (data.count) {
|
if (data.count) {
|
||||||
commit('setUuids', data.uuids)
|
commit('setUuids', { uuids: data.uuids, offset })
|
||||||
// loadMaterials is on mixins
|
// loadMaterials is on mixins
|
||||||
// https://github.com/huybuidac/vuex-extensions
|
// https://github.com/huybuidac/vuex-extensions
|
||||||
// dispatch('loadMaterialsGQL', {
|
// dispatch('loadMaterialsGQL', {
|
||||||
@@ -175,7 +207,11 @@ export default {
|
|||||||
MGQ.post('', { query: print(ast) })
|
MGQ.post('', { query: print(ast) })
|
||||||
.then(resp => {
|
.then(resp => {
|
||||||
console.log('loadSearchResultsGQL resp', resp)
|
console.log('loadSearchResultsGQL resp', resp)
|
||||||
dispatch('loadSearchResultsCallBack', { items: resp.data.data.searchresults })
|
// Discard if a newer search was started while GraphQL was in flight.
|
||||||
|
if (searchId !== state.searchId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dispatch('loadSearchResultsCallBack', { items: resp.data.data.searchresults, offset })
|
||||||
})
|
})
|
||||||
.catch(error => {
|
.catch(error => {
|
||||||
console.warn('Issue with loadSearchResults', error)
|
console.warn('Issue with loadSearchResults', error)
|
||||||
@@ -191,9 +227,9 @@ export default {
|
|||||||
Promise.reject(error)
|
Promise.reject(error)
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
loadSearchResultsCallBack ({ commit, state }, { items, callBackArgs }) {
|
loadSearchResultsCallBack ({ commit, state }, { items, offset, callBackArgs }) {
|
||||||
console.log('search loadSearchResultsCallBack', items)
|
console.log('search loadSearchResultsCallBack', items)
|
||||||
commit('setResults', items)
|
commit('setResults', { items, offset })
|
||||||
if (state.infiniteLoadingState) {
|
if (state.infiniteLoadingState) {
|
||||||
if (state.offset + state.limit > state.count) {
|
if (state.offset + state.limit > state.count) {
|
||||||
console.log('Search infinite completed')
|
console.log('Search infinite completed')
|
||||||
|
|||||||
Reference in New Issue
Block a user