From e5e020457af2cce923bea77b0eac55a87e737279 Mon Sep 17 00:00:00 2001 From: Esa Kataja Date: Sat, 5 Sep 2026 23:40:22 +0300 Subject: [PATCH] Patch the catalog in place instead of navigating MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deleting a dish partway down the list sent the browser back to the top. The first attempt at fixing it used anchors, which cannot work: the browser positions the element with no knowledge of where the page was scrolled, so it still jumps. Datastar was already loaded and doing nothing but search. Every catalog action now patches. The bin, the pencil and Peruuta stay real links; the forms stay real forms. Datastar intercepts them with data-on:click__prevent and data-on:submit__prevent, and the same handlers redirect when the Datastar-Request header is absent, so none of it requires JavaScript. Posting with {contentType: 'form'} sends the enclosing form as FormData, which means the handlers keep reading r.FormValue and no input had to be rewritten as a signal. The response is one SSE event carrying three elements: the list and both forms. They have to move together — opening an edit form also has to clear a delete that was mid-confirmation — and a text/html response can only replace one element. The writer is twenty lines rather than re-adding the SDK and the four modules it brings for a generator we would otherwise never call. Smoke checks assert the wire format: that these answer with an event stream carrying all three elements, that the deleted dish is absent from the patched list, and that a header-less post still redirects. --- cmd/foodster/handlers.go | 100 ++++++++++++++++++++++++++++++++++++--- cmd/foodster/main.go | 1 + cmd/foodster/views.templ | 42 +++++++++++++--- scripts/smoke.sh | 29 +++++++++++- 4 files changed, 156 insertions(+), 16 deletions(-) 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 {
Poista? -
+
- Peruuta + Peruuta
} else {
- + @iconPencil() @@ -746,7 +766,11 @@ templ mainForm_(f mainForm) { if f.Err != "" {

{ f.Err }

} -
+ if f.ID != 0 { } @@ -774,7 +798,7 @@ templ mainForm_(f mainForm) {
if f.ID != 0 { -
Peruuta + Peruuta } } @@ -803,7 +827,11 @@ templ sideForm_(f sideForm) { if f.Err != "" {

{ f.Err }

} -
+ if f.ID != 0 { } @@ -814,7 +842,7 @@ templ sideForm_(f sideForm) {
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" \