Search spelling suggestions across every list

This commit is contained in:
2026-08-21 11:15:06 -04:00
parent fdf3c51cb0
commit 04107ff8ab
18 changed files with 1005 additions and 82 deletions

View File

@@ -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,
}
}

View 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 }
}