fix: enabled toggle — list-row click + edit-form save
CI / Build (windows/amd64) (pull_request) Successful in 22s
CI / Build (linux/amd64) (pull_request) Successful in 24s
CI / Build (linux/arm64) (pull_request) Successful in 24s
CI / Lint (pull_request) Successful in 1m15s
CI / Test (linux/amd64) (pull_request) Successful in 1m36s

Two bugs in the channel-enabled affordance:

1. List-row toggle was a static span with no handler; the row's
   row-link overlay swallowed every click and routed to /edit. Add
   POST /settings/notifications/{id}/toggle backed by a new store
   method SetNotificationChannelEnabled, and turn the row toggle
   into an htmx-driven button that swaps in the new state. Use
   event.stopPropagation() on the toggle so it beats the row link.

2. Edit-form toggle visually flipped but the underlying checkbox
   reverted: the visual span lives inside the <label>, so clicking
   it fired the inline JS handler AND the label's native
   checkbox-toggle, cancelling out. Bind to the checkbox 'change'
   event instead and let the label do the toggling — the JS just
   mirrors check.checked into the .on class.
This commit is contained in:
2026-05-04 22:21:45 +01:00
parent 84e121bb9c
commit cffad4b4f3
4 changed files with 85 additions and 5 deletions
+1
View File
@@ -325,6 +325,7 @@ func (s *Server) routes(r chi.Router) {
r.Get("/settings/notifications/{id}/edit", s.handleUINotificationEditGet)
r.Post("/settings/notifications/{id}/edit", s.handleUINotificationEditPost)
r.Post("/settings/notifications/{id}/delete", s.handleUINotificationDelete)
r.Post("/settings/notifications/{id}/toggle", s.handleUINotificationToggle)
}
// Browser job-log stream (separate from /ws/agent so the auth
+49
View File
@@ -661,6 +661,55 @@ func (s *Server) handleUINotificationDelete(w stdhttp.ResponseWriter, r *stdhttp
stdhttp.Redirect(w, r, "/settings/notifications", stdhttp.StatusSeeOther)
}
// handleUINotificationToggle flips the enabled flag for one channel
// and re-renders the row. Wired to the inline toggle in the channel
// list so operators don't need to enter the edit form just to flip a
// channel on or off. HTMX-aware: returns just the toggle fragment when
// the request carries HX-Request, otherwise redirects back to the list.
func (s *Server) handleUINotificationToggle(w stdhttp.ResponseWriter, r *stdhttp.Request) {
u := s.requireUIUser(w, r)
if u == nil {
return
}
channelID := chi.URLParam(r, "id")
ch, err := s.deps.Store.GetNotificationChannel(r.Context(), channelID)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
stdhttp.NotFound(w, r)
return
}
slog.Error("ui notifications: get for toggle", "id", channelID, "err", err)
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
return
}
now := time.Now().UTC()
want := !ch.Enabled
if err := s.deps.Store.SetNotificationChannelEnabled(r.Context(), ch.ID, want, now); err != nil {
slog.Error("ui notifications: set enabled", "id", ch.ID, "err", err)
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
return
}
_ = s.deps.Store.AppendAudit(r.Context(), store.AuditEntry{
ID: ulid.Make().String(),
UserID: &u.ID,
Actor: "user",
Action: "notification_channel.toggled",
TargetKind: ptr("notification_channel"),
TargetID: &ch.ID,
TS: now,
})
if r.Header.Get("HX-Request") == "true" {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if want {
_, _ = w.Write([]byte(`<span class="toggle on" hx-post="/settings/notifications/` + ch.ID + `/toggle" hx-target="this" hx-swap="outerHTML" onclick="event.stopPropagation()" style="cursor:pointer"></span>`))
} else {
_, _ = w.Write([]byte(`<span class="toggle" hx-post="/settings/notifications/` + ch.ID + `/toggle" hx-target="this" hx-swap="outerHTML" onclick="event.stopPropagation()" style="cursor:pointer"></span>`))
}
return
}
stdhttp.Redirect(w, r, "/settings/notifications", stdhttp.StatusSeeOther)
}
// ── API handler ───────────────────────────────────────────────────────────────
// testResultFragment is the JSON body returned by handleAPINotificationTest.