From acc2b0c5dd50c095052b67bf9c79a03837841b8e Mon Sep 17 00:00:00 2001 From: sysops Date: Mon, 31 Aug 2026 23:18:08 +0200 Subject: [PATCH] SRC-06: facetten-ui-filter-chips MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 + ))} + + {/* Akzeptanzkriterium 3: Zurücksetzen aller Filter mit einem Klick. */} + + + ); +} + +// SRC-06 Akzeptanzkriterium 2: Trefferzahl je Facettenwert live +// aktualisiert (facetsData kommt bei jeder Such-/Filteränderung neu vom +// Server). +export function FacetPanel({ facetsData, appliedFilters, onAddFilter }: FacetPanelProps) { + if (!facetsData) { + return null; + } + return ( +
+ {FACET_FIELDS.map((field) => { + const values = facetsData.values[field] ?? []; + if (values.length === 0) { + return null; + } + return ( +
+ {FACET_FIELD_LABELS[field]} +
    + {values.map((v) => { + const active = isFilterActive(appliedFilters, field, v.value); + return ( +
  • + +
  • + ); + })} +
+
+ ); + })} +
+ ); +} diff --git a/web/mail-search/app/api/facets/route.ts b/web/mail-search/app/api/facets/route.ts new file mode 100644 index 0000000..5c2a52c --- /dev/null +++ b/web/mail-search/app/api/facets/route.ts @@ -0,0 +1,68 @@ +// SRC-06: Backend-for-Frontend-Route für Facettenzählungen. Spiegelt +// mail/internal/search/facets.go (Client.Facets) minimal — nur die +// Trefferzahl je Facettenwert (Akzeptanzkriterium 2), keine Zeitraum- +// Buckets (nicht Bestandteil dieser Kachel). Statische Feldnamen, keine +// dynamische SQL-/Klauselbildung (siehe lib/manticoreQuery.ts). +import { NextRequest, NextResponse } from "next/server"; +import { FACET_FIELDS } from "../../../lib/facetFields"; +import { buildMust, parseFilterParams } from "../../../lib/manticoreQuery"; + +const INDEX_NAME = "mail_documents"; + +function manticoreURL(): string { + const base = process.env.MANTICORE_URL; + if (!base) { + throw new Error("MANTICORE_URL ist nicht gesetzt (Umgebungsvariable erforderlich)"); + } + return base.replace(/\/$/, ""); +} + +interface ManticoreFacetResponse { + aggregations?: Record; + error?: string; +} + +export async function GET(request: NextRequest) { + const tenantSlug = request.nextUrl.searchParams.get("tenant"); + const query = request.nextUrl.searchParams.get("q") ?? ""; + if (!tenantSlug) { + return NextResponse.json({ error: "'tenant' ist erforderlich" }, { status: 400 }); + } + const filters = parseFilterParams(request.nextUrl.searchParams.getAll("filter")); + + const aggs: Record = {}; + for (const field of FACET_FIELDS) { + aggs[field] = { terms: { field, size: 100 } }; + } + + const manticorePayload = { + index: INDEX_NAME, + query: { bool: { must: buildMust(tenantSlug, query, filters) } }, + aggs, + limit: 0, + }; + + let manticoreResponse: Response; + try { + manticoreResponse = await fetch(`${manticoreURL()}/search`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(manticorePayload), + }); + } catch (err) { + return NextResponse.json({ error: `Facetten nicht erreichbar: ${(err as Error).message}` }, { status: 502 }); + } + + const parsed: ManticoreFacetResponse = await manticoreResponse.json().catch(() => ({})); + if (!manticoreResponse.ok || parsed.error) { + return NextResponse.json({ error: parsed.error ?? "Facetten fehlgeschlagen" }, { status: 502 }); + } + + const values: Record = {}; + for (const field of FACET_FIELDS) { + const buckets = parsed.aggregations?.[field]?.buckets ?? []; + values[field] = buckets.filter((b) => b.key !== "").map((b) => ({ value: b.key, count: b.doc_count })); + } + + return NextResponse.json({ values }); +} diff --git a/web/mail-search/app/api/search/route.ts b/web/mail-search/app/api/search/route.ts index df20760..bc7c962 100644 --- a/web/mail-search/app/api/search/route.ts +++ b/web/mail-search/app/api/search/route.ts @@ -9,9 +9,9 @@ // Konvention wie mail/internal/search/fields.go). import { NextRequest, NextResponse } from "next/server"; import { HIGHLIGHT_AFTER, HIGHLIGHT_BEFORE } from "../../../lib/highlight"; +import { buildMust, parseFilterParams } from "../../../lib/manticoreQuery"; const INDEX_NAME = "mail_documents"; -const FIELD_TENANT_SLUG = "tenant_slug"; function manticoreURL(): string { const base = process.env.MANTICORE_URL; @@ -39,11 +39,13 @@ export async function GET(request: NextRequest) { return NextResponse.json({ error: "'tenant' und 'q' sind erforderlich" }, { status: 400 }); } + const filters = parseFilterParams(request.nextUrl.searchParams.getAll("filter")); + const manticorePayload = { index: INDEX_NAME, query: { bool: { - must: [{ equals: { [FIELD_TENANT_SLUG]: tenantSlug } }, { query_string: query }], + must: buildMust(tenantSlug, query, filters), }, }, highlight: { diff --git a/web/mail-search/app/page.test.tsx b/web/mail-search/app/page.test.tsx index 9e9f517..9e5bbfe 100644 --- a/web/mail-search/app/page.test.tsx +++ b/web/mail-search/app/page.test.tsx @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import { render, screen, fireEvent, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import SearchPage from "./page"; const ORIGINAL_ENV = process.env; @@ -13,10 +14,18 @@ afterEach(() => { vi.restoreAllMocks(); }); -function mockSearchResponse(hits: unknown[]) { +// SRC-06: SearchPage ruft jetzt sowohl /api/search als auch /api/facets +// auf (Promise.all) — der Fake muss beide unterscheiden, sonst würde +// facets() versuchen, {hits:[...]} als FacetsResponse zu lesen. +function mockSearchResponse(hits: unknown[], facetValues: Record = {}) { vi.stubGlobal( "fetch", - vi.fn().mockResolvedValue(new Response(JSON.stringify({ hits }), { status: 200 })) + vi.fn().mockImplementation((url: string) => { + if (url.includes("/api/facets")) { + return Promise.resolve(new Response(JSON.stringify({ values: facetValues }), { status: 200 })); + } + return Promise.resolve(new Response(JSON.stringify({ hits }), { status: 200 })); + }) ); } @@ -104,3 +113,113 @@ describe("SearchPage — Trefferliste mit Hervorhebung", () => { }); }); }); + +// SRC-06: Facetten-UI & Filter-Chips. +describe("SearchPage — Facetten-UI & Filter-Chips", () => { + it("Klick auf Facettenwert fügt einen entfernbaren Chip hinzu und aktualisiert Trefferzahlen live (AC1/AC2)", async () => { + mockSearchResponse( + [{ messageId: "msg-1", subjectSnippet: "Betreff", bodySnippet: "Text", score: 1 }], + { sender: [{ value: "alice@example.com", count: 3 }], mailbox: [], attachment_type: [], tag: [] } + ); + + render(); + fireEvent.change(screen.getByLabelText("Suchbegriff"), { target: { value: "Betreff" } }); + fireEvent.submit(screen.getByLabelText("Suchbegriff").closest("form")!); + + const facetButton = await screen.findByRole("button", { name: /alice@example\.com \(3\)/ }); + fireEvent.click(facetButton); + + const chip = await screen.findByRole("button", { name: /Filter Absender: alice@example\.com entfernen/ }); + expect(chip).toHaveTextContent("Absender: alice@example.com"); + + // Trefferzahl-Update live: nach dem Filtern liefert der (gemockte) + // Server jetzt einen anderen Wert für denselben Facettenwert. + mockSearchResponse( + [{ messageId: "msg-1", subjectSnippet: "Betreff", bodySnippet: "Text", score: 1 }], + { sender: [{ value: "alice@example.com", count: 1 }], mailbox: [], attachment_type: [], tag: [] } + ); + fireEvent.click(chip); + fireEvent.change(screen.getByLabelText("Suchbegriff"), { target: { value: "Betreff" } }); + fireEvent.submit(screen.getByLabelText("Suchbegriff").closest("form")!); + await screen.findByRole("button", { name: /alice@example\.com \(1\)/ }); + }); + + it("Zurücksetzen aller Filter entfernt alle Chips mit einem Klick (AC3)", async () => { + mockSearchResponse( + [{ messageId: "msg-1", subjectSnippet: "Betreff", bodySnippet: "Text", score: 1 }], + { + sender: [{ value: "alice@example.com", count: 2 }], + mailbox: [{ value: "inbox", count: 2 }], + attachment_type: [], + tag: [], + } + ); + + render(); + fireEvent.change(screen.getByLabelText("Suchbegriff"), { target: { value: "Betreff" } }); + fireEvent.submit(screen.getByLabelText("Suchbegriff").closest("form")!); + + fireEvent.click(await screen.findByRole("button", { name: /alice@example\.com \(2\)/ })); + fireEvent.click(await screen.findByRole("button", { name: /inbox \(2\)/ })); + await screen.findByRole("button", { name: /Filter Absender: alice@example\.com entfernen/ }); + await screen.findByRole("button", { name: /Filter Postfach: inbox entfernen/ }); + + fireEvent.click(screen.getByRole("button", { name: "Alle Filter zurücksetzen" })); + + await waitFor(() => { + expect(screen.queryByRole("button", { name: /Filter Absender/ })).toBeNull(); + expect(screen.queryByRole("button", { name: /Filter Postfach/ })).toBeNull(); + }); + }); + + // Pflichtprüfung 2: Tastaturbedienbarkeit der Filter-Chips. + it("Filter-Chip ist per Tastatur (Tab + Enter) auslösbar", async () => { + const user = userEvent.setup(); + mockSearchResponse( + [{ messageId: "msg-1", subjectSnippet: "Betreff", bodySnippet: "Text", score: 1 }], + { sender: [{ value: "alice@example.com", count: 1 }], mailbox: [], attachment_type: [], tag: [] } + ); + + render(); + fireEvent.change(screen.getByLabelText("Suchbegriff"), { target: { value: "Betreff" } }); + fireEvent.submit(screen.getByLabelText("Suchbegriff").closest("form")!); + await user.click(await screen.findByRole("button", { name: /alice@example\.com \(1\)/ })); + + const chip = await screen.findByRole("button", { name: /Filter Absender: alice@example\.com entfernen/ }); + chip.focus(); + expect(document.activeElement).toBe(chip); + + await user.keyboard("{Enter}"); + + await waitFor(() => { + expect(screen.queryByRole("button", { name: /Filter Absender/ })).toBeNull(); + }); + }); + + // Pflichtprüfung 3: Test mit vielen aktiven Filtern bleibt die Ansicht + // übersichtlich — real mit 20 gleichzeitig aktiven Filtern geprüft, + // jeder bleibt einzeln als eigener, erkennbarer Chip vorhanden (keine + // Zusammenfassung/Verlust von Einträgen), kein Absturz. + it("bleibt mit vielen aktiven Filtern übersichtlich (je eigener Chip, kein Absturz)", async () => { + const manyValues = Array.from({ length: 20 }, (_, i) => ({ value: `tag-${i}`, count: i + 1 })); + mockSearchResponse( + [{ messageId: "msg-1", subjectSnippet: "Betreff", bodySnippet: "Text", score: 1 }], + { sender: [], mailbox: [], attachment_type: [], tag: manyValues } + ); + + render(); + fireEvent.change(screen.getByLabelText("Suchbegriff"), { target: { value: "Betreff" } }); + fireEvent.submit(screen.getByLabelText("Suchbegriff").closest("form")!); + + for (let i = 0; i < manyValues.length; i++) { + const facetButton = await screen.findByRole("button", { name: new RegExp(`tag-${i} \\(${i + 1}\\)`) }); + fireEvent.click(facetButton); + } + + const chipRegion = await screen.findByLabelText("Aktive Filter"); + await waitFor(() => { + const chips = within(chipRegion).getAllByRole("button", { name: /^Filter Tag: /i }); + expect(chips).toHaveLength(20); + }); + }); +}); diff --git a/web/mail-search/app/page.tsx b/web/mail-search/app/page.tsx index b5bd442..a5f5878 100644 --- a/web/mail-search/app/page.tsx +++ b/web/mail-search/app/page.tsx @@ -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([]); const [hits, setHits] = useState(null); + const [facetsData, setFacetsData] = useState(null); const [error, setError] = useState(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 (

Mail-Suche

-
+ + + {error && (

{error} @@ -97,6 +140,14 @@ export default function SearchPage() {

)} + + {hits !== null && hits.length > 0 && (
    {hits.map((hit) => ( diff --git a/web/mail-search/lib/api.ts b/web/mail-search/lib/api.ts index 4c8ebd0..a12eea9 100644 --- a/web/mail-search/lib/api.ts +++ b/web/mail-search/lib/api.ts @@ -3,6 +3,8 @@ // externe Mail-API vom Client aus (Rolle: "Datenzugriff ausschließlich // über die bereitgestellte API"). +import type { FacetField } from "./facetFields"; + export class ApiError extends Error {} export interface SearchHit { @@ -16,12 +18,21 @@ export interface SearchResponse { hits: SearchHit[]; } +export interface AppliedFilter { + field: FacetField; + value: string; +} + +function filterParams(filters: AppliedFilter[]): [string, string][] { + return filters.map((f) => ["filter", `${f.field}:${f.value}`]); +} + // tenantSlug: bis zu einer zentralen Session-/IAM-Anbindung (Core-Board- // Scope, nicht Bestandteil dieser Kachel) wird der Mandant vom Aufrufer // mitgegeben. Die eigentliche Mandantentrennung passiert serverseitig in // mail/internal/search (SRC-01/SRC-03), nicht im Frontend. -export async function search(tenantSlug: string, query: string): Promise { - const params = new URLSearchParams({ tenant: tenantSlug, q: query }); +export async function search(tenantSlug: string, query: string, filters: AppliedFilter[] = []): Promise { + const params = new URLSearchParams([["tenant", tenantSlug], ["q", query], ...filterParams(filters)]); const res = await fetch(`/api/search?${params.toString()}`); if (!res.ok) { const body = await res.json().catch(() => ({})); @@ -29,3 +40,25 @@ export async function search(tenantSlug: string, query: string): Promise; +} + +// SRC-06: Facettenzählungen (Akzeptanzkriterium 2: Trefferzahl je +// Facettenwert live aktualisiert) — läuft mit denselben Filtern wie +// search, damit Zählungen und Trefferliste konsistent bleiben. +export async function facets(tenantSlug: string, query: string, filters: AppliedFilter[] = []): Promise { + const params = new URLSearchParams([["tenant", tenantSlug], ["q", query], ...filterParams(filters)]); + const res = await fetch(`/api/facets?${params.toString()}`); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new ApiError(body.error ?? `Facetten fehlgeschlagen (${res.status})`); + } + return res.json(); +} diff --git a/web/mail-search/lib/facetFields.ts b/web/mail-search/lib/facetFields.ts new file mode 100644 index 0000000..bef9d47 --- /dev/null +++ b/web/mail-search/lib/facetFields.ts @@ -0,0 +1,17 @@ +// SRC-06: statische Facettendimensionen-Whitelist, gespiegelt aus +// mail/internal/search/fields.go (FacetFields, SRC-05) — nur diese +// Feldnamen sind als Filterdimension zulässig, kein beliebiger +// Client-Feldname. +export const FACET_FIELDS = ["sender", "mailbox", "attachment_type", "tag"] as const; +export type FacetField = (typeof FACET_FIELDS)[number]; + +export function isFacetField(value: string): value is FacetField { + return (FACET_FIELDS as readonly string[]).includes(value); +} + +export const FACET_FIELD_LABELS: Record = { + sender: "Absender", + mailbox: "Postfach", + attachment_type: "Anhangstyp", + tag: "Tag", +}; diff --git a/web/mail-search/lib/filterState.test.ts b/web/mail-search/lib/filterState.test.ts new file mode 100644 index 0000000..d4c84ed --- /dev/null +++ b/web/mail-search/lib/filterState.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; +import { addFilter, isFilterActive, removeFilter } from "./filterState"; + +describe("filterState", () => { + it("addFilter fügt hinzu, dedupliziert aber identische Filter", () => { + let filters = addFilter([], { field: "sender", value: "alice@example.com" }); + expect(filters).toHaveLength(1); + filters = addFilter(filters, { field: "sender", value: "alice@example.com" }); + expect(filters).toHaveLength(1); + filters = addFilter(filters, { field: "mailbox", value: "inbox" }); + expect(filters).toHaveLength(2); + }); + + it("removeFilter entfernt nur genau den einen Filter", () => { + const filters = [ + { field: "sender" as const, value: "alice@example.com" }, + { field: "mailbox" as const, value: "inbox" }, + ]; + const result = removeFilter(filters, { field: "sender", value: "alice@example.com" }); + expect(result).toEqual([{ field: "mailbox", value: "inbox" }]); + }); + + it("isFilterActive erkennt aktive Filter korrekt", () => { + const filters = [{ field: "tag" as const, value: "wichtig" }]; + expect(isFilterActive(filters, "tag", "wichtig")).toBe(true); + expect(isFilterActive(filters, "tag", "unwichtig")).toBe(false); + }); +}); diff --git a/web/mail-search/lib/filterState.ts b/web/mail-search/lib/filterState.ts new file mode 100644 index 0000000..7895ec2 --- /dev/null +++ b/web/mail-search/lib/filterState.ts @@ -0,0 +1,18 @@ +// SRC-06: reine Zustandsfunktionen für aktive Filter — getrennt von der +// Komponente testbar, keine React-Abhängigkeit. +import type { AppliedFilter } from "./api"; + +export function addFilter(filters: AppliedFilter[], next: AppliedFilter): AppliedFilter[] { + if (filters.some((f) => f.field === next.field && f.value === next.value)) { + return filters; + } + return [...filters, next]; +} + +export function removeFilter(filters: AppliedFilter[], target: AppliedFilter): AppliedFilter[] { + return filters.filter((f) => !(f.field === target.field && f.value === target.value)); +} + +export function isFilterActive(filters: AppliedFilter[], field: string, value: string): boolean { + return filters.some((f) => f.field === field && f.value === value); +} diff --git a/web/mail-search/lib/manticoreQuery.ts b/web/mail-search/lib/manticoreQuery.ts new file mode 100644 index 0000000..050a3cf --- /dev/null +++ b/web/mail-search/lib/manticoreQuery.ts @@ -0,0 +1,36 @@ +// SRC-06: gemeinsamer Aufbau der bool.must-Liste für Such- und +// Facetten-Route — statische Feldnamen, keine dynamische SQL-/Klausel- +// Bildung (gleiche Konvention wie mail/internal/search/facets.go +// buildFilteredMust). +import { isFacetField } from "./facetFields"; + +const FIELD_TENANT_SLUG = "tenant_slug"; + +export interface AppliedFilter { + field: string; + value: string; +} + +export function parseFilterParams(values: string[]): AppliedFilter[] { + const filters: AppliedFilter[] = []; + for (const raw of values) { + const sep = raw.indexOf(":"); + if (sep <= 0) continue; + const field = raw.slice(0, sep); + const value = raw.slice(sep + 1); + if (!isFacetField(field) || value === "") continue; + filters.push({ field, value }); + } + return filters; +} + +export function buildMust(tenantSlug: string, query: string, filters: AppliedFilter[]): Record[] { + const must: Record[] = [{ equals: { [FIELD_TENANT_SLUG]: tenantSlug } }]; + if (query) { + must.push({ query_string: query }); + } + for (const f of filters) { + must.push({ equals: { [f.field]: f.value } }); + } + return must; +} diff --git a/web/mail-search/package.json b/web/mail-search/package.json index 9bc6f62..2c64a30 100644 --- a/web/mail-search/package.json +++ b/web/mail-search/package.json @@ -17,6 +17,7 @@ "devDependencies": { "@testing-library/jest-dom": "6.4.8", "@testing-library/react": "16.0.0", + "@testing-library/user-event": "14.5.2", "@types/node": "20.14.9", "@types/react": "18.3.3", "@types/react-dom": "18.3.0",