fix: enabled toggle — list-row click + edit-form save

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 373d74cdaf
commit d830635a2e
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.
+16
View File
@@ -77,6 +77,22 @@ func (s *Store) UpdateNotificationChannel(ctx context.Context, ch NotificationCh
return nil
}
// SetNotificationChannelEnabled flips the enabled flag without
// touching kind/name/config — used by the inline list-row toggle.
func (s *Store) SetNotificationChannelEnabled(ctx context.Context, id string, enabled bool, when time.Time) error {
v := 0
if enabled {
v = 1
}
_, err := s.db.ExecContext(ctx,
`UPDATE notification_channels SET enabled = ?, updated_at = ? WHERE id = ?`,
v, when.UTC().Format(time.RFC3339Nano), id)
if err != nil {
return fmt.Errorf("store: set channel enabled: %w", err)
}
return nil
}
// DeleteNotificationChannel removes a channel row; cascades to notification_log.
func (s *Store) DeleteNotificationChannel(ctx context.Context, id string) error {
_, err := s.db.ExecContext(ctx,
+19 -5
View File
@@ -99,8 +99,18 @@
<div class="mono text-ink-mute" style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">
{{if eq $ch.Kind "webhook"}}webhook · click to edit{{else if eq $ch.Kind "ntfy"}}ntfy · click to edit{{else}}smtp · click to edit{{end}}
</div>
<div>
{{if $ch.Enabled}}<span class="toggle on"></span>{{else}}<span class="toggle"></span>{{end}}
<div class="row-action">
{{if $ch.Enabled}}
<span class="toggle on" hx-post="/settings/notifications/{{$ch.ID}}/toggle"
hx-target="this" hx-swap="outerHTML"
onclick="event.stopPropagation()" style="cursor:pointer"
title="click to disable"></span>
{{else}}
<span class="toggle" hx-post="/settings/notifications/{{$ch.ID}}/toggle"
hx-target="this" hx-swap="outerHTML"
onclick="event.stopPropagation()" style="cursor:pointer"
title="click to enable"></span>
{{end}}
</div>
<div class="mono text-ink-mid">
{{if $ch.LastFiredAt}}{{relTime $ch.LastFiredAt}}{{else}}<span class="text-ink-fade">never</span>{{end}}
@@ -521,12 +531,16 @@ https://restic-manager.example/alerts/01KQTABCDEFGHJ</pre>
});
});
// Enabled toggle
// Enabled toggle: the visual <span class="toggle"> is inside a <label>
// wrapping a hidden checkbox, so clicking the span already flips the
// checkbox via the label's native behaviour. We only need to mirror
// that into the .on class — listening on the toggle's own click would
// race the label and cancel out, leaving check.checked at its original
// value (so Save would persist the unchanged setting).
var check = document.getElementById('enabled-check');
var tog = document.getElementById('enabled-toggle');
if (check && tog) {
tog.addEventListener('click', function() {
check.checked = !check.checked;
check.addEventListener('change', function() {
tog.classList.toggle('on', check.checked);
});
}