Search spelling suggestions across every list
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useSearchSuggestion } from './searchSuggestion'
|
||||
|
||||
// Keep a list page's current page and search term in the URL query so the
|
||||
// browser Back button restores them. Without this a list keeps page in local
|
||||
@@ -16,6 +17,18 @@ import { useRoute, useRouter } from 'vue-router'
|
||||
// deep link) so the list reloads at the restored page.
|
||||
// extraKeys optional extra query keys to persist (e.g. ['typeid']); each
|
||||
// gets a ref exposed under the returned `extras` object.
|
||||
//
|
||||
// SPELLING SUGGESTIONS come along for free, because this composable already
|
||||
// owns the search term. A list reports how many rows it got and gets back a
|
||||
// suggestion to offer when that number is zero:
|
||||
//
|
||||
// const { search, suggestion, reportCount } = useListQuery({ onChange: load })
|
||||
// // at the end of load(): reportCount(rows.value.length)
|
||||
// // in the empty state: <SearchSuggestion :suggestion="suggestion"
|
||||
// // @pick="applySuggestion" />
|
||||
//
|
||||
// A list that never calls reportCount simply never offers one, which is what
|
||||
// every list did before.
|
||||
export function useListQuery(options = {}) {
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -98,5 +111,17 @@ export function useListQuery(options = {}) {
|
||||
if (changed) onChange()
|
||||
})
|
||||
|
||||
return { page, search, extras, setPage, setSearch, setExtra }
|
||||
const { suggestion, checkSuggestion, clearSuggestion } = useSearchSuggestion()
|
||||
|
||||
// Called by the list after a fetch. Zero rows for a non-empty search is the
|
||||
// only moment a suggestion is worth asking for.
|
||||
function reportCount(count) {
|
||||
if (count > 0 || !search.value) clearSuggestion()
|
||||
else checkSuggestion(search.value)
|
||||
}
|
||||
|
||||
return {
|
||||
page, search, extras, setPage, setSearch, setExtra,
|
||||
suggestion, reportCount,
|
||||
}
|
||||
}
|
||||
|
||||
56
frontend/src/composables/searchSuggestion.js
Normal file
56
frontend/src/composables/searchSuggestion.js
Normal file
@@ -0,0 +1,56 @@
|
||||
import { ref } from 'vue'
|
||||
import api from '../api'
|
||||
|
||||
/**
|
||||
* "Did you mean" for a search that found nothing.
|
||||
*
|
||||
* ONE composable for every list in the product. Thirty routes take a search
|
||||
* parameter across a dozen files, so carrying a suggestion in each of their
|
||||
* responses would be a large change now and a thing every future plugin has to
|
||||
* remember. Instead a page that renders no rows asks for a spelling, and a page
|
||||
* that has not adopted this shows nothing - exactly what it showed before.
|
||||
*
|
||||
* Usage in a list view:
|
||||
*
|
||||
* const { suggestion, checkSuggestion, clearSuggestion } = useSearchSuggestion()
|
||||
* // after a fetch:
|
||||
* items.value.length ? clearSuggestion() : checkSuggestion(searchTerm.value)
|
||||
*
|
||||
* and in the template, beside the empty state:
|
||||
*
|
||||
* <SearchSuggestion :suggestion="suggestion" @pick="applySuggestion" />
|
||||
*/
|
||||
export function useSearchSuggestion() {
|
||||
const suggestion = ref(null)
|
||||
// Guards a slow answer arriving after a newer search has already been typed,
|
||||
// which would offer a spelling for a query nobody is looking at any more.
|
||||
let latest = 0
|
||||
|
||||
function clearSuggestion() {
|
||||
suggestion.value = null
|
||||
latest += 1
|
||||
}
|
||||
|
||||
async function checkSuggestion(query) {
|
||||
const term = (query || '').trim()
|
||||
if (term.length < 2) {
|
||||
clearSuggestion()
|
||||
return
|
||||
}
|
||||
const ticket = ++latest
|
||||
try {
|
||||
const response = await api.get('/search/suggest', { params: { q: term } })
|
||||
if (ticket !== latest) return
|
||||
const offered = response.data?.data?.suggestion || null
|
||||
// Never offer back what was typed: a suggestion identical to the query
|
||||
// reads as a broken feature.
|
||||
suggestion.value = offered && offered !== term.toLowerCase() ? offered : null
|
||||
} catch (err) {
|
||||
// A search that found nothing is already the answer. Failing to improve
|
||||
// on it is not worth an error in front of anyone.
|
||||
if (ticket === latest) suggestion.value = null
|
||||
}
|
||||
}
|
||||
|
||||
return { suggestion, checkSuggestion, clearSuggestion }
|
||||
}
|
||||
Reference in New Issue
Block a user