Files
projax/web/system_views_test.go
mAi 9a8ea8f31e feat(views): Phase 5j slice G — show_count badges + icon registry
Per m's v1 picks (2026-05-29):
- Q6 (icon picker): yes, with curated keys + SVG registry.
- Q8 (show_count badge): yes, opt-in checkbox + sidebar badge.

Icon registry (web/icons.go):
- 7 curated keys: folder (default), clock, star, tag, inbox, box,
  file-text. Each maps to a Feather-style 24x24 SVG matching the rest
  of the projax sidebar aesthetic. Returns template.HTML so layout.tmpl
  emits markup verbatim. Unknown / nil keys fall back to folder.
- RenderViewIcon(*string) is template-callable; IconRegistryKeys()
  feeds the editor's <select>.
- Funcs map in web/server.go gains a "renderIcon" entry.

show_count badge (web/server.go + web/templates/layout.tmpl):
- render() now computes per-saved-view counts when ANY view in the
  list has ShowCount=true. One ListAll per render, shared across all
  show-count views; for each opted-in view the persisted filter_json
  is decoded into a TreeFilter and matched against every item.
- Counts pass to the template as UserViewCounts (slug → count). The
  template renders {{index $counts $slug}} inside a nav-badge span
  next to the view's name.

Template updates:
- layout.tmpl: replaces the diamond-glyph placeholder with
  {{renderIcon .Icon}}; show_count views emit a .nav-badge next to
  their name.
- view_editor.tmpl: icon <select> now sourced from IconKeys data
  (the editor handler passes IconRegistryKeys()).

CSS additions:
- nav-badge: muted-color, surface-background, pill-shaped, pushed to
  the right via margin-left:auto so the badge aligns with the row's
  end regardless of name length.
- nav-item-user-view.active .nav-badge: switches to accent border +
  color so the active row's badge stays legible.

Tests:
- TestSidebarShowCountBadge — seeds show_count=true view, asserts
  .nav-badge markup in the sidebar.
- TestSidebarIconRenders — seeds icon=star view, asserts the
  distinctive star polygon path lands in the sidebar SVG.

Drag-reorder UI stays parked (m's Q7=(b) v2). sort_order column is
server-assigned MAX+1 on create; the column was wired in slice A and
ReorderViews is ready for slice G's followup.
2026-05-29 12:07:54 +02:00

176 lines
6.3 KiB
Go

package web_test
import (
"context"
"strings"
"testing"
"time"
"github.com/m/projax/web"
)
// TestSystemViewLookup verifies the code-resident lookup returns the
// expected slugs in display order, and that LookupSystemView round-trips
// each entry.
func TestSystemViewLookup(t *testing.T) {
all := web.AllSystemViews()
wantSlugs := []string{"tree", "dashboard", "calendar", "timeline", "graph"}
if len(all) != len(wantSlugs) {
t.Fatalf("AllSystemViews len = %d, want %d", len(all), len(wantSlugs))
}
for i, sv := range all {
if sv.Slug != wantSlugs[i] {
t.Errorf("position %d: slug = %q, want %q", i, sv.Slug, wantSlugs[i])
}
if sv.URL != "/views/"+sv.Slug {
t.Errorf("position %d: URL = %q, want /views/%s", i, sv.URL, sv.Slug)
}
round := web.LookupSystemView(sv.Slug)
if round == nil || round.Slug != sv.Slug {
t.Errorf("LookupSystemView(%q) round-trip failed", sv.Slug)
}
}
if web.LookupSystemView("not-a-system-slug") != nil {
t.Error("LookupSystemView should return nil for unknown slugs")
}
}
// TestLegacyRedirects verifies the slice C URL migration: each legacy
// route 301-redirects to its /views/{slug} counterpart with chip params
// preserved.
func TestLegacyRedirects(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
cases := []struct {
path, want string
}{
{"/", "/views/tree"},
{"/dashboard", "/views/dashboard"},
{"/calendar", "/views/calendar"},
{"/timeline", "/views/timeline"},
{"/graph", "/views/graph"},
// chip params survive the redirect:
{"/dashboard?tag=work", "/views/dashboard?tag=work"},
{"/timeline?from=2026-05-01", "/views/timeline?from=2026-05-01"},
}
for _, tc := range cases {
code, body := get(t, h, tc.path)
if code != 301 {
t.Errorf("GET %s status=%d body=%q, want 301", tc.path, code, body)
}
if !strings.Contains(body, `href="`+tc.want+`"`) {
t.Errorf("GET %s body=%q, want redirect to %q", tc.path, body, tc.want)
}
}
}
// TestSidebarListsUserViews — slice E: every chrome-bearing page renders
// the saved-view list under the main nav. Each entry links to
// /views/{slug} with the name as the label. Active state fires when the
// current URL matches.
func TestSidebarListsUserViews(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
ctx := context.Background()
stamp := strings.ReplaceAll(time.Now().UTC().Format("150405.000"), ".", "")
slug := "p5j-e-sidebar-" + stamp
defer pool.Exec(context.Background(), `DELETE FROM projax.views WHERE slug = $1`, slug)
if _, err := pool.Exec(ctx, `
INSERT INTO projax.views (slug, name, filter_json)
VALUES ($1, 'P5jE Sidebar', '{"view_type":"list"}'::jsonb)`, slug); err != nil {
t.Fatalf("seed: %v", err)
}
_, body := get(t, h, "/views/tree")
if !strings.Contains(body, `href="/views/`+slug+`"`) {
t.Error("sidebar should list saved view as /views/<slug>")
}
if !strings.Contains(body, "P5jE Sidebar") {
t.Error("sidebar should show saved view's display name")
}
if !strings.Contains(body, `href="/views/new"`) {
t.Error("sidebar Views section should include a + New view link")
}
// Active state when the URL matches.
_, onView := get(t, h, "/views/"+slug)
if !strings.Contains(onView, `class="nav-item nav-item-user-view active"`) {
t.Error("user-view nav-item should carry .active when its URL is current")
}
}
// TestSidebarShowCountBadge — slice G: a saved view with show_count=true
// renders a row-count badge in the sidebar reflecting the filter's match
// count against ListAll().
func TestSidebarShowCountBadge(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
ctx := context.Background()
stamp := strings.ReplaceAll(time.Now().UTC().Format("150405.000"), ".", "")
slug := "p5j-g-badge-" + stamp
defer pool.Exec(context.Background(), `DELETE FROM projax.views WHERE slug = $1`, slug)
// Seed a view scoped to dev → its count = count of items under dev that
// match status=active (default).
if _, err := pool.Exec(ctx, `
INSERT INTO projax.views (slug, name, filter_json, show_count)
VALUES ($1, 'P5jG Badge', '{"view_type":"list","project_path":"dev"}'::jsonb, true)`,
slug); err != nil {
t.Fatalf("seed view: %v", err)
}
_, body := get(t, h, "/views/tree")
if !strings.Contains(body, `class="nav-badge"`) {
t.Error("show_count view should render a nav-badge in the sidebar")
}
}
// TestSidebarIconRenders — slice G: a view with an icon key emits the
// SVG from the registry; missing key falls back to folder default.
func TestSidebarIconRenders(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
ctx := context.Background()
stamp := strings.ReplaceAll(time.Now().UTC().Format("150405.000"), ".", "")
slug := "p5j-g-icon-" + stamp
defer pool.Exec(context.Background(), `DELETE FROM projax.views WHERE slug = $1`, slug)
if _, err := pool.Exec(ctx, `
INSERT INTO projax.views (slug, name, filter_json, icon)
VALUES ($1, 'P5jG Icon', '{"view_type":"list"}'::jsonb, 'star')`, slug); err != nil {
t.Fatalf("seed: %v", err)
}
_, body := get(t, h, "/views/tree")
// The star icon's SVG path includes its distinctive 5-point polygon.
if !strings.Contains(body, `polygon points="12 2 15.09 8.26`) {
t.Error("sidebar should render the star icon SVG for icon=star")
}
}
// TestLegacyViewUUIDRedirect — when a legacy URL carries the 5i overlay
// `?view=<uuid>` param, the redirect resolves the uuid to the current
// slug (per m's Q3 pick), so old bookmarks land on the right user view.
func TestLegacyViewUUIDRedirect(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
ctx := context.Background()
stamp := strings.ReplaceAll(time.Now().UTC().Format("150405.000"), ".", "")
slug := "p5j-c-legacy-" + stamp
defer pool.Exec(context.Background(), `DELETE FROM projax.views WHERE slug = $1`, slug)
var id string
if err := pool.QueryRow(ctx, `
INSERT INTO projax.views (slug, name, filter_json)
VALUES ($1, 'Legacy', '{"view_type":"list"}'::jsonb)
RETURNING id`, slug).Scan(&id); err != nil {
t.Fatalf("seed view: %v", err)
}
// Old-style URL: /?view=<uuid>
code, body := get(t, h, "/?view="+id)
if code != 301 {
t.Fatalf("GET /?view=<uuid> status=%d body=%q want 301", code, body)
}
if !strings.Contains(body, "/views/"+slug) {
t.Errorf("redirect should resolve uuid → slug; got body=%q", body)
}
}