release: repair the catalog 404 and stop the page jumping #3

Merged
Kessinen merged 6 commits from dev into main 2026-09-05 21:03:04 +00:00
4 changed files with 156 additions and 16 deletions
Showing only changes of commit e5e020457a - Show all commits
+93 -7
View File
@@ -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. // searchBoard re-renders the dish board as the search box is typed into.
func (a *app) searchBoard(w http.ResponseWriter, r *http.Request) { func (a *app) searchBoard(w http.ResponseWriter, r *http.Request) {
var signals searchSignals var signals searchSignals
@@ -410,7 +454,20 @@ type catalogView struct {
DeleteKind string 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) { 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{ v := catalogView{
Main: mainForm{Categories: map[string]bool{}, HasSides: true}, Main: mainForm{Categories: map[string]bool{}, HasSides: true},
Search: strings.TrimSpace(r.URL.Query().Get("haku")), 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 { if v.Main.Categories == nil {
v.Main.Categories = map[string]bool{} 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 { if v.Sides, err = listSides(a.db, v.Search); err != nil {
log.Printf("list sides: %v", err) 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)) 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 // 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. // the values still in it; a good one redirects, so refresh cannot re-submit.
func (a *app) saveMain(w http.ResponseWriter, r *http.Request) { 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) log.Printf("save main: %v", err)
form.Err = "Tallennus epäonnistui." form.Err = "Tallennus epäonnistui."
default: 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 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) { 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) log.Printf("save side: %v", err)
form.Err = "Tallennus epäonnistui." form.Err = "Tallennus epäonnistui."
default: default:
http.Redirect(w, r, "/ruuat", http.StatusSeeOther) a.finishCatalog(w, r, catalogView{})
return 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). // 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) http.Error(w, "poisto epäonnistui", http.StatusInternalServerError)
return 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 // importDishes takes a bundle either pasted into the textarea or uploaded as a
+1
View File
@@ -162,6 +162,7 @@ func routes(db *sql.DB, loc *time.Location, password string) http.Handler {
mux.HandleFunc("POST /poista", a.delete) mux.HandleFunc("POST /poista", a.delete)
mux.HandleFunc("GET /ruuat", a.catalog) mux.HandleFunc("GET /ruuat", a.catalog)
mux.HandleFunc("GET /ruuat/etsi", a.searchCatalog) 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/paaruoka", a.saveMain)
mux.HandleFunc("POST /ruuat/lisuke", a.saveSide) mux.HandleFunc("POST /ruuat/lisuke", a.saveSide)
mux.HandleFunc("POST /ruuat/poista", a.deleteDish) mux.HandleFunc("POST /ruuat/poista", a.deleteDish)
+35 -7
View File
@@ -43,6 +43,12 @@ func dayURL(base string, d, now time.Time) string {
return base + "?pvm=" + isoDate(d) 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. // dayAnchor names the element a day expands into.
func dayAnchor(d time.Time) string { func dayAnchor(d time.Time) string {
return "paiva-" + isoDate(d) 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 // 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 // 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. // 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) { templ rowActions(v catalogView, editURL string, id int64, kind string) {
if v.DeleteID == id && v.DeleteKind == kind { if v.DeleteID == id && v.DeleteKind == kind {
<div class="rowactions confirming"> <div class="rowactions confirming">
<span>Poista?</span> <span>Poista?</span>
<form method="post" action="/ruuat/poista"> <form
method="post"
action="/ruuat/poista"
data-on:submit__prevent="@post('/ruuat/poista', {contentType: 'form'})"
>
<input type="hidden" name="id" value={ strconv.FormatInt(id, 10) }/> <input type="hidden" name="id" value={ strconv.FormatInt(id, 10) }/>
<input type="hidden" name="tyyppi" value={ kind }/> <input type="hidden" name="tyyppi" value={ kind }/>
<button type="submit" class="del">Kyllä</button> <button type="submit" class="del">Kyllä</button>
</form> </form>
<a href="/ruuat">Peruuta</a> <a href="/ruuat" data-on:click__prevent="@get('/ruuat/nayta')">Peruuta</a>
</div> </div>
} else { } else {
<div class="rowactions"> <div class="rowactions">
<a href={ templ.SafeURL(editURL) } aria-label="Muokkaa" title="Muokkaa"> <a
href={ templ.SafeURL(editURL) }
data-on:click__prevent={ "@get('" + showURL(editURL) + "')" }
aria-label="Muokkaa"
title="Muokkaa"
>
@iconPencil() @iconPencil()
</a> </a>
<a <a
class="del" class="del"
href={ templ.SafeURL("/ruuat?poista=" + strconv.FormatInt(id, 10) + "&tyyppi=" + kind) } href={ templ.SafeURL("/ruuat?poista=" + strconv.FormatInt(id, 10) + "&tyyppi=" + kind) }
data-on:click__prevent={ "@get('/ruuat/nayta?poista=" + strconv.FormatInt(id, 10) + "&tyyppi=" + kind + "')" }
aria-label="Poista" aria-label="Poista"
title="Poista" title="Poista"
> >
@@ -746,7 +766,11 @@ templ mainForm_(f mainForm) {
if f.Err != "" { if f.Err != "" {
<p class="formerr">{ f.Err }</p> <p class="formerr">{ f.Err }</p>
} }
<form method="post" action="/ruuat/paaruoka"> <form
method="post"
action="/ruuat/paaruoka"
data-on:submit__prevent="@post('/ruuat/paaruoka', {contentType: 'form'})"
>
if f.ID != 0 { if f.ID != 0 {
<input type="hidden" name="id" value={ strconv.FormatInt(f.ID, 10) }/> <input type="hidden" name="id" value={ strconv.FormatInt(f.ID, 10) }/>
} }
@@ -774,7 +798,7 @@ templ mainForm_(f mainForm) {
<button class="primary" type="submit">Tallenna</button> <button class="primary" type="submit">Tallenna</button>
</form> </form>
if f.ID != 0 { if f.ID != 0 {
<a class="ghost" href="/ruuat">Peruuta</a> <a class="ghost" href="/ruuat" data-on:click__prevent="@get('/ruuat/nayta')">Peruuta</a>
} }
</details> </details>
} }
@@ -803,7 +827,11 @@ templ sideForm_(f sideForm) {
if f.Err != "" { if f.Err != "" {
<p class="formerr">{ f.Err }</p> <p class="formerr">{ f.Err }</p>
} }
<form method="post" action="/ruuat/lisuke"> <form
method="post"
action="/ruuat/lisuke"
data-on:submit__prevent="@post('/ruuat/lisuke', {contentType: 'form'})"
>
if f.ID != 0 { if f.ID != 0 {
<input type="hidden" name="id" value={ strconv.FormatInt(f.ID, 10) }/> <input type="hidden" name="id" value={ strconv.FormatInt(f.ID, 10) }/>
} }
@@ -814,7 +842,7 @@ templ sideForm_(f sideForm) {
<button class="primary" type="submit">Tallenna</button> <button class="primary" type="submit">Tallenna</button>
</form> </form>
if f.ID != 0 { if f.ID != 0 {
<a class="ghost" href="/ruuat">Peruuta</a> <a class="ghost" href="/ruuat" data-on:click__prevent="@get('/ruuat/nayta')">Peruuta</a>
} }
</details> </details>
} }
+27 -2
View File
@@ -268,9 +268,34 @@ check "the bin asks before deleting" \
check "the dish is still there while it asks" \ check "the dish is still there while it asks" \
"$(curl -s -u ":$pass" "http://$addr/ruuat?poista=$uusi&tyyppi=paa")" "Uunikala" "$(curl -s -u ":$pass" "http://$addr/ruuat?poista=$uusi&tyyppi=paa")" "Uunikala"
check "confirming the delete redirects back to the catalog" \ # ---- the catalog patches in place instead of navigating -----------------
"$(curl -s -o /dev/null -w '%{redirect_url}' -u ":$pass" \
# 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")" \ -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" "/ruuat"
refute "the dish is gone once confirmed" \ refute "the dish is gone once confirmed" \