diff --git a/web/themes/custom/materiotheme/vuejs/components/Form/SearchForm.vue b/web/themes/custom/materiotheme/vuejs/components/Form/SearchForm.vue index a95929bd..bdaf404d 100644 --- a/web/themes/custom/materiotheme/vuejs/components/Form/SearchForm.vue +++ b/web/themes/custom/materiotheme/vuejs/components/Form/SearchForm.vue @@ -135,16 +135,17 @@ export default { // let tag_list = n // } // remove terms from autocomplete if removed from typed + if (typeof n !== 'string') { + return + } const r = /,\s?$/ // remove last comma space let tag_list = n.replace(r,'').split(', ') console.log('watch typed tag_list', tag_list) console.log('watch typed autocomplete before', this.autocomplete) - this.autocomplete.forEach( (term,index,array) => { - console.log("watch typed autocomplete term", term, index, array) - if (tag_list.indexOf(term.label) < 0) { - this.autocomplete.splice(index,1) - } - }); + // Keep only the terms whose label is still present in the typed text. + // Filtering into a new array avoids skipping items when splicing during + // iteration (which left stale terms in autocomplete, corrupting `terms`). + this.autocomplete = this.autocomplete.filter(term => tag_list.indexOf(term.label) >= 0) console.log('watch typed autocomplete after', this.autocomplete) }, keys(n, o){ @@ -166,6 +167,20 @@ export default { mounted(){ // console.log('SearchForm mounted') 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 this.$input = this.$el.querySelector('#edit-search') // // focus on input diff --git a/web/themes/custom/materiotheme/vuejs/components/Pages/Base.vue b/web/themes/custom/materiotheme/vuejs/components/Pages/Base.vue index 1076b0eb..af113df6 100644 --- a/web/themes/custom/materiotheme/vuejs/components/Pages/Base.vue +++ b/web/themes/custom/materiotheme/vuejs/components/Pages/Base.vue @@ -11,13 +11,14 @@ {{ $t('default.Loading…') }}
No more results
@@ -48,8 +49,14 @@ export default { searchinfos: state => state.Search.infos, count: state => state.Search.count, 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: { ...mapActions({ @@ -98,7 +105,10 @@ export default { // todo text field of search form is not emptying on clicking on base after a search // this.$store.commit('Search/setKeys', to.query.keys) 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{ this.$store.commit('Search/reSetTerms') } diff --git a/web/themes/custom/materiotheme/vuejs/store/modules/search.js b/web/themes/custom/materiotheme/vuejs/store/modules/search.js index 2480e5a6..738c3f4f 100644 --- a/web/themes/custom/materiotheme/vuejs/store/modules/search.js +++ b/web/themes/custom/materiotheme/vuejs/store/modules/search.js @@ -25,6 +25,9 @@ export default { infos: null, count: 0, 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 // implemented in vuejs/components/Content/Base.vue infiniteLoadingState: null @@ -35,23 +38,38 @@ export default { // mutations mutations: { - setUuids (state, uuids) { - state.uuids = state.uuids.concat(uuids) + setUuids (state, { uuids, offset }) { + // 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) { state.uuids = [] }, - setResults (state, items) { - // console.log('setResults, items', items) - if (items) { // avoid bug like items = [null] - state.items = state.items.concat(items) + setResults (state, { items, offset }) { + // Same as setUuids: position each page by its offset so the display order + // always matches the backend relevance order, whatever the network race. + 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) { // state.items[data.index] = data.item // this is not triggering vuejs refresh // 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 if (index !== -1) { Vue.set(state.items, index, item) @@ -107,6 +125,9 @@ export default { }, setInfiniteState (state, infiniteLoadingstate) { state.infiniteLoadingState = infiniteLoadingstate + }, + incrementSearchId (state) { + state.searchId++ } }, @@ -120,6 +141,8 @@ export default { commit('resetNoresults') commit('resetOffset') commit('resetInfos') + // Invalidate any in-flight response from a previous search. + commit('incrementSearchId') if (state.keys || state.terms.length) { this.commit('Common/setPagetitle', state.keys.join(', ')) } else { @@ -134,10 +157,15 @@ export default { dispatch('getResults') }, 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 = { keys: state.keys.join(', '), terms: JSON.stringify(state.terms), - offset: state.offset, + offset: offset, limit: state.limit } console.log('search store getResults, params', params) @@ -153,11 +181,15 @@ export default { return MA.get('/materio_sapi/getresults?' + q) .then(({ data }) => { 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('setInfos', data.infos) commit('setCount', data.count) if (data.count) { - commit('setUuids', data.uuids) + commit('setUuids', { uuids: data.uuids, offset }) // loadMaterials is on mixins // https://github.com/huybuidac/vuex-extensions // dispatch('loadMaterialsGQL', { @@ -175,7 +207,11 @@ export default { MGQ.post('', { query: print(ast) }) .then(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 => { console.warn('Issue with loadSearchResults', error) @@ -191,9 +227,9 @@ export default { Promise.reject(error) }) }, - loadSearchResultsCallBack ({ commit, state }, { items, callBackArgs }) { + loadSearchResultsCallBack ({ commit, state }, { items, offset, callBackArgs }) { console.log('search loadSearchResultsCallBack', items) - commit('setResults', items) + commit('setResults', { items, offset }) if (state.infiniteLoadingState) { if (state.offset + state.limit > state.count) { console.log('Search infinite completed')