release: repair the catalog 404 and stop the page jumping #3
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
<div class="rowactions confirming">
|
||||
<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="tyyppi" value={ kind }/>
|
||||
<button type="submit" class="del">Kyllä</button>
|
||||
</form>
|
||||
<a href="/ruuat">Peruuta</a>
|
||||
<a href="/ruuat" data-on:click__prevent="@get('/ruuat/nayta')">Peruuta</a>
|
||||
</div>
|
||||
} else {
|
||||
<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()
|
||||
</a>
|
||||
<a
|
||||
class="del"
|
||||
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"
|
||||
title="Poista"
|
||||
>
|
||||
@@ -746,7 +766,11 @@ templ mainForm_(f mainForm) {
|
||||
if f.Err != "" {
|
||||
<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 {
|
||||
<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>
|
||||
</form>
|
||||
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>
|
||||
}
|
||||
@@ -803,7 +827,11 @@ templ sideForm_(f sideForm) {
|
||||
if f.Err != "" {
|
||||
<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 {
|
||||
<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>
|
||||
</form>
|
||||
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>
|
||||
}
|
||||
|
||||
+27
-2
@@ -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" \
|
||||
|
||||
Reference in New Issue
Block a user