Restores the /views URL family in the paliad shape m asked for:
GET /views → MRU 302 or onboarding shell
GET /views/{slug} → render saved view as its own page
GET /views/new → editor blank
GET /views/{slug}/edit → editor existing
POST /views → create
POST /views/{slug} → update
POST /views/{slug}/delete → delete
POST /views/reorder → drag-reorder hook (used in slice G)
Render path:
- handleViewRender resolves the slug against user views (slice C adds
system views), touches last_used_at fire-and-forget so the next /views
landing 302s here, then dispatches the same view_type renderers the
tree page uses (list / card / kanban). filter_json is decoded into a
TreeFilter + view_type + group_by; URL chip params overlay the saved
filter so chips narrow the view further without losing the saved
baseline. calendar / timeline view_types fall back to list in slice B;
slice D wires their dedicated templates.
Editor path:
- handleViewEditor renders templates/view_editor.tmpl, a minimal form
for slice B (slice D adds the live chip strip, slug auto-derivation,
and the icon registry). Pre-fills every persisted field on edit.
Templates:
- views_landing.tmpl — index card list + "+ new view" link.
- view_render.tmpl — header (name + slug + edit/delete) + tree-section
partial. Bundled with tree_section / tree_card / tree_kanban /
project_chip so the rendered view shares the dispatch chain.
- view_editor.tmpl — form for create + edit.
Encoding:
- encodeFilterToJSON canonicalises (filter_query, view_type) into the
filter_json shape. view_type lives INSIDE the JSON per m's Q2 pick.
- decodeViewSpec is the inverse — slice C's system-view code reuses it
to convert SystemView definitions into the same shape.
- overlayURLOntoSavedFilter mirrors the 5i fix-shift pattern: URL chip
values selectively override the saved baseline (q / tag / mgmt /
status / has / show-archived / public / project / project_descendants).
Error mapping:
- writeViewError translates the typed store errors (ErrViewSlugFormat /
Reserved / Taken / NotFound) into 400 / 409 with human-readable
banners. handlers map ErrViewNotFound to 404 directly.
Tests (HTTP integration):
- TestViewsLandingOnboarding — empty store → shell with "+ New view".
- TestViewsLandingMRURedirects — touched view triggers 302 to it.
- TestViewRenderShowsSavedView — name + slug + view_type=card grid.
- TestViewRender404OnUnknownSlug — unknown slug 404s, no silent
fall-back to tree.
- TestViewCreateAndDelete — POST /views creates; reserved slug 400s;
POST /views/<slug>/delete removes the row.
- TestSavedViewFilterOverlay — ?tag=work narrows the saved view; URL
chip values overlay the persisted filter.
198 lines
6.8 KiB
Go
198 lines
6.8 KiB
Go
package web_test
|
|
|
|
import (
|
|
"context"
|
|
"net/url"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// TestViewsLandingOnboarding asserts that GET /views with no views and no
|
|
// MRU renders the onboarding shell ("No saved views yet" + "+ New view").
|
|
func TestViewsLandingOnboarding(t *testing.T) {
|
|
srv, pool := mustServer(t)
|
|
defer pool.Close()
|
|
h := srv.Routes()
|
|
// Clear any leftover touched views from prior runs so the MRU 302
|
|
// doesn't fire and steal the response.
|
|
if _, err := pool.Exec(context.Background(),
|
|
`UPDATE projax.views SET last_used_at = NULL`); err != nil {
|
|
t.Fatalf("reset mru: %v", err)
|
|
}
|
|
// Also clear ALL views so the onboarding shell renders (othewise the
|
|
// landing still ListViews-displays them).
|
|
if _, err := pool.Exec(context.Background(), `DELETE FROM projax.views`); err != nil {
|
|
t.Fatalf("clear views: %v", err)
|
|
}
|
|
code, body := get(t, h, "/views")
|
|
if code != 200 {
|
|
t.Fatalf("GET /views status=%d body=%q", code, body)
|
|
}
|
|
if !strings.Contains(body, "No saved views yet") {
|
|
t.Error("onboarding shell should surface the no-views nudge")
|
|
}
|
|
if !strings.Contains(body, `href="/views/new"`) {
|
|
t.Error("onboarding shell should link to /views/new")
|
|
}
|
|
}
|
|
|
|
// TestViewsLandingMRURedirects asserts that GET /views 302s to the most
|
|
// recently used view when one exists.
|
|
func TestViewsLandingMRURedirects(t *testing.T) {
|
|
srv, pool := mustServer(t)
|
|
defer pool.Close()
|
|
h := srv.Routes()
|
|
stamp := strings.ReplaceAll(time.Now().UTC().Format("150405.000"), ".", "")
|
|
slug := "p5j-b-landing-" + stamp
|
|
defer pool.Exec(context.Background(), `DELETE FROM projax.views WHERE slug = $1`, slug)
|
|
// Seed + touch.
|
|
if _, err := pool.Exec(context.Background(), `
|
|
INSERT INTO projax.views (slug, name, filter_json, last_used_at)
|
|
VALUES ($1, 'P5j B Landing', '{"view_type":"list"}'::jsonb, now())`, slug); err != nil {
|
|
t.Fatalf("seed view: %v", err)
|
|
}
|
|
code, body := get(t, h, "/views")
|
|
if code != 302 {
|
|
t.Errorf("GET /views status=%d (want 302 to MRU); body=%q", code, body)
|
|
}
|
|
}
|
|
|
|
// TestViewRenderShowsSavedView asserts that GET /views/{slug} renders the
|
|
// view's name + slug in the header and the tree-section body.
|
|
func TestViewRenderShowsSavedView(t *testing.T) {
|
|
srv, pool := mustServer(t)
|
|
defer pool.Close()
|
|
h := srv.Routes()
|
|
stamp := strings.ReplaceAll(time.Now().UTC().Format("150405.000"), ".", "")
|
|
slug := "p5j-b-render-" + stamp
|
|
defer pool.Exec(context.Background(), `DELETE FROM projax.views WHERE slug = $1`, slug)
|
|
if _, err := pool.Exec(context.Background(), `
|
|
INSERT INTO projax.views (slug, name, filter_json)
|
|
VALUES ($1, 'P5j B Render', '{"view_type":"card"}'::jsonb)`, slug); err != nil {
|
|
t.Fatalf("seed: %v", err)
|
|
}
|
|
code, body := get(t, h, "/views/"+slug)
|
|
if code != 200 {
|
|
t.Fatalf("GET /views/<slug> status=%d body=%q", code, body)
|
|
}
|
|
if !strings.Contains(body, "P5j B Render") {
|
|
t.Error("render should surface the view's name")
|
|
}
|
|
if !strings.Contains(body, `/views/`+slug) {
|
|
t.Error("render should surface the view's slug in the header")
|
|
}
|
|
if !strings.Contains(body, `class="tree-card-grid"`) {
|
|
t.Error("view_type=card should render the card grid")
|
|
}
|
|
}
|
|
|
|
// TestViewRender404OnUnknownSlug — an unknown slug returns 404, not a
|
|
// silent fallback to the tree.
|
|
func TestViewRender404OnUnknownSlug(t *testing.T) {
|
|
srv, pool := mustServer(t)
|
|
defer pool.Close()
|
|
h := srv.Routes()
|
|
code, _ := get(t, h, "/views/this-slug-does-not-exist-anywhere-9876")
|
|
if code != 404 {
|
|
t.Errorf("unknown slug should 404, got %d", code)
|
|
}
|
|
}
|
|
|
|
// TestViewCreateAndDelete — POST /views creates; POST /views/<slug>/delete
|
|
// removes. Verifies the slug-format error path too.
|
|
func TestViewCreateAndDelete(t *testing.T) {
|
|
srv, pool := mustServer(t)
|
|
defer pool.Close()
|
|
h := srv.Routes()
|
|
stamp := strings.ReplaceAll(time.Now().UTC().Format("150405.000"), ".", "")
|
|
slug := "p5j-b-crud-" + stamp
|
|
defer pool.Exec(context.Background(), `DELETE FROM projax.views WHERE slug = $1`, slug)
|
|
|
|
form := url.Values{}
|
|
form.Set("slug", slug)
|
|
form.Set("name", "P5j B CRUD")
|
|
form.Set("view_type", "list")
|
|
form.Set("filter_query", "tag=work")
|
|
code, _ := post(t, h, "/views", form)
|
|
if code != 303 {
|
|
t.Fatalf("create status=%d want 303", code)
|
|
}
|
|
|
|
// Reserved-slug 400.
|
|
form2 := url.Values{}
|
|
form2.Set("slug", "dashboard")
|
|
form2.Set("name", "Should be rejected")
|
|
form2.Set("view_type", "list")
|
|
code, body := post(t, h, "/views", form2)
|
|
if code != 400 {
|
|
t.Errorf("reserved-slug create should 400, got %d body=%q", code, body)
|
|
}
|
|
|
|
// Delete.
|
|
code, _ = post(t, h, "/views/"+slug+"/delete", url.Values{})
|
|
if code != 303 {
|
|
t.Errorf("delete status=%d want 303", code)
|
|
}
|
|
}
|
|
|
|
// TestSavedViewFilterOverlay — chip params on /views/<slug>?tag=x narrow
|
|
// the saved filter. Verifies the slice B render-path overlay.
|
|
func TestSavedViewFilterOverlay(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-b-overlay-" + stamp
|
|
devSlug := "p5j-b-overlay-d-" + stamp
|
|
homeSlug := "p5j-b-overlay-h-" + stamp
|
|
defer pool.Exec(context.Background(), `DELETE FROM projax.views WHERE slug = $1`, slug)
|
|
|
|
var dev, home 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, `select id from projax.items where slug='home' and cardinality(parent_ids)=0`).Scan(&home); err != nil {
|
|
t.Fatalf("home: %v", err)
|
|
}
|
|
var devID, homeID string
|
|
if err := pool.QueryRow(ctx, `
|
|
INSERT INTO projax.items (kind, title, slug, parent_ids, tags)
|
|
VALUES (array['project']::text[], 'P5jB Dev', $1, ARRAY[$2]::uuid[], ARRAY['work'])
|
|
RETURNING id`, devSlug, dev).Scan(&devID); err != nil {
|
|
t.Fatalf("seed dev item: %v", err)
|
|
}
|
|
if err := pool.QueryRow(ctx, `
|
|
INSERT INTO projax.items (kind, title, slug, parent_ids, tags)
|
|
VALUES (array['project']::text[], 'P5jB Home', $1, ARRAY[$2]::uuid[], ARRAY['home'])
|
|
RETURNING id`, homeSlug, home).Scan(&homeID); err != nil {
|
|
t.Fatalf("seed home item: %v", err)
|
|
}
|
|
defer pool.Exec(context.Background(), `DELETE FROM projax.items WHERE id IN ($1,$2)`, devID, homeID)
|
|
|
|
if _, err := pool.Exec(ctx, `
|
|
INSERT INTO projax.views (slug, name, filter_json)
|
|
VALUES ($1, 'P5jB Overlay', '{"view_type":"list"}'::jsonb)`, slug); err != nil {
|
|
t.Fatalf("seed view: %v", err)
|
|
}
|
|
|
|
devLink := `href="/i/dev.` + devSlug + `"`
|
|
homeLink := `href="/i/home.` + homeSlug + `"`
|
|
|
|
_, base := get(t, h, "/views/"+slug)
|
|
if !strings.Contains(base, devLink) {
|
|
t.Error("saved view without tag should show dev row")
|
|
}
|
|
if !strings.Contains(base, homeLink) {
|
|
t.Error("saved view without tag should show home row")
|
|
}
|
|
_, narrowed := get(t, h, "/views/"+slug+"?tag=work")
|
|
if !strings.Contains(narrowed, devLink) {
|
|
t.Error("URL chip tag=work should keep dev (work-tagged)")
|
|
}
|
|
if strings.Contains(narrowed, homeLink) {
|
|
t.Error("URL chip tag=work should hide home")
|
|
}
|
|
}
|