fix(typeahead): surface search failures instead of an empty dropdown

The document picker parsed the response without checking r.ok, so a 401/500
rendered identically to 'no matches' and the dropdown silently vanished —
which is how the broken relevance path shipped invisibly. The fetch now
throws on non-OK, the useTypeahead hook exposes an error flag, and the
picker renders a visible failure message (de/en/es).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-06-09 23:53:29 +02:00
parent e988c3eae7
commit 6e006bafd0
8 changed files with 68 additions and 2 deletions

View File

@@ -301,6 +301,7 @@
"comp_multiselect_placeholder": "Namen tippen...",
"comp_multiselect_remove": "Entfernen",
"comp_multiselect_loading": "Suche...",
"comp_typeahead_error": "Suche fehlgeschlagen. Bitte versuchen Sie es erneut.",
"comp_taginput_placeholder_create": "Schlagworte hinzufügen...",
"comp_taginput_placeholder_filter": "Nach Schlagworten filtern...",
"comp_taginput_remove": "Schlagwort entfernen",

View File

@@ -301,6 +301,7 @@
"comp_multiselect_placeholder": "Type a name...",
"comp_multiselect_remove": "Remove",
"comp_multiselect_loading": "Searching...",
"comp_typeahead_error": "Search failed. Please try again.",
"comp_taginput_placeholder_create": "Add tags...",
"comp_taginput_placeholder_filter": "Filter by tags...",
"comp_taginput_remove": "Remove tag",

View File

@@ -301,6 +301,7 @@
"comp_multiselect_placeholder": "Escriba un nombre...",
"comp_multiselect_remove": "Eliminar",
"comp_multiselect_loading": "Buscando...",
"comp_typeahead_error": "La búsqueda falló. Inténtelo de nuevo.",
"comp_taginput_placeholder_create": "Añadir etiquetas...",
"comp_taginput_placeholder_filter": "Filtrar por etiquetas...",
"comp_taginput_remove": "Eliminar etiqueta",

View File

@@ -62,13 +62,15 @@ function handleSelect(doc: DocumentOption) {
class="block w-full rounded border border-line bg-surface px-3 py-2 text-sm text-ink placeholder:text-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
/>
{#if picker.isOpen && (picker.results.length > 0 || picker.loading)}
{#if picker.isOpen && (picker.results.length > 0 || picker.loading || picker.error)}
<ul
id={listboxId}
class="ring-opacity-5 absolute z-50 mt-1 max-h-60 w-full overflow-auto rounded-md bg-surface py-1 text-sm shadow-lg ring-1 ring-black"
>
{#if picker.loading}
<li class="px-3 py-2 text-ink-2">{m.comp_multiselect_loading()}</li>
{:else if picker.error}
<li role="alert" class="px-3 py-2 text-danger">{m.comp_typeahead_error()}</li>
{:else}
{#each picker.results as doc (doc.id)}
{@const disabled = alreadyAddedIds.has(doc.id!)}

View File

@@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page, userEvent } from 'vitest/browser';
import DocumentPickerDropdown from './DocumentPickerDropdown.svelte';
import { m } from '$lib/paraglide/messages.js';
const waitForDebounce = () => new Promise((r) => setTimeout(r, 350));
@@ -93,3 +94,24 @@ describe('DocumentPickerDropdown — selection', () => {
expect(onSelect).not.toHaveBeenCalled();
});
});
describe('DocumentPickerDropdown — search failure', () => {
it('shows an error message when the search request fails instead of vanishing', async () => {
// 500 from /api/documents/search — must surface, not render as "no results"
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: false,
status: 500,
json: vi.fn().mockResolvedValue({ code: 'INTERNAL_ERROR' })
})
);
render(DocumentPickerDropdown, { onSelect: vi.fn() });
await userEvent.fill(page.getByRole('combobox'), 'Brief');
await waitForDebounce();
await expect.element(page.getByText(m.comp_typeahead_error())).toBeInTheDocument();
});
});

View File

@@ -14,7 +14,12 @@ export function createDocumentTypeahead() {
return createTypeahead<DocumentOption>({
fetchUrl: (q) =>
fetch(`/api/documents/search?q=${encodeURIComponent(q)}&size=10`)
.then((r) => r.json())
.then((r) => {
// Without this check a 401/500 parses as JSON without `items` and
// renders as "no results" — errors must reach the hook's error state.
if (!r.ok) throw new Error(`document search failed: ${r.status}`);
return r.json();
})
.then((b: { items: DocumentListItem[] }) =>
b.items.map((it) => ({
id: it.id,

View File

@@ -78,6 +78,34 @@ describe('createTypeahead', () => {
errorSpy.mockRestore();
});
it('fetch error sets the error flag so callers can render a failure state', async () => {
const fetchUrl = vi.fn().mockRejectedValue(new Error('500'));
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const ta = createTypeahead({ fetchUrl, debounceMs: 0 });
expect(ta.error).toBe(false);
ta.setQuery('foo');
await vi.advanceTimersByTimeAsync(0);
expect(ta.error).toBe(true);
errorSpy.mockRestore();
});
it('error flag clears on the next successful fetch', async () => {
const fetchUrl = vi
.fn()
.mockRejectedValueOnce(new Error('500'))
.mockResolvedValueOnce([{ id: '1' }]);
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const ta = createTypeahead({ fetchUrl, debounceMs: 0 });
ta.setQuery('foo');
await vi.advanceTimersByTimeAsync(0);
expect(ta.error).toBe(true);
ta.setQuery('foob');
await vi.advanceTimersByTimeAsync(0);
expect(ta.error).toBe(false);
expect(ta.results).toEqual([{ id: '1' }]);
errorSpy.mockRestore();
});
it('setActiveIndex updates activeIndex', () => {
const ta = createTypeahead({ fetchUrl: vi.fn().mockResolvedValue([]) });
expect(ta.activeIndex).toBe(-1);

View File

@@ -11,6 +11,7 @@ export function createTypeahead<T>(options: Options<T>) {
let results: T[] = $state([]);
let isOpen = $state(false);
let loading = $state(false);
let error = $state(false);
let activeIndex = $state(-1);
let debounceTimer: ReturnType<typeof setTimeout> | undefined;
@@ -21,11 +22,13 @@ export function createTypeahead<T>(options: Options<T>) {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(async () => {
loading = true;
error = false;
try {
results = await fetchUrl(q);
} catch (e) {
console.error('typeahead fetch error', e);
results = [];
error = true;
} finally {
loading = false;
}
@@ -65,6 +68,9 @@ export function createTypeahead<T>(options: Options<T>) {
get loading() {
return loading;
},
get error() {
return error;
},
get activeIndex() {
return activeIndex;
},