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:
@@ -325,6 +325,7 @@ func (s *Server) routes(r chi.Router) {
|
|||||||
r.Get("/settings/notifications/{id}/edit", s.handleUINotificationEditGet)
|
r.Get("/settings/notifications/{id}/edit", s.handleUINotificationEditGet)
|
||||||
r.Post("/settings/notifications/{id}/edit", s.handleUINotificationEditPost)
|
r.Post("/settings/notifications/{id}/edit", s.handleUINotificationEditPost)
|
||||||
r.Post("/settings/notifications/{id}/delete", s.handleUINotificationDelete)
|
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
|
// Browser job-log stream (separate from /ws/agent so the auth
|
||||||
|
|||||||
@@ -661,6 +661,55 @@ func (s *Server) handleUINotificationDelete(w stdhttp.ResponseWriter, r *stdhttp
|
|||||||
stdhttp.Redirect(w, r, "/settings/notifications", stdhttp.StatusSeeOther)
|
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 ───────────────────────────────────────────────────────────────
|
// ── API handler ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
// testResultFragment is the JSON body returned by handleAPINotificationTest.
|
// testResultFragment is the JSON body returned by handleAPINotificationTest.
|
||||||
|
|||||||
@@ -77,6 +77,22 @@ func (s *Store) UpdateNotificationChannel(ctx context.Context, ch NotificationCh
|
|||||||
return nil
|
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.
|
// DeleteNotificationChannel removes a channel row; cascades to notification_log.
|
||||||
func (s *Store) DeleteNotificationChannel(ctx context.Context, id string) error {
|
func (s *Store) DeleteNotificationChannel(ctx context.Context, id string) error {
|
||||||
_, err := s.db.ExecContext(ctx,
|
_, err := s.db.ExecContext(ctx,
|
||||||
|
|||||||
@@ -99,8 +99,18 @@
|
|||||||
<div class="mono text-ink-mute" style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">
|
<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}}
|
{{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>
|
||||||
<div>
|
<div class="row-action">
|
||||||
{{if $ch.Enabled}}<span class="toggle on"></span>{{else}}<span class="toggle"></span>{{end}}
|
{{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>
|
||||||
<div class="mono text-ink-mid">
|
<div class="mono text-ink-mid">
|
||||||
{{if $ch.LastFiredAt}}{{relTime $ch.LastFiredAt}}{{else}}<span class="text-ink-fade">never</span>{{end}}
|
{{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 check = document.getElementById('enabled-check');
|
||||||
var tog = document.getElementById('enabled-toggle');
|
var tog = document.getElementById('enabled-toggle');
|
||||||
if (check && tog) {
|
if (check && tog) {
|
||||||
tog.addEventListener('click', function() {
|
check.addEventListener('change', function() {
|
||||||
check.checked = !check.checked;
|
|
||||||
tog.classList.toggle('on', check.checked);
|
tog.classList.toggle('on', check.checked);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user