Files
projax/web/dashboard_pin.go
mAi 2925c43a1e feat(dashboard): pin toggle on tiles + handleDashboardPin handler
Phase 5h slice 4 — adds the star button on each tile that flips
Pinned on the projax item via POST /dashboard/pin.

Backend:
- store.SetPinned(ids, pinned bool) — minimal-write helper that
  mirrors SetPublic, only touching the pinned column.
- web/dashboard_pin.go — handleDashboardPin parses id + pin from
  form, calls SetPinned, invalidates the entire dashboard cache (pin
  affects sort order across every view/scope/filter combo), then
  re-renders by delegating to handleDashboard so HTMX receives the
  updated #dashboard-section HTML.
- Route: POST /dashboard/pin (sibling of /dashboard/task/*).

Frontend:
- Tile template now leads with a <form class="tile-pin-form"> that
  POSTs id + the inverted pin state. Button glyph is ☆ when unpinned,
  ★ when pinned; aria-label flips accordingly.
- HTMX swaps the entire #dashboard-section so the tile moves to the
  pinned-first position (or back to alphabetical) without a full reload.
- CSS: .tile-pin (transparent button, muted color, accent on hover);
  .tile-pin.pinned for the filled-star state.

Test helper: server_test.go gains a post() helper paired with the
existing get() — form-encoded POSTs for writeback tests.

Tests (dashboard_pin_test.go):
- TestDashboardPinTogglesItem — POST pin=true flips the row, and the
  re-render shows the .tile-pinned class on the tile <article>.
- TestDashboardPinUnpinsItem — POST pin=false on a pinned row unpins.
- TestDashboardPinRequiresID — missing id returns 400.
- TestDashboardPinInvalidatesCache — primes with unpinned cache,
  POSTs pin, asserts the next GET reflects the pinned class (proving
  the prior cache entry was busted).
2026-05-26 12:31:24 +02:00

47 lines
1.3 KiB
Go

package web
import (
"net/http"
"strings"
)
// handleDashboardPin flips the Pinned boolean on a single item from the
// Tiles view's star toggle, then re-renders the dashboard section so
// the tile moves to the pinned-first slot (or back to alphabetical when
// unpinned). POST body carries:
//
// id — required uuid of the item
// pin — "true" / "false" / "1" / "0"
//
// The handler invalidates ALL dashboard cache entries on success
// because Pinned affects sort order across every (view, scope, filter)
// combination; a per-key invalidation would race with siblings.
func (s *Server) handleDashboardPin(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
s.fail(w, r, err)
return
}
id := strings.TrimSpace(r.FormValue("id"))
if id == "" {
http.Error(w, "id required", http.StatusBadRequest)
return
}
pinned := parseFormBool(r.FormValue("pin"))
if err := s.Store.SetPinned(r.Context(), []string{id}, pinned); err != nil {
s.fail(w, r, err)
return
}
s.dashboard.InvalidateAll()
s.handleDashboard(w, r)
}
// parseFormBool reads the common shapes ("true"/"1"/"on" → true; "false"/"0"/"" → false).
func parseFormBool(raw string) bool {
switch strings.ToLower(strings.TrimSpace(raw)) {
case "true", "1", "on", "yes":
return true
default:
return false
}
}