Offer a spelling on every list, not just the global search
Some checks failed
CI / backend (push) Failing after 7m15s
CI / frontend (push) Has been cancelled
CI / migrations-mysql (push) Has been cancelled
CI / naming (push) Has been cancelled

Correction only helped on the global search page, and people search from the
list they are already on. Extending it turned out to be a shape question rather
than a volume one: thirty routes take a search parameter across a dozen files,
so carrying a suggestion in each of their responses is a large change today and
one more thing every future plugin author has to remember.

So the suggestion moved to ITS OWN ENDPOINT, /api/search/suggest, which any page
can call after rendering no rows. A page that has not adopted it shows nothing,
which is exactly what it showed before - nothing breaks by omission.

The plumbing lives in useListQuery, which already owned the search term, so a
list needs three lines: take `suggestion` and `reportCount` from the composable,
call reportCount(rows.length) after a fetch, and drop SearchSuggestion into the
empty state it already has. A list that never calls reportCount never offers a
suggestion.

Wired: global search, machines, printers, PCs, network devices, measuring tools,
knowledge base, vendors. NOT wired, deliberately: the type and reference lists
(machine types, PC types, VLANs, subnets, operating systems and the rest), which
are small controlled vocabularies nobody typo-searches, and USB, whose empty
state has a different shape and wants doing by hand rather than by pattern.

TRAP FOUND WHILE WIRING IT, and left commented in every page: applying a
suggestion by calling setSearch alone updates the box and the URL and does NOT
reload the list. setSearch only syncs the URL, and the watcher that would reload
is suppressed because search.value already holds the new term - the same trap
the global search page documents in performSearch. Each page calls its own load
function directly.

The composable guards a stale answer arriving after a newer search was typed,
never offers back the word that was typed, and swallows its own errors: a search
that found nothing is already the answer, and failing to improve on it is not
worth an error in front of anyone.

The route-parity gate caught the new endpoint being served without an entry in
docs/api-inventory.json, which is hand-written on purpose; added, and the spec
regenerated from it (283 paths, 418 operations). That regeneration also carries
the openapi version to 0.12.0, left over from the release.
This commit is contained in:
cproudlock
2026-08-21 11:14:56 -04:00
parent 1abb6430f5
commit 5001aedcf9
15 changed files with 330 additions and 9 deletions

View File

@@ -351,6 +351,14 @@
"params": "q (required, 2-200 chars)", "params": "q (required, 2-200 chars)",
"example": "curl 'http://localhost:5001/api/search?q=WKSTN0042'" "example": "curl 'http://localhost:5001/api/search?q=WKSTN0042'"
}, },
{
"method": "GET",
"path": "/api/search/suggest",
"purpose": "A better spelling for a query that found nothing, or null. Its own endpoint so any list page can ask after rendering no rows, rather than a suggestion key added to every search route. The vocabulary is harvested from searched columns (asset numbers and names, vendors, models, applications, asset types) and cached, so site-specific words are correctable; digits must match exactly, which keeps CSF16 from being 'corrected' to CSF17. Suggests, never rewrites",
"auth": "jwt-optional",
"params": "q (required, 2-200 chars)",
"example": "curl 'http://localhost:5001/api/search/suggest?q=keyecne'"
},
{ {
"method": "GET", "method": "GET",
"path": "/api/dashboard", "path": "/api/dashboard",

View File

@@ -2,7 +2,7 @@
"openapi": "3.1.0", "openapi": "3.1.0",
"info": { "info": {
"title": "ShopDB Flask API", "title": "ShopDB Flask API",
"version": "0.11.3", "version": "0.12.0",
"description": "Asset-management API (core + plugins). Responses use a `success_response` envelope: `{status, data, meta}`. Auth: Bearer JWT (login or a managed PAT) for `jwt`/`admin`/`permission:*`; `X-API-Key` for collector/managed-token endpoints; public endpoints need neither." "description": "Asset-management API (core + plugins). Responses use a `success_response` envelope: `{status, data, meta}`. Auth: Bearer JWT (login or a managed PAT) for `jwt`/`admin`/`permission:*`; `X-API-Key` for collector/managed-token endpoints; public endpoints need neither."
}, },
"servers": [ "servers": [
@@ -2092,6 +2092,39 @@
} }
} }
}, },
"/api/search/suggest": {
"get": {
"tags": [
"core-platform"
],
"summary": "A better spelling for a query that found nothing, or null. Its own endpoint so any list page can ask after rendering no...",
"description": "A better spelling for a query that found nothing, or null. Its own endpoint so any list page can ask after rendering no rows, rather than a suggestion key added to every search route. The vocabulary is harvested from searched columns (asset numbers and names, vendors, models, applications, asset types) and cached, so site-specific words are correctable; digits must match exactly, which keeps CSF16 from being 'corrected' to CSF17. Suggests, never rewrites\n\n**Auth:** jwt-optional\n\n**Params:** q (required, 2-200 chars)\n\n**Example:**\n```\ncurl 'http://localhost:5001/api/search/suggest?q=keyecne'\n```",
"security": [
{},
{
"bearerAuth": []
}
],
"responses": {
"200": {
"description": "Success. Body is the success_response envelope: {status, data, meta}.",
"content": {
"application/json": {
"$ref": "#/components/schemas/SuccessEnvelope"
}
}
},
"default": {
"description": "Error. Body is the error envelope; the code and message are nested under data.error.",
"content": {
"application/json": {
"$ref": "#/components/schemas/ErrorEnvelope"
}
}
}
}
}
},
"/api/dashboard": { "/api/dashboard": {
"get": { "get": {
"tags": [ "tags": [

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

View File

@@ -1,5 +1,6 @@
import { ref, watch } from 'vue' import { ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router' 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 // 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 // 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. // deep link) so the list reloads at the restored page.
// extraKeys optional extra query keys to persist (e.g. ['typeid']); each // extraKeys optional extra query keys to persist (e.g. ['typeid']); each
// gets a ref exposed under the returned `extras` object. // 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 = {}) { export function useListQuery(options = {}) {
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
@@ -98,5 +111,17 @@ export function useListQuery(options = {}) {
if (changed) onChange() 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 }
}

View File

@@ -42,6 +42,7 @@
<div v-else-if="results.length === 0" class="no-results"> <div v-else-if="results.length === 0" class="no-results">
No results found for "{{ query }}" No results found for "{{ query }}"
<SearchSuggestion :suggestion="suggestion" @pick="applySuggestion" />
</div> </div>
<div v-else class="results-list"> <div v-else class="results-list">
@@ -94,12 +95,14 @@
import { ref, computed, onMounted, watch, nextTick } from 'vue' import { ref, computed, onMounted, watch, nextTick } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { searchApi, knowledgebaseApi } from '../api' import { searchApi, knowledgebaseApi } from '../api'
import SearchSuggestion from '../components/SearchSuggestion.vue'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const loading = ref(false) const loading = ref(false)
const results = ref([]) const results = ref([])
const suggestion = ref(null)
const query = ref('') const query = ref('')
const searchInput = ref('') const searchInput = ref('')
const activeFilter = ref('all') const activeFilter = ref('all')
@@ -175,6 +178,7 @@ const filteredResults = computed(() => {
async function search(q) { async function search(q) {
if (!q || q.length < 2) { if (!q || q.length < 2) {
results.value = [] results.value = []
suggestion.value = null
return return
} }
@@ -184,6 +188,10 @@ async function search(q) {
try { try {
const response = await searchApi.search(q) const response = await searchApi.search(q)
const data = response.data.data 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 // Handle ServiceNOW redirect
if (data?.redirect?.type === 'servicenow') { 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() { function performSearch() {
const term = searchInput.value.trim() const term = searchInput.value.trim()
if (!term) return if (!term) return

View File

@@ -55,6 +55,7 @@
<tr v-if="vendors.length === 0"> <tr v-if="vendors.length === 0">
<td colspan="5" style="text-align: center; color: var(--text-light);"> <td colspan="5" style="text-align: center; color: var(--text-light);">
No vendors found No vendors found
<SearchSuggestion :suggestion="suggestion" @pick="applySuggestion" />
</td> </td>
</tr> </tr>
</tbody> </tbody>
@@ -189,11 +190,21 @@ import PaginationBar from '../../components/PaginationBar.vue'
import { useToast } from '../../composables/toast' import { useToast } from '../../composables/toast'
import { apiError } from '../../utils/apiError' import { apiError } from '../../utils/apiError'
import { useListQuery } from '@/composables/listQuery' import { useListQuery } from '@/composables/listQuery'
import SearchSuggestion from '@/components/SearchSuggestion.vue'
const toast = useToast() const toast = useToast()
const vendors = ref([]) const vendors = ref([])
const loading = ref(true) 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 totalPages = ref(1)
const perPage = ref(20) const perPage = ref(20)
@@ -232,6 +243,7 @@ async function loadVendors() {
const response = await vendorsApi.list(params) const response = await vendorsApi.list(params)
vendors.value = response.data.data || [] vendors.value = response.data.data || []
reportCount(vendors.value.length)
totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1 totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1
} catch (err) { } catch (err) {
console.error('Error loading vendors:', err) console.error('Error loading vendors:', err)

View File

@@ -66,6 +66,7 @@
<tr v-if="computers.length === 0"> <tr v-if="computers.length === 0">
<td colspan="8" style="text-align: center; color: var(--text-light);"> <td colspan="8" style="text-align: center; color: var(--text-light);">
No computers found No computers found
<SearchSuggestion :suggestion="suggestion" @pick="applySuggestion" />
</td> </td>
</tr> </tr>
</tbody> </tbody>
@@ -91,10 +92,20 @@ import { computersApi } from '@/api'
import PaginationBar from '@/components/PaginationBar.vue' import PaginationBar from '@/components/PaginationBar.vue'
import { colorStyle } from '@/utils/colorStyle' import { colorStyle } from '@/utils/colorStyle'
import { useListQuery } from '@/composables/listQuery' import { useListQuery } from '@/composables/listQuery'
import SearchSuggestion from '@/components/SearchSuggestion.vue'
const computers = ref([]) const computers = ref([])
const loading = ref(true) const loading = ref(true)
const { page, search, setPage, setSearch } = useListQuery({ onChange: loadComputers }) const { page, search, setPage, setSearch, suggestion, reportCount } = useListQuery({ onChange: loadComputers })
// 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)
loadComputers()
}
const totalPages = ref(1) const totalPages = ref(1)
const perPage = ref(20) const perPage = ref(20)
@@ -115,6 +126,7 @@ async function loadComputers() {
const response = await computersApi.list(params) const response = await computersApi.list(params)
computers.value = response.data.data || [] computers.value = response.data.data || []
reportCount(computers.value.length)
totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1 totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1
} catch (error) { } catch (error) {
console.error('Error loading computers:', error) console.error('Error loading computers:', error)

View File

@@ -88,6 +88,7 @@
<tr v-if="articles.length === 0"> <tr v-if="articles.length === 0">
<td colspan="4" style="text-align: center; color: var(--text-light);"> <td colspan="4" style="text-align: center; color: var(--text-light);">
No articles found No articles found
<SearchSuggestion :suggestion="suggestion" @pick="applySuggestion" />
</td> </td>
</tr> </tr>
</tbody> </tbody>
@@ -112,12 +113,22 @@ import { ref, onMounted } from 'vue'
import { knowledgebaseApi, applicationsApi } from '@/api' import { knowledgebaseApi, applicationsApi } from '@/api'
import PaginationBar from '@/components/PaginationBar.vue' import PaginationBar from '@/components/PaginationBar.vue'
import { useListQuery } from '@/composables/listQuery' import { useListQuery } from '@/composables/listQuery'
import SearchSuggestion from '@/components/SearchSuggestion.vue'
const loading = ref(true) const loading = ref(true)
const articles = ref([]) const articles = ref([])
const topics = ref([]) const topics = ref([])
const stats = ref(null) const stats = ref(null)
const { page, search, setPage, setSearch } = useListQuery({ onChange: loadArticles }) const { page, search, setPage, setSearch, suggestion, reportCount } = useListQuery({ onChange: loadArticles })
// 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)
loadArticles()
}
const perPage = ref(20) const perPage = ref(20)
const totalPages = ref(1) const totalPages = ref(1)
const topicFilter = ref('') const topicFilter = ref('')
@@ -161,6 +172,7 @@ async function loadArticles() {
const response = await knowledgebaseApi.list(params) const response = await knowledgebaseApi.list(params)
articles.value = response.data.data || [] articles.value = response.data.data || []
reportCount(articles.value.length)
totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1 totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1
} catch (error) { } catch (error) {
console.error('Error loading articles:', error) console.error('Error loading articles:', error)

View File

@@ -79,6 +79,7 @@
<tr v-if="machines.length === 0"> <tr v-if="machines.length === 0">
<td colspan="8" style="text-align: center; color: var(--text-light);"> <td colspan="8" style="text-align: center; color: var(--text-light);">
No machines found No machines found
<SearchSuggestion :suggestion="suggestion" @pick="applySuggestion" />
</td> </td>
</tr> </tr>
</tbody> </tbody>
@@ -105,10 +106,22 @@ import { colorStyle } from "@/utils/colorStyle"
import { machinesApi } from '@/api' import { machinesApi } from '@/api'
import PaginationBar from '@/components/PaginationBar.vue' import PaginationBar from '@/components/PaginationBar.vue'
import { useListQuery } from '@/composables/listQuery' import { useListQuery } from '@/composables/listQuery'
import SearchSuggestion from '@/components/SearchSuggestion.vue'
const machines = ref([]) const machines = ref([])
const loading = ref(true) const loading = ref(true)
const { page, search, setPage, setSearch } = useListQuery({ onChange: loadMachines }) const { page, search, setPage, setSearch, suggestion, reportCount } = useListQuery({ onChange: loadMachines })
// Take the offered spelling: put it in the box and search it, so what was
// searched is what the person sees.
function applySuggestion(word) {
search.value = word
setSearch(word)
// setSearch only syncs the URL, and the watcher that reloads is suppressed
// because search.value already holds the new term - the same trap the global
// search page documents. Load directly.
loadMachines()
}
const totalPages = ref(1) const totalPages = ref(1)
const perPage = ref(20) const perPage = ref(20)
@@ -129,6 +142,7 @@ async function loadMachines() {
const response = await machinesApi.list(params) const response = await machinesApi.list(params)
machines.value = response.data.data || [] machines.value = response.data.data || []
reportCount(machines.value.length)
totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1 totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1
} catch (error) { } catch (error) {
console.error('Error loading machines:', error) console.error('Error loading machines:', error)

View File

@@ -73,6 +73,7 @@
<tr v-if="tools.length === 0"> <tr v-if="tools.length === 0">
<td colspan="8" style="text-align: center; color: var(--text-light);"> <td colspan="8" style="text-align: center; color: var(--text-light);">
No measuring tools found No measuring tools found
<SearchSuggestion :suggestion="suggestion" @pick="applySuggestion" />
</td> </td>
</tr> </tr>
</tbody> </tbody>
@@ -97,11 +98,21 @@ import { colorStyle } from '@/utils/colorStyle'
import { measuringtoolsApi } from '@/api' import { measuringtoolsApi } from '@/api'
import PaginationBar from '@/components/PaginationBar.vue' import PaginationBar from '@/components/PaginationBar.vue'
import { useListQuery } from '@/composables/listQuery' import { useListQuery } from '@/composables/listQuery'
import SearchSuggestion from '@/components/SearchSuggestion.vue'
const tools = ref([]) const tools = ref([])
const types = ref([]) const types = ref([])
const loading = ref(true) const loading = ref(true)
const { page, search, setPage, setSearch } = useListQuery({ onChange: loadTools }) const { page, search, setPage, setSearch, suggestion, reportCount } = useListQuery({ onChange: loadTools })
// 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)
loadTools()
}
const typeid = ref('') const typeid = ref('')
const calibrationstatus = ref('') const calibrationstatus = ref('')
const totalPages = ref(1) const totalPages = ref(1)
@@ -133,6 +144,7 @@ async function loadTools() {
const response = await measuringtoolsApi.list(params) const response = await measuringtoolsApi.list(params)
tools.value = response.data.data || [] tools.value = response.data.data || []
reportCount(tools.value.length)
totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1 totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1
} catch (err) { } catch (err) {
console.error('Error loading measuring tools:', err) console.error('Error loading measuring tools:', err)

View File

@@ -97,6 +97,7 @@
<tr v-if="devices.length === 0"> <tr v-if="devices.length === 0">
<td colspan="9" style="text-align: center; color: var(--text-light);"> <td colspan="9" style="text-align: center; color: var(--text-light);">
No network devices found No network devices found
<SearchSuggestion :suggestion="suggestion" @pick="applySuggestion" />
</td> </td>
</tr> </tr>
</tbody> </tbody>
@@ -122,13 +123,23 @@ import { colorStyle } from "@/utils/colorStyle"
import { networkApi, vendorsApi, locationsApi } from '@/api' import { networkApi, vendorsApi, locationsApi } from '@/api'
import PaginationBar from '@/components/PaginationBar.vue' import PaginationBar from '@/components/PaginationBar.vue'
import { useListQuery } from '@/composables/listQuery' import { useListQuery } from '@/composables/listQuery'
import SearchSuggestion from '@/components/SearchSuggestion.vue'
const devices = ref([]) const devices = ref([])
const deviceTypes = ref([]) const deviceTypes = ref([])
const vendors = ref([]) const vendors = ref([])
const locations = ref([]) const locations = ref([])
const loading = ref(true) const loading = ref(true)
const { page, search, setPage, setSearch } = useListQuery({ onChange: loadDevices }) const { page, search, setPage, setSearch, suggestion, reportCount } = useListQuery({ onChange: loadDevices })
// 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)
loadDevices()
}
const selectedType = ref(null) const selectedType = ref(null)
const vendorFilter = ref('') const vendorFilter = ref('')
const locationFilter = ref('') const locationFilter = ref('')
@@ -220,6 +231,7 @@ async function loadDevices() {
const response = await networkApi.list(params) const response = await networkApi.list(params)
devices.value = response.data.data || [] devices.value = response.data.data || []
reportCount(devices.value.length)
totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1 totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1
} catch (error) { } catch (error) {
console.error('Error loading network devices:', error) console.error('Error loading network devices:', error)

View File

@@ -68,6 +68,7 @@
<tr v-if="printers.length === 0"> <tr v-if="printers.length === 0">
<td colspan="7" style="text-align: center; color: var(--text-light);"> <td colspan="7" style="text-align: center; color: var(--text-light);">
No printers found No printers found
<SearchSuggestion :suggestion="suggestion" @pick="applySuggestion" />
</td> </td>
</tr> </tr>
</tbody> </tbody>
@@ -92,12 +93,24 @@ import { ref, onMounted } from 'vue'
import { printersApi } from '@/api' import { printersApi } from '@/api'
import PaginationBar from '@/components/PaginationBar.vue' import PaginationBar from '@/components/PaginationBar.vue'
import { useListQuery } from '@/composables/listQuery' import { useListQuery } from '@/composables/listQuery'
import SearchSuggestion from '@/components/SearchSuggestion.vue'
const printers = ref([]) const printers = ref([])
const printerTypes = ref([]) const printerTypes = ref([])
const typeFilter = ref('') const typeFilter = ref('')
const loading = ref(true) const loading = ref(true)
const { page, search, setPage, setSearch } = useListQuery({ onChange: loadPrinters }) const { page, search, setPage, setSearch, suggestion, reportCount } = useListQuery({ onChange: loadPrinters })
// Take the offered spelling: put it in the box and search it, so what was
// searched is what the person sees.
function applySuggestion(word) {
search.value = word
setSearch(word)
// setSearch only syncs the URL, and the watcher that reloads is suppressed
// because search.value already holds the new term - the same trap the global
// search page documents. Load directly.
loadPrinters()
}
const totalPages = ref(1) const totalPages = ref(1)
const perPage = ref(20) const perPage = ref(20)
@@ -125,6 +138,7 @@ async function loadPrinters() {
const response = await printersApi.list(params) const response = await printersApi.list(params)
printers.value = response.data.data || [] printers.value = response.data.data || []
reportCount(printers.value.length)
totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1 totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1
} catch (error) { } catch (error) {
console.error('Error loading printers:', error) console.error('Error loading printers:', error)

View File

@@ -1068,6 +1068,30 @@ def _suggestion_for(query):
return None return None
@search_bp.route('/suggest', methods=['GET'])
@jwt_required(optional=True)
def search_suggest():
"""A better spelling for a query that found nothing, or null.
ITS OWN ENDPOINT, deliberately, rather than a `suggestion` key added to
every list response. Thirty routes take a search parameter across a dozen
files, so folding it into each one is a large change today and a thing every
future plugin has to remember. A page that finds no rows calls this instead;
a page that has not adopted it simply shows no suggestion, which is what it
showed before.
Returns null rather than a poor guess. The caller offers "did you mean" and
a person decides - nothing searches on their behalf.
"""
query = request.args.get('q', '').strip()
if not query or len(query) < 2 or len(query) > 200:
return success_response({'query': query, 'suggestion': None})
return success_response({
'query': query,
'suggestion': _suggestion_for(query),
})
@search_bp.route('', methods=['GET']) @search_bp.route('', methods=['GET'])
@jwt_required(optional=True) @jwt_required(optional=True)
def global_search(): def global_search():

View File

@@ -160,3 +160,32 @@ def test_a_mistyped_asset_number_is_not_corrected_to_a_real_one(
data = resp.get_json()['data'] data = resp.get_json()['data']
assert data['results'] == [] assert data['results'] == []
assert 'suggestion' not in data assert 'suggestion' not in data
def test_the_suggest_endpoint_serves_any_page(client, db, auth_headers):
"""The shared endpoint every list uses, rather than a suggestion key added
to thirty search routes."""
from shopdb.core.models import Vendor
from shopdb.extensions import cache
db.session.add(Vendor(vendor='Keyence'))
db.session.commit()
cache.delete('search_vocabulary')
resp = client.get('/api/search/suggest?q=keyecne', headers=auth_headers)
assert resp.status_code == 200
assert resp.get_json()['data']['suggestion'] == 'keyence'
def test_the_suggest_endpoint_says_null_rather_than_guessing(client, db,
auth_headers):
from shopdb.extensions import cache
cache.delete('search_vocabulary')
resp = client.get('/api/search/suggest?q=helicopter', headers=auth_headers)
assert resp.get_json()['data']['suggestion'] is None
def test_the_suggest_endpoint_ignores_a_too_short_query(client, auth_headers):
resp = client.get('/api/search/suggest?q=c', headers=auth_headers)
assert resp.status_code == 200
assert resp.get_json()['data']['suggestion'] is None