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).
This commit is contained in:
mAi
2026-05-26 12:31:24 +02:00
parent 87132ee166
commit 2925c43a1e
7 changed files with 261 additions and 6 deletions

View File

@@ -365,6 +365,19 @@ func (s *Store) SetPublic(ctx context.Context, ids []string, public bool) error
return err
}
// SetPinned flips just the pinned boolean on selected items. Used by the
// dashboard Tiles view's star toggle so a tile flip doesn't have to
// re-send every other item field. Same minimal-write shape as SetPublic.
func (s *Store) SetPinned(ctx context.Context, ids []string, pinned bool) error {
if len(ids) == 0 {
return nil
}
_, err := s.Pool.Exec(ctx,
`update projax.items set pinned = $2 where id = any($1::uuid[]) and deleted_at is null`,
ids, pinned)
return err
}
// Reparent replaces parent_ids entirely with the given set. Used by the
// classify page's inline form and any "move to under X" action.
func (s *Store) Reparent(ctx context.Context, id string, parentIDs []string) (*Item, error) {

46
web/dashboard_pin.go Normal file
View File

@@ -0,0 +1,46 @@
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
}
}

169
web/dashboard_pin_test.go Normal file
View File

@@ -0,0 +1,169 @@
package web_test
import (
"context"
"net/url"
"strings"
"testing"
"time"
)
// TestDashboardPinTogglesItem seeds an item with pinned=false, POSTs to
// /dashboard/pin with pin=true, then asserts the row in projax.items is
// now pinned and the Tiles view renders the tile with the .pinned class.
func TestDashboardPinTogglesItem(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
stamp := strings.ReplaceAll(time.Now().UTC().Format("150405.000000"), ".", "")
slug := "pin-target-" + stamp
var dev, id string
if err := pool.QueryRow(ctx, `select id from projax.items where slug='dev' and cardinality(parent_ids)=0`).Scan(&dev); err != nil {
t.Fatalf("dev: %v", err)
}
if err := pool.QueryRow(ctx,
`insert into projax.items (kind, title, slug, parent_ids)
values (array['project']::text[], 'pin target', $1, ARRAY[$2]::uuid[])
returning id`,
slug, dev,
).Scan(&id); err != nil {
t.Fatalf("seed item: %v", err)
}
defer pool.Exec(context.Background(), `delete from projax.items where id=$1`, id)
// POST pin=true.
form := url.Values{"id": {id}, "pin": {"true"}}
code, _ := post(t, h, "/dashboard/pin", form)
if code != 200 {
t.Fatalf("POST /dashboard/pin → %d", code)
}
var pinned bool
if err := pool.QueryRow(ctx, `select pinned from projax.items where id=$1`, id).Scan(&pinned); err != nil {
t.Fatalf("read pinned: %v", err)
}
if !pinned {
t.Errorf("expected pinned=true after POST")
}
// The re-render should mark the tile as .tile-pinned.
_, body := get(t, h, "/dashboard")
tileIdx := strings.Index(body, `data-item-id="`+id+`"`)
if tileIdx < 0 {
t.Fatalf("pinned tile not found in re-render")
}
openTag := body[strings.LastIndex(body[:tileIdx], "<article"):tileIdx]
if !strings.Contains(openTag, "tile-pinned") {
t.Errorf("tile should carry 'tile-pinned' class — got %q", openTag)
}
}
// TestDashboardPinUnpinsItem seeds a pinned item, POSTs pin=false, and
// asserts the row is no longer pinned.
func TestDashboardPinUnpinsItem(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
stamp := strings.ReplaceAll(time.Now().UTC().Format("150405.000000"), ".", "")
slug := "unpin-target-" + stamp
var dev, id string
if err := pool.QueryRow(ctx, `select id from projax.items where slug='dev' and cardinality(parent_ids)=0`).Scan(&dev); err != nil {
t.Fatalf("dev: %v", err)
}
if err := pool.QueryRow(ctx,
`insert into projax.items (kind, title, slug, parent_ids, pinned)
values (array['project']::text[], 'unpin target', $1, ARRAY[$2]::uuid[], true)
returning id`,
slug, dev,
).Scan(&id); err != nil {
t.Fatalf("seed item: %v", err)
}
defer pool.Exec(context.Background(), `delete from projax.items where id=$1`, id)
form := url.Values{"id": {id}, "pin": {"false"}}
code, _ := post(t, h, "/dashboard/pin", form)
if code != 200 {
t.Fatalf("POST /dashboard/pin → %d", code)
}
var pinned bool
if err := pool.QueryRow(ctx, `select pinned from projax.items where id=$1`, id).Scan(&pinned); err != nil {
t.Fatalf("read pinned: %v", err)
}
if pinned {
t.Errorf("expected pinned=false after POST pin=false")
}
}
// TestDashboardPinRequiresID asserts the handler rejects missing-id
// requests with 400 rather than silently no-op'ing.
func TestDashboardPinRequiresID(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
form := url.Values{"pin": {"true"}}
code, _ := post(t, h, "/dashboard/pin", form)
if code != 400 {
t.Errorf("expected 400 for missing id, got %d", code)
}
}
// TestDashboardPinInvalidatesCache asserts a pin flip busts the
// dashboard cache so subsequent renders reflect the new pinned state.
// The pin handler invalidates then re-renders, which re-populates the
// cache with FRESH data — so the next external GET serves the new
// state (the assertion is on data correctness, not the cached label).
func TestDashboardPinInvalidatesCache(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
stamp := strings.ReplaceAll(time.Now().UTC().Format("150405.000000"), ".", "")
slug := "cache-pin-" + stamp
var dev, id string
if err := pool.QueryRow(ctx, `select id from projax.items where slug='dev' and cardinality(parent_ids)=0`).Scan(&dev); err != nil {
t.Fatalf("dev: %v", err)
}
if err := pool.QueryRow(ctx,
`insert into projax.items (kind, title, slug, parent_ids)
values (array['project']::text[], 'cache pin', $1, ARRAY[$2]::uuid[])
returning id`,
slug, dev,
).Scan(&id); err != nil {
t.Fatalf("seed item: %v", err)
}
defer pool.Exec(context.Background(), `delete from projax.items where id=$1`, id)
// Prime the cache — first GET caches an unpinned tile state.
_, primed := get(t, h, "/dashboard")
tileIdx := strings.Index(primed, `data-item-id="`+id+`"`)
if tileIdx < 0 {
t.Fatalf("seeded tile missing from primed dashboard")
}
openTag := primed[strings.LastIndex(primed[:tileIdx], "<article"):tileIdx]
if strings.Contains(openTag, "tile-pinned") {
t.Fatalf("setup: fresh tile should not be pinned yet — got %q", openTag)
}
// Flip pin.
form := url.Values{"id": {id}, "pin": {"true"}}
_, _ = post(t, h, "/dashboard/pin", form)
// Next GET must reflect the new pinned state — proves the cache
// entry for the previous (unpinned) state was invalidated.
_, after := get(t, h, "/dashboard")
tileIdx2 := strings.Index(after, `data-item-id="`+id+`"`)
if tileIdx2 < 0 {
t.Fatalf("tile missing from post-pin dashboard")
}
openTag2 := after[strings.LastIndex(after[:tileIdx2], "<article"):tileIdx2]
if !strings.Contains(openTag2, "tile-pinned") {
t.Errorf("pin flip should invalidate cache so next GET shows pinned tile — got %q", openTag2)
}
}

View File

@@ -354,6 +354,7 @@ func (s *Server) Routes() http.Handler {
mux.HandleFunc("POST /dashboard/task/done", s.handleDashboardTaskDone)
mux.HandleFunc("POST /dashboard/task/edit", s.handleDashboardTaskEdit)
mux.HandleFunc("POST /dashboard/task/delete", s.handleDashboardTaskDelete)
mux.HandleFunc("POST /dashboard/pin", s.handleDashboardPin)
mux.HandleFunc("GET /admin/bulk", s.handleBulk)
mux.HandleFunc("POST /admin/bulk/apply", s.handleBulkApply)
mux.HandleFunc("POST /admin/bulk/chip", s.handleBulkChip)

View File

@@ -65,6 +65,18 @@ func get(t *testing.T, h http.Handler, url string) (int, string) {
return w.Result().StatusCode, string(body)
}
// post submits an application/x-www-form-urlencoded POST to the given
// path. Mirrors the get helper for form-flavored writeback tests.
func post(t *testing.T, h http.Handler, path string, form url.Values) (int, string) {
t.Helper()
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
body, _ := io.ReadAll(w.Result().Body)
return w.Result().StatusCode, string(body)
}
func TestTreeRenders(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()

View File

@@ -355,7 +355,13 @@ table.bulk .chip-add-btn:hover { background: var(--accent); color: var(--accent-
.dashboard .tile .tile-head { display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap; }
.dashboard .tile .tile-title { font-weight: 600; color: var(--fg); text-decoration: none; }
.dashboard .tile .tile-title:hover { text-decoration: underline; }
.dashboard .tile .tile-star { color: var(--accent); margin-right: 2px; }
.dashboard .tile .tile-pin-form { display: inline-flex; margin: 0 4px 0 0; }
.dashboard .tile .tile-pin {
background: transparent; border: none; cursor: pointer;
color: var(--muted); padding: 0; font-size: 1.1em; line-height: 1;
}
.dashboard .tile .tile-pin.pinned { color: var(--accent); }
.dashboard .tile .tile-pin:hover { color: var(--accent); }
.dashboard .tile .tile-path {
font-family: ui-monospace, SFMono-Regular, monospace;
font-size: 0.78em;

View File

@@ -36,12 +36,20 @@
{{define "tile"}}
{{$path := .Item.PrimaryPath}}
<article class="tile{{if .Item.Pinned}} tile-pinned{{end}}{{if .Stale}} tile-stale{{end}}" data-item-path="{{$path}}">
{{$id := .Item.ID}}
<article class="tile{{if .Item.Pinned}} tile-pinned{{end}}{{if .Stale}} tile-stale{{end}}" data-item-path="{{$path}}" data-item-id="{{$id}}">
<header class="tile-head">
<a class="tile-title" href="/i/{{$path}}">
{{if .Item.Pinned}}<span class="tile-star" title="pinned">★</span>{{end}}
{{.Item.Title}}
</a>
<form class="tile-pin-form"
hx-post="/dashboard/pin"
hx-target="#dashboard-section"
hx-swap="outerHTML">
<input type="hidden" name="id" value="{{$id}}">
<input type="hidden" name="pin" value="{{if .Item.Pinned}}false{{else}}true{{end}}">
<button type="submit" class="tile-pin{{if .Item.Pinned}} pinned{{end}}"
title="{{if .Item.Pinned}}Unpin{{else}}Pin{{end}}"
aria-label="{{if .Item.Pinned}}Unpin{{else}}Pin{{end}}">{{if .Item.Pinned}}★{{else}}☆{{end}}</button>
</form>
<a class="tile-title" href="/i/{{$path}}">{{.Item.Title}}</a>
<span class="tile-path muted">{{$path}}</span>
{{if .IsLive}}<a class="tile-live" href="{{.Item.PublicLiveURL}}" target="_blank" rel="noopener" title="live">live</a>{{end}}
{{if .Stale}}<span class="tile-stale-flag muted" title="mai-managed · quiet repo · no open work">stale</span>{{end}}