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:
@@ -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
46
web/dashboard_pin.go
Normal 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
169
web/dashboard_pin_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}}
|
||||
|
||||
Reference in New Issue
Block a user