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).
170 lines
5.9 KiB
Go
170 lines
5.9 KiB
Go
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)
|
|
}
|
|
}
|