refactor(search): remove NLP/smart-search feature entirely (#772)
All checks were successful
CI / Unit & Component Tests (push) Successful in 3m23s
CI / OCR Service Tests (push) Successful in 24s
CI / Backend Unit Tests (push) Successful in 3m46s
CI / fail2ban Regex (push) Successful in 46s
CI / Semgrep Security Scan (push) Successful in 25s
CI / Compose Bucket Idempotency (push) Successful in 1m8s
All checks were successful
CI / Unit & Component Tests (push) Successful in 3m23s
CI / OCR Service Tests (push) Successful in 24s
CI / Backend Unit Tests (push) Successful in 3m46s
CI / fail2ban Regex (push) Successful in 46s
CI / Semgrep Security Scan (push) Successful in 25s
CI / Compose Bucket Idempotency (push) Successful in 1m8s
## Summary - Removes the NLP/smart-search feature completely — the feature was too unreliable and slow; users get better results with the regular search filters - Deletes the entire backend `search/` package (NlSearchController, NlQueryParserService, NlpClient, NlSearchRateLimiter — 14 classes + 6 test classes) - Deletes the `nlp-service/` Python microservice (FastAPI, rapidfuzz, DB-backed person matching) - Removes all frontend NL search components: SmartModeToggle, SmartSearchStatus, InterpretationChipRow, DisambiguationPicker, chip-types, theme-chip-removal - Strips smart-mode logic from SearchFilterBar and documents/+page.svelte - Removes `SMART_SEARCH_UNAVAILABLE` / `SMART_SEARCH_RATE_LIMITED` error codes from backend, frontend types, and all three i18n files (de/en/es) - Removes `nlp-service` container and `APP_NLP_BASE_URL` from both docker-compose files - Removes Ollama/NLP Prometheus scrape job and Grafana dashboard - Deletes ADRs 028 (×2), 034, 035 ## Test plan - [ ] Backend compiles: `cd backend && ./mvnw compile -q` → BUILD SUCCESS - [ ] Frontend server tests pass: `cd frontend && npm run test -- --project=server` - [ ] No NLP/smart-search references remain in source: `grep -r "SmartSearch\|NlSearch\|nlp-service\|SMART_SEARCH" backend/src frontend/src` - [ ] `docker compose config` validates both compose files - [ ] Search page loads, filter bar works, no smart-mode toggle visible 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Marcel <marcel@familienarchiv> Reviewed-on: #772
This commit was merged in pull request #772.
This commit is contained in:
@@ -228,22 +228,6 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/search/nl": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
post: operations["search"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/persons": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -1835,9 +1819,6 @@ export interface components {
|
||||
/** Format: uuid */
|
||||
targetId: string;
|
||||
};
|
||||
NlSearchRequest: {
|
||||
query: string;
|
||||
};
|
||||
Pageable: {
|
||||
/** Format: int32 */
|
||||
page?: number;
|
||||
@@ -1897,34 +1878,6 @@ export interface components {
|
||||
/** Format: int32 */
|
||||
length: number;
|
||||
};
|
||||
NlQueryInterpretation: {
|
||||
resolvedPersons: components["schemas"]["PersonHint"][];
|
||||
ambiguousPersons: components["schemas"]["PersonHint"][];
|
||||
/** Format: date */
|
||||
dateFrom?: string;
|
||||
/** Format: date */
|
||||
dateTo?: string;
|
||||
keywords: string[];
|
||||
resolvedTags: components["schemas"]["TagHint"][];
|
||||
rawQuery: string;
|
||||
keywordsApplied: boolean;
|
||||
tagsApplied: boolean;
|
||||
};
|
||||
NlSearchResponse: {
|
||||
result: components["schemas"]["DocumentSearchResult"];
|
||||
interpretation: components["schemas"]["NlQueryInterpretation"];
|
||||
};
|
||||
PersonHint: {
|
||||
/** Format: uuid */
|
||||
id: string;
|
||||
displayName: string;
|
||||
};
|
||||
TagHint: {
|
||||
/** Format: uuid */
|
||||
id: string;
|
||||
name: string;
|
||||
color?: string;
|
||||
};
|
||||
SearchMatchData: {
|
||||
transcriptionSnippet?: string;
|
||||
titleOffsets: components["schemas"]["MatchOffset"][];
|
||||
@@ -3244,32 +3197,6 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
search: {
|
||||
parameters: {
|
||||
query: {
|
||||
pageable: components["schemas"]["Pageable"];
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["NlSearchRequest"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["NlSearchResponse"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
getPersons: {
|
||||
parameters: {
|
||||
query?: {
|
||||
|
||||
@@ -53,8 +53,6 @@ export type ErrorCode =
|
||||
| 'FORBIDDEN'
|
||||
| 'CSRF_TOKEN_MISSING'
|
||||
| 'TOO_MANY_LOGIN_ATTEMPTS'
|
||||
| 'SMART_SEARCH_UNAVAILABLE'
|
||||
| 'SMART_SEARCH_RATE_LIMITED'
|
||||
| 'VALIDATION_ERROR'
|
||||
| 'BATCH_TOO_LARGE'
|
||||
| 'BULK_EDIT_TOO_MANY_IDS'
|
||||
@@ -180,10 +178,6 @@ export function getErrorMessage(code: ErrorCode | string | undefined): string {
|
||||
return m.error_csrf_token_missing();
|
||||
case 'TOO_MANY_LOGIN_ATTEMPTS':
|
||||
return m.error_too_many_login_attempts();
|
||||
case 'SMART_SEARCH_UNAVAILABLE':
|
||||
return m.error_smart_search_unavailable();
|
||||
case 'SMART_SEARCH_RATE_LIMITED':
|
||||
return m.error_smart_search_rate_limited();
|
||||
case 'VALIDATION_ERROR':
|
||||
return m.error_validation_error();
|
||||
case 'BATCH_TOO_LARGE':
|
||||
|
||||
@@ -3,7 +3,6 @@ import PersonTypeahead from '$lib/person/PersonTypeahead.svelte';
|
||||
import TagInput from '$lib/tag/TagInput.svelte';
|
||||
import DateInput from '$lib/shared/primitives/DateInput.svelte';
|
||||
import SortDropdown from '$lib/shared/primitives/SortDropdown.svelte';
|
||||
import SmartModeToggle from './search/SmartModeToggle.svelte';
|
||||
import { slide } from 'svelte/transition';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
|
||||
@@ -21,15 +20,12 @@ let {
|
||||
sort = $bindable('DATE'),
|
||||
dir = $bindable('desc'),
|
||||
showAdvanced = $bindable(false),
|
||||
smartMode = $bindable(false),
|
||||
initialSenderName = '',
|
||||
initialReceiverName = '',
|
||||
navKey = 0,
|
||||
isLoading = false,
|
||||
onSearch,
|
||||
onSearchImmediate,
|
||||
onSmartSearch,
|
||||
onModeToggle,
|
||||
onfocus,
|
||||
onblur
|
||||
}: {
|
||||
@@ -46,28 +42,16 @@ let {
|
||||
sort?: string;
|
||||
dir?: string;
|
||||
showAdvanced?: boolean;
|
||||
smartMode?: boolean;
|
||||
initialSenderName?: string;
|
||||
initialReceiverName?: string;
|
||||
navKey?: number;
|
||||
isLoading?: boolean;
|
||||
onSearch: () => void;
|
||||
onSearchImmediate?: () => void;
|
||||
onSmartSearch?: () => void;
|
||||
onModeToggle?: () => void;
|
||||
onfocus?: () => void;
|
||||
onblur?: () => void;
|
||||
} = $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
|
||||
let sortDirMounted = false;
|
||||
|
||||
@@ -92,19 +76,13 @@ $effect(() => {
|
||||
<input
|
||||
type="text"
|
||||
bind:value={q}
|
||||
oninput={smartMode ? undefined : onSearch}
|
||||
onkeydown={onSearchKeydown}
|
||||
oninput={onSearch}
|
||||
onfocus={onfocus}
|
||||
onblur={onblur}
|
||||
maxlength={smartMode ? 500 : undefined}
|
||||
aria-label={m.docs_search_placeholder()}
|
||||
placeholder={m.docs_search_placeholder()}
|
||||
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'}"
|
||||
class="block w-full border-line py-2.5 pr-4 pl-10 placeholder-ink-3 shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
/>
|
||||
<!-- 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}
|
||||
<svg
|
||||
@@ -132,7 +110,6 @@ $effect(() => {
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
<SmartModeToggle bind:smartMode={smartMode} onToggle={onModeToggle} />
|
||||
</div>
|
||||
|
||||
<!-- Sort Dropdown -->
|
||||
|
||||
@@ -195,39 +195,3 @@ describe('SearchFilterBar – tagQ live filter', () => {
|
||||
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,22 +8,9 @@ import DocumentList from '../DocumentList.svelte';
|
||||
import Pagination from '$lib/shared/primitives/Pagination.svelte';
|
||||
import BulkSelectionBar from '$lib/document/BulkSelectionBar.svelte';
|
||||
import TimelineDensityFilter from '$lib/document/TimelineDensityFilter.svelte';
|
||||
import SmartSearchStatus from '../search/SmartSearchStatus.svelte';
|
||||
import InterpretationChipRow from '../search/InterpretationChipRow.svelte';
|
||||
import type { ChipType } from '../search/chip-types.js';
|
||||
import { buildThemeRemovalUrl } from './theme-chip-removal.js';
|
||||
import DisambiguationPicker from '../search/DisambiguationPicker.svelte';
|
||||
import { bulkSelectionStore } from '$lib/document/bulkSelection.svelte';
|
||||
import { getErrorMessage, parseBackendError } from '$lib/shared/errors';
|
||||
import { csrfFetch } from '$lib/shared/cookies';
|
||||
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();
|
||||
|
||||
@@ -47,27 +34,6 @@ let tagQ = $state(untrack(() => data.tagQ || ''));
|
||||
let tagOperator = $state<'AND' | 'OR'>(untrack(() => (data.tagOp as 'AND' | 'OR') || 'AND'));
|
||||
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 ambiguousPersons = $derived(nlInterpretation?.ambiguousPersons ?? []);
|
||||
const nlIsAmbiguous = $derived(ambiguousPersons.length > 0);
|
||||
// A 1-item picker is always a "did you mean …?" suggestion (a single direct match auto-selects
|
||||
// and never reaches the picker); ≥2 keeps the "choose a person" framing and the action cue.
|
||||
const disambiguationHeading = $derived(
|
||||
ambiguousPersons.length === 1
|
||||
? m.search_disambiguation_did_you_mean({ name: ambiguousPersons[0].displayName })
|
||||
: m.search_disambiguation_heading()
|
||||
);
|
||||
const showDisambiguationCue = $derived(ambiguousPersons.length >= 2);
|
||||
|
||||
function hasAdvancedFilters() {
|
||||
return (
|
||||
(data.tags?.length ?? 0) > 0 ||
|
||||
@@ -198,124 +164,6 @@ function handleImmediateSearch() {
|
||||
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(' ') : ''
|
||||
};
|
||||
}
|
||||
|
||||
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(' ');
|
||||
} else if (type === 'theme' && value) {
|
||||
const url = buildThemeRemovalUrl(nlInterpretation, value);
|
||||
resetNlState();
|
||||
goto(url, { keepFocus: true, noScroll: true });
|
||||
return;
|
||||
}
|
||||
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.
|
||||
let prevTagStr = untrack(() => tagNames.map((t) => t.name).join(','));
|
||||
$effect(() => {
|
||||
@@ -420,7 +268,6 @@ $effect(() => {
|
||||
bind:tagQ={tagQ}
|
||||
bind:tagOperator={tagOperator}
|
||||
bind:undated={undated}
|
||||
bind:smartMode={smartMode}
|
||||
undatedCount={data.undatedCount ?? 0}
|
||||
initialSenderName={initialSenderName}
|
||||
initialReceiverName={initialReceiverName}
|
||||
@@ -428,71 +275,20 @@ $effect(() => {
|
||||
isLoading={navigating.to !== null}
|
||||
onSearch={handleTextSearch}
|
||||
onSearchImmediate={handleImmediateSearch}
|
||||
onSmartSearch={runSmartSearch}
|
||||
onModeToggle={onModeToggle}
|
||||
onfocus={() => (qFocused = true)}
|
||||
onblur={() => (qFocused = false)}
|
||||
/>
|
||||
|
||||
{#if showNlView}
|
||||
<!-- Smart-search results area: loading / error / chips + results / empty / disambiguation. -->
|
||||
<div data-testid="smart-search-results">
|
||||
{#if nlLoading}
|
||||
<SmartSearchStatus status="loading" />
|
||||
{:else if nlError}
|
||||
<SmartSearchStatus
|
||||
status="error"
|
||||
errorCode={nlError}
|
||||
onSwitchToKeyword={switchToKeywordMode}
|
||||
/>
|
||||
{:else if nlInterpretation}
|
||||
{#key nlInterpretation}
|
||||
<div class="mb-4">
|
||||
{#if nlIsAmbiguous}
|
||||
<DisambiguationPicker
|
||||
persons={nlInterpretation.ambiguousPersons}
|
||||
heading={disambiguationHeading}
|
||||
showCue={showDisambiguationCue}
|
||||
onSelect={selectDisambiguated}
|
||||
/>
|
||||
{:else}
|
||||
<InterpretationChipRow interpretation={nlInterpretation} onRemoveChip={removeChip} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if !nlIsAmbiguous}
|
||||
{#if nlHasResults}
|
||||
<p class="mb-3 font-sans text-base text-ink-2">
|
||||
{m.docs_result_count({ count: nlResult?.totalElements ?? 0 })}
|
||||
</p>
|
||||
<DocumentList items={nlResult?.items ?? []} canWrite={data.canWrite} sort={sort} />
|
||||
{:else}
|
||||
<div class="flex flex-col items-center justify-center gap-3 py-16 text-center">
|
||||
<p class="text-sm font-bold text-ink">{m.search_empty_nl()}</p>
|
||||
<button
|
||||
type="button"
|
||||
onclick={switchToKeywordMode}
|
||||
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"
|
||||
>
|
||||
{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) => {
|
||||
<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).
|
||||
@@ -503,70 +299,69 @@ $effect(() => {
|
||||
triggerSearchKeepZoom();
|
||||
}
|
||||
}}
|
||||
onzoomchange={(event) => {
|
||||
onzoomchange={(event) => {
|
||||
triggerSearchWithZoom(event?.zoomFrom ?? null, event?.zoomTo ?? null);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
/>
|
||||
</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"
|
||||
<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/Add/Add-General-MD.svg"
|
||||
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Edit-Content-MD.svg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
{m.docs_btn_new()}
|
||||
</a>
|
||||
</div>
|
||||
{#if editAllError}
|
||||
<p role="alert" class="text-xs text-danger" data-testid="bulk-edit-all-x-error">
|
||||
{editAllError}
|
||||
</p>
|
||||
{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
|
||||
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>
|
||||
{/if}
|
||||
</div>
|
||||
{#if editAllError}
|
||||
<p role="alert" class="text-xs text-danger" data-testid="bulk-edit-all-x-error">
|
||||
{editAllError}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<DocumentList
|
||||
items={data.items}
|
||||
q={data.q}
|
||||
canWrite={data.canWrite}
|
||||
error={data.error}
|
||||
sort={sort}
|
||||
from={data.from}
|
||||
to={data.to}
|
||||
/>
|
||||
<DocumentList
|
||||
items={data.items}
|
||||
q={data.q}
|
||||
canWrite={data.canWrite}
|
||||
error={data.error}
|
||||
sort={sort}
|
||||
from={data.from}
|
||||
to={data.to}
|
||||
/>
|
||||
|
||||
<Pagination page={data.pageNumber} totalPages={data.totalPages} makeHref={buildPageHref} />
|
||||
{/if}
|
||||
<Pagination page={data.pageNumber} totalPages={data.totalPages} makeHref={buildPageHref} />
|
||||
</main>
|
||||
|
||||
<BulkSelectionBar canWrite={data.canWrite} />
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { buildThemeRemovalUrl } from './theme-chip-removal.js';
|
||||
import type { components } from '$lib/generated/api';
|
||||
|
||||
type NlQueryInterpretation = components['schemas']['NlQueryInterpretation'];
|
||||
|
||||
function makeInterp(overrides: Partial<NlQueryInterpretation> = {}): NlQueryInterpretation {
|
||||
return {
|
||||
resolvedPersons: [],
|
||||
ambiguousPersons: [],
|
||||
keywords: [],
|
||||
resolvedTags: [],
|
||||
rawQuery: '',
|
||||
keywordsApplied: false,
|
||||
tagsApplied: true,
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
function makeTag(id: string, name: string, color?: string) {
|
||||
return color ? { id, name, color } : { id, name };
|
||||
}
|
||||
|
||||
describe('buildThemeRemovalUrl', () => {
|
||||
it('N remaining tags → N tag params + tagOp=OR', () => {
|
||||
const interp = makeInterp({
|
||||
resolvedTags: [
|
||||
makeTag('aaa', 'Hochzeit'),
|
||||
makeTag('bbb', 'Weltkrieg'),
|
||||
makeTag('ccc', 'Familie')
|
||||
]
|
||||
});
|
||||
const url = buildThemeRemovalUrl(interp, 'Hochzeit');
|
||||
const params = new URL(url, 'http://x').searchParams;
|
||||
expect(params.getAll('tag')).toEqual(['Weltkrieg', 'Familie']);
|
||||
expect(params.get('tagOp')).toBe('OR');
|
||||
});
|
||||
|
||||
it('last tag removed → no tag or tagOp params in URL', () => {
|
||||
const interp = makeInterp({
|
||||
resolvedTags: [makeTag('aaa', 'Hochzeit')]
|
||||
});
|
||||
const url = buildThemeRemovalUrl(interp, 'Hochzeit');
|
||||
const params = new URL(url, 'http://x').searchParams;
|
||||
expect(params.getAll('tag')).toEqual([]);
|
||||
expect(params.get('tagOp')).toBeNull();
|
||||
});
|
||||
|
||||
it('last tag removed with resolved sender person → sender param intact', () => {
|
||||
const interp = makeInterp({
|
||||
resolvedPersons: [{ id: '11111111-1111-1111-1111-111111111111', displayName: 'Walter' }],
|
||||
resolvedTags: [makeTag('aaa', 'Hochzeit')]
|
||||
});
|
||||
const url = buildThemeRemovalUrl(interp, 'Hochzeit');
|
||||
const params = new URL(url, 'http://x').searchParams;
|
||||
expect(params.get('senderId')).toBe('11111111-1111-1111-1111-111111111111');
|
||||
expect(params.getAll('tag')).toEqual([]);
|
||||
expect(params.get('tagOp')).toBeNull();
|
||||
});
|
||||
|
||||
it('null-color tag → tag name emitted correctly; color does not affect params', () => {
|
||||
const interp = makeInterp({
|
||||
resolvedTags: [makeTag('aaa', 'Erbschaft'), makeTag('bbb', 'Migration')]
|
||||
});
|
||||
const url = buildThemeRemovalUrl(interp, 'Erbschaft');
|
||||
const params = new URL(url, 'http://x').searchParams;
|
||||
expect(params.getAll('tag')).toEqual(['Migration']);
|
||||
expect(params.get('tagOp')).toBe('OR');
|
||||
});
|
||||
|
||||
it('directional pair → senderId and receiverId both emitted', () => {
|
||||
const interp = makeInterp({
|
||||
resolvedPersons: [
|
||||
{ id: '11111111-1111-1111-1111-111111111111', displayName: 'Walter' },
|
||||
{ id: '22222222-2222-2222-2222-222222222222', displayName: 'Emma' }
|
||||
],
|
||||
resolvedTags: [makeTag('aaa', 'Krieg'), makeTag('bbb', 'Heimat')]
|
||||
});
|
||||
const url = buildThemeRemovalUrl(interp, 'Krieg');
|
||||
const params = new URL(url, 'http://x').searchParams;
|
||||
expect(params.get('senderId')).toBe('11111111-1111-1111-1111-111111111111');
|
||||
expect(params.get('receiverId')).toBe('22222222-2222-2222-2222-222222222222');
|
||||
expect(params.getAll('tag')).toEqual(['Heimat']);
|
||||
});
|
||||
});
|
||||
@@ -1,26 +0,0 @@
|
||||
import type { components } from '$lib/generated/api';
|
||||
|
||||
type NlQueryInterpretation = components['schemas']['NlQueryInterpretation'];
|
||||
|
||||
export function buildThemeRemovalUrl(
|
||||
interp: NlQueryInterpretation,
|
||||
removedTagName: string
|
||||
): string {
|
||||
const remaining = interp.resolvedTags.filter((t) => t.name !== removedTagName);
|
||||
const params = new URLSearchParams();
|
||||
|
||||
const resolved = interp.resolvedPersons;
|
||||
if (resolved.length >= 1) params.set('senderId', resolved[0].id);
|
||||
if (resolved.length >= 2) params.set('receiverId', resolved[1].id);
|
||||
if (interp.dateFrom) params.set('from', interp.dateFrom);
|
||||
if (interp.dateTo) params.set('to', interp.dateTo);
|
||||
if (interp.keywordsApplied && interp.keywords.length > 0) {
|
||||
params.set('q', interp.keywords.join(' '));
|
||||
}
|
||||
|
||||
remaining.forEach((tag) => params.append('tag', tag.name));
|
||||
if (remaining.length > 0) params.set('tagOp', 'OR');
|
||||
|
||||
const qs = params.toString();
|
||||
return qs ? `/documents?${qs}` : '/documents';
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
<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,
|
||||
heading,
|
||||
showCue,
|
||||
onSelect
|
||||
}: {
|
||||
persons: PersonHint[];
|
||||
heading: string;
|
||||
showCue: boolean;
|
||||
onSelect: (person: PersonHint) => void;
|
||||
} = $props();
|
||||
|
||||
let open = $state(false);
|
||||
let triggerEl = $state<HTMLButtonElement>();
|
||||
let listEl = $state<HTMLUListElement>();
|
||||
|
||||
const panelId = 'disambiguation-panel';
|
||||
const headingId = 'disambiguation-heading';
|
||||
const names = $derived(persons.map((person) => person.displayName).join(', '));
|
||||
const triggerLabel = $derived(
|
||||
persons.length === 1 ? heading : m.search_disambiguation_trigger_label()
|
||||
);
|
||||
|
||||
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={triggerLabel}
|
||||
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>
|
||||
{#if showCue}
|
||||
<span class="text-ink-3">{m.search_disambiguation_cue()}</span>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
{#if open}
|
||||
<div
|
||||
id={panelId}
|
||||
class="absolute left-0 z-10 mt-1 min-w-[12rem] rounded-sm border border-line bg-surface py-1 shadow-md"
|
||||
>
|
||||
<p id={headingId} class="px-4 py-1.5 text-sm font-bold text-ink">{heading}</p>
|
||||
<ul bind:this={listEl} aria-labelledby={headingId}>
|
||||
{#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>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -1,118 +0,0 @@
|
||||
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' }
|
||||
];
|
||||
|
||||
const multiProps = { persons, heading: 'Person auswählen', showCue: true };
|
||||
|
||||
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, { ...multiProps, 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, { ...multiProps, 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, { ...multiProps, 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, { ...multiProps, 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, { ...multiProps, 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]);
|
||||
});
|
||||
|
||||
it('renders the supplied heading as a visible panel heading', async () => {
|
||||
render(DisambiguationPicker, {
|
||||
persons: [{ id: 'c1', displayName: 'Clara Cramer' }],
|
||||
heading: 'Meintest du Clara Cramer?',
|
||||
showCue: false,
|
||||
onSelect: vi.fn()
|
||||
});
|
||||
await page.getByRole('button', { name: 'Meintest du Clara Cramer?' }).click();
|
||||
await expect.element(page.getByText('Meintest du Clara Cramer?')).toBeVisible();
|
||||
});
|
||||
|
||||
it('suppresses the cue when showCue is false', async () => {
|
||||
render(DisambiguationPicker, {
|
||||
persons: [{ id: 'c1', displayName: 'Clara Cramer' }],
|
||||
heading: 'Meintest du Clara Cramer?',
|
||||
showCue: false,
|
||||
onSelect: vi.fn()
|
||||
});
|
||||
await expect.element(page.getByText('(auswählen…)')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the cue when showCue is true', async () => {
|
||||
render(DisambiguationPicker, { ...multiProps, onSelect: vi.fn() });
|
||||
await expect.element(page.getByText('(auswählen…)')).toBeVisible();
|
||||
});
|
||||
|
||||
it('announces the did-you-mean heading as the trigger accessible name for a single suggestion', async () => {
|
||||
render(DisambiguationPicker, {
|
||||
persons: [{ id: 'c1', displayName: 'Clara Cramer' }],
|
||||
heading: 'Meintest du Clara Cramer?',
|
||||
showCue: false,
|
||||
onSelect: vi.fn()
|
||||
});
|
||||
await expect
|
||||
.element(page.getByRole('button', { name: 'Meintest du Clara Cramer?' }))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('keeps the multiple-people trigger accessible name for two or more suggestions', async () => {
|
||||
render(DisambiguationPicker, { ...multiProps, onSelect: vi.fn() });
|
||||
await expect
|
||||
.element(page.getByRole('button', { name: /Mehrere Personen gefunden/ }))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,181 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import type { components } from '$lib/generated/api';
|
||||
|
||||
import type { ChipType } from './chip-types.js';
|
||||
|
||||
type NlQueryInterpretation = components['schemas']['NlQueryInterpretation'];
|
||||
type TagHint = components['schemas']['TagHint'];
|
||||
|
||||
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 }
|
||||
| { key: string; type: 'theme'; tag: TagHint; 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 ?? '';
|
||||
}
|
||||
|
||||
function tagColorStyle(color: string | undefined): string | undefined {
|
||||
if (!color) return undefined;
|
||||
return `background-color: var(--c-tag-${color}); border-left-color: var(--c-tag-${color})`;
|
||||
}
|
||||
|
||||
const chips = $derived.by(() => {
|
||||
const list: Chip[] = [];
|
||||
const {
|
||||
resolvedPersons,
|
||||
dateFrom,
|
||||
dateTo,
|
||||
keywords,
|
||||
keywordsApplied,
|
||||
resolvedTags,
|
||||
tagsApplied
|
||||
} = 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}`
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (tagsApplied) {
|
||||
for (const tag of resolvedTags) {
|
||||
list.push({
|
||||
key: 'theme:' + tag.id,
|
||||
type: 'theme',
|
||||
tag,
|
||||
label: `${m.search_chip_theme_prefix()}: ${tag.name}`
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
if (chip.type === 'keyword') {
|
||||
onRemoveChip(chip.type, chip.value);
|
||||
} else if (chip.type === 'theme') {
|
||||
onRemoveChip(chip.type, chip.tag.name);
|
||||
} else {
|
||||
onRemoveChip(chip.type, 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 if chip.type === 'theme'}
|
||||
<span data-chip-type="theme" class={chipWrapper} style={tagColorStyle(chip.tag.color)}>
|
||||
<span>{m.search_chip_theme_prefix()}:</span>
|
||||
<span class={nameSpan}>{chip.tag.name}</span>
|
||||
<button
|
||||
type="button"
|
||||
class={removeButton}
|
||||
aria-label={m.search_filter_remove_label({
|
||||
label: `${m.search_chip_theme_prefix()}: ${chip.tag.name}`
|
||||
})}
|
||||
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}
|
||||
@@ -1,214 +0,0 @@
|
||||
// NOTE: vitest-browser fails silently when the project path contains '+' (common in git worktrees
|
||||
// named 'feat+issue-NNN-slug'). If tests fail with iframe routing errors, copy the frontend
|
||||
// directory to a path without '+' (e.g. /tmp/fe-copy) and run the suite from there.
|
||||
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'];
|
||||
type TagHint = components['schemas']['TagHint'];
|
||||
|
||||
afterEach(() => cleanup());
|
||||
|
||||
const makePerson = (id: string, displayName: string): PersonHint => ({ id, displayName });
|
||||
|
||||
const makeInterpretation = (
|
||||
overrides: Partial<NlQueryInterpretation> = {}
|
||||
): NlQueryInterpretation => ({
|
||||
resolvedPersons: [],
|
||||
ambiguousPersons: [],
|
||||
keywords: [],
|
||||
resolvedTags: [],
|
||||
rawQuery: 'test',
|
||||
keywordsApplied: true,
|
||||
tagsApplied: false,
|
||||
...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();
|
||||
});
|
||||
|
||||
// ── theme chips ─────────────────────────────────────────────────────────────
|
||||
|
||||
const makeTag = (id: string, name: string, color?: string): TagHint => ({ id, name, color });
|
||||
|
||||
it('renders theme chips when tagsApplied is true', async () => {
|
||||
const { container } = render(InterpretationChipRow, {
|
||||
interpretation: makeInterpretation({
|
||||
resolvedTags: [makeTag('t1', 'Hochzeit')],
|
||||
tagsApplied: true
|
||||
}),
|
||||
onRemoveChip: vi.fn()
|
||||
});
|
||||
expect(container.querySelectorAll('[data-chip-type="theme"]')).toHaveLength(1);
|
||||
await expect.element(page.getByText(/Thema: Hochzeit/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders no theme chips when tagsApplied is false', async () => {
|
||||
const { container } = render(InterpretationChipRow, {
|
||||
interpretation: makeInterpretation({
|
||||
resolvedTags: [makeTag('t1', 'Hochzeit')],
|
||||
tagsApplied: false
|
||||
}),
|
||||
onRemoveChip: vi.fn()
|
||||
});
|
||||
expect(container.querySelectorAll('[data-chip-type="theme"]')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('renders exactly N theme chips for N resolved tags', async () => {
|
||||
const { container } = render(InterpretationChipRow, {
|
||||
interpretation: makeInterpretation({
|
||||
resolvedTags: [makeTag('t1', 'Krieg'), makeTag('t2', 'Hochzeit'), makeTag('t3', 'Familie')],
|
||||
tagsApplied: true
|
||||
}),
|
||||
onRemoveChip: vi.fn()
|
||||
});
|
||||
expect(container.querySelectorAll('[data-chip-type="theme"]')).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('calls onRemoveChip with "theme" and tag name when × is clicked', async () => {
|
||||
const onRemoveChip = vi.fn();
|
||||
render(InterpretationChipRow, {
|
||||
interpretation: makeInterpretation({
|
||||
resolvedTags: [makeTag('t1', 'Hochzeit')],
|
||||
tagsApplied: true
|
||||
}),
|
||||
onRemoveChip
|
||||
});
|
||||
await page.getByRole('button', { name: /Thema: Hochzeit/ }).click();
|
||||
expect(onRemoveChip).toHaveBeenCalledWith('theme', 'Hochzeit');
|
||||
});
|
||||
|
||||
it('applies inline color style for a tag with a color', async () => {
|
||||
const { container } = render(InterpretationChipRow, {
|
||||
interpretation: makeInterpretation({
|
||||
resolvedTags: [makeTag('t1', 'Hochzeit', 'sage')],
|
||||
tagsApplied: true
|
||||
}),
|
||||
onRemoveChip: vi.fn()
|
||||
});
|
||||
const chip = container.querySelector('[data-chip-type="theme"]') as HTMLElement;
|
||||
expect(chip.style.backgroundColor).toBeTruthy();
|
||||
});
|
||||
|
||||
it('omits color style for a tag with no color', async () => {
|
||||
const { container } = render(InterpretationChipRow, {
|
||||
interpretation: makeInterpretation({
|
||||
resolvedTags: [makeTag('t1', 'Hochzeit')],
|
||||
tagsApplied: true
|
||||
}),
|
||||
onRemoveChip: vi.fn()
|
||||
});
|
||||
const chip = container.querySelector('[data-chip-type="theme"]') as HTMLElement;
|
||||
expect(chip.getAttribute('style')).toBeFalsy();
|
||||
});
|
||||
});
|
||||
@@ -1,38 +0,0 @@
|
||||
<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>
|
||||
@@ -1,81 +0,0 @@
|
||||
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());
|
||||
});
|
||||
});
|
||||
@@ -1,69 +0,0 @@
|
||||
<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}
|
||||
@@ -1,60 +0,0 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -1 +0,0 @@
|
||||
export type ChipType = 'sender' | 'directional' | 'date' | 'keyword' | 'theme';
|
||||
Reference in New Issue
Block a user