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.
328 lines
11 KiB
Go
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")
|
|
}
|
|
}
|