feat(search): NL search frontend — toggle, chips, disambiguation, empty state (#739) #757
@@ -10,6 +10,11 @@ System_Boundary(frontend, "Web Frontend (SvelteKit / SSR)") {
|
|||||||
Component(homePage, "/ (Home / Search)", "SvelteKit Route", "Loader: parses URL params (q, from, to, senderId, receiverId, tags), fetches /api/documents/search and /api/persons. Renders search form with full-text, date range, sender/receiver typeahead, and tag filters.")
|
Component(homePage, "/ (Home / Search)", "SvelteKit Route", "Loader: parses URL params (q, from, to, senderId, receiverId, tags), fetches /api/documents/search and /api/persons. Renders search form with full-text, date range, sender/receiver typeahead, and tag filters.")
|
||||||
Component(docsListPageTs, "/documents/+page.ts", "SvelteKit Client Loader", "Client-side load gated by matchMedia('(min-width: 1024px)') and ?view query. Fetches /api/documents/density only on desktop (Tailwind lg breakpoint) and outside calendar view; degrades to empty buckets on network failure.")
|
Component(docsListPageTs, "/documents/+page.ts", "SvelteKit Client Loader", "Client-side load gated by matchMedia('(min-width: 1024px)') and ?view query. Fetches /api/documents/density only on desktop (Tailwind lg breakpoint) and outside calendar view; degrades to empty buckets on network failure.")
|
||||||
Component(timelineFilter, "TimelineDensityFilter.svelte", "Svelte Component", "Per-month density bars above the document list. Click selects a single month, emits onchange({from, to}) using YYYY-MM-DD boundaries. Hidden on mobile and tablet (below lg, 1024px) and in calendar view.")
|
Component(timelineFilter, "TimelineDensityFilter.svelte", "Svelte Component", "Per-month density bars above the document list. Click selects a single month, emits onchange({from, to}) using YYYY-MM-DD boundaries. Hidden on mobile and tablet (below lg, 1024px) and in calendar view.")
|
||||||
|
Component(searchFilterBar, "SearchFilterBar.svelte", "Svelte Component", "Search/filter card on /documents. Hosts the keyword input, sort, advanced filters, and the smart-mode toggle. In smart mode submits the NL query on Enter via onSmartSearch instead of the live keyword search.")
|
||||||
|
Component(smartToggle, "search/SmartModeToggle.svelte", "Svelte Component", "Toggle pill (KI/Text) inside the search input. aria-pressed; switches between keyword and NL (smart) search modes.")
|
||||||
|
Component(chipRow, "search/InterpretationChipRow.svelte", "Svelte Component", "Renders NL interpretation chips (Absender / directional / Zeitraum / Stichwort). Removing a chip emits onRemoveChip; the page re-runs a keyword GET with the remaining params.")
|
||||||
|
Component(smartStatus, "search/SmartSearchStatus.svelte", "Svelte Component", "Full-area panels for NL search: loading (role=status), 503 SMART_SEARCH_UNAVAILABLE (with keyword fallback), 429 SMART_SEARCH_RATE_LIMITED.")
|
||||||
|
Component(disambig, "search/DisambiguationPicker.svelte", "Svelte Component", "Accessible single-select disclosure for ambiguous person names; selecting a candidate re-runs the search via GET.")
|
||||||
Component(docDetail, "/documents/[id]", "SvelteKit Route", "Loader: GET /api/documents/{id}. Page: metadata panel, inline file viewer, transcription editor, annotation layer, and comment thread.")
|
Component(docDetail, "/documents/[id]", "SvelteKit Route", "Loader: GET /api/documents/{id}. Page: metadata panel, inline file viewer, transcription editor, annotation layer, and comment thread.")
|
||||||
Component(docEdit, "/documents/[id]/edit", "SvelteKit Route", "Edit form with PersonTypeahead, TagInput, date/location fields. Form action: PUT /api/documents/{id}.")
|
Component(docEdit, "/documents/[id]/edit", "SvelteKit Route", "Edit form with PersonTypeahead, TagInput, date/location fields. Form action: PUT /api/documents/{id}.")
|
||||||
Component(docNew, "/documents/new", "SvelteKit Route", "Upload form for a new document. Loader: GET /api/persons. Form action: POST /api/documents with multipart file.")
|
Component(docNew, "/documents/new", "SvelteKit Route", "Upload form for a new document. Loader: GET /api/persons. Form action: POST /api/documents with multipart file.")
|
||||||
@@ -25,6 +30,12 @@ Rel(user, homePage, "Searches and browses", "HTTPS / Browser")
|
|||||||
Rel(homePage, backend, "GET /api/documents/search, GET /api/persons", "HTTP / JSON")
|
Rel(homePage, backend, "GET /api/documents/search, GET /api/persons", "HTTP / JSON")
|
||||||
Rel(docsListPageTs, backend, "GET /api/documents/density (desktop only, ≥1024px)", "HTTP / JSON")
|
Rel(docsListPageTs, backend, "GET /api/documents/density (desktop only, ≥1024px)", "HTTP / JSON")
|
||||||
Rel(homePage, timelineFilter, "Mounts above the result list")
|
Rel(homePage, timelineFilter, "Mounts above the result list")
|
||||||
|
Rel(homePage, searchFilterBar, "Mounts the search/filter card")
|
||||||
|
Rel(searchFilterBar, smartToggle, "Embeds the smart-mode toggle in the input")
|
||||||
|
Rel(homePage, backend, "POST /api/search/nl (smart mode)", "HTTP / JSON")
|
||||||
|
Rel(homePage, smartStatus, "Renders loading / 503 / 429 panels")
|
||||||
|
Rel(homePage, chipRow, "Renders interpretation chips; handles chip removal")
|
||||||
|
Rel(homePage, disambig, "Renders the picker when names are ambiguous")
|
||||||
Rel(docsListPageTs, timelineFilter, "Provides density / minDate / maxDate props")
|
Rel(docsListPageTs, timelineFilter, "Provides density / minDate / maxDate props")
|
||||||
Rel(docDetail, backend, "GET /api/documents/{id}, GET /api/documents/{id}/file", "HTTP / JSON + Binary")
|
Rel(docDetail, backend, "GET /api/documents/{id}, GET /api/documents/{id}/file", "HTTP / JSON + Binary")
|
||||||
Rel(docEdit, backend, "PUT /api/documents/{id}", "HTTP / Multipart")
|
Rel(docEdit, backend, "PUT /api/documents/{id}", "HTTP / Multipart")
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ src/
|
|||||||
│ ├── +layout.server.ts # Loads current user, injects auth cookie
|
│ ├── +layout.server.ts # Loads current user, injects auth cookie
|
||||||
│ ├── +page.svelte # Home / document search dashboard
|
│ ├── +page.svelte # Home / document search dashboard
|
||||||
│ ├── documents/ # Document CRUD, detail, edit, upload
|
│ ├── documents/ # Document CRUD, detail, edit, upload
|
||||||
|
│ ├── search/ # Smart (NL) search sub-components — SmartModeToggle, InterpretationChipRow, SmartSearchStatus, DisambiguationPicker (no +page; consumed by documents/ and SearchFilterBar)
|
||||||
│ ├── persons/ # Person directory (filtered, paginated), detail, edit, merge, review (triage)
|
│ ├── persons/ # Person directory (filtered, paginated), detail, edit, merge, review (triage)
|
||||||
│ ├── aktivitaeten/ # Unified activity feed (Chronik)
|
│ ├── aktivitaeten/ # Unified activity feed (Chronik)
|
||||||
│ ├── admin/ # User, group, tag, OCR, system management
|
│ ├── admin/ # User, group, tag, OCR, system management
|
||||||
|
|||||||
83
frontend/e2e/nl-search.spec.ts
Normal file
83
frontend/e2e/nl-search.spec.ts
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import AxeBuilder from '@axe-core/playwright';
|
||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
|
||||||
|
// NL search is mocked at the network boundary — Ollama is not required in CI.
|
||||||
|
// CSRF enforcement is bypassed by page.route (the real request is never sent),
|
||||||
|
// so it is only verified in manual full-stack runs (see issue #739 DevOps notes).
|
||||||
|
const interpretation = {
|
||||||
|
resolvedPersons: [
|
||||||
|
{ id: '11111111-1111-1111-1111-111111111111', displayName: 'Walter Raddatz' },
|
||||||
|
{ id: '22222222-2222-2222-2222-222222222222', displayName: 'Emma Raddatz' }
|
||||||
|
],
|
||||||
|
ambiguousPersons: [],
|
||||||
|
dateFrom: '1914-01-01',
|
||||||
|
dateTo: '1918-12-31',
|
||||||
|
keywords: ['krieg'],
|
||||||
|
rawQuery: 'Was hat Walter an Emma im Krieg geschrieben?',
|
||||||
|
keywordsApplied: true
|
||||||
|
};
|
||||||
|
|
||||||
|
const nlResponse = {
|
||||||
|
result: {
|
||||||
|
items: [],
|
||||||
|
totalElements: 0,
|
||||||
|
pageNumber: 0,
|
||||||
|
pageSize: 20,
|
||||||
|
totalPages: 0,
|
||||||
|
undatedCount: 0
|
||||||
|
},
|
||||||
|
interpretation
|
||||||
|
};
|
||||||
|
|
||||||
|
test.describe('NL (smart) search — happy path', () => {
|
||||||
|
test('toggle → loading → chips → remove chip re-runs keyword search; axe clean light + dark', async ({
|
||||||
|
page
|
||||||
|
}) => {
|
||||||
|
// Deliberate delay so the loading state is assertable before the response arrives.
|
||||||
|
await page.route('**/api/search/nl', async (route) => {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 150));
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify(nlResponse)
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto('/documents');
|
||||||
|
await page.waitForSelector('[data-hydrated]');
|
||||||
|
|
||||||
|
// Switch to smart mode via the toggle pill (keyword label = "Text").
|
||||||
|
await page.getByRole('button', { name: /Text/ }).click();
|
||||||
|
|
||||||
|
const input = page.getByPlaceholder('Titel, Personen, Tags durchsuchen…');
|
||||||
|
await input.fill('Was hat Walter an Emma im Krieg geschrieben?');
|
||||||
|
await input.press('Enter');
|
||||||
|
|
||||||
|
// Loading panel announced to screen readers.
|
||||||
|
await expect(page.getByText(/Archiv wird befragt/)).toBeVisible();
|
||||||
|
|
||||||
|
// Directional chip (Walter → Emma) + keyword chip render once the fixture resolves.
|
||||||
|
await expect(page.getByText('→')).toBeVisible();
|
||||||
|
await expect(page.getByText('Stichwort: krieg')).toBeVisible();
|
||||||
|
|
||||||
|
// Accessibility — light mode.
|
||||||
|
const lightScan = await new AxeBuilder({ page })
|
||||||
|
.include('[data-testid="smart-search-results"]')
|
||||||
|
.analyze();
|
||||||
|
expect(lightScan.violations).toEqual([]);
|
||||||
|
|
||||||
|
// Accessibility — dark mode.
|
||||||
|
await page.evaluate(() => document.documentElement.setAttribute('data-theme', 'dark'));
|
||||||
|
const darkScan = await new AxeBuilder({ page })
|
||||||
|
.include('[data-testid="smart-search-results"]')
|
||||||
|
.analyze();
|
||||||
|
expect(darkScan.violations).toEqual([]);
|
||||||
|
await page.evaluate(() => document.documentElement.setAttribute('data-theme', 'light'));
|
||||||
|
|
||||||
|
// Removing the keyword chip re-runs a keyword GET with the remaining resolved
|
||||||
|
// params (sender + receiver from the directional pair).
|
||||||
|
await page.getByRole('button', { name: 'Filter entfernen: Stichwort: krieg' }).click();
|
||||||
|
await page.waitForURL(/senderId=11111111-1111-1111-1111-111111111111/);
|
||||||
|
await expect(page).toHaveURL(/receiverId=22222222-2222-2222-2222-222222222222/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -25,6 +25,28 @@
|
|||||||
"error_smart_search_unavailable": "Die intelligente Suche ist momentan nicht verfügbar. Bitte nutzen Sie die normale Suche.",
|
"error_smart_search_unavailable": "Die intelligente Suche ist momentan nicht verfügbar. Bitte nutzen Sie die normale Suche.",
|
||||||
"error_smart_search_rate_limited": "Sie haben die Suchfunktion zu häufig genutzt. Bitte warten Sie eine Minute.",
|
"error_smart_search_rate_limited": "Sie haben die Suchfunktion zu häufig genutzt. Bitte warten Sie eine Minute.",
|
||||||
"smart_search_keywords_not_applied": "Schlüsselwörter konnten bei dieser Suche nicht berücksichtigt werden.",
|
"smart_search_keywords_not_applied": "Schlüsselwörter konnten bei dieser Suche nicht berücksichtigt werden.",
|
||||||
|
"search_toggle_smart_label": "KI",
|
||||||
|
"search_toggle_smart_label_suffix": "-Suche",
|
||||||
|
"search_toggle_keyword_label": "Text",
|
||||||
|
"search_toggle_keyword_label_suffix": "suche",
|
||||||
|
"search_loading_nl": "Archiv wird befragt…",
|
||||||
|
"search_loading_nl_sub": "Die KI analysiert Ihre Anfrage. Das kann bis zu 15 Sekunden dauern.",
|
||||||
|
"search_error_unavailable": "Intelligente Suche nicht verfügbar",
|
||||||
|
"search_error_unavailable_body": "Die KI-Suche ist momentan nicht erreichbar. Sie können Ihre Anfrage als einfache Volltextsuche wiederholen.",
|
||||||
|
"search_switch_to_keyword": "Zur Volltextsuche wechseln",
|
||||||
|
"search_error_rate_limited": "Zu viele Anfragen",
|
||||||
|
"search_error_rate_limited_body": "Sie haben die intelligente Suche zu häufig genutzt. Bitte warten Sie eine Minute und versuchen Sie es erneut.",
|
||||||
|
"search_empty_nl": "Keine Ergebnisse",
|
||||||
|
"search_empty_retry_keyword": "Als Volltextsuche wiederholen",
|
||||||
|
"search_filter_remove_label": "Filter entfernen: {label}",
|
||||||
|
"search_chip_sender": "Absender",
|
||||||
|
"search_chip_date": "Zeitraum",
|
||||||
|
"search_chip_keyword": "Stichwort",
|
||||||
|
"search_chip_directional_label": "Von {from} zu {to}, Filter entfernen",
|
||||||
|
"search_disambiguation_trigger_label": "Mehrere Personen gefunden — zum Auswählen klicken",
|
||||||
|
"search_disambiguation_cue": "(auswählen…)",
|
||||||
|
"search_disambiguation_heading": "Person auswählen",
|
||||||
|
"search_disambiguation_select_label": "{name} auswählen",
|
||||||
"error_validation_error": "Die Eingabe ist ungültig.",
|
"error_validation_error": "Die Eingabe ist ungültig.",
|
||||||
"error_internal_error": "Ein unerwarteter Fehler ist aufgetreten.",
|
"error_internal_error": "Ein unerwarteter Fehler ist aufgetreten.",
|
||||||
"nav_documents": "Dokumente",
|
"nav_documents": "Dokumente",
|
||||||
|
|||||||
@@ -25,6 +25,28 @@
|
|||||||
"error_smart_search_unavailable": "The smart search is currently unavailable. Please use the regular search.",
|
"error_smart_search_unavailable": "The smart search is currently unavailable. Please use the regular search.",
|
||||||
"error_smart_search_rate_limited": "You have used the search function too frequently. Please wait a minute.",
|
"error_smart_search_rate_limited": "You have used the search function too frequently. Please wait a minute.",
|
||||||
"smart_search_keywords_not_applied": "Keywords could not be applied to this search.",
|
"smart_search_keywords_not_applied": "Keywords could not be applied to this search.",
|
||||||
|
"search_toggle_smart_label": "AI",
|
||||||
|
"search_toggle_smart_label_suffix": " search",
|
||||||
|
"search_toggle_keyword_label": "Text",
|
||||||
|
"search_toggle_keyword_label_suffix": " search",
|
||||||
|
"search_loading_nl": "Querying the archive…",
|
||||||
|
"search_loading_nl_sub": "The AI is analysing your request. This can take up to 15 seconds.",
|
||||||
|
"search_error_unavailable": "Smart search unavailable",
|
||||||
|
"search_error_unavailable_body": "The AI search is currently unreachable. You can repeat your request as a plain full-text search.",
|
||||||
|
"search_switch_to_keyword": "Switch to full-text search",
|
||||||
|
"search_error_rate_limited": "Too many requests",
|
||||||
|
"search_error_rate_limited_body": "You have used the smart search too frequently. Please wait a minute and try again.",
|
||||||
|
"search_empty_nl": "No results",
|
||||||
|
"search_empty_retry_keyword": "Repeat as full-text search",
|
||||||
|
"search_filter_remove_label": "Remove filter: {label}",
|
||||||
|
"search_chip_sender": "Sender",
|
||||||
|
"search_chip_date": "Period",
|
||||||
|
"search_chip_keyword": "Keyword",
|
||||||
|
"search_chip_directional_label": "From {from} to {to}, remove filter",
|
||||||
|
"search_disambiguation_trigger_label": "Several people found — click to choose",
|
||||||
|
"search_disambiguation_cue": "(choose…)",
|
||||||
|
"search_disambiguation_heading": "Choose a person",
|
||||||
|
"search_disambiguation_select_label": "Select {name}",
|
||||||
"error_validation_error": "The input is invalid.",
|
"error_validation_error": "The input is invalid.",
|
||||||
"error_internal_error": "An unexpected error occurred.",
|
"error_internal_error": "An unexpected error occurred.",
|
||||||
"nav_documents": "Documents",
|
"nav_documents": "Documents",
|
||||||
|
|||||||
@@ -25,6 +25,28 @@
|
|||||||
"error_smart_search_unavailable": "La búsqueda inteligente no está disponible en este momento. Por favor, usa la búsqueda normal.",
|
"error_smart_search_unavailable": "La búsqueda inteligente no está disponible en este momento. Por favor, usa la búsqueda normal.",
|
||||||
"error_smart_search_rate_limited": "Has utilizado la función de búsqueda demasiadas veces. Por favor, espera un minuto.",
|
"error_smart_search_rate_limited": "Has utilizado la función de búsqueda demasiadas veces. Por favor, espera un minuto.",
|
||||||
"smart_search_keywords_not_applied": "Las palabras clave no pudieron aplicarse a esta búsqueda.",
|
"smart_search_keywords_not_applied": "Las palabras clave no pudieron aplicarse a esta búsqueda.",
|
||||||
|
"search_toggle_smart_label": "IA",
|
||||||
|
"search_toggle_smart_label_suffix": " búsqueda",
|
||||||
|
"search_toggle_keyword_label": "Texto",
|
||||||
|
"search_toggle_keyword_label_suffix": " búsqueda",
|
||||||
|
"search_loading_nl": "Consultando el archivo…",
|
||||||
|
"search_loading_nl_sub": "La IA está analizando su solicitud. Esto puede tardar hasta 15 segundos.",
|
||||||
|
"search_error_unavailable": "Búsqueda inteligente no disponible",
|
||||||
|
"search_error_unavailable_body": "La búsqueda con IA no está disponible en este momento. Puede repetir su solicitud como una búsqueda de texto completo.",
|
||||||
|
"search_switch_to_keyword": "Cambiar a búsqueda de texto completo",
|
||||||
|
"search_error_rate_limited": "Demasiadas solicitudes",
|
||||||
|
"search_error_rate_limited_body": "Ha utilizado la búsqueda inteligente con demasiada frecuencia. Espere un minuto e inténtelo de nuevo.",
|
||||||
|
"search_empty_nl": "Sin resultados",
|
||||||
|
"search_empty_retry_keyword": "Repetir como búsqueda de texto completo",
|
||||||
|
"search_filter_remove_label": "Eliminar filtro: {label}",
|
||||||
|
"search_chip_sender": "Remitente",
|
||||||
|
"search_chip_date": "Período",
|
||||||
|
"search_chip_keyword": "Palabra clave",
|
||||||
|
"search_chip_directional_label": "De {from} a {to}, eliminar filtro",
|
||||||
|
"search_disambiguation_trigger_label": "Se encontraron varias personas — haga clic para elegir",
|
||||||
|
"search_disambiguation_cue": "(elegir…)",
|
||||||
|
"search_disambiguation_heading": "Elegir una persona",
|
||||||
|
"search_disambiguation_select_label": "Seleccionar {name}",
|
||||||
"error_validation_error": "La entrada no es válida.",
|
"error_validation_error": "La entrada no es válida.",
|
||||||
"error_internal_error": "Se ha producido un error inesperado.",
|
"error_internal_error": "Se ha producido un error inesperado.",
|
||||||
"nav_documents": "Documentos",
|
"nav_documents": "Documentos",
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import PersonTypeahead from '$lib/person/PersonTypeahead.svelte';
|
|||||||
import TagInput from '$lib/tag/TagInput.svelte';
|
import TagInput from '$lib/tag/TagInput.svelte';
|
||||||
import DateInput from '$lib/shared/primitives/DateInput.svelte';
|
import DateInput from '$lib/shared/primitives/DateInput.svelte';
|
||||||
import SortDropdown from '$lib/shared/primitives/SortDropdown.svelte';
|
import SortDropdown from '$lib/shared/primitives/SortDropdown.svelte';
|
||||||
|
import SmartModeToggle from './search/SmartModeToggle.svelte';
|
||||||
import { slide } from 'svelte/transition';
|
import { slide } from 'svelte/transition';
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
|
||||||
@@ -20,12 +21,15 @@ let {
|
|||||||
sort = $bindable('DATE'),
|
sort = $bindable('DATE'),
|
||||||
dir = $bindable('desc'),
|
dir = $bindable('desc'),
|
||||||
showAdvanced = $bindable(false),
|
showAdvanced = $bindable(false),
|
||||||
|
smartMode = $bindable(false),
|
||||||
initialSenderName = '',
|
initialSenderName = '',
|
||||||
initialReceiverName = '',
|
initialReceiverName = '',
|
||||||
navKey = 0,
|
navKey = 0,
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
onSearch,
|
onSearch,
|
||||||
onSearchImmediate,
|
onSearchImmediate,
|
||||||
|
onSmartSearch,
|
||||||
|
onModeToggle,
|
||||||
onfocus,
|
onfocus,
|
||||||
onblur
|
onblur
|
||||||
}: {
|
}: {
|
||||||
@@ -42,16 +46,28 @@ let {
|
|||||||
sort?: string;
|
sort?: string;
|
||||||
dir?: string;
|
dir?: string;
|
||||||
showAdvanced?: boolean;
|
showAdvanced?: boolean;
|
||||||
|
smartMode?: boolean;
|
||||||
initialSenderName?: string;
|
initialSenderName?: string;
|
||||||
initialReceiverName?: string;
|
initialReceiverName?: string;
|
||||||
navKey?: number;
|
navKey?: number;
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
onSearch: () => void;
|
onSearch: () => void;
|
||||||
onSearchImmediate?: () => void;
|
onSearchImmediate?: () => void;
|
||||||
|
onSmartSearch?: () => void;
|
||||||
|
onModeToggle?: () => void;
|
||||||
onfocus?: () => void;
|
onfocus?: () => void;
|
||||||
onblur?: () => void;
|
onblur?: () => void;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
|
// In smart mode the keyword search must not fire on every keystroke — the NL
|
||||||
|
// query is submitted only on Enter (or an explicit button click).
|
||||||
|
function onSearchKeydown(event: KeyboardEvent) {
|
||||||
|
if (smartMode && event.key === 'Enter') {
|
||||||
|
event.preventDefault();
|
||||||
|
onSmartSearch?.();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Plain (non-reactive) flag — not $state, so no reactive assignment inside $effect
|
// Plain (non-reactive) flag — not $state, so no reactive assignment inside $effect
|
||||||
let sortDirMounted = false;
|
let sortDirMounted = false;
|
||||||
|
|
||||||
@@ -76,14 +92,20 @@ $effect(() => {
|
|||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
bind:value={q}
|
bind:value={q}
|
||||||
oninput={onSearch}
|
oninput={smartMode ? undefined : onSearch}
|
||||||
|
onkeydown={onSearchKeydown}
|
||||||
onfocus={onfocus}
|
onfocus={onfocus}
|
||||||
onblur={onblur}
|
onblur={onblur}
|
||||||
|
maxlength={smartMode ? 500 : undefined}
|
||||||
aria-label={m.docs_search_placeholder()}
|
aria-label={m.docs_search_placeholder()}
|
||||||
placeholder={m.docs_search_placeholder()}
|
placeholder={m.docs_search_placeholder()}
|
||||||
class="block w-full border-line py-2.5 pr-10 pl-3 placeholder-ink-3 shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
class="block w-full border-line py-2.5 pl-10 placeholder-ink-3 shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring {smartMode
|
||||||
|
? 'pr-28'
|
||||||
|
: 'pr-20'}"
|
||||||
/>
|
/>
|
||||||
<div class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3">
|
<!-- Decorative search icon / loading spinner — left slot keeps the right
|
||||||
|
slot free for the always-visible smart-mode toggle pill. -->
|
||||||
|
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
|
||||||
{#if isLoading}
|
{#if isLoading}
|
||||||
<svg
|
<svg
|
||||||
role="status"
|
role="status"
|
||||||
@@ -110,6 +132,7 @@ $effect(() => {
|
|||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
<SmartModeToggle bind:smartMode={smartMode} onToggle={onModeToggle} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Sort Dropdown -->
|
<!-- Sort Dropdown -->
|
||||||
|
|||||||
@@ -195,3 +195,39 @@ describe('SearchFilterBar – tagQ live filter', () => {
|
|||||||
vi.unstubAllGlobals();
|
vi.unstubAllGlobals();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('SearchFilterBar – smart-mode chip lifecycle hooks', () => {
|
||||||
|
// The interpretation chips live in the result area (parent page). SearchFilterBar
|
||||||
|
// drives chip-clearing through callbacks: onModeToggle (mode switch) and
|
||||||
|
// onSmartSearch (new query). These tests pin that contract.
|
||||||
|
it('invokes onModeToggle when toggling back to keyword mode (parent clears chips)', async () => {
|
||||||
|
const onModeToggle = vi.fn();
|
||||||
|
render(SearchFilterBar, {
|
||||||
|
...defaultProps,
|
||||||
|
sort: 'DATE',
|
||||||
|
dir: 'desc',
|
||||||
|
smartMode: true,
|
||||||
|
onModeToggle
|
||||||
|
});
|
||||||
|
await page.getByRole('button', { name: /KI/ }).click();
|
||||||
|
expect(onModeToggle).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('invokes onSmartSearch when a new query is submitted in smart mode (parent resets chips)', async () => {
|
||||||
|
const onSmartSearch = vi.fn();
|
||||||
|
render(SearchFilterBar, {
|
||||||
|
...defaultProps,
|
||||||
|
sort: 'DATE',
|
||||||
|
dir: 'desc',
|
||||||
|
smartMode: true,
|
||||||
|
onSmartSearch
|
||||||
|
});
|
||||||
|
const input = page.getByPlaceholder('Titel, Personen, Tags durchsuchen…');
|
||||||
|
await input.fill('Walter im Krieg');
|
||||||
|
await input.click();
|
||||||
|
(document.activeElement as HTMLElement).dispatchEvent(
|
||||||
|
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })
|
||||||
|
);
|
||||||
|
await vi.waitFor(() => expect(onSmartSearch).toHaveBeenCalled());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -8,9 +8,20 @@ import DocumentList from '../DocumentList.svelte';
|
|||||||
import Pagination from '$lib/shared/primitives/Pagination.svelte';
|
import Pagination from '$lib/shared/primitives/Pagination.svelte';
|
||||||
import BulkSelectionBar from '$lib/document/BulkSelectionBar.svelte';
|
import BulkSelectionBar from '$lib/document/BulkSelectionBar.svelte';
|
||||||
import TimelineDensityFilter from '$lib/document/TimelineDensityFilter.svelte';
|
import TimelineDensityFilter from '$lib/document/TimelineDensityFilter.svelte';
|
||||||
|
import SmartSearchStatus from '../search/SmartSearchStatus.svelte';
|
||||||
|
import InterpretationChipRow from '../search/InterpretationChipRow.svelte';
|
||||||
|
import DisambiguationPicker from '../search/DisambiguationPicker.svelte';
|
||||||
import { bulkSelectionStore } from '$lib/document/bulkSelection.svelte';
|
import { bulkSelectionStore } from '$lib/document/bulkSelection.svelte';
|
||||||
import { getErrorMessage, parseBackendError } from '$lib/shared/errors';
|
import { getErrorMessage, parseBackendError } from '$lib/shared/errors';
|
||||||
|
import { csrfFetch } from '$lib/shared/cookies';
|
||||||
import * as m from '$lib/paraglide/messages.js';
|
import * as m from '$lib/paraglide/messages.js';
|
||||||
|
import type { components } from '$lib/generated/api';
|
||||||
|
|
||||||
|
type NlQueryInterpretation = components['schemas']['NlQueryInterpretation'];
|
||||||
|
type NlSearchResponse = components['schemas']['NlSearchResponse'];
|
||||||
|
type DocumentSearchResult = components['schemas']['DocumentSearchResult'];
|
||||||
|
type PersonHint = components['schemas']['PersonHint'];
|
||||||
|
type SmartSearchErrorCode = 'SMART_SEARCH_UNAVAILABLE' | 'SMART_SEARCH_RATE_LIMITED';
|
||||||
|
|
||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
|
|
||||||
@@ -34,6 +45,18 @@ let tagQ = $state(untrack(() => data.tagQ || ''));
|
|||||||
let tagOperator = $state<'AND' | 'OR'>(untrack(() => (data.tagOp as 'AND' | 'OR') || 'AND'));
|
let tagOperator = $state<'AND' | 'OR'>(untrack(() => (data.tagOp as 'AND' | 'OR') || 'AND'));
|
||||||
let undated = $state(untrack(() => data.undated ?? false));
|
let undated = $state(untrack(() => data.undated ?? false));
|
||||||
|
|
||||||
|
// Smart (NL) search — UI-local state, resets on real page navigation (away + back).
|
||||||
|
let smartMode = $state(false);
|
||||||
|
let nlSubmitted = $state(false);
|
||||||
|
let nlLoading = $state(false);
|
||||||
|
let nlError = $state<SmartSearchErrorCode | null>(null);
|
||||||
|
let nlInterpretation = $state<NlQueryInterpretation | null>(null);
|
||||||
|
let nlResult = $state<DocumentSearchResult | null>(null);
|
||||||
|
|
||||||
|
const showNlView = $derived(smartMode && nlSubmitted);
|
||||||
|
const nlHasResults = $derived((nlResult?.items.length ?? 0) > 0);
|
||||||
|
const nlIsAmbiguous = $derived((nlInterpretation?.ambiguousPersons.length ?? 0) > 0);
|
||||||
|
|
||||||
function hasAdvancedFilters() {
|
function hasAdvancedFilters() {
|
||||||
return (
|
return (
|
||||||
(data.tags?.length ?? 0) > 0 ||
|
(data.tags?.length ?? 0) > 0 ||
|
||||||
@@ -164,6 +187,121 @@ function handleImmediateSearch() {
|
|||||||
triggerSearchKeepZoom();
|
triggerSearchKeepZoom();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resetNlState() {
|
||||||
|
nlSubmitted = false;
|
||||||
|
nlLoading = false;
|
||||||
|
nlError = null;
|
||||||
|
nlInterpretation = null;
|
||||||
|
nlResult = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Toggling the mode (either direction) always clears any prior NL interpretation. */
|
||||||
|
function onModeToggle() {
|
||||||
|
resetNlState();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Submit the natural-language query to the server-side parser. */
|
||||||
|
async function runSmartSearch() {
|
||||||
|
const query = q.trim();
|
||||||
|
if (query.length < 3) return;
|
||||||
|
nlSubmitted = true;
|
||||||
|
nlLoading = true;
|
||||||
|
nlError = null;
|
||||||
|
nlInterpretation = null;
|
||||||
|
nlResult = null;
|
||||||
|
try {
|
||||||
|
const res = await csrfFetch('/api/search/nl', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ query })
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const backend = await parseBackendError(res);
|
||||||
|
nlError =
|
||||||
|
backend?.code === 'SMART_SEARCH_RATE_LIMITED'
|
||||||
|
? 'SMART_SEARCH_RATE_LIMITED'
|
||||||
|
: 'SMART_SEARCH_UNAVAILABLE';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const body: NlSearchResponse = await res.json();
|
||||||
|
nlInterpretation = body.interpretation;
|
||||||
|
nlResult = body.result;
|
||||||
|
} catch {
|
||||||
|
nlError = 'SMART_SEARCH_UNAVAILABLE';
|
||||||
|
} finally {
|
||||||
|
nlLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Option A empty/error fallback: drop NL mode, keep the raw query, run a keyword search. */
|
||||||
|
function switchToKeywordMode() {
|
||||||
|
resetNlState();
|
||||||
|
smartMode = false;
|
||||||
|
handleImmediateSearch();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Applies a resolved param set to the keyword filters and re-runs via GET. */
|
||||||
|
function applyResolvedAndSearch(p: {
|
||||||
|
senderId: string;
|
||||||
|
receiverId: string;
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
q: string;
|
||||||
|
}) {
|
||||||
|
resetNlState();
|
||||||
|
smartMode = false;
|
||||||
|
senderId = p.senderId;
|
||||||
|
receiverId = p.receiverId;
|
||||||
|
from = p.from;
|
||||||
|
to = p.to;
|
||||||
|
q = p.q;
|
||||||
|
handleImmediateSearch();
|
||||||
|
}
|
||||||
|
|
||||||
|
function paramsFromInterpretation(interp: NlQueryInterpretation) {
|
||||||
|
const resolved = interp.resolvedPersons;
|
||||||
|
return {
|
||||||
|
senderId: resolved.length >= 1 ? resolved[0].id : '',
|
||||||
|
receiverId: resolved.length >= 2 ? resolved[1].id : '',
|
||||||
|
from: interp.dateFrom ?? '',
|
||||||
|
to: interp.dateTo ?? '',
|
||||||
|
q: interp.keywordsApplied ? interp.keywords.join(' ') : ''
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
type ChipType = 'sender' | 'directional' | 'date' | 'keyword';
|
||||||
|
|
||||||
|
function removeChip(type: ChipType, value?: string) {
|
||||||
|
if (!nlInterpretation) return;
|
||||||
|
const p = paramsFromInterpretation(nlInterpretation);
|
||||||
|
if (type === 'sender') {
|
||||||
|
p.senderId = '';
|
||||||
|
} else if (type === 'directional') {
|
||||||
|
p.senderId = '';
|
||||||
|
p.receiverId = '';
|
||||||
|
} else if (type === 'date') {
|
||||||
|
p.from = '';
|
||||||
|
p.to = '';
|
||||||
|
} else if (type === 'keyword' && value) {
|
||||||
|
const remaining = nlInterpretation.keywords.filter((keyword) => keyword !== value);
|
||||||
|
p.q = remaining.join(' ');
|
||||||
|
}
|
||||||
|
applyResolvedAndSearch(p);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Single-select disambiguation: resolved person becomes sender, chosen becomes receiver. */
|
||||||
|
function selectDisambiguated(person: PersonHint) {
|
||||||
|
if (!nlInterpretation) return;
|
||||||
|
const resolved = nlInterpretation.resolvedPersons;
|
||||||
|
applyResolvedAndSearch({
|
||||||
|
senderId: resolved.length >= 1 ? resolved[0].id : person.id,
|
||||||
|
receiverId: resolved.length >= 1 ? person.id : '',
|
||||||
|
from: nlInterpretation.dateFrom ?? '',
|
||||||
|
to: nlInterpretation.dateTo ?? '',
|
||||||
|
q: nlInterpretation.keywordsApplied ? nlInterpretation.keywords.join(' ') : ''
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Trigger search reactively when the tag list changes.
|
// Trigger search reactively when the tag list changes.
|
||||||
let prevTagStr = untrack(() => tagNames.map((t) => t.name).join(','));
|
let prevTagStr = untrack(() => tagNames.map((t) => t.name).join(','));
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
@@ -268,6 +406,7 @@ $effect(() => {
|
|||||||
bind:tagQ={tagQ}
|
bind:tagQ={tagQ}
|
||||||
bind:tagOperator={tagOperator}
|
bind:tagOperator={tagOperator}
|
||||||
bind:undated={undated}
|
bind:undated={undated}
|
||||||
|
bind:smartMode={smartMode}
|
||||||
undatedCount={data.undatedCount ?? 0}
|
undatedCount={data.undatedCount ?? 0}
|
||||||
initialSenderName={initialSenderName}
|
initialSenderName={initialSenderName}
|
||||||
initialReceiverName={initialReceiverName}
|
initialReceiverName={initialReceiverName}
|
||||||
@@ -275,93 +414,143 @@ $effect(() => {
|
|||||||
isLoading={navigating.to !== null}
|
isLoading={navigating.to !== null}
|
||||||
onSearch={handleTextSearch}
|
onSearch={handleTextSearch}
|
||||||
onSearchImmediate={handleImmediateSearch}
|
onSearchImmediate={handleImmediateSearch}
|
||||||
|
onSmartSearch={runSmartSearch}
|
||||||
|
onModeToggle={onModeToggle}
|
||||||
onfocus={() => (qFocused = true)}
|
onfocus={() => (qFocused = true)}
|
||||||
onblur={() => (qFocused = false)}
|
onblur={() => (qFocused = false)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="mt-3 mb-4 hidden lg:block">
|
{#if showNlView}
|
||||||
<TimelineDensityFilter
|
<!-- Smart-search results area: loading / error / chips + results / empty / disambiguation. -->
|
||||||
density={data.density}
|
<div data-testid="smart-search-results">
|
||||||
minDate={data.minDate}
|
{#if nlLoading}
|
||||||
maxDate={data.maxDate}
|
<SmartSearchStatus status="loading" />
|
||||||
zoomFrom={data.zoomFrom}
|
{:else if nlError}
|
||||||
zoomTo={data.zoomTo}
|
<SmartSearchStatus
|
||||||
from={from}
|
status="error"
|
||||||
to={to}
|
errorCode={nlError}
|
||||||
onchange={(event) => {
|
onSwitchToKeyword={switchToKeywordMode}
|
||||||
from = event.from;
|
/>
|
||||||
to = event.to;
|
{:else if nlInterpretation}
|
||||||
// Drag commits filter + zoom atomically (Graylog-style range selector).
|
{#key nlInterpretation}
|
||||||
// Single click and clear omit zoomFrom/zoomTo so existing zoom is preserved.
|
<div class="mb-4">
|
||||||
if ('zoomFrom' in event) {
|
{#if nlIsAmbiguous}
|
||||||
triggerSearchWithZoom(event.zoomFrom ?? null, event.zoomTo ?? null);
|
<DisambiguationPicker
|
||||||
} else {
|
persons={nlInterpretation.ambiguousPersons}
|
||||||
triggerSearchKeepZoom();
|
onSelect={selectDisambiguated}
|
||||||
}
|
/>
|
||||||
}}
|
{:else}
|
||||||
onzoomchange={(event) => {
|
<InterpretationChipRow interpretation={nlInterpretation} onRemoveChip={removeChip} />
|
||||||
triggerSearchWithZoom(event?.zoomFrom ?? null, event?.zoomTo ?? null);
|
{/if}
|
||||||
}}
|
</div>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mb-3 flex items-center justify-between gap-4">
|
{#if !nlIsAmbiguous}
|
||||||
<p class="font-sans text-base text-ink-2">
|
{#if nlHasResults}
|
||||||
{#if data.totalElements > 0}{m.docs_result_count({ count: data.totalElements })}{/if}
|
<p class="mb-3 font-sans text-base text-ink-2">
|
||||||
</p>
|
{m.docs_result_count({ count: nlResult?.totalElements ?? 0 })}
|
||||||
{#if data.canWrite}
|
</p>
|
||||||
<div class="flex flex-col items-end gap-1">
|
<DocumentList items={nlResult?.items ?? []} canWrite={data.canWrite} sort={sort} />
|
||||||
<div class="flex items-center gap-4">
|
{:else}
|
||||||
{#if data.totalElements > 0}
|
<div class="flex flex-col items-center justify-center gap-3 py-16 text-center">
|
||||||
<button
|
<p class="text-sm font-bold text-ink">{m.search_empty_nl()}</p>
|
||||||
type="button"
|
<button
|
||||||
onclick={editAllMatching}
|
type="button"
|
||||||
disabled={editingAll}
|
onclick={switchToKeywordMode}
|
||||||
class="inline-flex cursor-pointer items-center gap-1 text-sm font-medium text-ink-2 transition-colors hover:text-ink disabled:opacity-50"
|
class="inline-flex min-h-[44px] items-center rounded px-3 py-2 text-sm font-bold text-primary underline underline-offset-4 outline-none hover:text-brand-mint focus-visible:ring-2 focus-visible:ring-brand-navy"
|
||||||
data-testid="bulk-edit-all-x"
|
>
|
||||||
|
{m.search_empty_retry_keyword()}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
{/key}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="mt-3 mb-4 hidden lg:block">
|
||||||
|
<TimelineDensityFilter
|
||||||
|
density={data.density}
|
||||||
|
minDate={data.minDate}
|
||||||
|
maxDate={data.maxDate}
|
||||||
|
zoomFrom={data.zoomFrom}
|
||||||
|
zoomTo={data.zoomTo}
|
||||||
|
from={from}
|
||||||
|
to={to}
|
||||||
|
onchange={(event) => {
|
||||||
|
from = event.from;
|
||||||
|
to = event.to;
|
||||||
|
// Drag commits filter + zoom atomically (Graylog-style range selector).
|
||||||
|
// Single click and clear omit zoomFrom/zoomTo so existing zoom is preserved.
|
||||||
|
if ('zoomFrom' in event) {
|
||||||
|
triggerSearchWithZoom(event.zoomFrom ?? null, event.zoomTo ?? null);
|
||||||
|
} else {
|
||||||
|
triggerSearchKeepZoom();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onzoomchange={(event) => {
|
||||||
|
triggerSearchWithZoom(event?.zoomFrom ?? null, event?.zoomTo ?? null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3 flex items-center justify-between gap-4">
|
||||||
|
<p class="font-sans text-base text-ink-2">
|
||||||
|
{#if data.totalElements > 0}{m.docs_result_count({ count: data.totalElements })}{/if}
|
||||||
|
</p>
|
||||||
|
{#if data.canWrite}
|
||||||
|
<div class="flex flex-col items-end gap-1">
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
{#if data.totalElements > 0}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={editAllMatching}
|
||||||
|
disabled={editingAll}
|
||||||
|
class="inline-flex cursor-pointer items-center gap-1 text-sm font-medium text-ink-2 transition-colors hover:text-ink disabled:opacity-50"
|
||||||
|
data-testid="bulk-edit-all-x"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Edit-Content-MD.svg"
|
||||||
|
alt=""
|
||||||
|
aria-hidden="true"
|
||||||
|
class="h-4 w-4"
|
||||||
|
/>
|
||||||
|
{m.bulk_edit_all_x({ count: data.totalElements })}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
<a
|
||||||
|
href="/documents/new"
|
||||||
|
class="inline-flex items-center gap-1 text-sm font-medium text-ink-2 transition-colors hover:text-ink"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Edit-Content-MD.svg"
|
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Add/Add-General-MD.svg"
|
||||||
alt=""
|
alt=""
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
class="h-4 w-4"
|
class="h-4 w-4"
|
||||||
/>
|
/>
|
||||||
{m.bulk_edit_all_x({ count: data.totalElements })}
|
{m.docs_btn_new()}
|
||||||
</button>
|
</a>
|
||||||
|
</div>
|
||||||
|
{#if editAllError}
|
||||||
|
<p role="alert" class="text-xs text-danger" data-testid="bulk-edit-all-x-error">
|
||||||
|
{editAllError}
|
||||||
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
<a
|
|
||||||
href="/documents/new"
|
|
||||||
class="inline-flex items-center gap-1 text-sm font-medium text-ink-2 transition-colors hover:text-ink"
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Add/Add-General-MD.svg"
|
|
||||||
alt=""
|
|
||||||
aria-hidden="true"
|
|
||||||
class="h-4 w-4"
|
|
||||||
/>
|
|
||||||
{m.docs_btn_new()}
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
{#if editAllError}
|
{/if}
|
||||||
<p role="alert" class="text-xs text-danger" data-testid="bulk-edit-all-x-error">
|
</div>
|
||||||
{editAllError}
|
|
||||||
</p>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DocumentList
|
<DocumentList
|
||||||
items={data.items}
|
items={data.items}
|
||||||
q={data.q}
|
q={data.q}
|
||||||
canWrite={data.canWrite}
|
canWrite={data.canWrite}
|
||||||
error={data.error}
|
error={data.error}
|
||||||
sort={sort}
|
sort={sort}
|
||||||
from={data.from}
|
from={data.from}
|
||||||
to={data.to}
|
to={data.to}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Pagination page={data.pageNumber} totalPages={data.totalPages} makeHref={buildPageHref} />
|
<Pagination page={data.pageNumber} totalPages={data.totalPages} makeHref={buildPageHref} />
|
||||||
|
{/if}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<BulkSelectionBar canWrite={data.canWrite} />
|
<BulkSelectionBar canWrite={data.canWrite} />
|
||||||
|
|||||||
86
frontend/src/routes/search/DisambiguationPicker.svelte
Normal file
86
frontend/src/routes/search/DisambiguationPicker.svelte
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { tick } from 'svelte';
|
||||||
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
import { clickOutside } from '$lib/shared/actions/clickOutside';
|
||||||
|
import type { components } from '$lib/generated/api';
|
||||||
|
|
||||||
|
type PersonHint = components['schemas']['PersonHint'];
|
||||||
|
|
||||||
|
let { persons, onSelect }: { persons: PersonHint[]; onSelect: (person: PersonHint) => void } =
|
||||||
|
$props();
|
||||||
|
|
||||||
|
let open = $state(false);
|
||||||
|
let triggerEl = $state<HTMLButtonElement>();
|
||||||
|
let listEl = $state<HTMLUListElement>();
|
||||||
|
|
||||||
|
const panelId = 'disambiguation-panel';
|
||||||
|
const names = $derived(persons.map((person) => person.displayName).join(', '));
|
||||||
|
|
||||||
|
async function openPicker() {
|
||||||
|
open = true;
|
||||||
|
await tick();
|
||||||
|
listEl?.querySelector<HTMLButtonElement>('button')?.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function closePicker() {
|
||||||
|
open = false;
|
||||||
|
triggerEl?.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggle() {
|
||||||
|
if (open) closePicker();
|
||||||
|
else openPicker();
|
||||||
|
}
|
||||||
|
|
||||||
|
function select(person: PersonHint) {
|
||||||
|
open = false;
|
||||||
|
onSelect(person);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKeydown(event: KeyboardEvent) {
|
||||||
|
if (event.key === 'Escape' && open) {
|
||||||
|
event.stopPropagation();
|
||||||
|
closePicker();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window onkeydown={onKeydown} />
|
||||||
|
|
||||||
|
<div class="relative inline-block" use:clickOutside onclickoutside={() => open && closePicker()}>
|
||||||
|
<button
|
||||||
|
bind:this={triggerEl}
|
||||||
|
type="button"
|
||||||
|
aria-haspopup="true"
|
||||||
|
aria-expanded={open}
|
||||||
|
aria-controls={panelId}
|
||||||
|
aria-label={m.search_disambiguation_trigger_label()}
|
||||||
|
onclick={toggle}
|
||||||
|
class="inline-flex min-h-[44px] items-center gap-1.5 rounded-full border border-line bg-muted px-3 text-sm text-ink-2 outline-none focus-visible:ring-2 focus-visible:ring-brand-navy"
|
||||||
|
>
|
||||||
|
<span class="max-w-[8rem] truncate sm:max-w-[12rem]">{names}</span>
|
||||||
|
<span class="text-ink-3">{m.search_disambiguation_cue()}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{#if open}
|
||||||
|
<ul
|
||||||
|
bind:this={listEl}
|
||||||
|
id={panelId}
|
||||||
|
aria-label={m.search_disambiguation_heading()}
|
||||||
|
class="absolute left-0 z-10 mt-1 min-w-[12rem] rounded-sm border border-line bg-surface py-1 shadow-md"
|
||||||
|
>
|
||||||
|
{#each persons as person (person.id)}
|
||||||
|
<li>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={m.search_disambiguation_select_label({ name: person.displayName })}
|
||||||
|
onclick={() => select(person)}
|
||||||
|
class="flex min-h-[44px] w-full items-center px-4 text-left text-sm text-ink outline-none hover:bg-muted focus-visible:bg-muted focus-visible:ring-2 focus-visible:ring-brand-navy"
|
||||||
|
>
|
||||||
|
{person.displayName}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { describe, expect, it, vi, afterEach } from 'vitest';
|
||||||
|
import { cleanup, render } from 'vitest-browser-svelte';
|
||||||
|
import { page } from 'vitest/browser';
|
||||||
|
import DisambiguationPicker from './DisambiguationPicker.svelte';
|
||||||
|
import type { components } from '$lib/generated/api';
|
||||||
|
|
||||||
|
type PersonHint = components['schemas']['PersonHint'];
|
||||||
|
|
||||||
|
afterEach(() => cleanup());
|
||||||
|
|
||||||
|
const persons: PersonHint[] = [
|
||||||
|
{ id: 'w1', displayName: 'Walter Raddatz' },
|
||||||
|
{ id: 'w2', displayName: 'Walter Müller' }
|
||||||
|
];
|
||||||
|
|
||||||
|
function pressEscape() {
|
||||||
|
(document.activeElement as HTMLElement).dispatchEvent(
|
||||||
|
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('DisambiguationPicker', () => {
|
||||||
|
it('opens the picker and shows a select option per ambiguous person', async () => {
|
||||||
|
render(DisambiguationPicker, { persons, onSelect: vi.fn() });
|
||||||
|
await page.getByRole('button', { name: /Mehrere Personen gefunden/ }).click();
|
||||||
|
await expect
|
||||||
|
.element(page.getByRole('button', { name: 'Walter Raddatz auswählen' }))
|
||||||
|
.toBeInTheDocument();
|
||||||
|
await expect
|
||||||
|
.element(page.getByRole('button', { name: 'Walter Müller auswählen' }))
|
||||||
|
.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('moves focus into the picker list on open', async () => {
|
||||||
|
render(DisambiguationPicker, { persons, onSelect: vi.fn() });
|
||||||
|
await page.getByRole('button', { name: /Mehrere Personen gefunden/ }).click();
|
||||||
|
await expect
|
||||||
|
.element(page.getByRole('button', { name: 'Walter Raddatz auswählen' }))
|
||||||
|
.toHaveFocus();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns focus to the trigger when closed with Escape', async () => {
|
||||||
|
render(DisambiguationPicker, { persons, onSelect: vi.fn() });
|
||||||
|
const trigger = page.getByRole('button', { name: /Mehrere Personen gefunden/ });
|
||||||
|
await trigger.click();
|
||||||
|
await expect
|
||||||
|
.element(page.getByRole('button', { name: 'Walter Raddatz auswählen' }))
|
||||||
|
.toHaveFocus();
|
||||||
|
pressEscape();
|
||||||
|
await expect.element(trigger).toHaveFocus();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not call onSelect when dismissed without choosing', async () => {
|
||||||
|
const onSelect = vi.fn();
|
||||||
|
render(DisambiguationPicker, { persons, onSelect });
|
||||||
|
await page.getByRole('button', { name: /Mehrere Personen gefunden/ }).click();
|
||||||
|
await expect
|
||||||
|
.element(page.getByRole('button', { name: 'Walter Raddatz auswählen' }))
|
||||||
|
.toHaveFocus();
|
||||||
|
pressEscape();
|
||||||
|
expect(onSelect).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onSelect with the chosen person', async () => {
|
||||||
|
const onSelect = vi.fn();
|
||||||
|
render(DisambiguationPicker, { persons, onSelect });
|
||||||
|
await page.getByRole('button', { name: /Mehrere Personen gefunden/ }).click();
|
||||||
|
await page.getByRole('button', { name: 'Walter Müller auswählen' }).click();
|
||||||
|
expect(onSelect).toHaveBeenCalledWith(persons[1]);
|
||||||
|
});
|
||||||
|
});
|
||||||
133
frontend/src/routes/search/InterpretationChipRow.svelte
Normal file
133
frontend/src/routes/search/InterpretationChipRow.svelte
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { SvelteSet } from 'svelte/reactivity';
|
||||||
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
import type { components } from '$lib/generated/api';
|
||||||
|
|
||||||
|
type NlQueryInterpretation = components['schemas']['NlQueryInterpretation'];
|
||||||
|
type ChipType = 'sender' | 'directional' | 'date' | 'keyword';
|
||||||
|
|
||||||
|
let {
|
||||||
|
interpretation,
|
||||||
|
onRemoveChip
|
||||||
|
}: {
|
||||||
|
interpretation: NlQueryInterpretation;
|
||||||
|
onRemoveChip: (type: ChipType, value?: string) => void;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
type Chip =
|
||||||
|
| { key: string; type: 'sender'; label: string }
|
||||||
|
| { key: string; type: 'directional'; from: string; to: string }
|
||||||
|
| { key: string; type: 'date'; label: string }
|
||||||
|
| { key: string; type: 'keyword'; value: string; label: string };
|
||||||
|
|
||||||
|
// Locally removed chips. The parent remounts this component (via {#key}) on every
|
||||||
|
// new NL search, so this set never needs an explicit reset.
|
||||||
|
const removed = new SvelteSet<string>();
|
||||||
|
|
||||||
|
function yearOf(iso: string | undefined): string | undefined {
|
||||||
|
return iso?.slice(0, 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
function dateRangeLabel(from: string | undefined, to: string | undefined): string {
|
||||||
|
const fromYear = yearOf(from);
|
||||||
|
const toYear = yearOf(to);
|
||||||
|
if (fromYear && toYear) return fromYear === toYear ? fromYear : `${fromYear}–${toYear}`;
|
||||||
|
return fromYear ?? toYear ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const chips = $derived.by(() => {
|
||||||
|
const list: Chip[] = [];
|
||||||
|
const { resolvedPersons, dateFrom, dateTo, keywords, keywordsApplied } = interpretation;
|
||||||
|
|
||||||
|
if (resolvedPersons.length >= 2) {
|
||||||
|
list.push({
|
||||||
|
key: 'directional',
|
||||||
|
type: 'directional',
|
||||||
|
from: resolvedPersons[0].displayName,
|
||||||
|
to: resolvedPersons[1].displayName
|
||||||
|
});
|
||||||
|
} else if (resolvedPersons.length === 1) {
|
||||||
|
list.push({
|
||||||
|
key: 'sender:' + resolvedPersons[0].id,
|
||||||
|
type: 'sender',
|
||||||
|
label: `${m.search_chip_sender()}: ${resolvedPersons[0].displayName}`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dateFrom || dateTo) {
|
||||||
|
list.push({
|
||||||
|
key: 'date',
|
||||||
|
type: 'date',
|
||||||
|
label: `${m.search_chip_date()}: ${dateRangeLabel(dateFrom, dateTo)}`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (keywordsApplied) {
|
||||||
|
for (const keyword of keywords) {
|
||||||
|
list.push({
|
||||||
|
key: 'keyword:' + keyword,
|
||||||
|
type: 'keyword',
|
||||||
|
value: keyword,
|
||||||
|
label: `${m.search_chip_keyword()}: ${keyword}`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return list.filter((chip) => !removed.has(chip.key));
|
||||||
|
});
|
||||||
|
|
||||||
|
const showKeywordsNotApplied = $derived(
|
||||||
|
!interpretation.keywordsApplied && interpretation.keywords.length > 0
|
||||||
|
);
|
||||||
|
|
||||||
|
function remove(chip: Chip) {
|
||||||
|
removed.add(chip.key);
|
||||||
|
onRemoveChip(chip.type, chip.type === 'keyword' ? chip.value : undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
const nameSpan = 'sm:max-w-[12rem] max-w-[8rem] truncate';
|
||||||
|
const chipWrapper =
|
||||||
|
'inline-flex items-center gap-1.5 rounded-full border border-line bg-muted px-3 text-sm text-ink-2 focus-within:ring-2 focus-within:ring-brand-navy';
|
||||||
|
const removeButton =
|
||||||
|
'flex min-h-[44px] w-6 shrink-0 items-center justify-center text-ink-3 outline-none hover:text-red-500 focus-visible:ring-2 focus-visible:ring-brand-navy';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
{#each chips as chip (chip.key)}
|
||||||
|
{#if chip.type === 'directional'}
|
||||||
|
<span
|
||||||
|
data-chip-type="directional"
|
||||||
|
class={chipWrapper}
|
||||||
|
aria-label={m.search_chip_directional_label({ from: chip.from, to: chip.to })}
|
||||||
|
>
|
||||||
|
<span class={nameSpan}>{chip.from}</span>
|
||||||
|
<span aria-hidden="true">→</span>
|
||||||
|
<span class={nameSpan}>{chip.to}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class={removeButton}
|
||||||
|
aria-label={m.search_filter_remove_label({ label: `${chip.from} → ${chip.to}` })}
|
||||||
|
onclick={() => remove(chip)}
|
||||||
|
>
|
||||||
|
<span aria-hidden="true">×</span>
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
{:else}
|
||||||
|
<span data-chip-type={chip.type} class={chipWrapper}>
|
||||||
|
<span class={nameSpan}>{chip.label}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class={removeButton}
|
||||||
|
aria-label={m.search_filter_remove_label({ label: chip.label })}
|
||||||
|
onclick={() => remove(chip)}
|
||||||
|
>
|
||||||
|
<span aria-hidden="true">×</span>
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if showKeywordsNotApplied}
|
||||||
|
<p class="mt-2 text-xs text-ink-3">{m.smart_search_keywords_not_applied()}</p>
|
||||||
|
{/if}
|
||||||
133
frontend/src/routes/search/InterpretationChipRow.svelte.spec.ts
Normal file
133
frontend/src/routes/search/InterpretationChipRow.svelte.spec.ts
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
import { describe, expect, it, vi, afterEach } from 'vitest';
|
||||||
|
import { cleanup, render } from 'vitest-browser-svelte';
|
||||||
|
import { page } from 'vitest/browser';
|
||||||
|
import InterpretationChipRow from './InterpretationChipRow.svelte';
|
||||||
|
import type { components } from '$lib/generated/api';
|
||||||
|
|
||||||
|
type NlQueryInterpretation = components['schemas']['NlQueryInterpretation'];
|
||||||
|
type PersonHint = components['schemas']['PersonHint'];
|
||||||
|
|
||||||
|
afterEach(() => cleanup());
|
||||||
|
|
||||||
|
const makePerson = (id: string, displayName: string): PersonHint => ({ id, displayName });
|
||||||
|
|
||||||
|
const makeInterpretation = (
|
||||||
|
overrides: Partial<NlQueryInterpretation> = {}
|
||||||
|
): NlQueryInterpretation => ({
|
||||||
|
resolvedPersons: [],
|
||||||
|
ambiguousPersons: [],
|
||||||
|
keywords: [],
|
||||||
|
rawQuery: 'test',
|
||||||
|
keywordsApplied: true,
|
||||||
|
...overrides
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('InterpretationChipRow', () => {
|
||||||
|
it('renders type-prefixed labels for sender, date and keyword chips', async () => {
|
||||||
|
render(InterpretationChipRow, {
|
||||||
|
interpretation: makeInterpretation({
|
||||||
|
resolvedPersons: [makePerson('p1', 'Walter Raddatz')],
|
||||||
|
dateFrom: '1914-01-01',
|
||||||
|
dateTo: '1918-12-31',
|
||||||
|
keywords: ['krieg']
|
||||||
|
}),
|
||||||
|
onRemoveChip: vi.fn()
|
||||||
|
});
|
||||||
|
await expect.element(page.getByText('Absender: Walter Raddatz')).toBeInTheDocument();
|
||||||
|
await expect.element(page.getByText('Zeitraum: 1914–1918')).toBeInTheDocument();
|
||||||
|
await expect.element(page.getByText('Stichwort: krieg')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onRemoveChip with "sender" when the sender chip × is clicked', async () => {
|
||||||
|
const onRemoveChip = vi.fn();
|
||||||
|
render(InterpretationChipRow, {
|
||||||
|
interpretation: makeInterpretation({
|
||||||
|
resolvedPersons: [makePerson('p1', 'Walter Raddatz')]
|
||||||
|
}),
|
||||||
|
onRemoveChip
|
||||||
|
});
|
||||||
|
await page.getByRole('button', { name: /Absender: Walter Raddatz/ }).click();
|
||||||
|
expect(onRemoveChip).toHaveBeenCalledWith('sender', undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes a chip from the DOM but keeps the rest when one × is clicked', async () => {
|
||||||
|
const { container } = render(InterpretationChipRow, {
|
||||||
|
interpretation: makeInterpretation({
|
||||||
|
resolvedPersons: [makePerson('p1', 'Walter Raddatz')],
|
||||||
|
dateFrom: '1914-01-01',
|
||||||
|
dateTo: '1918-12-31',
|
||||||
|
keywords: ['krieg']
|
||||||
|
}),
|
||||||
|
onRemoveChip: vi.fn()
|
||||||
|
});
|
||||||
|
expect(container.querySelectorAll('[data-chip-type]')).toHaveLength(3);
|
||||||
|
await page.getByRole('button', { name: /Absender/ }).click();
|
||||||
|
await vi.waitFor(() => expect(container.querySelectorAll('[data-chip-type]')).toHaveLength(2));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders a single directional chip with an arrow for a 2-name query', async () => {
|
||||||
|
const { container } = render(InterpretationChipRow, {
|
||||||
|
interpretation: makeInterpretation({
|
||||||
|
resolvedPersons: [makePerson('p1', 'Walter Raddatz'), makePerson('p2', 'Emma Raddatz')]
|
||||||
|
}),
|
||||||
|
onRemoveChip: vi.fn()
|
||||||
|
});
|
||||||
|
expect(container.querySelectorAll('[data-chip-type="directional"]')).toHaveLength(1);
|
||||||
|
await expect.element(page.getByText(/→/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onRemoveChip with "directional" when the directional chip × is clicked', async () => {
|
||||||
|
const onRemoveChip = vi.fn();
|
||||||
|
render(InterpretationChipRow, {
|
||||||
|
interpretation: makeInterpretation({
|
||||||
|
resolvedPersons: [makePerson('p1', 'Walter Raddatz'), makePerson('p2', 'Emma Raddatz')]
|
||||||
|
}),
|
||||||
|
onRemoveChip
|
||||||
|
});
|
||||||
|
await page.getByRole('button', { name: /Walter Raddatz/ }).click();
|
||||||
|
expect(onRemoveChip).toHaveBeenCalledWith('directional', undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not render keyword chips when keywordsApplied is false', async () => {
|
||||||
|
const { container } = render(InterpretationChipRow, {
|
||||||
|
interpretation: makeInterpretation({
|
||||||
|
keywordsApplied: false,
|
||||||
|
keywords: ['krieg', 'brief']
|
||||||
|
}),
|
||||||
|
onRemoveChip: vi.fn()
|
||||||
|
});
|
||||||
|
expect(container.querySelectorAll('[data-chip-type="keyword"]')).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders no keyword chips when keywords is empty', async () => {
|
||||||
|
const { container } = render(InterpretationChipRow, {
|
||||||
|
interpretation: makeInterpretation({ keywordsApplied: true, keywords: [] }),
|
||||||
|
onRemoveChip: vi.fn()
|
||||||
|
});
|
||||||
|
expect(container.querySelectorAll('[data-chip-type="keyword"]')).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders exactly one keyword chip per keyword', async () => {
|
||||||
|
const { container } = render(InterpretationChipRow, {
|
||||||
|
interpretation: makeInterpretation({
|
||||||
|
keywordsApplied: true,
|
||||||
|
keywords: ['krieg', 'brief', 'front']
|
||||||
|
}),
|
||||||
|
onRemoveChip: vi.fn()
|
||||||
|
});
|
||||||
|
expect(container.querySelectorAll('[data-chip-type="keyword"]')).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the × button in the DOM when a display name is 100 characters', async () => {
|
||||||
|
const longName = 'W'.repeat(100);
|
||||||
|
render(InterpretationChipRow, {
|
||||||
|
interpretation: makeInterpretation({
|
||||||
|
resolvedPersons: [makePerson('p1', longName)]
|
||||||
|
}),
|
||||||
|
onRemoveChip: vi.fn()
|
||||||
|
});
|
||||||
|
await expect
|
||||||
|
.element(page.getByRole('button', { name: new RegExp('Absender') }))
|
||||||
|
.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
38
frontend/src/routes/search/SmartModeToggle.svelte
Normal file
38
frontend/src/routes/search/SmartModeToggle.svelte
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
|
||||||
|
let { smartMode = $bindable(false), onToggle }: { smartMode?: boolean; onToggle?: () => void } =
|
||||||
|
$props();
|
||||||
|
|
||||||
|
const label = $derived(smartMode ? m.search_toggle_smart_label() : m.search_toggle_keyword_label());
|
||||||
|
const labelSuffix = $derived(
|
||||||
|
smartMode ? m.search_toggle_smart_label_suffix() : m.search_toggle_keyword_label_suffix()
|
||||||
|
);
|
||||||
|
|
||||||
|
function toggle() {
|
||||||
|
smartMode = !smartMode;
|
||||||
|
onToggle?.();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-pressed={smartMode}
|
||||||
|
onclick={toggle}
|
||||||
|
class="pointer-events-auto absolute top-1/2 right-2 flex -translate-y-1/2 cursor-pointer items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-bold uppercase outline-none focus-visible:ring-2 focus-visible:ring-brand-navy {smartMode
|
||||||
|
? 'border border-primary bg-primary text-primary-fg'
|
||||||
|
: 'border border-line bg-muted text-ink-2'}"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
aria-hidden="true"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="currentColor"
|
||||||
|
class="h-3.5 w-3.5"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path d="M12 2l2.09 6.26L20 10l-5.91 1.74L12 18l-2.09-6.26L4 10l5.91-1.74L12 2z" />
|
||||||
|
</svg>
|
||||||
|
<span>
|
||||||
|
{label}<span class="sm:hidden">{labelSuffix}</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
81
frontend/src/routes/search/SmartModeToggle.svelte.spec.ts
Normal file
81
frontend/src/routes/search/SmartModeToggle.svelte.spec.ts
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import { describe, expect, it, vi, afterEach } from 'vitest';
|
||||||
|
import { cleanup, render } from 'vitest-browser-svelte';
|
||||||
|
import { page } from 'vitest/browser';
|
||||||
|
import SmartModeToggle from './SmartModeToggle.svelte';
|
||||||
|
import SearchFilterBar from '../SearchFilterBar.svelte';
|
||||||
|
|
||||||
|
afterEach(() => cleanup());
|
||||||
|
|
||||||
|
const SEARCH_PLACEHOLDER = 'Titel, Personen, Tags durchsuchen…';
|
||||||
|
|
||||||
|
describe('SmartModeToggle', () => {
|
||||||
|
it('renders aria-pressed="false" by default and toggles on click', async () => {
|
||||||
|
render(SmartModeToggle, { smartMode: false });
|
||||||
|
const btn = page.getByRole('button');
|
||||||
|
await expect.element(btn).toHaveAttribute('aria-pressed', 'false');
|
||||||
|
await btn.click();
|
||||||
|
await expect.element(btn).toHaveAttribute('aria-pressed', 'true');
|
||||||
|
await btn.click();
|
||||||
|
await expect.element(btn).toHaveAttribute('aria-pressed', 'false');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the smart label when smartMode is true', async () => {
|
||||||
|
render(SmartModeToggle, { smartMode: true });
|
||||||
|
const btn = page.getByRole('button');
|
||||||
|
await expect.element(btn).toHaveTextContent('KI');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the keyword label when smartMode is false', async () => {
|
||||||
|
render(SmartModeToggle, { smartMode: false });
|
||||||
|
const btn = page.getByRole('button');
|
||||||
|
await expect.element(btn).toHaveTextContent('Text');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies the active pill style only in smart mode', async () => {
|
||||||
|
render(SmartModeToggle, { smartMode: true });
|
||||||
|
const btn = page.getByRole('button');
|
||||||
|
await expect.element(btn).toHaveClass(/bg-primary/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('SmartModeToggle inside SearchFilterBar', () => {
|
||||||
|
it('adds maxlength="500" to the search input only in smart mode', async () => {
|
||||||
|
render(SearchFilterBar, { onSearch: vi.fn(), sort: 'DATE', dir: 'desc', smartMode: true });
|
||||||
|
await expect
|
||||||
|
.element(page.getByPlaceholder(SEARCH_PLACEHOLDER))
|
||||||
|
.toHaveAttribute('maxlength', '500');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('omits maxlength from the search input in keyword mode', async () => {
|
||||||
|
render(SearchFilterBar, { onSearch: vi.fn(), sort: 'DATE', dir: 'desc', smartMode: false });
|
||||||
|
await expect
|
||||||
|
.element(page.getByPlaceholder(SEARCH_PLACEHOLDER))
|
||||||
|
.not.toHaveAttribute('maxlength');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not fire the keyword search on input while in smart mode', async () => {
|
||||||
|
const onSearch = vi.fn();
|
||||||
|
render(SearchFilterBar, { onSearch, sort: 'DATE', dir: 'desc', smartMode: true });
|
||||||
|
await page.getByPlaceholder(SEARCH_PLACEHOLDER).fill('Walter im Krieg');
|
||||||
|
expect(onSearch).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fires the smart search callback on Enter in smart mode', async () => {
|
||||||
|
const onSmartSearch = vi.fn();
|
||||||
|
render(SearchFilterBar, {
|
||||||
|
onSearch: vi.fn(),
|
||||||
|
onSmartSearch,
|
||||||
|
sort: 'DATE',
|
||||||
|
dir: 'desc',
|
||||||
|
smartMode: true
|
||||||
|
});
|
||||||
|
const input = page.getByPlaceholder(SEARCH_PLACEHOLDER);
|
||||||
|
await input.fill('Walter im Krieg');
|
||||||
|
await input.click();
|
||||||
|
// Enter submits the NL query in smart mode
|
||||||
|
(document.activeElement as HTMLElement).dispatchEvent(
|
||||||
|
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })
|
||||||
|
);
|
||||||
|
await vi.waitFor(() => expect(onSmartSearch).toHaveBeenCalled());
|
||||||
|
});
|
||||||
|
});
|
||||||
69
frontend/src/routes/search/SmartSearchStatus.svelte
Normal file
69
frontend/src/routes/search/SmartSearchStatus.svelte
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
|
||||||
|
type SmartSearchErrorCode = 'SMART_SEARCH_UNAVAILABLE' | 'SMART_SEARCH_RATE_LIMITED';
|
||||||
|
|
||||||
|
let {
|
||||||
|
status,
|
||||||
|
errorCode,
|
||||||
|
onSwitchToKeyword
|
||||||
|
}: {
|
||||||
|
status: 'loading' | 'error';
|
||||||
|
errorCode?: SmartSearchErrorCode;
|
||||||
|
onSwitchToKeyword?: () => void;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
const isRateLimited = $derived(errorCode === 'SMART_SEARCH_RATE_LIMITED');
|
||||||
|
const title = $derived(
|
||||||
|
isRateLimited ? m.search_error_rate_limited() : m.search_error_unavailable()
|
||||||
|
);
|
||||||
|
const body = $derived(
|
||||||
|
isRateLimited ? m.search_error_rate_limited_body() : m.search_error_unavailable_body()
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if status === 'loading'}
|
||||||
|
<div
|
||||||
|
role="status"
|
||||||
|
aria-live="polite"
|
||||||
|
class="flex flex-col items-center justify-center gap-3 py-16 text-center"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
class="h-9 w-9 rounded-full border-[3px] border-primary/12 border-t-primary motion-safe:animate-spin"
|
||||||
|
></div>
|
||||||
|
<p class="text-sm font-bold text-ink">{m.search_loading_nl()}</p>
|
||||||
|
<p class="max-w-xs text-xs text-ink-3 motion-safe:animate-pulse">
|
||||||
|
{m.search_loading_nl_sub()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{:else if status === 'error'}
|
||||||
|
<div role="alert" class="flex flex-col items-center justify-center gap-3 py-16 text-center">
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
class="flex h-10 w-10 items-center justify-center rounded-full border-2 text-lg font-bold {isRateLimited
|
||||||
|
? 'border-amber-400 bg-amber-50 text-amber-600'
|
||||||
|
: 'border-red-400 bg-red-50 text-red-600'}"
|
||||||
|
>
|
||||||
|
{#if isRateLimited}
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="h-5 w-5">
|
||||||
|
<circle cx="12" cy="12" r="9" />
|
||||||
|
<path d="M12 7v5l3 2" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
|
</svg>
|
||||||
|
{:else}
|
||||||
|
<span>!</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<p class="text-sm font-bold text-ink">{title}</p>
|
||||||
|
<p class="max-w-xs text-xs text-ink-3">{body}</p>
|
||||||
|
{#if !isRateLimited}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={onSwitchToKeyword}
|
||||||
|
class="mt-2 inline-flex min-h-[44px] items-center rounded border border-primary bg-primary px-4 py-2 text-sm font-bold text-primary-fg outline-none focus-visible:ring-2 focus-visible:ring-brand-navy"
|
||||||
|
>
|
||||||
|
{m.search_switch_to_keyword()}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
60
frontend/src/routes/search/SmartSearchStatus.svelte.spec.ts
Normal file
60
frontend/src/routes/search/SmartSearchStatus.svelte.spec.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import { describe, expect, it, vi, afterEach } from 'vitest';
|
||||||
|
import { cleanup, render } from 'vitest-browser-svelte';
|
||||||
|
import { page } from 'vitest/browser';
|
||||||
|
import SmartSearchStatus from './SmartSearchStatus.svelte';
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('SmartSearchStatus', () => {
|
||||||
|
it('renders a role="status" loading panel with the loading title', async () => {
|
||||||
|
render(SmartSearchStatus, { status: 'loading' });
|
||||||
|
const status = page.getByRole('status');
|
||||||
|
await expect.element(status).toBeInTheDocument();
|
||||||
|
await expect.element(status).toHaveTextContent('Archiv wird befragt');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides the loading panel once the status changes away from loading', async () => {
|
||||||
|
const { rerender } = render(SmartSearchStatus, { status: 'loading' });
|
||||||
|
await expect.element(page.getByRole('status')).toBeInTheDocument();
|
||||||
|
await rerender({ status: 'error', errorCode: 'SMART_SEARCH_UNAVAILABLE' });
|
||||||
|
await expect.element(page.getByRole('status')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the 503 panel with title, body and a switch-to-keyword button', async () => {
|
||||||
|
render(SmartSearchStatus, {
|
||||||
|
status: 'error',
|
||||||
|
errorCode: 'SMART_SEARCH_UNAVAILABLE',
|
||||||
|
onSwitchToKeyword: vi.fn()
|
||||||
|
});
|
||||||
|
await expect.element(page.getByText('Intelligente Suche nicht verfügbar')).toBeInTheDocument();
|
||||||
|
await expect
|
||||||
|
.element(page.getByRole('button', { name: /Volltextsuche wechseln/ }))
|
||||||
|
.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('invokes onSwitchToKeyword when the 503 fallback button is clicked', async () => {
|
||||||
|
const onSwitchToKeyword = vi.fn();
|
||||||
|
render(SmartSearchStatus, {
|
||||||
|
status: 'error',
|
||||||
|
errorCode: 'SMART_SEARCH_UNAVAILABLE',
|
||||||
|
onSwitchToKeyword
|
||||||
|
});
|
||||||
|
await page.getByRole('button', { name: /Volltextsuche wechseln/ }).click();
|
||||||
|
expect(onSwitchToKeyword).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the 429 panel with title and body but no switch-to-keyword button', async () => {
|
||||||
|
render(SmartSearchStatus, {
|
||||||
|
status: 'error',
|
||||||
|
errorCode: 'SMART_SEARCH_RATE_LIMITED',
|
||||||
|
onSwitchToKeyword: vi.fn()
|
||||||
|
});
|
||||||
|
await expect.element(page.getByText('Zu viele Anfragen')).toBeInTheDocument();
|
||||||
|
await expect
|
||||||
|
.element(page.getByRole('button', { name: /Volltextsuche wechseln/ }))
|
||||||
|
.not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user