Seitenzustand in die Adresszeile
Neu web/src/urlState.js mit useUrlWert/useUrlZahl/useUrlWerte. Zwei
Regeln stecken darin, beide damit die Adresszeile brauchbar bleibt:
geschrieben wird immer mit replace - sonst legt jeder Tastendruck in
einem Suchfeld einen Verlaufseintrag an und die Zurueck-Taste braeuchte
zwanzig Versuche, um die Seite zu verlassen -, und Vorgabewerte fliegen
aus der URL, sonst sammelt sich "?typ=&q=&kat=" an.
useUrlWerte gibt es, weil zwei getrennte Schreibvorgaenge im selben
Durchlauf einander ueberschreiben: Beide bekommen denselben
Ausgangsstand. Wo ein Klick zwei Dinge aendert ("Kategorie 12, Karte
Masse"), muss es ein Aufruf sein.
Damit wandern in die URL:
- Rezepte: Das Rezept steht jetzt im PFAD (/rezepte/<slug>), weil es
benennt, was man ansieht - so wie /products/:id und /i/:uid. Portionen
und Suchbegriff als Parameter. "Pfannkuchen fuer 12" ist damit ein
verschickbarer Link.
- Kategorien: Segment-Umschalter und die geoeffnete Seitenkarte
(?typ=food&kat=6&panel=masse). Gespeichert wird die Kennung, nicht das
Objekt - die Kategorien kommen erst vom Server.
- Gruppen: die geoeffnete Seitenkarte.
- Artikel: der Segment-Umschalter.
- Ein- und Auslagern: der gewaehlte Artikel als Kennung, nachgeladen beim
Aufruf. Eine halbfertige Buchung ueberlebt damit ein Neuladen.
Die Portionszahl landet NUR in der URL, wenn jemand sie selbst gestellt
hat. Die Vorbelegung aus dem Rezept bleibt draussen - sonst traegt jedes
geoeffnete Rezept ein ?portionen mit sich herum, das gar keine
Entscheidung ist.
Dazu im Backend: Ein unbekanntes Rezept ist jetzt 404 statt 502 und sagt
"Dieses Rezept gibt es in Mealie nicht (mehr)" statt "Mealie kennt
/api/recipes/x nicht". Das wurde noetig, weil ein Rezept seit diesem
Commit verlinkbar ist und ein alter Link irgendwann ins Leere zeigt -
eine gewoehnliche Auskunft, kein Gateway-Fehler.
Von Hand durchgeklickt: Rezept oeffnen und Portionen auf 12 ->
/rezepte/pfannkuchen?portionen=12, Neuladen bringt 250 g -> 750 g zurueck;
unbekannter Slug zeigt den klaren Hinweis und laesst die Liste stehen;
Kategorie-Karte und Segment ueberstehen F5, Schliessen raeumt die URL;
/checkin?artikel=1 laedt den Artikel nach, ?artikel=999 raeumt die
Adresse und zeigt die Suchmaske. 1 neuer Test, Suite bei 518.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
a1b10d9ec3
commit
18481bbca0
@@ -266,6 +266,7 @@ export default function App() {
|
||||
<Route path="/categories" element={<Protected adminOnly wide><Categories /></Protected>} />
|
||||
<Route path="/shopping" element={<Protected><ShoppingList /></Protected>} />
|
||||
<Route path="/rezepte" element={<Protected wide><Rezepte /></Protected>} />
|
||||
<Route path="/rezepte/:slug" element={<Protected wide><Rezepte /></Protected>} />
|
||||
<Route path="/mindestbestaende" element={<Protected wide><MinStock /></Protected>} />
|
||||
{/* Ohne adminOnly: Verleihen und Zurücknehmen ist eine Buchung wie Ein-
|
||||
und Auslagern, keine Stammdatenänderung. */}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { api } from "../api";
|
||||
import { useUrlWerte } from "../urlState";
|
||||
import { useConfirm } from "../confirm";
|
||||
import { useAuth } from "../auth";
|
||||
import Icon from "../components/Icon";
|
||||
@@ -22,12 +23,28 @@ export default function Categories() {
|
||||
const [categories, setCategories] = useState([]);
|
||||
const [form, setForm] = useState({ name: "", parent_id: "", tracking: "" });
|
||||
const [error, setError] = useState(null);
|
||||
// Kategorie, deren Felder gerade verwaltet werden.
|
||||
const [feldKat, setFeldKat] = useState(null);
|
||||
// Welche Kategorie gerade ihre Masse und ihre Oeffnungsfrist zeigt.
|
||||
const [massKat, setMassKat] = useState(null);
|
||||
// "" = alle, sonst "food"/"object": trennt Lebensmittel und Gegenstände.
|
||||
const [typFilter, setTypFilter] = useState("");
|
||||
// Welche Kategorie ihre Seitenkarte zeigt und welche davon: alles in der
|
||||
// URL, damit ein Neuladen dieselbe Ansicht wiederherstellt. Gespeichert
|
||||
// wird die Kennung, nicht das Objekt - die Kategorien kommen erst vom
|
||||
// Server, und ein Objekt laesst sich nicht in eine Adresse schreiben.
|
||||
const [{ kat, panel, typ: typFilter }, setzeAnsicht] = useUrlWerte(
|
||||
{ kat: "", panel: "", typ: "" },
|
||||
);
|
||||
const katId = Number(kat) || null;
|
||||
const gewaehlt = categories.find((c) => c.id === katId) ?? null;
|
||||
const feldKat = panel === "felder" ? gewaehlt : null;
|
||||
const massKat = panel === "masse" ? gewaehlt : null;
|
||||
|
||||
// Ein Klick aendert zwei Werte - deshalb EIN Schreibvorgang, sonst
|
||||
// ueberschreibt der zweite den ersten.
|
||||
function zeigeKarte(c, welche) {
|
||||
const schonOffen = katId === c.id && panel === welche;
|
||||
setzeAnsicht(schonOffen ? { kat: "", panel: "" } : { kat: c.id, panel: welche });
|
||||
}
|
||||
function schliesseKarte() {
|
||||
setzeAnsicht({ kat: "", panel: "" });
|
||||
}
|
||||
const setTypFilter = (wert) => setzeAnsicht({ typ: wert });
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
async function load() {
|
||||
@@ -82,7 +99,7 @@ export default function Categories() {
|
||||
if (!ok) return;
|
||||
try {
|
||||
await api.deleteCategory(category.id);
|
||||
if (feldKat && feldKat.id === category.id) setFeldKat(null);
|
||||
if (katId === category.id) schliesseKarte();
|
||||
load();
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
@@ -137,11 +154,11 @@ export default function Categories() {
|
||||
render: (c) => (
|
||||
<span className="cell-row" style={{ justifyContent: "flex-end" }}>
|
||||
<button className="btn-icon" title="Felder verwalten"
|
||||
onClick={() => { setMassKat(null); setFeldKat(feldKat && feldKat.id === c.id ? null : c); }}>
|
||||
onClick={() => zeigeKarte(c, "felder")}>
|
||||
<Icon name="tag" size={16} />
|
||||
</button>
|
||||
<button className="btn-icon" title="Maße & Frist"
|
||||
onClick={() => { setFeldKat(null); setMassKat(massKat && massKat.id === c.id ? null : c); }}>
|
||||
onClick={() => zeigeKarte(c, "masse")}>
|
||||
<Icon name="split" size={16} />
|
||||
</button>
|
||||
{isAdmin && (
|
||||
@@ -225,7 +242,7 @@ export default function Categories() {
|
||||
key={feldKat.id}
|
||||
category={feldKat}
|
||||
isAdmin={isAdmin}
|
||||
onClose={() => setFeldKat(null)}
|
||||
onClose={schliesseKarte}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -234,7 +251,7 @@ export default function Categories() {
|
||||
key={massKat.id}
|
||||
category={massKat}
|
||||
isAdmin={isAdmin}
|
||||
onClose={() => setMassKat(null)}
|
||||
onClose={schliesseKarte}
|
||||
onFrist={() => load()}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link, useSearchParams } from "react-router-dom";
|
||||
import { api } from "../api";
|
||||
import { useUrlZahl } from "../urlState";
|
||||
import { useAuth } from "../auth";
|
||||
import Icon from "../components/Icon";
|
||||
import { ProduktThumb } from "../components/ProduktBild";
|
||||
@@ -18,6 +19,11 @@ export default function CheckIn() {
|
||||
const { isAdmin } = useAuth();
|
||||
const toast = useToast();
|
||||
const [barcode, setBarcode] = useState("");
|
||||
// Der gewaehlte Artikel steht als Kennung in der Adresse. Das Objekt selbst
|
||||
// bleibt im Speicher - in die URL passt es nicht, und nachladen kann man es
|
||||
// jederzeit. Damit ueberlebt die halbfertige Buchung ein Neuladen, und ein
|
||||
// Link "lager diesen Artikel ein" laesst sich verschicken.
|
||||
const [artikelId, setArtikelId] = useUrlZahl("artikel");
|
||||
const [product, setProduct] = useState(null);
|
||||
const [suggestion, setSuggestion] = useState(null);
|
||||
// Gruppe, die diesem Code zugeordnet ist (z.B. alle Mehl-Marken in "Mehl").
|
||||
@@ -59,6 +65,17 @@ export default function CheckIn() {
|
||||
if (loc) setLocationId(loc);
|
||||
}, [searchParams]);
|
||||
|
||||
// Artikel aus der Adresse holen. Die Sperre verhindert, dass das Setzen der
|
||||
// Kennung in selectProduct den Artikel gleich noch einmal laedt.
|
||||
useEffect(() => {
|
||||
if (!artikelId) { setProduct(null); return; }
|
||||
if (product && product.id === artikelId) return;
|
||||
api.getProduct(artikelId)
|
||||
.then(selectProduct)
|
||||
.catch(() => setArtikelId(null)); // geloescht oder Unsinn in der URL
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [artikelId]);
|
||||
|
||||
function resetLookup() {
|
||||
setSuggestion(null);
|
||||
setUnknownBarcode(null);
|
||||
@@ -67,6 +84,7 @@ export default function CheckIn() {
|
||||
|
||||
function selectProduct(p) {
|
||||
setProduct(p);
|
||||
setArtikelId(p.id);
|
||||
setResults([]);
|
||||
resetLookup();
|
||||
const opts = buildUnitOptions(p, units);
|
||||
@@ -375,7 +393,7 @@ export default function CheckIn() {
|
||||
<div className="title">{product.name}</div>
|
||||
<div className="muted small">Bestand: {fmt(product.stock / (product.unit_factor || 1))} {product.unit_name}</div>
|
||||
</div>
|
||||
<button type="button" className="btn ghost" onClick={() => setProduct(null)}>Ändern</button>
|
||||
<button type="button" className="btn ghost" onClick={() => setArtikelId(null)}>Ändern</button>
|
||||
</div>
|
||||
|
||||
<div className="row">
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { api } from "../api";
|
||||
import { useUrlZahl } from "../urlState";
|
||||
import Icon from "../components/Icon";
|
||||
import { ProduktThumb } from "../components/ProduktBild";
|
||||
import { useToast } from "../toast";
|
||||
@@ -12,6 +13,11 @@ export default function CheckOut() {
|
||||
const [barcode, setBarcode] = useState("");
|
||||
const [search, setSearch] = useState("");
|
||||
const [results, setResults] = useState([]);
|
||||
// Der gewaehlte Artikel steht als Kennung in der Adresse. Das Objekt selbst
|
||||
// bleibt im Speicher - in die URL passt es nicht, und nachladen kann man es
|
||||
// jederzeit. Damit ueberlebt die halbfertige Buchung ein Neuladen, und ein
|
||||
// Link "lager diesen Artikel ein" laesst sich verschicken.
|
||||
const [artikelId, setArtikelId] = useUrlZahl("artikel");
|
||||
const [product, setProduct] = useState(null);
|
||||
const [units, setUnits] = useState([]);
|
||||
const [lots, setLots] = useState([]);
|
||||
@@ -27,8 +33,20 @@ export default function CheckOut() {
|
||||
api.listUnits().then(setUnits).catch(() => {});
|
||||
}, []);
|
||||
|
||||
// Artikel aus der Adresse holen. Die Sperre verhindert, dass das Setzen der
|
||||
// Kennung in selectProduct den Artikel gleich noch einmal laedt.
|
||||
useEffect(() => {
|
||||
if (!artikelId) { setProduct(null); return; }
|
||||
if (product && product.id === artikelId) return;
|
||||
api.getProduct(artikelId)
|
||||
.then(selectProduct)
|
||||
.catch(() => setArtikelId(null)); // geloescht oder Unsinn in der URL
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [artikelId]);
|
||||
|
||||
async function selectProduct(p) {
|
||||
setProduct(p);
|
||||
setArtikelId(p.id);
|
||||
setResults([]);
|
||||
setLotId("");
|
||||
setQuantity("");
|
||||
@@ -155,7 +173,7 @@ export default function CheckOut() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" className="btn ghost" onClick={() => setProduct(null)}>Ändern</button>
|
||||
<button type="button" className="btn ghost" onClick={() => setArtikelId(null)}>Ändern</button>
|
||||
</div>
|
||||
|
||||
<label>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { api } from "../api";
|
||||
import { useUrlZahl } from "../urlState";
|
||||
import { useConfirm } from "../confirm";
|
||||
import { useAuth } from "../auth";
|
||||
import BarcodeList from "../components/BarcodeList";
|
||||
@@ -18,7 +19,9 @@ export default function Groups() {
|
||||
const [form, setForm] = useState({
|
||||
name: "", min_stock: "", min_stock_unit_id: "", parent_ids: [],
|
||||
});
|
||||
const [selectedId, setSelectedId] = useState(null);
|
||||
// Welche Gruppe ihre Seitenkarten zeigt - in der URL, damit ein Neuladen
|
||||
// (und ein verschickter Link) dieselbe Gruppe wieder aufschlaegt.
|
||||
const [selectedId, setSelectedId] = useUrlZahl("gruppe");
|
||||
const [error, setError] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { api } from "../api";
|
||||
import { useUrlWert } from "../urlState";
|
||||
import { useAuth } from "../auth";
|
||||
import Icon from "../components/Icon";
|
||||
import DataTable from "../components/DataTable";
|
||||
@@ -47,7 +48,9 @@ export default function Products({ fixedType = null }) {
|
||||
// Gruppen nur fuer die Pfad-Anzeige der Gruppen-Spalte.
|
||||
const [groups, setGroups] = useState([]);
|
||||
// "" = alle, sonst "food"/"object": trennt Lebensmittel und Gegenstände.
|
||||
const [typ, setTyp] = useState("");
|
||||
// In der URL, damit ein Neuladen die Auswahl behält und sich ein Stand
|
||||
// verschicken lässt.
|
||||
const [typ, setTyp] = useUrlWert("typ");
|
||||
const [error, setError] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
|
||||
+52
-13
@@ -1,9 +1,11 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { api } from "../api";
|
||||
import Icon from "../components/Icon";
|
||||
import { Skeleton } from "../components/Skeleton";
|
||||
import ZuordnungDialog from "../components/ZuordnungDialog";
|
||||
import { useToast } from "../toast";
|
||||
import { useUrlWerte } from "../urlState";
|
||||
import { fmt } from "../units";
|
||||
|
||||
/**
|
||||
@@ -34,11 +36,23 @@ function Zustand({ status }) {
|
||||
}
|
||||
|
||||
export default function Rezepte() {
|
||||
const navigate = useNavigate();
|
||||
// Das gewaehlte Rezept steht im PFAD, nicht in einem Parameter: Es benennt,
|
||||
// was man ansieht - so wie /products/:id und /i/:uid es schon tun.
|
||||
const { slug: routeSlug } = useParams();
|
||||
const slug = routeSlug || null;
|
||||
// Suchbegriff der Liste und gewuenschte Portionen reisen als Parameter mit,
|
||||
// damit ein Neuladen denselben Stand zeigt und sich ein Link verschicken
|
||||
// laesst ("Pfannkuchen fuer 12").
|
||||
const [{ q: urlSuche, portionen: urlPortionen }, setzeAnsicht] = useUrlWerte(
|
||||
{ q: "", portionen: "" },
|
||||
);
|
||||
|
||||
const [liste, setListe] = useState([]);
|
||||
const [suche, setSuche] = useState("");
|
||||
const [slug, setSlug] = useState(null);
|
||||
// Eingabefelder: Sie folgen dem Tippen sofort, die URL erst verzoegert.
|
||||
const [suche, setSuche] = useState(urlSuche);
|
||||
const [rezept, setRezept] = useState(null);
|
||||
const [portionen, setPortionen] = useState("");
|
||||
const [portionen, setPortionen] = useState(urlPortionen);
|
||||
const [fehler, setFehler] = useState(null);
|
||||
const [laedt, setLaedt] = useState(true);
|
||||
// Welche Zutat gerade von Hand zugeordnet wird (null = keine).
|
||||
@@ -65,7 +79,10 @@ export default function Rezepte() {
|
||||
setFehler(null);
|
||||
} catch (err) { setFehler(err.message); } finally { setLaedt(false); }
|
||||
}
|
||||
useEffect(() => { ladeListe(); }, []);
|
||||
// Die Liste haengt am Suchbegriff IN DER URL, nicht am Eingabefeld - sonst
|
||||
// suchte jeder Tastendruck.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
useEffect(() => { ladeListe(urlSuche); }, [urlSuche]);
|
||||
|
||||
/**
|
||||
* Rezept laden. ``behalten`` heisst: Es ist dasselbe Rezept, nur mit anderen
|
||||
@@ -73,7 +90,6 @@ export default function Rezepte() {
|
||||
* Ohne das flackerte die ganze Seite bei jedem Druck auf den Zahlenpfeil.
|
||||
*/
|
||||
async function oeffne(s, wunschPortionen, { behalten = false } = {}) {
|
||||
setSlug(s);
|
||||
if (behalten) setRechnet(true);
|
||||
else setRezept(null);
|
||||
const lauf = ++anfrage.current;
|
||||
@@ -82,7 +98,10 @@ export default function Rezepte() {
|
||||
if (lauf !== anfrage.current) return; // eine neuere Anfrage laeuft
|
||||
setRezept(daten);
|
||||
setStand((n) => n + 1);
|
||||
// Beim ersten Öffnen das Feld mit der Rezept-Portionszahl vorbelegen.
|
||||
// Beim ersten Öffnen das Feld mit der Rezept-Portionszahl vorbelegen -
|
||||
// aber NICHT in die URL schreiben. Dort steht nur, was jemand selbst
|
||||
// eingestellt hat; sonst traegt jedes geoeffnete Rezept ein ?portionen
|
||||
// mit sich herum, das gar keine Entscheidung ist.
|
||||
if (wunschPortionen == null) setPortionen(daten.servings ? String(daten.servings) : "");
|
||||
setFehler(null);
|
||||
} catch (err) {
|
||||
@@ -92,13 +111,32 @@ export default function Rezepte() {
|
||||
}
|
||||
}
|
||||
|
||||
/** Portionen aus der URL als Zahl - unbrauchbares wird ignoriert. */
|
||||
function gewuenschtePortionen() {
|
||||
const zahl = Number(String(urlPortionen).replace(",", "."));
|
||||
return Number.isFinite(zahl) && zahl > 0 ? zahl : undefined;
|
||||
}
|
||||
|
||||
// Geladen wird, was in der Adresse steht - Rezept aus dem Pfad, Portionen
|
||||
// aus dem Parameter. Damit ist ein Neuladen dasselbe wie ein Klick.
|
||||
useEffect(() => {
|
||||
if (!slug) { setRezept(null); return; }
|
||||
oeffne(slug, gewuenschtePortionen(), { behalten: true });
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [slug, urlPortionen]);
|
||||
|
||||
// Nach einer Zuordnung muss der Abgleich neu laufen: Die Zutat hat jetzt ein
|
||||
// Ziel, einen Bestand und einen Zustand.
|
||||
async function neuRechnen() {
|
||||
if (slug) {
|
||||
await oeffne(slug, Number(String(portionen).replace(",", ".")) || undefined,
|
||||
{ behalten: true });
|
||||
}
|
||||
if (slug) await oeffne(slug, gewuenschtePortionen(), { behalten: true });
|
||||
}
|
||||
|
||||
/** Rezept oeffnen - der Suchbegriff der Liste bleibt dabei stehen. */
|
||||
function oeffneRezept(s) {
|
||||
navigate({
|
||||
pathname: `/rezepte/${encodeURIComponent(s)}`,
|
||||
search: urlSuche ? `?q=${encodeURIComponent(urlSuche)}` : "",
|
||||
});
|
||||
}
|
||||
|
||||
// Das Zahlenfeld reagiert sofort, die Anfrage wartet kurz: Wer von 4 auf 12
|
||||
@@ -109,7 +147,8 @@ export default function Rezepte() {
|
||||
if (tippTimer.current) clearTimeout(tippTimer.current);
|
||||
if (!slug || !Number.isFinite(zahl) || zahl <= 0) return;
|
||||
setRechnet(true);
|
||||
tippTimer.current = setTimeout(() => oeffne(slug, zahl, { behalten: true }), 280);
|
||||
// Geschrieben wird in die URL; das Laden erledigt der Effekt darueber.
|
||||
tippTimer.current = setTimeout(() => setzeAnsicht({ portionen: wert }), 280);
|
||||
}
|
||||
|
||||
useEffect(() => () => { if (tippTimer.current) clearTimeout(tippTimer.current); }, []);
|
||||
@@ -157,7 +196,7 @@ export default function Rezepte() {
|
||||
|
||||
<div className="card" style={{ marginBottom: "var(--sp-3)" }}>
|
||||
<form className="field-inline" style={{ marginBottom: 0 }}
|
||||
onSubmit={(e) => { e.preventDefault(); ladeListe(suche); }}>
|
||||
onSubmit={(e) => { e.preventDefault(); setzeAnsicht({ q: suche }); }}>
|
||||
<input value={suche} placeholder="Rezept suchen…"
|
||||
onChange={(e) => setSuche(e.target.value)} />
|
||||
<button className="btn"><Icon name="search" size={16} />Suchen</button>
|
||||
@@ -178,7 +217,7 @@ export default function Rezepte() {
|
||||
<button type="button"
|
||||
className={`btn sm ghost${r.slug === slug ? " primary" : ""}`}
|
||||
style={{ width: "100%", justifyContent: "flex-start" }}
|
||||
onClick={() => oeffne(r.slug, null)}>
|
||||
onClick={() => oeffneRezept(r.slug)}>
|
||||
{r.name}
|
||||
</button>
|
||||
</li>
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* Seitenzustand in der Adresszeile.
|
||||
*
|
||||
* Was man gerade ansieht, gehört in die URL und nicht nur in den Speicher der
|
||||
* Komponente: Dann überlebt es ein Neuladen, lässt sich verschicken, und man
|
||||
* sieht der Adresszeile an, wo man ist. Was man gerade *eingibt* gehört dagegen
|
||||
* nicht hinein — Formularentwürfe, Passwörter, halbfertige Mengen.
|
||||
*
|
||||
* Zwei Regeln setzt der Hook durch, beide aus demselben Grund (die Adresszeile
|
||||
* soll brauchbar bleiben):
|
||||
*
|
||||
* 1. **Immer `replace`.** Sonst legt jeder Tastendruck in einem Suchfeld einen
|
||||
* Verlaufseintrag an, und die Zurück-Taste bräuchte zwanzig Versuche, um die
|
||||
* Seite zu verlassen. Sie bleibt damit für die Sprünge frei, die zählen —
|
||||
* etwa heraus aus dem Bearbeitungsmodus des Dashboards.
|
||||
* 2. **Vorgabewerte verschwinden.** Steht der Wert auf dem Standard, fliegt der
|
||||
* Parameter raus. Ohne das sammelt sich `?typ=&q=&kat=` an und ein geteilter
|
||||
* Link ist nicht mehr zu lesen.
|
||||
*
|
||||
* Angefasst wird ausschließlich `useSearchParams` — nie `window.history`
|
||||
* direkt, sonst bekommt der Router die Änderung nicht mit.
|
||||
*/
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
|
||||
/**
|
||||
* Ein einzelner Wert in der Adresszeile.
|
||||
*
|
||||
* Gibt `[wert, setzeWert]` zurück, wie `useState` — die Aufrufstelle merkt vom
|
||||
* Unterschied nur, dass der Wert ein String ist.
|
||||
*/
|
||||
export function useUrlWert(schluessel, vorgabe = "") {
|
||||
const [params, setParams] = useSearchParams();
|
||||
const wert = params.get(schluessel) ?? vorgabe;
|
||||
|
||||
const setzeWert = useCallback(
|
||||
(neu) => {
|
||||
setParams(
|
||||
(alt) => {
|
||||
const naechste = new URLSearchParams(alt);
|
||||
const text = neu == null ? "" : String(neu);
|
||||
if (text === "" || text === String(vorgabe)) naechste.delete(schluessel);
|
||||
else naechste.set(schluessel, text);
|
||||
return naechste;
|
||||
},
|
||||
{ replace: true },
|
||||
);
|
||||
},
|
||||
[schluessel, vorgabe, setParams],
|
||||
);
|
||||
|
||||
return [wert, setzeWert];
|
||||
}
|
||||
|
||||
/**
|
||||
* Mehrere Werte, die zusammengehören — in EINEM Schreibvorgang.
|
||||
*
|
||||
* Zwei getrennte Aufrufe von `setzeWert` im selben Durchlauf können einander
|
||||
* überschreiben: Beide bekommen denselben Ausgangsstand und der zweite macht
|
||||
* den ersten rückgängig. Wo ein Klick zwei Dinge ändert („Kategorie 12, Karte
|
||||
* Maße"), muss es deshalb ein Aufruf sein.
|
||||
*
|
||||
* `vorgaben` nennt die Schlüssel und ihre Standardwerte; übergeben wird beim
|
||||
* Setzen nur, was sich ändert.
|
||||
*/
|
||||
export function useUrlWerte(vorgaben) {
|
||||
const [params, setParams] = useSearchParams();
|
||||
|
||||
const werte = {};
|
||||
for (const [schluessel, vorgabe] of Object.entries(vorgaben)) {
|
||||
werte[schluessel] = params.get(schluessel) ?? vorgabe;
|
||||
}
|
||||
|
||||
const setzeWerte = useCallback(
|
||||
(aenderungen) => {
|
||||
setParams(
|
||||
(alt) => {
|
||||
const naechste = new URLSearchParams(alt);
|
||||
for (const [schluessel, neu] of Object.entries(aenderungen)) {
|
||||
const text = neu == null ? "" : String(neu);
|
||||
if (text === "" || text === String(vorgaben[schluessel] ?? "")) {
|
||||
naechste.delete(schluessel);
|
||||
} else {
|
||||
naechste.set(schluessel, text);
|
||||
}
|
||||
}
|
||||
return naechste;
|
||||
},
|
||||
{ replace: true },
|
||||
);
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[setParams, JSON.stringify(vorgaben)],
|
||||
);
|
||||
|
||||
return [werte, setzeWerte];
|
||||
}
|
||||
|
||||
/**
|
||||
* Dasselbe für eine Zahl (Artikel-, Kategorie-, Chargen-Kennungen).
|
||||
*
|
||||
* Unbrauchbares in der URL — `?kat=abc`, von Hand verstümmelt — ergibt `null`
|
||||
* statt `NaN`. Eine kaputte Adresse soll die Seite nicht mit in den Abgrund
|
||||
* ziehen, sondern einfach so aussehen, als stünde dort nichts.
|
||||
*/
|
||||
export function useUrlZahl(schluessel) {
|
||||
const [roh, setzeRoh] = useUrlWert(schluessel);
|
||||
const zahl = roh === "" ? null : Number(roh);
|
||||
return [Number.isFinite(zahl) ? zahl : null, setzeRoh];
|
||||
}
|
||||
Reference in New Issue
Block a user