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.
|
// 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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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" \
|
||||||
|
|||||||
Reference in New Issue
Block a user