SRC-06: facetten-ui-filter-chips
Facetten-Oberfläche mit Filter-Chips: aktive Filter sichtbar, einzeln entfernbar, Trefferzahl je Facettenwert live angezeigt. - app/api/facets/route.ts: neue Backend-for-Frontend-Route, spiegelt mail/internal/search/facets.go minimal (nur Trefferzahl je Facettenwert). - lib/manticoreQuery.ts: gemeinsamer statischer bool.must-Aufbau für Such- und Facetten-Route, kein Sprintf/Join-artiger Klauselbau. - app/api/search/route.ts (SRC-04): akzeptiert jetzt wiederholbare ?filter=feld:wert-Parameter. - app/FacetPanel.tsx: ActiveFilterChips (echte <button>-Elemente, nativ tastaturbedienbar) + FacetPanel (Klick fügt Filter hinzu) + "Alle Filter zurücksetzen". - lib/filterState.ts: reine Filterzustandsfunktionen, ohne React. Prüfungen (alle real durchgeführt, siehe mail/docs/SRC-06-PRUEFPROTOKOLL.md): 1. Manueller Test gegen echten next start + live Manticore auf 192.168.1.131: Filterkombination reduziert Treffer UND Facettenzählungen real konsistent von 2 auf 1. 2. @testing-library/user-event: echte Tastatursimulation (Enter) löst Chip-Entfernung real aus. 3. 20 gleichzeitig aktive Filter erzeugen real 20 einzelne, nicht zusammengefasste Chips, kein Absturz. Kein Umbau: mail/internal/*, web/shl, web/retention-admin unverändert, bestehendes SRC-04-Verhalten unverändert. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HhgFcLS8tYMhDJpP74C6AQ
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
0065b959b3
commit
acc2b0c5dd
@@ -1,11 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { useState, useEffect, useCallback, type FormEvent } from "react";
|
||||
import Link from "next/link";
|
||||
import { TextField } from "@nexarch/shl";
|
||||
import { search, ApiError, type SearchHit } from "../lib/api";
|
||||
import { search, facets, ApiError, type SearchHit, type AppliedFilter, type FacetsResponse } from "../lib/api";
|
||||
import { splitHighlighted } from "../lib/highlight";
|
||||
import { HIGHLIGHT_BG_LIGHT, HIGHLIGHT_FG_LIGHT } from "../lib/highlightColors";
|
||||
import { ActiveFilterChips, FacetPanel } from "./FacetPanel";
|
||||
import { addFilter, removeFilter } from "../lib/filterState";
|
||||
|
||||
function tenantSlug(): string {
|
||||
return process.env.NEXT_PUBLIC_MAIL_TENANT_SLUG ?? "";
|
||||
@@ -43,37 +45,70 @@ function resultHref(hit: SearchHit): string {
|
||||
|
||||
export default function SearchPage() {
|
||||
const [query, setQuery] = useState("");
|
||||
const [appliedFilters, setAppliedFilters] = useState<AppliedFilter[]>([]);
|
||||
const [hits, setHits] = useState<SearchHit[] | null>(null);
|
||||
const [facetsData, setFacetsData] = useState<FacetsResponse | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
async function runSearch(e?: FormEvent) {
|
||||
e?.preventDefault();
|
||||
if (!query.trim()) {
|
||||
setHits(null);
|
||||
const runSearch = useCallback(
|
||||
async (q: string, filters: AppliedFilter[]) => {
|
||||
if (!q.trim() && filters.length === 0) {
|
||||
setHits(null);
|
||||
setFacetsData(null);
|
||||
setError(null);
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await search(tenantSlug(), query);
|
||||
setHits(result.hits);
|
||||
} catch (err) {
|
||||
// Akzeptanzkriterium 3 (sinngemäß auf Fehlerfall übertragen): auch
|
||||
// ein Suchfehler zeigt einen verständlichen Hinweis statt einer
|
||||
// leeren Fläche.
|
||||
setHits([]);
|
||||
setError(err instanceof ApiError ? err.message : "Suche konnte nicht ausgeführt werden.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
try {
|
||||
const [searchResult, facetsResult] = await Promise.all([
|
||||
search(tenantSlug(), q, filters),
|
||||
facets(tenantSlug(), q, filters),
|
||||
]);
|
||||
setHits(searchResult.hits);
|
||||
setFacetsData(facetsResult);
|
||||
} catch (err) {
|
||||
// Akzeptanzkriterium 3 (SRC-04, sinngemäß auf Fehlerfall
|
||||
// übertragen): auch ein Suchfehler zeigt einen verständlichen
|
||||
// Hinweis statt einer leeren Fläche.
|
||||
setHits([]);
|
||||
setFacetsData(null);
|
||||
setError(err instanceof ApiError ? err.message : "Suche konnte nicht ausgeführt werden.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
function onSubmit(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
void runSearch(query, appliedFilters);
|
||||
}
|
||||
|
||||
// SRC-06 Akzeptanzkriterium 2: Trefferzahl je Facettenwert live
|
||||
// aktualisiert — jede Filteränderung löst Such- UND Facettenabfrage
|
||||
// erneut aus.
|
||||
useEffect(() => {
|
||||
void runSearch(query, appliedFilters);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [appliedFilters]);
|
||||
|
||||
function onAddFilter(filter: AppliedFilter) {
|
||||
setAppliedFilters((prev) => addFilter(prev, filter));
|
||||
}
|
||||
function onRemoveFilter(filter: AppliedFilter) {
|
||||
setAppliedFilters((prev) => removeFilter(prev, filter));
|
||||
}
|
||||
function onClearFilters() {
|
||||
setAppliedFilters([]);
|
||||
}
|
||||
|
||||
return (
|
||||
<main style={{ maxWidth: 720, margin: "40px auto", padding: "0 16px" }}>
|
||||
<h1>Mail-Suche</h1>
|
||||
<form onSubmit={runSearch}>
|
||||
<form onSubmit={onSubmit}>
|
||||
<TextField
|
||||
label="Suchbegriff"
|
||||
value={query}
|
||||
@@ -85,6 +120,14 @@ export default function SearchPage() {
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<ActiveFilterChips
|
||||
facetsData={facetsData}
|
||||
appliedFilters={appliedFilters}
|
||||
onAddFilter={onAddFilter}
|
||||
onRemoveFilter={onRemoveFilter}
|
||||
onClearFilters={onClearFilters}
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<p role="alert" style={{ marginTop: 16 }}>
|
||||
{error}
|
||||
@@ -97,6 +140,14 @@ export default function SearchPage() {
|
||||
</p>
|
||||
)}
|
||||
|
||||
<FacetPanel
|
||||
facetsData={facetsData}
|
||||
appliedFilters={appliedFilters}
|
||||
onAddFilter={onAddFilter}
|
||||
onRemoveFilter={onRemoveFilter}
|
||||
onClearFilters={onClearFilters}
|
||||
/>
|
||||
|
||||
{hits !== null && hits.length > 0 && (
|
||||
<ul style={{ listStyle: "none", padding: 0, marginTop: 16 }}>
|
||||
{hits.map((hit) => (
|
||||
|
||||
Reference in New Issue
Block a user