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
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:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user