Search spelling suggestions across every list
This commit is contained in:
@@ -1763,3 +1763,31 @@ td.actions .btn + .btn {
|
||||
independently. */
|
||||
.clickable-row { cursor: pointer; }
|
||||
.clickable-row:hover td { background: var(--bg); }
|
||||
|
||||
/* --- Printing from inside the app shell -------------------------------------
|
||||
*
|
||||
* Core's label pages are full-screen routes precisely so the shell cannot reach
|
||||
* the stock. A tool someone works in for ten minutes wants the navigation
|
||||
* though, so the shell is hidden here instead: the sidebar goes, the content
|
||||
* column gives back the margin it was holding for it, and the notification
|
||||
* banner - which is chrome, not content - goes with them.
|
||||
*
|
||||
* Anything the page itself must keep off the paper marks itself .no-print. */
|
||||
@media print {
|
||||
.sidebar,
|
||||
.notification-banner,
|
||||
.no-print {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.app-layout {
|
||||
display: block !important;
|
||||
min-height: 0 !important;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
margin-left: 0 !important;
|
||||
padding: 0 !important;
|
||||
overflow: visible !important;
|
||||
}
|
||||
}
|
||||
|
||||
42
frontend/src/components/SearchSuggestion.vue
Normal file
42
frontend/src/components/SearchSuggestion.vue
Normal file
@@ -0,0 +1,42 @@
|
||||
<template>
|
||||
<!-- Renders nothing at all when there is no suggestion, so a list can drop
|
||||
this in beside its empty state without an extra v-if of its own. -->
|
||||
<p v-if="suggestion" class="search-suggestion">
|
||||
Did you mean
|
||||
<button type="button" class="linklike" @click="$emit('pick', suggestion)">
|
||||
{{ suggestion }}
|
||||
</button>?
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
suggestion: { type: String, default: null },
|
||||
})
|
||||
defineEmits(['pick'])
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.search-suggestion {
|
||||
margin: 0.5rem 0 0;
|
||||
color: var(--text-light, #666);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* A button, not an anchor: it re-runs the search in place rather than
|
||||
navigating, and a link that does not link is a lie to anyone using a
|
||||
keyboard or a screen reader. */
|
||||
.linklike {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
font: inherit;
|
||||
color: var(--primary, #0066cc);
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.linklike:hover {
|
||||
text-decoration: none;
|
||||
}
|
||||
</style>
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -42,6 +42,7 @@
|
||||
|
||||
<div v-else-if="results.length === 0" class="no-results">
|
||||
No results found for "{{ query }}"
|
||||
<SearchSuggestion :suggestion="suggestion" @pick="applySuggestion" />
|
||||
</div>
|
||||
|
||||
<div v-else class="results-list">
|
||||
@@ -94,12 +95,14 @@
|
||||
import { ref, computed, onMounted, watch, nextTick } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { searchApi, knowledgebaseApi } from '../api'
|
||||
import SearchSuggestion from '../components/SearchSuggestion.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const loading = ref(false)
|
||||
const results = ref([])
|
||||
const suggestion = ref(null)
|
||||
const query = ref('')
|
||||
const searchInput = ref('')
|
||||
const activeFilter = ref('all')
|
||||
@@ -175,6 +178,7 @@ const filteredResults = computed(() => {
|
||||
async function search(q) {
|
||||
if (!q || q.length < 2) {
|
||||
results.value = []
|
||||
suggestion.value = null
|
||||
return
|
||||
}
|
||||
|
||||
@@ -184,6 +188,10 @@ async function search(q) {
|
||||
try {
|
||||
const response = await searchApi.search(q)
|
||||
const data = response.data.data
|
||||
// The global endpoint carries the suggestion with the results, so no second
|
||||
// call is needed here. A list page that has no such key uses the
|
||||
// useSearchSuggestion composable against /search/suggest instead.
|
||||
suggestion.value = data?.suggestion || null
|
||||
|
||||
// Handle ServiceNOW redirect
|
||||
if (data?.redirect?.type === 'servicenow') {
|
||||
@@ -212,6 +220,14 @@ async function search(q) {
|
||||
}
|
||||
}
|
||||
|
||||
// Take the offered spelling: put it in the box and search it, so the person
|
||||
// sees what was searched rather than results for a word they did not type.
|
||||
function applySuggestion(word) {
|
||||
searchInput.value = word
|
||||
suggestion.value = null
|
||||
performSearch()
|
||||
}
|
||||
|
||||
function performSearch() {
|
||||
const term = searchInput.value.trim()
|
||||
if (!term) return
|
||||
|
||||
14
frontend/src/views/vendors/VendorsList.vue
vendored
14
frontend/src/views/vendors/VendorsList.vue
vendored
@@ -55,6 +55,7 @@
|
||||
<tr v-if="vendors.length === 0">
|
||||
<td colspan="5" style="text-align: center; color: var(--text-light);">
|
||||
No vendors found
|
||||
<SearchSuggestion :suggestion="suggestion" @pick="applySuggestion" />
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
@@ -189,11 +190,21 @@ import PaginationBar from '../../components/PaginationBar.vue'
|
||||
import { useToast } from '../../composables/toast'
|
||||
import { apiError } from '../../utils/apiError'
|
||||
import { useListQuery } from '@/composables/listQuery'
|
||||
import SearchSuggestion from '@/components/SearchSuggestion.vue'
|
||||
const toast = useToast()
|
||||
|
||||
const vendors = ref([])
|
||||
const loading = ref(true)
|
||||
const { page, search, setPage, setSearch } = useListQuery({ onChange: loadVendors })
|
||||
const { page, search, setPage, setSearch, suggestion, reportCount } = useListQuery({ onChange: loadVendors })
|
||||
|
||||
// Take the offered spelling. setSearch only syncs the URL and the watcher
|
||||
// that reloads is suppressed because search.value already holds the new
|
||||
// term, so load directly.
|
||||
function applySuggestion(word) {
|
||||
search.value = word
|
||||
setSearch(word)
|
||||
loadVendors()
|
||||
}
|
||||
const totalPages = ref(1)
|
||||
const perPage = ref(20)
|
||||
|
||||
@@ -232,6 +243,7 @@ async function loadVendors() {
|
||||
|
||||
const response = await vendorsApi.list(params)
|
||||
vendors.value = response.data.data || []
|
||||
reportCount(vendors.value.length)
|
||||
totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1
|
||||
} catch (err) {
|
||||
console.error('Error loading vendors:', err)
|
||||
|
||||
Reference in New Issue
Block a user