Files
projax/web/views_test.go
mAi 59a89ef044 fix(views): edit UI + URL chip overlay on saved-view pages
m's bug (verbatim from /views): "we cant edit views yet. and the filters
on custom views dont seem to work. No apply button and no instant apply"

Two distinct gaps, both surgically fixed.

## Gap 1 — edit UI missing

Slice D shipped POST /views/<id> (update) but no GET form to drive it.
The index page had delete + redirect-open links only.

Fix:
- New handleViewEdit serves GET /views/<id>/edit with the form pre-filled
  from the persisted row.
- New templates/view_edit.tmpl mirrors the create form, selecting the
  current values on each <select>, populating each <input value="">.
- filterJSONToQuery rebuilds the URL-query representation of filter_json
  so the `filter_query` text input round-trips on edit.
- /views index row gets an "edit" link next to delete.
- Route registered before the catch-all GET /views/ so the more specific
  pattern wins. handleViewRedirect also defensively forwards /edit
  suffix in case routing falls through.

## Gap 2 — URL chips clobbered by saved-view filter

applySavedView did `*filter = filterFromJSONPayload(payload)` — wholesale
replace. URL chip params parsed earlier in handleTree were thrown away.
Compounded by chip URLs not preserving `?view=<id>`, so even if the
overlay had worked, chip clicks would have stripped the saved view.

Fix:
- TreeFilter grows a `ViewID` field that round-trips through
  ParseTreeFilter + QueryString. Not a "filter dimension" in the
  matching sense (Matches ignores it); just a URL anchor that
  every chip URL emits forward.
- applySavedView builds the saved filter, then overlayURLFields()
  selectively replaces any dimension the user set via URL chip on top
  (q/tag/mgmt/status/has/show-archived/public/project/project_descendants).
- view_type: URL wins when explicitly set, saved value otherwise.
- Drift is transient — URL bookmarkable as a "narrowed saved view"
  without auto-saving back to the row. To persist, user opens /edit.

## Tests

- TestViewEditFlow — GET /<id>/edit pre-fills name + filter_query; POST
  /<id> updates name + view_type + filter_json round-trip in DB.
- TestSavedViewPageFilterApply — seed two items + an empty saved view;
  /?view=<id> shows both; /?view=<id>&tag=work shows only the work
  one. Also asserts chip URLs contain view=<id> so navigation stays in
  the saved view.

Out of scope (per brief):
- No schema changes.
- No view sharing / multi-user.
- HTMX modal save UI deferred — the existing inline edit page is the
  surgical fix m's bug actually needs.
2026-05-26 15:08:44 +02:00

328 lines
11 KiB
Go

package web_test
import (
"context"
"encoding/json"
"net/url"
"strings"
"testing"
"time"
)
// TestViewsCRUDRoundTrip covers create → list → open (redirect to scoped page) →
// delete, end-to-end. Requires DB. Slice D — projax.views table CRUD.
func TestViewsCRUDRoundTrip(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
stamp := strings.ReplaceAll(time.Now().UTC().Format("150405.000"), ".", "")
name := "p5i-D-view-" + stamp
defer pool.Exec(context.Background(),
`UPDATE projax.views SET deleted_at = now() WHERE name = $1 AND deleted_at IS NULL`, name)
// Create.
form := url.Values{}
form.Set("name", name)
form.Set("view_type", "card")
form.Set("filter_query", "tag=work&mgmt=mai")
code, _ := post(t, h, "/views", form)
if code != 303 {
t.Fatalf("POST /views status=%d, want 303", code)
}
// List page lists the new view.
code, body := get(t, h, "/views")
if code != 200 {
t.Fatalf("GET /views status=%d", code)
}
if !strings.Contains(body, name) {
t.Errorf("GET /views body missing %q", name)
}
// Fetch row to grab the id (and validate filter_json round-trip).
var (
id string
filterJSON []byte
viewType string
)
if err := pool.QueryRow(context.Background(),
`SELECT id, filter_json, view_type FROM projax.views WHERE name=$1 AND deleted_at IS NULL`,
name,
).Scan(&id, &filterJSON, &viewType); err != nil {
t.Fatalf("fetch row: %v", err)
}
if viewType != "card" {
t.Errorf("view_type = %q, want 'card'", viewType)
}
var payload map[string]any
if err := json.Unmarshal(filterJSON, &payload); err != nil {
t.Fatalf("filter_json unmarshal: %v", err)
}
if got, _ := payload["tags"].([]any); len(got) != 1 || got[0] != "work" {
t.Errorf("filter_json tags = %v, want [work]", payload["tags"])
}
if got, _ := payload["management"].([]any); len(got) != 1 || got[0] != "mai" {
t.Errorf("filter_json management = %v, want [mai]", payload["management"])
}
// GET /views/<id> redirects to the right page with ?view=<id>.
code, _ = get(t, h, "/views/"+id)
if code != 303 {
t.Errorf("GET /views/<id> status=%d, want 303 redirect", code)
}
// Soft delete.
code, _ = post(t, h, "/views/"+id+"/delete", url.Values{})
if code != 303 {
t.Errorf("POST delete status=%d, want 303", code)
}
var deletedAt *time.Time
if err := pool.QueryRow(context.Background(),
`SELECT deleted_at FROM projax.views WHERE id=$1`, id,
).Scan(&deletedAt); err != nil {
t.Fatalf("post-delete read: %v", err)
}
if deletedAt == nil {
t.Error("expected deleted_at to be set after POST /views/<id>/delete")
}
}
// TestViewEditFlow exercises the fix for m's bug "we cant edit views yet".
// GET /views/<id>/edit renders the pre-filled form; POST /views/<id> updates
// the row in place. Verifies name + view_type + filter_json round-trip.
func TestViewEditFlow(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"), ".", "")
name := "p5i-fix-edit-" + stamp
defer pool.Exec(context.Background(),
`UPDATE projax.views SET deleted_at = now() WHERE name = $1 AND deleted_at IS NULL OR name = $2`,
name, name+"-renamed")
var id string
if err := pool.QueryRow(ctx, `
INSERT INTO projax.views (name, view_type, filter_json)
VALUES ($1, 'list', $2::jsonb)
RETURNING id`, name, []byte(`{"tags":["dev"]}`)).Scan(&id); err != nil {
t.Fatalf("seed: %v", err)
}
// GET /views/<id>/edit renders the pre-filled form (not the redirect).
code, body := get(t, h, "/views/"+id+"/edit")
if code != 200 {
t.Fatalf("GET /views/<id>/edit status=%d, want 200", code)
}
if !strings.Contains(body, `value="`+name+`"`) {
t.Error("edit form should pre-fill the name input")
}
if !strings.Contains(body, `value="tag=dev"`) {
t.Error("edit form should pre-fill filter_query from filter_json")
}
// Index page now shows an edit link per row.
_, idx := get(t, h, "/views")
if !strings.Contains(idx, `/views/`+id+`/edit`) {
t.Error("/views should expose an edit link per row")
}
// POST /views/<id> updates the row.
form := url.Values{}
form.Set("name", name+"-renamed")
form.Set("view_type", "card")
form.Set("filter_query", "tag=work&mgmt=mai")
code, _ = post(t, h, "/views/"+id, form)
if code != 303 {
t.Fatalf("POST /views/<id> status=%d, want 303", code)
}
var newName, newType string
var newFilter []byte
if err := pool.QueryRow(ctx,
`SELECT name, view_type, filter_json FROM projax.views WHERE id = $1`, id,
).Scan(&newName, &newType, &newFilter); err != nil {
t.Fatalf("post-update read: %v", err)
}
if newName != name+"-renamed" {
t.Errorf("name = %q, want %q", newName, name+"-renamed")
}
if newType != "card" {
t.Errorf("view_type = %q, want 'card'", newType)
}
payload := map[string]any{}
_ = json.Unmarshal(newFilter, &payload)
tags, _ := payload["tags"].([]any)
if len(tags) != 1 || tags[0] != "work" {
t.Errorf("filter_json tags = %v, want [work] post-update", payload["tags"])
}
}
// TestSavedViewPageFilterApply exercises the fix for m's bug "the filters on
// custom views dont seem to work". A request to /?view=<id>&tag=work narrows
// the saved view further by overlaying the URL chip onto the persisted
// filter_json. Previously the saved filter clobbered the URL chips
// wholesale.
func TestSavedViewPageFilterApply(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"), ".", "")
name := "p5i-fix-overlay-" + stamp
devSlug := "p5i-fix-overlay-d-" + stamp
homeSlug := "p5i-fix-overlay-h-" + stamp
defer pool.Exec(context.Background(),
`UPDATE projax.views SET deleted_at = now() WHERE name = $1 AND deleted_at IS NULL`, name)
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[], 'Fix 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[], 'Fix 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)
// Saved view with view_type=list and NO tag filter — both items should pass.
var id string
if err := pool.QueryRow(ctx, `
INSERT INTO projax.views (name, view_type, filter_json)
VALUES ($1, 'list', '{}'::jsonb)
RETURNING id`, name).Scan(&id); err != nil {
t.Fatalf("seed view: %v", err)
}
devLink := `href="/i/dev.` + devSlug + `"`
homeLink := `href="/i/home.` + homeSlug + `"`
// Open view alone — both rows should appear.
_, baseBody := get(t, h, "/?view="+id)
if !strings.Contains(baseBody, devLink) {
t.Error("saved view without tag filter should show dev row")
}
if !strings.Contains(baseBody, homeLink) {
t.Error("saved view without tag filter should show home row")
}
// Overlay ?tag=work — home row should disappear; dev should remain.
_, narrowedBody := get(t, h, "/?view="+id+"&tag=work")
if !strings.Contains(narrowedBody, devLink) {
t.Error("?view=<id>&tag=work should still show dev row (work-tagged)")
}
if strings.Contains(narrowedBody, homeLink) {
t.Error("?view=<id>&tag=work should hide home row — URL chip must overlay saved filter")
}
// Chip URLs inside the saved view must round-trip the view= param so
// chip clicks don't strip the saved view.
if !strings.Contains(narrowedBody, "view="+id) {
t.Error("chip URLs inside a saved view should carry view=<id> forward")
}
}
// TestDefaultViewAppliedOnCleanURL verifies the Slice E behaviour: when /
// is requested with no chip params and a default view exists for the page,
// the saved filter + view_type apply and a "Showing default view: …"
// banner renders. Adding any chip param (?tag=…) bypasses the default.
// ?nodefault=1 is the explicit opt-out.
func TestDefaultViewAppliedOnCleanURL(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"), ".", "")
name := "p5i-E-default-" + stamp
defer pool.Exec(context.Background(),
`UPDATE projax.views SET deleted_at = now() WHERE name = $1 AND deleted_at IS NULL`, name)
if _, err := pool.Exec(ctx, `
INSERT INTO projax.views (name, view_type, filter_json, is_default_for)
VALUES ($1, 'card', $2::jsonb, 'tree')`,
name, []byte(`{"tags":["work"]}`)); err != nil {
t.Fatalf("seed default view: %v", err)
}
// Clean URL: default applies → card view + banner.
_, body := get(t, h, "/")
if !strings.Contains(body, `class="tree-card-grid"`) {
t.Error("clean / should auto-apply default view (card grid expected)")
}
if !strings.Contains(body, `default-banner`) {
t.Error("default-banner should render when a default applies")
}
if !strings.Contains(body, name) {
t.Error("banner should name the applied default view")
}
// Any chip param bypasses the default → list view (no banner).
_, withChip := get(t, h, "/?tag=dev")
if strings.Contains(withChip, `default-banner`) {
t.Error("default banner should disappear once user types a chip")
}
if !strings.Contains(withChip, `<ul class="forest">`) {
t.Error("?tag=dev should render the forest (default not applied)")
}
// Explicit opt-out via ?nodefault=1.
_, optOut := get(t, h, "/?nodefault=1")
if strings.Contains(optOut, `default-banner`) {
t.Error("?nodefault=1 should suppress the default banner")
}
if !strings.Contains(optOut, `<ul class="forest">`) {
t.Error("?nodefault=1 should render the forest (default suppressed)")
}
}
// TestSavedViewAppliedOnQueryParam verifies that opening / with ?view=<uuid>
// re-applies the saved filter+view_type. We seed a view tagged work=patents
// and assert the rendered tree has the right ProjectChip / chip-on state.
func TestSavedViewAppliedOnQueryParam(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"), ".", "")
name := "p5i-D-saved-" + stamp
defer pool.Exec(context.Background(),
`UPDATE projax.views SET deleted_at = now() WHERE name = $1 AND deleted_at IS NULL`, name)
// Seed directly via SQL so the assertion focuses on the resolver, not the
// form flow tested above.
var id string
if err := pool.QueryRow(ctx, `
INSERT INTO projax.views (name, view_type, filter_json)
VALUES ($1, 'card', $2::jsonb)
RETURNING id`, name, []byte(`{"project_path":"dev","include_descendants":true}`)).Scan(&id); err != nil {
t.Fatalf("seed view: %v", err)
}
_, body := get(t, h, "/?view="+id)
if !strings.Contains(body, `class="tree-card-grid"`) {
t.Error("?view= should override view_type → card view should render")
}
if !strings.Contains(body, `class="proj-chip chip-on"`) {
t.Error("?view= should apply project filter chip → proj-chip should be on")
}
}