Tabellenfilter und Sortierung in die Adresszeile

Filter, Sortierung und ausgeblendete Spalten lagen nur im Browserspeicher.
Ein Neuladen ueberstanden sie damit schon, aber verschicken liess sich ein
gefilterter Stand nicht, und der Adresszeile sah man nicht an, was man
gerade ansieht.

Das Format kommt ohne Maskierung aus, weil Mehrfachauswahlen als
WIEDERHOLTER Parameter stehen statt als Liste mit eigenem Trennzeichen:

  ?f.product=milch&v.art=Lebensmittel&v.art=Gegenstand&sort=menge:desc&aus=created

Sonst zerbraeche ein Filterwert wie "Klamotten -> kurze Hosen" oder
"(ohne)" an der erstbesten Kommastelle. Der Sonderfall "keiner der Werte
gewaehlt" bekommt ein eigenes vn.<spalte>=1 - ein leerer Wert ist belegt,
es gibt echte Leerwerte ("(Leere)").

Die Vorrangregel: Der Browserspeicher ist die persoenliche Vorgabe, die
URL schlaegt sie. Geschrieben wird der Speicher aber NUR von
Bedienhandlungen. Wer einen fremden gefilterten Link oeffnet, soll seine
eigenen Einstellungen nicht verlieren, bloss weil er geschaut hat.

Dabei ist mein erster Versuch durchgefallen, und zwar sichtbar: Ich
wollte den ersten Speicherlauf ueberspringen. React fuehrt Effekte im
Entwicklungsmodus doppelt aus - der zweite Lauf schrieb dann doch, und
die Probe zeigte die fremden Filter im eigenen Speicher stehen. Jetzt
haengt ein Merker an den Settern statt am Mounten, und Spaltenbreiten und
-reihenfolge werden davon ausgenommen: Die entstehen immer durch Ziehen
und sind reines Layout.

Fremde Parameter derselben Seite bleiben stehen - auf der
Kategorienseite stehen kat und panel in derselben Adresse. Deshalb
bekommt setSearchParams eine Funktion und keinen fertigen Wert, sonst
raeumte ein veralteter Abschluss sie weg.

Von Hand durchgeklickt: Filter, Sortierung und ausgeblendete Spalte
stehen in der URL; eine blanke Adresse fuellt sich aus der gemerkten
Vorgabe; ein fremder Link gewinnt in der Ansicht und laesst den eigenen
Speicher unangetastet; eine eigene Aenderung wird gemerkt; "Filter
loeschen" raeumt beides; sieben Tastendruecke im Filterfeld erzeugen
NULL Verlaufseintraege; ein Tabellenfilter laesst kat/panel stehen;
/auth/callback wird weiterhin nicht vom Auffangmuster geschluckt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-10-02 16:19:55 +02:00
co-authored by Claude Opus 5
parent 18481bbca0
commit 25e7f23c6c
2 changed files with 191 additions and 15 deletions
+80 -11
View File
@@ -1,7 +1,9 @@
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { useSearchParams } from "react-router-dom";
import Icon from "./Icon";
import { Skeleton } from "./Skeleton";
import { leseTabelle, schreibeTabelle } from "../urlState";
/**
* Excel-artige Tabelle in der bestehenden Designsprache: feste Filterzeile je
@@ -34,6 +36,16 @@ export default function DataTable({
}) {
const colByKey = useMemo(() => Object.fromEntries(columns.map((c) => [c.key, c])), [columns]);
const [params, setParams] = useSearchParams();
/**
* Was beim Aufruf in der Adresse stand - einmal beim Mounten festgehalten.
*
* Die Regel dahinter: Der Browserspeicher ist die persoenliche Vorgabe, die
* URL schlaegt sie. Ein verschickter Link zeigt damit beim Empfaenger
* dasselbe, auch wenn der sich etwas anderes eingestellt hat.
*/
const [vonUrl] = useState(() => leseTabelle(params, (k) => !!colByKey[k]));
const [order, setOrder] = useState(() => {
const p = loadPersisted(id);
const saved = Array.isArray(p.order) ? p.order.filter((k) => colByKey[k]) : [];
@@ -61,6 +73,7 @@ export default function DataTable({
// „Filter löschen" drückt oder anders sortiert – auch nach Navigieren/Neuladen.
// Beim Speichern werden Sets zu Arrays; beim Laden zurück zu Sets.
const [filters, setFilters] = useState(() => {
if (vonUrl) return vonUrl.filters;
const saved = loadPersisted(id).filters || {};
const out = {};
for (const [k, v] of Object.entries(saved)) {
@@ -70,28 +83,84 @@ export default function DataTable({
return out;
}); // { key: { text, values:Set|null } }
const [sort, setSort] = useState(() => {
if (vonUrl) return vonUrl.sort;
const s = loadPersisted(id).sort;
return s && s.key && colByKey[s.key] ? s : null;
}); // { key, dir }
const [filterShown, setFilterShown] = useState(true);
const [openFilter, setOpenFilter] = useState(null);
const [collapsed, setCollapsed] = useState(() => new Set());
const [hidden, setHidden] = useState(
() => new Set((loadPersisted(id).hidden || []).filter((k) => colByKey[k])),
);
const [hidden, setHidden] = useState(() => (vonUrl
? vonUrl.hidden
: new Set((loadPersisted(id).hidden || []).filter((k) => colByKey[k]))));
const [chooserOpen, setChooserOpen] = useState(false);
/**
* Kam die letzte Aenderung an Filter, Sortierung oder Spaltenauswahl von
* einer Bedienhandlung?
*
* Nur dann wird die persoenliche Vorgabe im Browserspeicher fortgeschrieben.
* Wer einen fremden gefilterten Link oeffnet, soll seine eigenen
* Einstellungen nicht verlieren, bloss weil er geschaut hat.
*
* Bewusst ein Merker an den Settern und kein "ueberspring den ersten
* Durchlauf": React fuehrt Effekte im Entwicklungsmodus doppelt aus, ein
* Zaehler am Mounten wird dadurch wertlos.
*/
const vomBenutzer = useRef(false);
const setzeFilters = (wert) => { vomBenutzer.current = true; setFilters(wert); };
const setzeSort = (wert) => { vomBenutzer.current = true; setSort(wert); };
const setzeHidden = (wert) => { vomBenutzer.current = true; setHidden(wert); };
/**
* Die persoenliche Vorgabe fortschreiben.
*
* Spaltenreihenfolge und -breiten sind reines Layout und entstehen immer
* durch Ziehen - die werden ohne Wenn und Aber gemerkt. Filter, Sortierung
* und ausgeblendete Spalten koennen dagegen aus einer fremden URL stammen;
* dann bleibt stehen, was vorher gespeichert war.
*/
useEffect(() => {
// Nur echte Einschränkungen sichern (leerer Text + values=null = kein Filter);
// Sets werden zu Arrays serialisiert.
const bisher = loadPersisted(id);
const neu = { order, widths };
if (vomBenutzer.current) {
vomBenutzer.current = false;
// Nur echte Einschränkungen sichern (leerer Text + values=null = kein
// Filter); Sets werden zu Arrays serialisiert.
const filtersSer = {};
for (const [k, v] of Object.entries(filters)) {
if (!v || (!v.text && v.values == null)) continue;
filtersSer[k] = { text: v.text || "", values: v.values ? [...v.values] : null };
}
savePersisted(id, { order, widths, hidden: [...hidden], filters: filtersSer, sort });
Object.assign(neu, { hidden: [...hidden], filters: filtersSer, sort });
} else {
Object.assign(neu, {
hidden: bisher.hidden || [],
filters: bisher.filters || {},
sort: bisher.sort ?? null,
});
}
savePersisted(id, neu);
}, [id, order, widths, hidden, filters, sort]);
/**
* Was man sieht, steht in der Adresse - auch wenn es aus der gemerkten
* Vorgabe stammt. Erst damit laesst sich ein gefilterter Stand verschicken.
*
* `setParams` bekommt eine Funktion, damit fremde Parameter derselben Seite
* (auf der Kategorienseite etwa `kat` und `panel`) aus dem AKTUELLEN Stand
* kommen und nicht aus einem veralteten Abschluss. Immer `replace`: Ein
* Filter ist kein Ort, zu dem man zurueckblaettern will, und jeder
* Tastendruck im Filterfeld wuerde sonst einen Verlaufseintrag anlegen.
*/
useEffect(() => {
setParams((alt) => {
const neu = schreibeTabelle(alt, { filters, sort, hidden });
return neu.toString() === alt.toString() ? alt : neu;
}, { replace: true });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [filters, sort, hidden]);
// Containerbreite messen, damit die grow-Spalte den Rest füllt.
const wrapRef = useRef(null);
const [containerWidth, setContainerWidth] = useState(0);
@@ -135,7 +204,7 @@ export default function DataTable({
}, [chooserOpen]);
function toggleHidden(key) {
setHidden((s) => {
setzeHidden((s) => {
const n = new Set(s);
if (n.has(key)) { n.delete(key); return n; }
// Mindestens eine Spalte sichtbar lassen.
@@ -254,7 +323,7 @@ export default function DataTable({
function toggleSort(c) {
if (!c.sortValue && !c.filterText) return;
setSort((s) => {
setzeSort((s) => {
if (!s || s.key !== c.key) return { key: c.key, dir: "asc" };
if (s.dir === "asc") return { key: c.key, dir: "desc" };
return null;
@@ -362,7 +431,7 @@ export default function DataTable({
// Sortierung bleiben; der Speicher-Effekt schreibt den neuen Stand.
setOrder(columns.map((c) => c.key));
setWidths({});
setHidden(new Set());
setzeHidden(new Set());
}
return (
@@ -372,7 +441,7 @@ export default function DataTable({
onClick={() => setFilterShown((v) => !v)}>
<Icon name="search" size={14} />Filter
</button>
<button type="button" className="btn sm ghost" onClick={() => setFilters({})}>Filter löschen</button>
<button type="button" className="btn sm ghost" onClick={() => setzeFilters({})}>Filter löschen</button>
<button type="button" ref={chooserBtn} className={`btn sm ${chooserOpen ? "" : "ghost"}`}
onClick={() => setChooserOpen((o) => !o)}>
<Icon name="settings" size={14} />Spalten
@@ -428,7 +497,7 @@ export default function DataTable({
hasText={Boolean(c.filterText)}
open={openFilter === c.key}
onToggleOpen={setOpenFilter}
onChange={(f) => setFilters((old) => ({ ...old, [c.key]: f }))}
onChange={(f) => setzeFilters((old) => ({ ...old, [c.key]: f }))}
/>
) : null}
</td>
+107
View File
@@ -97,6 +97,113 @@ export function useUrlWerte(vorgaben) {
return [werte, setzeWerte];
}
// --------------------------------------------------------------------------
// Tabellenzustand
// --------------------------------------------------------------------------
//
// Filter, Sortierung und ausgeblendete Spalten einer Tabelle. Die Form ist so
// gewählt, dass **nichts maskiert werden muss**: Mehrfachauswahlen stehen als
// wiederholter Parameter da, nicht als Liste mit eigenem Trennzeichen. Sonst
// zerbräche ein Filterwert wie „Klamotten → kurze Hosen" oder „(ohne)" an der
// erstbesten Kommastelle.
//
// ?f.product=milch&v.art=Lebensmittel&v.art=Gegenstand&sort=menge:desc&aus=created
//
// `f.` = Freitext, `v.` = gewählte Werte, `sort` = Spalte:Richtung,
// `aus` = ausgeblendete Spalten (deren Namen sind einfache Bezeichner, da ist
// das Komma sicher).
const TEXT = "f.";
const WERT = "v.";
const LEER = "vn."; // „keiner der Werte gewählt" – siehe unten
function istTabellenParam(schluessel) {
return schluessel.startsWith(TEXT) || schluessel.startsWith(WERT)
|| schluessel.startsWith(LEER) || schluessel === "sort" || schluessel === "aus";
}
/**
* Tabellenzustand aus der Adresse lesen.
*
* ``null`` heißt „dazu steht nichts in der URL" — dann gilt, was sich der
* Benutzer zuletzt eingestellt hat. Unbekannte Spaltennamen fliegen raus, wie
* es die Tabelle mit dem Browserspeicher auch hält: Eine Adresse kann von Hand
* verstümmelt oder älter als die Spaltenliste sein.
*/
export function leseTabelle(params, kennt) {
const filters = {};
let etwas = false;
const hole = (k) => (filters[k] ||= { text: "", values: null });
for (const schluessel of new Set(params.keys())) {
if (schluessel.startsWith(TEXT)) {
const spalte = schluessel.slice(TEXT.length);
if (!kennt(spalte)) continue;
hole(spalte).text = params.get(schluessel) || "";
etwas = true;
} else if (schluessel.startsWith(WERT)) {
const spalte = schluessel.slice(WERT.length);
if (!kennt(spalte)) continue;
hole(spalte).values = new Set(params.getAll(schluessel));
etwas = true;
} else if (schluessel.startsWith(LEER)) {
const spalte = schluessel.slice(LEER.length);
if (!kennt(spalte)) continue;
hole(spalte).values = new Set();
etwas = true;
}
}
let sort = null;
const sortRoh = params.get("sort");
if (sortRoh) {
const [spalte, richtung] = sortRoh.split(":");
if (kennt(spalte)) {
sort = { key: spalte, dir: richtung === "desc" ? "desc" : "asc" };
etwas = true;
}
}
const aus = params.get("aus");
const hidden = new Set((aus ? aus.split(",") : []).filter(kennt));
if (aus) etwas = true;
return etwas ? { filters, sort, hidden } : null;
}
/**
* Tabellenzustand in die Adresse schreiben — fremde Parameter bleiben stehen.
*
* Das ist nicht nur Höflichkeit: Auf der Kategorienseite steht neben der
* Tabelle auch `?kat=&panel=` in derselben Adresse.
*/
export function schreibeTabelle(params, { filters, sort, hidden }) {
const neu = new URLSearchParams();
for (const [schluessel, wert] of params.entries()) {
if (!istTabellenParam(schluessel)) neu.append(schluessel, wert);
}
for (const [spalte, f] of Object.entries(filters || {})) {
if (!f) continue;
if (f.text) neu.set(TEXT + spalte, f.text);
if (f.values) {
// Ein leeres Set heißt „nichts gewählt" und zeigt gar keine Zeile. Das
// braucht eine eigene Marke: Ein Parameter ohne Werte liesse sich nicht
// von „nicht gesetzt" unterscheiden, und ein leerer Wert ist belegt — es
// gibt echte Leerwerte („(Leere)").
if (f.values.size === 0) neu.set(LEER + spalte, "1");
else for (const wert of f.values) neu.append(WERT + spalte, wert);
}
}
if (sort && sort.key) neu.set("sort", `${sort.key}:${sort.dir || "asc"}`);
const ausgeblendet = [...(hidden || [])];
if (ausgeblendet.length) neu.set("aus", ausgeblendet.join(","));
return neu;
}
/**
* Dasselbe für eine Zahl (Artikel-, Kategorie-, Chargen-Kennungen).
*