diff --git a/cmd/foodster/handlers.go b/cmd/foodster/handlers.go index 3ffbcf7..3428392 100644 --- a/cmd/foodster/handlers.go +++ b/cmd/foodster/handlers.go @@ -215,6 +215,50 @@ func fragment(w http.ResponseWriter, r *http.Request, c templ.Component) { } } +// isDatastar reports whether the request came from the client library, which +// tags its own. Everything below keeps working without JavaScript: the same +// handlers redirect instead of patching when the header is absent. +func isDatastar(r *http.Request) bool { + return r.Header.Get("Datastar-Request") != "" +} + +// patchElements sends one Datastar event carrying several elements, each +// matched to the page by its id. A text/html response can only replace one +// element, and the catalog has to move its list and its forms together — +// opening an edit form also has to un-highlight whatever was open before. +// +// ponytail: about twenty lines instead of the SDK, which brought four modules +// for an SSE generator we would otherwise never call. +func patchElements(w http.ResponseWriter, r *http.Request, components ...templ.Component) { + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + + var out strings.Builder + out.WriteString("event: datastar-patch-elements\n") + for _, c := range components { + var html strings.Builder + if err := c.Render(r.Context(), &html); err != nil { + log.Printf("patch %s: %v", r.URL.Path, err) + return + } + // One `data: elements` line per line of HTML, as the protocol wants. + for _, line := range strings.Split(html.String(), "\n") { + if strings.TrimSpace(line) == "" { + continue + } + out.WriteString("data: elements ") + out.WriteString(line) + out.WriteString("\n") + } + } + out.WriteString("\n") + + io.WriteString(w, out.String()) + if f, ok := w.(http.Flusher); ok { + f.Flush() + } +} + // searchBoard re-renders the dish board as the search box is typed into. func (a *app) searchBoard(w http.ResponseWriter, r *http.Request) { var signals searchSignals @@ -410,7 +454,20 @@ type catalogView struct { DeleteKind string } +// catalog renders the whole page. show patches the same state in place, so +// nothing navigates: both build the view the same way. func (a *app) catalog(w http.ResponseWriter, r *http.Request) { + a.renderCatalog(w, r, a.catalogState(r)) +} + +// show is what every catalog link actually calls. It patches the list and both +// forms rather than loading a page, so opening an edit form or asking to +// delete a row leaves the scroll position exactly where it was. +func (a *app) show(w http.ResponseWriter, r *http.Request) { + a.patchCatalog(w, r, a.catalogState(r)) +} + +func (a *app) catalogState(r *http.Request) catalogView { v := catalogView{ Main: mainForm{Categories: map[string]bool{}, HasSides: true}, Search: strings.TrimSpace(r.URL.Query().Get("haku")), @@ -446,10 +503,11 @@ func (a *app) catalog(w http.ResponseWriter, r *http.Request) { } } - a.renderCatalog(w, r, v) + return v } -func (a *app) renderCatalog(w http.ResponseWriter, r *http.Request, v catalogView) { +// fillCatalog loads the lists into a view built from the request. +func (a *app) fillCatalog(v *catalogView) { if v.Main.Categories == nil { v.Main.Categories = map[string]bool{} } @@ -465,9 +523,20 @@ func (a *app) renderCatalog(w http.ResponseWriter, r *http.Request, v catalogVie if v.Sides, err = listSides(a.db, v.Search); err != nil { log.Printf("list sides: %v", err) } +} + +func (a *app) renderCatalog(w http.ResponseWriter, r *http.Request, v catalogView) { + a.fillCatalog(&v) render(w, r, catalogPage(v)) } +// patchCatalog swaps the list and both forms in one event. They move together: +// opening an edit form also has to clear whatever delete was being confirmed. +func (a *app) patchCatalog(w http.ResponseWriter, r *http.Request, v catalogView) { + a.fillCatalog(&v) + patchElements(w, r, catalogList(v), mainForm_(v.Main), sideForm_(v.Side)) +} + // saveMain adds or updates a main dish. A rejected form is re-rendered with // the values still in it; a good one redirects, so refresh cannot re-submit. func (a *app) saveMain(w http.ResponseWriter, r *http.Request) { @@ -508,11 +577,28 @@ func (a *app) saveMain(w http.ResponseWriter, r *http.Request) { log.Printf("save main: %v", err) form.Err = "Tallennus epäonnistui." default: - http.Redirect(w, r, "/ruuat", http.StatusSeeOther) + // Saved: hand back a blank form so it collapses, and a list with + // the dish in it. + a.finishCatalog(w, r, catalogView{}) return } } - a.renderCatalog(w, r, catalogView{Main: form}) + a.finishCatalog(w, r, catalogView{Main: form}) +} + +// finishCatalog answers a catalog write: a patch for Datastar, a redirect for +// a plain form post. Without the redirect, submitting with JavaScript off +// would leave the browser sitting on a POST it could not reload. +func (a *app) finishCatalog(w http.ResponseWriter, r *http.Request, v catalogView) { + if isDatastar(r) { + a.patchCatalog(w, r, v) + return + } + if v.Main.Err != "" || v.Side.Err != "" { + a.renderCatalog(w, r, v) + return + } + http.Redirect(w, r, "/ruuat", http.StatusSeeOther) } func (a *app) saveSide(w http.ResponseWriter, r *http.Request) { @@ -537,11 +623,11 @@ func (a *app) saveSide(w http.ResponseWriter, r *http.Request) { log.Printf("save side: %v", err) form.Err = "Tallennus epäonnistui." default: - http.Redirect(w, r, "/ruuat", http.StatusSeeOther) + a.finishCatalog(w, r, catalogView{}) return } } - a.renderCatalog(w, r, catalogView{Side: form}) + a.finishCatalog(w, r, catalogView{Side: form}) } // deleteDish soft-deletes, so log entries keep resolving the name (PRD §6). @@ -565,7 +651,7 @@ func (a *app) deleteDish(w http.ResponseWriter, r *http.Request) { http.Error(w, "poisto epäonnistui", http.StatusInternalServerError) return } - http.Redirect(w, r, "/ruuat", http.StatusSeeOther) + a.finishCatalog(w, r, catalogView{}) } // importDishes takes a bundle either pasted into the textarea or uploaded as a diff --git a/cmd/foodster/main.go b/cmd/foodster/main.go index b2182a9..d6d9051 100644 --- a/cmd/foodster/main.go +++ b/cmd/foodster/main.go @@ -162,6 +162,7 @@ func routes(db *sql.DB, loc *time.Location, password string) http.Handler { mux.HandleFunc("POST /poista", a.delete) mux.HandleFunc("GET /ruuat", a.catalog) mux.HandleFunc("GET /ruuat/etsi", a.searchCatalog) + mux.HandleFunc("GET /ruuat/nayta", a.show) mux.HandleFunc("POST /ruuat/paaruoka", a.saveMain) mux.HandleFunc("POST /ruuat/lisuke", a.saveSide) mux.HandleFunc("POST /ruuat/poista", a.deleteDish) diff --git a/cmd/foodster/views.templ b/cmd/foodster/views.templ index fa33b29..da2f85d 100644 --- a/cmd/foodster/views.templ +++ b/cmd/foodster/views.templ @@ -43,6 +43,12 @@ func dayURL(base string, d, now time.Time) string { return base + "?pvm=" + isoDate(d) } +// showURL turns a catalog page link into the patch endpoint behind it, so the +// href and the Datastar call never drift apart. +func showURL(pageURL string) string { + return strings.Replace(pageURL, "/ruuat?", "/ruuat/nayta?", 1) +} + // dayAnchor names the element a day expands into. func dayAnchor(d time.Time) string { return "paiva-" + isoDate(d) @@ -689,25 +695,39 @@ templ emptyNote(search string) { // rowActions is a pencil and a bin, until the bin is tapped: then the row // asks. An icon is a smaller target to hit by accident than a word, and the // dish disappears from every picker the moment it goes. +// Every control here is a real link or form, so the page still works without +// JavaScript. Datastar intercepts them and patches the list in place instead, +// which is the whole point: a delete confirmation halfway down a long list +// must not send the browser back to the top. templ rowActions(v catalogView, editURL string, id int64, kind string) { if v.DeleteID == id && v.DeleteKind == kind {
} else {{ f.Err }
} - if f.ID != 0 { - Peruuta + Peruuta } } @@ -803,7 +827,11 @@ templ sideForm_(f sideForm) { if f.Err != "" {{ f.Err }
} - if f.ID != 0 { - Peruuta + Peruuta } } diff --git a/scripts/smoke.sh b/scripts/smoke.sh index 1f1fa3d..2005054 100755 --- a/scripts/smoke.sh +++ b/scripts/smoke.sh @@ -268,9 +268,34 @@ check "the bin asks before deleting" \ check "the dish is still there while it asks" \ "$(curl -s -u ":$pass" "http://$addr/ruuat?poista=$uusi&tyyppi=paa")" "Uunikala" -check "confirming the delete redirects back to the catalog" \ - "$(curl -s -o /dev/null -w '%{redirect_url}' -u ":$pass" \ +# ---- the catalog patches in place instead of navigating ----------------- + +# A delete confirmation halfway down a long list must not send the browser +# back to the top, so these answer with a Datastar patch rather than a page. +patch=$(curl -s -u ":$pass" -H 'Datastar-Request: true' \ + "http://$addr/ruuat/nayta?poista=$uusi&tyyppi=paa") +check "asking to delete patches rather than navigates" "$patch" "event: datastar-patch-elements" +check "the patch carries the list" "$patch" 'id="ruokalista"' +check "and both forms, so an open one closes" "$patch" 'id="paaruoka"' +check "the row it patches in is asking" "$patch" "Poista?" + +check "patches are served as an event stream" \ + "$(curl -s -o /dev/null -w '%{content_type}' -u ":$pass" -H 'Datastar-Request: true' \ + "http://$addr/ruuat/nayta")" "text/event-stream" + +check "deleting from Datastar patches too" \ + "$(curl -s -u ":$pass" -H 'Datastar-Request: true' \ -d "id=$uusi&tyyppi=paa" "http://$addr/ruuat/poista")" \ + "event: datastar-patch-elements" + +refute "and the dish is gone from the patched list" \ + "$(curl -s -u ":$pass" -H 'Datastar-Request: true' "http://$addr/ruuat/nayta")" \ + "Uunikala" + +# Without the header it must still be an ordinary redirect, for no JavaScript. +check "a plain form post still redirects" \ + "$(curl -s -o /dev/null -w '%{redirect_url}' -u ":$pass" \ + -d 'nimi=Testiruoka&kategoria=fish' "http://$addr/ruuat/paaruoka")" \ "/ruuat" refute "the dish is gone once confirmed" \