From 18481bbca09097126b36bbc68c8ca1b08205dac4 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Fri, 2 Oct 2026 16:13:37 +0200 Subject: [PATCH] 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/), 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 --- backend/app/routers/recipes.py | 10 +++ backend/tests/test_mealie.py | 22 +++++++ web/src/App.jsx | 1 + web/src/pages/Categories.jsx | 39 ++++++++---- web/src/pages/CheckIn.jsx | 20 +++++- web/src/pages/CheckOut.jsx | 20 +++++- web/src/pages/Groups.jsx | 5 +- web/src/pages/Products.jsx | 5 +- web/src/pages/Rezepte.jsx | 65 +++++++++++++++---- web/src/urlState.js | 111 +++++++++++++++++++++++++++++++++ 10 files changed, 270 insertions(+), 28 deletions(-) create mode 100644 web/src/urlState.js diff --git a/backend/app/routers/recipes.py b/backend/app/routers/recipes.py index c05096b..4f47dc2 100644 --- a/backend/app/routers/recipes.py +++ b/backend/app/routers/recipes.py @@ -38,7 +38,17 @@ def _fehler(fehler: mealie.MealieFehler) -> HTTPException: 503 heisst „hier ist nichts eingerichtet", 502 „Mealie antwortet nicht" – die Oberfläche soll beides verschieden erklären können. + + 404 kam dazu, als das Rezept in den Pfad wanderte (/rezepte/): Seither + lässt sich ein Rezept verlinken, und ein alter Link zeigt irgendwann ins + Leere. Das ist kein Gateway-Fehler, sondern eine ganz gewöhnliche Auskunft – + und sie soll auch so klingen, nicht nach „Mealie kennt /api/recipes/x nicht". """ + if fehler.kennung == "nicht_gefunden": + return HTTPException( + status.HTTP_404_NOT_FOUND, + "Dieses Rezept gibt es in Mealie nicht (mehr).", + ) code = ( status.HTTP_503_SERVICE_UNAVAILABLE if fehler.kennung == "nicht_eingerichtet" diff --git a/backend/tests/test_mealie.py b/backend/tests/test_mealie.py index ed77a44..f0e07a3 100644 --- a/backend/tests/test_mealie.py +++ b/backend/tests/test_mealie.py @@ -18,6 +18,7 @@ from app.routers.mealie import ( mealie_token_setzen, mealie_trennen, ) +from app.routers.recipes import get_recipe from app.routers.settings import list_settings from app.schemas import MealieConfigIn, MealieTokenIn from app.services import mealie @@ -301,3 +302,24 @@ def test_pfadvariante_wird_gepuffert(db, admin): mealie.pfadvariante(db) assert len(gesehen) == anzahl # keine zweite Anfrage + + +def test_unbekanntes_rezept_ist_ein_404_mit_klarem_text(db, admin): + """Seit das Rezept im Pfad steht, kann ein Link ins Leere zeigen. + + Das ist eine gewoehnliche Auskunft und kein Gateway-Fehler - und der Text + landet in der Oberflaeche, darf also nicht nach Innenleben klingen. + """ + db.add(Setting(key="mealie_base_url", value="https://m.example.com")) + db.add(IntegrationSecret(service="mealie", secret="t")) + db.commit() + mealie.setze_transport(httpx.MockTransport( + lambda request: httpx.Response(404, json={"detail": "Not found"}), + )) + + with pytest.raises(HTTPException) as fehler: + get_recipe("gibtsnicht", db=db, _=admin) + + assert fehler.value.status_code == 404 + assert "Rezept" in str(fehler.value.detail) + assert "/api/" not in str(fehler.value.detail) diff --git a/web/src/App.jsx b/web/src/App.jsx index 5722edf..aca0152 100644 --- a/web/src/App.jsx +++ b/web/src/App.jsx @@ -266,6 +266,7 @@ export default function App() { } /> } /> } /> + } /> } /> {/* Ohne adminOnly: Verleihen und Zurücknehmen ist eine Buchung wie Ein- und Auslagern, keine Stammdatenänderung. */} diff --git a/web/src/pages/Categories.jsx b/web/src/pages/Categories.jsx index e971b50..ddc493c 100644 --- a/web/src/pages/Categories.jsx +++ b/web/src/pages/Categories.jsx @@ -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) => ( {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()} /> )} diff --git a/web/src/pages/CheckIn.jsx b/web/src/pages/CheckIn.jsx index 2428137..329fb57 100644 --- a/web/src/pages/CheckIn.jsx +++ b/web/src/pages/CheckIn.jsx @@ -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() {
{product.name}
Bestand: {fmt(product.stock / (product.unit_factor || 1))} {product.unit_name}
- +
diff --git a/web/src/pages/CheckOut.jsx b/web/src/pages/CheckOut.jsx index 7634c0d..04a61a9 100644 --- a/web/src/pages/CheckOut.jsx +++ b/web/src/pages/CheckOut.jsx @@ -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() { )}
- +