Offer a spelling on every list, not just the global search
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:
@@ -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",
|
||||||
|
|||||||
@@ -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": [
|
||||||
|
|||||||
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 { 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,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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">
|
<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
|
||||||
|
|||||||
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">
|
<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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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():
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user