diff --git a/docs/api-inventory.json b/docs/api-inventory.json
index 38f7717..e342067 100644
--- a/docs/api-inventory.json
+++ b/docs/api-inventory.json
@@ -351,6 +351,14 @@
"params": "q (required, 2-200 chars)",
"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",
"path": "/api/dashboard",
diff --git a/docs/openapi.json b/docs/openapi.json
index bbf1b17..340f6de 100644
--- a/docs/openapi.json
+++ b/docs/openapi.json
@@ -2,7 +2,7 @@
"openapi": "3.1.0",
"info": {
"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."
},
"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": {
"get": {
"tags": [
diff --git a/frontend/src/components/SearchSuggestion.vue b/frontend/src/components/SearchSuggestion.vue
new file mode 100644
index 0000000..0e8c9e9
--- /dev/null
+++ b/frontend/src/components/SearchSuggestion.vue
@@ -0,0 +1,42 @@
+
+
+
+ Did you mean
+ ?
+
+
+
+
+
+
diff --git a/frontend/src/composables/listQuery.js b/frontend/src/composables/listQuery.js
index cee6155..205dba4 100644
--- a/frontend/src/composables/listQuery.js
+++ b/frontend/src/composables/listQuery.js
@@ -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:
+//
+// 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,
+ }
}
diff --git a/frontend/src/composables/searchSuggestion.js b/frontend/src/composables/searchSuggestion.js
new file mode 100644
index 0000000..9242c9a
--- /dev/null
+++ b/frontend/src/composables/searchSuggestion.js
@@ -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:
+ *
+ *
+ */
+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 }
+}
diff --git a/frontend/src/views/SearchResults.vue b/frontend/src/views/SearchResults.vue
index 48d35d5..80d262f 100644
--- a/frontend/src/views/SearchResults.vue
+++ b/frontend/src/views/SearchResults.vue
@@ -42,6 +42,7 @@
No results found for "{{ query }}"
+
@@ -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
diff --git a/frontend/src/views/vendors/VendorsList.vue b/frontend/src/views/vendors/VendorsList.vue
index 734d551..1b08da3 100644
--- a/frontend/src/views/vendors/VendorsList.vue
+++ b/frontend/src/views/vendors/VendorsList.vue
@@ -55,6 +55,7 @@
No vendors found
+
@@ -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)
diff --git a/plugins/computers/frontend/views/PCsList.vue b/plugins/computers/frontend/views/PCsList.vue
index d5d8567..361c2c2 100644
--- a/plugins/computers/frontend/views/PCsList.vue
+++ b/plugins/computers/frontend/views/PCsList.vue
@@ -66,6 +66,7 @@
No computers found
+
@@ -91,10 +92,20 @@ import { computersApi } from '@/api'
import PaginationBar from '@/components/PaginationBar.vue'
import { colorStyle } from '@/utils/colorStyle'
import { useListQuery } from '@/composables/listQuery'
+import SearchSuggestion from '@/components/SearchSuggestion.vue'
const computers = ref([])
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 perPage = ref(20)
@@ -115,6 +126,7 @@ async function loadComputers() {
const response = await computersApi.list(params)
computers.value = response.data.data || []
+ reportCount(computers.value.length)
totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1
} catch (error) {
console.error('Error loading computers:', error)
diff --git a/plugins/knowledgebase/frontend/views/KnowledgeBaseList.vue b/plugins/knowledgebase/frontend/views/KnowledgeBaseList.vue
index 01c048c..dcbbfc6 100644
--- a/plugins/knowledgebase/frontend/views/KnowledgeBaseList.vue
+++ b/plugins/knowledgebase/frontend/views/KnowledgeBaseList.vue
@@ -88,6 +88,7 @@
No articles found
+
@@ -112,12 +113,22 @@ import { ref, onMounted } from 'vue'
import { knowledgebaseApi, applicationsApi } from '@/api'
import PaginationBar from '@/components/PaginationBar.vue'
import { useListQuery } from '@/composables/listQuery'
+import SearchSuggestion from '@/components/SearchSuggestion.vue'
const loading = ref(true)
const articles = ref([])
const topics = ref([])
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 totalPages = ref(1)
const topicFilter = ref('')
@@ -161,6 +172,7 @@ async function loadArticles() {
const response = await knowledgebaseApi.list(params)
articles.value = response.data.data || []
+ reportCount(articles.value.length)
totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1
} catch (error) {
console.error('Error loading articles:', error)
diff --git a/plugins/machines/frontend/views/MachinesList.vue b/plugins/machines/frontend/views/MachinesList.vue
index 19732aa..158d45a 100644
--- a/plugins/machines/frontend/views/MachinesList.vue
+++ b/plugins/machines/frontend/views/MachinesList.vue
@@ -79,6 +79,7 @@
No machines found
+
@@ -105,10 +106,22 @@ import { colorStyle } from "@/utils/colorStyle"
import { machinesApi } from '@/api'
import PaginationBar from '@/components/PaginationBar.vue'
import { useListQuery } from '@/composables/listQuery'
+import SearchSuggestion from '@/components/SearchSuggestion.vue'
const machines = ref([])
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 perPage = ref(20)
@@ -129,6 +142,7 @@ async function loadMachines() {
const response = await machinesApi.list(params)
machines.value = response.data.data || []
+ reportCount(machines.value.length)
totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1
} catch (error) {
console.error('Error loading machines:', error)
diff --git a/plugins/measuringtools/frontend/views/MeasuringToolsList.vue b/plugins/measuringtools/frontend/views/MeasuringToolsList.vue
index f0ea431..856c8fc 100644
--- a/plugins/measuringtools/frontend/views/MeasuringToolsList.vue
+++ b/plugins/measuringtools/frontend/views/MeasuringToolsList.vue
@@ -73,6 +73,7 @@
No measuring tools found
+
@@ -97,11 +98,21 @@ import { colorStyle } from '@/utils/colorStyle'
import { measuringtoolsApi } from '@/api'
import PaginationBar from '@/components/PaginationBar.vue'
import { useListQuery } from '@/composables/listQuery'
+import SearchSuggestion from '@/components/SearchSuggestion.vue'
const tools = ref([])
const types = ref([])
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 calibrationstatus = ref('')
const totalPages = ref(1)
@@ -133,6 +144,7 @@ async function loadTools() {
const response = await measuringtoolsApi.list(params)
tools.value = response.data.data || []
+ reportCount(tools.value.length)
totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1
} catch (err) {
console.error('Error loading measuring tools:', err)
diff --git a/plugins/network/frontend/views/NetworkDevicesList.vue b/plugins/network/frontend/views/NetworkDevicesList.vue
index 8195a9a..e6e5719 100644
--- a/plugins/network/frontend/views/NetworkDevicesList.vue
+++ b/plugins/network/frontend/views/NetworkDevicesList.vue
@@ -97,6 +97,7 @@
No network devices found
+
@@ -122,13 +123,23 @@ import { colorStyle } from "@/utils/colorStyle"
import { networkApi, vendorsApi, locationsApi } from '@/api'
import PaginationBar from '@/components/PaginationBar.vue'
import { useListQuery } from '@/composables/listQuery'
+import SearchSuggestion from '@/components/SearchSuggestion.vue'
const devices = ref([])
const deviceTypes = ref([])
const vendors = ref([])
const locations = ref([])
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 vendorFilter = ref('')
const locationFilter = ref('')
@@ -220,6 +231,7 @@ async function loadDevices() {
const response = await networkApi.list(params)
devices.value = response.data.data || []
+ reportCount(devices.value.length)
totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1
} catch (error) {
console.error('Error loading network devices:', error)
diff --git a/plugins/printers/frontend/views/PrintersList.vue b/plugins/printers/frontend/views/PrintersList.vue
index 36cacdf..adc9267 100644
--- a/plugins/printers/frontend/views/PrintersList.vue
+++ b/plugins/printers/frontend/views/PrintersList.vue
@@ -68,6 +68,7 @@
No printers found
+
@@ -92,12 +93,24 @@ import { ref, onMounted } from 'vue'
import { printersApi } from '@/api'
import PaginationBar from '@/components/PaginationBar.vue'
import { useListQuery } from '@/composables/listQuery'
+import SearchSuggestion from '@/components/SearchSuggestion.vue'
const printers = ref([])
const printerTypes = ref([])
const typeFilter = ref('')
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 perPage = ref(20)
@@ -125,6 +138,7 @@ async function loadPrinters() {
const response = await printersApi.list(params)
printers.value = response.data.data || []
+ reportCount(printers.value.length)
totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1
} catch (error) {
console.error('Error loading printers:', error)
diff --git a/shopdb/core/api/search.py b/shopdb/core/api/search.py
index 90dd66e..a308bc5 100644
--- a/shopdb/core/api/search.py
+++ b/shopdb/core/api/search.py
@@ -1068,6 +1068,30 @@ def _suggestion_for(query):
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'])
@jwt_required(optional=True)
def global_search():
diff --git a/tests/test_core/test_spellfix.py b/tests/test_core/test_spellfix.py
index 1f8b756..1426f4e 100644
--- a/tests/test_core/test_spellfix.py
+++ b/tests/test_core/test_spellfix.py
@@ -160,3 +160,32 @@ def test_a_mistyped_asset_number_is_not_corrected_to_a_real_one(
data = resp.get_json()['data']
assert data['results'] == []
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