Files
nexarch/web/mail-search/app/FacetPanel.tsx
sysopsandClaude Sonnet 5 acc2b0c5dd 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
2026-08-31 23:18:08 +02:00

101 lines
3.4 KiB
TypeScript

"use client";
import type { CSSProperties } from "react";
import { FACET_FIELDS, FACET_FIELD_LABELS } from "../lib/facetFields";
import type { AppliedFilter, FacetsResponse } from "../lib/api";
import { isFilterActive } from "../lib/filterState";
interface FacetPanelProps {
facetsData: FacetsResponse | null;
appliedFilters: AppliedFilter[];
onAddFilter: (filter: AppliedFilter) => void;
onRemoveFilter: (filter: AppliedFilter) => void;
onClearFilters: () => void;
}
const chipStyle: CSSProperties = {
display: "inline-flex",
alignItems: "center",
gap: 4,
padding: "4px 10px",
borderRadius: 999,
border: "1px solid var(--shl-color-border, #d7dbe0)",
background: "var(--shl-color-surface, #f5f6f8)",
marginRight: 8,
marginBottom: 8,
cursor: "pointer",
font: "inherit",
};
// SRC-06 Akzeptanzkriterium 1: aktive Filter erscheinen als entfernbare
// Chips oberhalb der Trefferliste. Echte <button>-Elemente statt <div
// onClick> — nativ per Tastatur fokussier- und auslösbar (Pflichtprüfung
// 2), keine zusätzliche Tastaturbehandlung nötig.
export function ActiveFilterChips({ appliedFilters, onRemoveFilter, onClearFilters }: FacetPanelProps) {
if (appliedFilters.length === 0) {
return null;
}
return (
<div style={{ marginTop: 16 }} aria-label="Aktive Filter">
<div style={{ display: "flex", flexWrap: "wrap", alignItems: "center" }}>
{appliedFilters.map((f) => (
<button
key={`${f.field}:${f.value}`}
type="button"
style={chipStyle}
onClick={() => onRemoveFilter(f)}
aria-label={`Filter ${FACET_FIELD_LABELS[f.field]}: ${f.value} entfernen`}
>
{FACET_FIELD_LABELS[f.field]}: {f.value}
</button>
))}
</div>
{/* Akzeptanzkriterium 3: Zurücksetzen aller Filter mit einem Klick. */}
<button type="button" onClick={onClearFilters}>
Alle Filter zurücksetzen
</button>
</div>
);
}
// 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 (
<div style={{ marginTop: 16 }}>
{FACET_FIELDS.map((field) => {
const values = facetsData.values[field] ?? [];
if (values.length === 0) {
return null;
}
return (
<div key={field} style={{ marginBottom: 12 }}>
<strong>{FACET_FIELD_LABELS[field]}</strong>
<ul style={{ listStyle: "none", padding: 0, margin: 0 }}>
{values.map((v) => {
const active = isFilterActive(appliedFilters, field, v.value);
return (
<li key={v.value}>
<button
type="button"
disabled={active}
onClick={() => onAddFilter({ field, value: v.value })}
style={{ background: "none", border: "none", padding: "2px 0", cursor: active ? "default" : "pointer", opacity: active ? 0.5 : 1 }}
>
{v.value} ({v.count})
</button>
</li>
);
})}
</ul>
</div>
);
})}
</div>
);
}