From 25e7f23c6c2c7b3513bfb68c077cf4447d4afc00 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Fri, 2 Oct 2026 16:19:55 +0200 Subject: [PATCH] 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.=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 --- web/src/components/DataTable.jsx | 99 +++++++++++++++++++++++----- web/src/urlState.js | 107 +++++++++++++++++++++++++++++++ 2 files changed, 191 insertions(+), 15 deletions(-) diff --git a/web/src/components/DataTable.jsx b/web/src/components/DataTable.jsx index 3803b42..b474d67 100644 --- a/web/src/components/DataTable.jsx +++ b/web/src/components/DataTable.jsx @@ -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 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 }; + 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 }; + } + Object.assign(neu, { hidden: [...hidden], filters: filtersSer, sort }); + } else { + Object.assign(neu, { + hidden: bisher.hidden || [], + filters: bisher.filters || {}, + sort: bisher.sort ?? null, + }); } - savePersisted(id, { order, widths, hidden: [...hidden], filters: filtersSer, sort }); + 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)}> Filter - +