(search) use debounce to prevent multiple requests while typing

This commit is contained in:
Valentyne Stigloher 2024-12-26 13:45:26 +01:00
parent 6a576b6c7a
commit 9318d64e44
7 changed files with 58 additions and 21 deletions

View File

@ -1,31 +1,34 @@
<script setup lang="ts"> <script setup lang="ts">
const search = ref(''); import { useDebounce } from '@vueuse/core';
const isSearchBlank = computed(() => { const query = ref('');
return search.value.trim().length === 0; const debouncedQuery = useDebounce(query);
});
const searchAsyncData = useAsyncData('search', async () => { const searchAsyncData = useAsyncData('search', async () => {
if (isSearchBlank.value) { if (debouncedQuery.value.trim().length === 0) {
return []; return [];
} }
return await $fetch('/api/search', { return await $fetch('/api/search', {
query: { query: {
text: search.value, query: debouncedQuery.value,
}, },
}); });
}, { }, {
watch: [search], watch: [debouncedQuery],
});
const isLoading = computed(() => {
return query.value !== debouncedQuery.value || searchAsyncData.status.value === 'pending';
}); });
const hasNoResults = computed(() => { const hasNoResults = computed(() => {
return searchAsyncData.status.value !== 'pending' && searchAsyncData.data.value?.length === 0; return !isLoading.value && searchAsyncData.data.value?.length === 0;
}); });
const dialogue = useTemplateRef('dialogue'); const dialogue = useTemplateRef('dialogue');
const open = () => { const open = () => {
search.value = ''; query.value = '';
searchAsyncData.clear(); searchAsyncData.clear();
dialogue.value?.showModal(); dialogue.value?.showModal();
}; };
@ -59,11 +62,11 @@ defineExpose({ open, close });
<dialog ref="dialogue" class="container m-auto h-100 rounded border"> <dialog ref="dialogue" class="container m-auto h-100 rounded border">
<div class="input-group mb-4"> <div class="input-group mb-4">
<span class="input-group-text"> <span class="input-group-text">
<Spinner v-if="searchAsyncData.status.value === 'pending'" size="1.25em" /> <Spinner v-if="isLoading" size="1.25em" />
<Icon v-else v="search" /> <Icon v-else v="search" />
</span> </span>
<input <input
v-model="search" v-model="query"
type="search" type="search"
class="form-control border-primary" class="form-control border-primary"
:placeholder="$t('crud.filterLong')" :placeholder="$t('crud.filterLong')"
@ -72,7 +75,7 @@ defineExpose({ open, close });
<Icon v="times" /> <Icon v="times" />
</button> </button>
</div> </div>
<div v-if="isSearchBlank" class="alert alert-info"> <div v-if="query.trim().length === 0" class="alert alert-info">
<T>search.noInput</T> <T>search.noInput</T>
</div> </div>
<ul <ul
@ -88,7 +91,7 @@ defineExpose({ open, close });
</li> </li>
</ul> </ul>
<div v-else-if="hasNoResults" class="alert alert-info"> <div v-else-if="hasNoResults" class="alert alert-info">
<T :params="{ search }">search.noResults</T> <T :params="{ query }">search.noResults</T>
</div> </div>
</dialog> </dialog>
</template> </template>

View File

@ -951,7 +951,7 @@ crud:
search: search:
header: 'Search' header: 'Search'
noInput: 'Start searching by entering a search term' noInput: 'Start searching by entering a search term'
noResults: 'No results for “%search%” found' noResults: 'No results for “%query%” found'
footer: footer:
license: > license: >

View File

@ -931,7 +931,7 @@ crud:
search: search:
header: 'Suche' header: 'Suche'
noInput: 'Starte eine Suche durch Eingabe eines Suchbegriffs' noInput: 'Starte eine Suche durch Eingabe eines Suchbegriffs'
noResults: 'Keine Ergebnisse für „%search%“ gefunden' noResults: 'Keine Ergebnisse für „%query%“ gefunden'
footer: footer:
license: > license: >

View File

@ -1153,7 +1153,7 @@ crud:
search: search:
header: 'Search' header: 'Search'
noInput: 'Start searching by entering a search term' noInput: 'Start searching by entering a search term'
noResults: 'No results for “%search%” found' noResults: 'No results for “%query%” found'
footer: footer:
license: > license: >

View File

@ -28,6 +28,7 @@
"@sentry/vue": "^7.109.0", "@sentry/vue": "^7.109.0",
"@vite-pwa/nuxt": "^0.10.1", "@vite-pwa/nuxt": "^0.10.1",
"@vuepic/vue-datepicker": "^8.8.1", "@vuepic/vue-datepicker": "^8.8.1",
"@vueuse/core": "^12.2.0",
"abort-controller": "^3.0.0", "abort-controller": "^3.0.0",
"autoprefixer": "^10.4.5", "autoprefixer": "^10.4.5",
"avris-columnist": "^0.3.4", "avris-columnist": "^0.3.4",

34
pnpm-lock.yaml generated
View File

@ -47,6 +47,9 @@ importers:
'@vuepic/vue-datepicker': '@vuepic/vue-datepicker':
specifier: ^8.8.1 specifier: ^8.8.1
version: 8.8.1(vue@3.5.13(typescript@5.6.2)) version: 8.8.1(vue@3.5.13(typescript@5.6.2))
'@vueuse/core':
specifier: ^12.2.0
version: 12.2.0(typescript@5.6.2)
abort-controller: abort-controller:
specifier: ^3.0.0 specifier: ^3.0.0
version: 3.0.0 version: 3.0.0
@ -2892,6 +2895,9 @@ packages:
'@types/uuid@8.3.2': '@types/uuid@8.3.2':
resolution: {integrity: sha512-u40ViizKDmdl5FhOXn9WQbulpigYCaiD5hD4KqR3xyQww6l3+0ND+A9TeFla8tFpqvR+UAkJdYb/8jdaQG4/nw==} resolution: {integrity: sha512-u40ViizKDmdl5FhOXn9WQbulpigYCaiD5hD4KqR3xyQww6l3+0ND+A9TeFla8tFpqvR+UAkJdYb/8jdaQG4/nw==}
'@types/web-bluetooth@0.0.20':
resolution: {integrity: sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==}
'@types/yauzl@2.10.3': '@types/yauzl@2.10.3':
resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==} resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==}
@ -3152,6 +3158,15 @@ packages:
peerDependencies: peerDependencies:
vue: '>=3.2.0' vue: '>=3.2.0'
'@vueuse/core@12.2.0':
resolution: {integrity: sha512-jksyNu+5EGwggNkRWd6xX+8qBkYbmrwdFQMgCABsz+wq8bKF6w3soPFLB8vocFp3wFIzn0OYkSPM9JP+AFKwsg==}
'@vueuse/metadata@12.2.0':
resolution: {integrity: sha512-x6zynZtTh1l52m0y8d/EgzpshnMjg8cNZ2KWoncJ62Z5qPSGoc4FUunmMVrrRM/I/5542rTEY89CGftngZvrkQ==}
'@vueuse/shared@12.2.0':
resolution: {integrity: sha512-SRr4AZwv/giS+EmyA1ZIzn3/iALjjnWAGaBNmoDTMEob9JwQaevAocuaMDnPAvU7Z35Y5g3CFRusCWgp1gVJ3Q==}
'@webassemblyjs/ast@1.12.1': '@webassemblyjs/ast@1.12.1':
resolution: {integrity: sha512-EKfMUOPRRUTy5UII4qJDGPpqfwjOmZ5jeGFwid9mnoqIFK+e0vqoi1qH56JpmZSzEL53jKnNzScdmftJyG5xWg==} resolution: {integrity: sha512-EKfMUOPRRUTy5UII4qJDGPpqfwjOmZ5jeGFwid9mnoqIFK+e0vqoi1qH56JpmZSzEL53jKnNzScdmftJyG5xWg==}
@ -11992,6 +12007,8 @@ snapshots:
'@types/uuid@8.3.2': {} '@types/uuid@8.3.2': {}
'@types/web-bluetooth@0.0.20': {}
'@types/yauzl@2.10.3': '@types/yauzl@2.10.3':
dependencies: dependencies:
'@types/node': 20.16.5 '@types/node': 20.16.5
@ -12442,6 +12459,23 @@ snapshots:
date-fns: 3.6.0 date-fns: 3.6.0
vue: 3.5.13(typescript@5.6.2) vue: 3.5.13(typescript@5.6.2)
'@vueuse/core@12.2.0(typescript@5.6.2)':
dependencies:
'@types/web-bluetooth': 0.0.20
'@vueuse/metadata': 12.2.0
'@vueuse/shared': 12.2.0(typescript@5.6.2)
vue: 3.5.13(typescript@5.6.2)
transitivePeerDependencies:
- typescript
'@vueuse/metadata@12.2.0': {}
'@vueuse/shared@12.2.0(typescript@5.6.2)':
dependencies:
vue: 3.5.13(typescript@5.6.2)
transitivePeerDependencies:
- typescript
'@webassemblyjs/ast@1.12.1': '@webassemblyjs/ast@1.12.1':
dependencies: dependencies:
'@webassemblyjs/helper-numbers': 1.11.6 '@webassemblyjs/helper-numbers': 1.11.6

View File

@ -76,10 +76,10 @@ const loadIndices = async (
return new Map(indices.map((loadedKind) => [loadedKind.kind.kind, loadedKind])); return new Map(indices.map((loadedKind) => [loadedKind.kind.kind, loadedKind]));
}; };
const searchIndices = (indices: Map<SearchDocument['kind'], LoadedSearchKind>, text: string): SearchResult[] => { const searchIndices = (indices: Map<SearchDocument['kind'], LoadedSearchKind>, query: string): SearchResult[] => {
return [...indices.values()] return [...indices.values()]
.flatMap(({ index }) => { .flatMap(({ index }) => {
return index.search(text, { prefix: true, fuzzy: 1 }); return index.search(query, { prefix: true, fuzzy: 1 });
}) })
.toSorted((resultA, resultB) => { .toSorted((resultA, resultB) => {
return resultB.score - resultA.score; return resultB.score - resultA.score;
@ -442,9 +442,8 @@ const SEARCH_LIMIT = 20;
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
const indices = await loadIndices(kinds, global.config); const indices = await loadIndices(kinds, global.config);
const query = getQuery(event); const query = getQuery(event).query as string;
const text = query.text as string; return searchIndices(indices, query)
return searchIndices(indices, text)
.slice(0, SEARCH_LIMIT) .slice(0, SEARCH_LIMIT)
.map((result) => transformResult(indices, result)); .map((result) => transformResult(indices, result));
}); });