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 @@ + + + + + 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