diff --git a/db/migrations/0016_views.sql b/db/migrations/0016_views.sql new file mode 100644 index 0000000..2144858 --- /dev/null +++ b/db/migrations/0016_views.sql @@ -0,0 +1,70 @@ +-- 0016_views.sql +-- +-- Phase 5i Slice D: persistent saved views. +-- +-- A saved view bundles (filter + view_type + sort + group_by) under a +-- name. Page-agnostic per m's Q2 pick (2026-05-26) — the view doesn't +-- own a route; `is_default_for` lets one view become the auto-applied +-- default for a given page. +-- +-- Singleton user; no `user_id` column. If multi-user ever lands, the +-- two partial unique indexes below need a `(user_id, …)` prefix. + +CREATE TABLE IF NOT EXISTS projax.views ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + name text NOT NULL, + description text, + filter_json jsonb NOT NULL DEFAULT '{}'::jsonb, + view_type text NOT NULL, + sort_field text, + sort_dir text, + group_by text, + pinned boolean NOT NULL DEFAULT false, + is_default_for text, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + CONSTRAINT views_view_type_chk + CHECK (view_type IN ('card','list','calendar','kanban','timeline')), + CONSTRAINT views_sort_dir_chk + CHECK (sort_dir IS NULL OR sort_dir IN ('asc','desc')), + CONSTRAINT views_kanban_needs_group + CHECK (view_type <> 'kanban' OR group_by IS NOT NULL), + CONSTRAINT views_default_for_chk + CHECK (is_default_for IS NULL OR is_default_for IN ('tree','dashboard','calendar','timeline')) +); + +-- Case-insensitive uniqueness on the visible name. Soft-deleted rows are +-- exempt so a re-create after delete doesn't collide. +CREATE UNIQUE INDEX IF NOT EXISTS views_name_uniq + ON projax.views (lower(name)) + WHERE deleted_at IS NULL; + +-- One default view per page. The handler should clear the prior default in +-- the same transaction as setting a new one; the index defends against any +-- code path that forgets. +CREATE UNIQUE INDEX IF NOT EXISTS views_default_for_uniq + ON projax.views (is_default_for) + WHERE is_default_for IS NOT NULL AND deleted_at IS NULL; + +-- updated_at trigger mirrors the items table pattern. +CREATE OR REPLACE FUNCTION projax.views_touch_updated_at() + RETURNS trigger LANGUAGE plpgsql AS $$ +BEGIN + NEW.updated_at := now(); + RETURN NEW; +END; +$$; + +DROP TRIGGER IF EXISTS views_touch_updated_at ON projax.views; +CREATE TRIGGER views_touch_updated_at + BEFORE UPDATE ON projax.views + FOR EACH ROW EXECUTE FUNCTION projax.views_touch_updated_at(); + +DO $own$ BEGIN + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'projax_admin') THEN + EXECUTE 'ALTER TABLE projax.views OWNER TO projax_admin'; + EXECUTE 'ALTER FUNCTION projax.views_touch_updated_at() OWNER TO projax_admin'; + EXECUTE 'GRANT SELECT, INSERT, UPDATE, DELETE ON projax.views TO projax_admin'; + END IF; +END $own$; diff --git a/store/views.go b/store/views.go new file mode 100644 index 0000000..fbba8d2 --- /dev/null +++ b/store/views.go @@ -0,0 +1,273 @@ +package store + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +// View is one row in projax.views. Phase 5i Slice D — saved views. +// +// FilterJSON carries the persisted filter state as raw JSON so callers can +// freely round-trip into their TreeFilter or another future filter type +// without forcing the store package to depend on web/. +type View struct { + ID string + Name string + Description string + FilterJSON []byte // raw jsonb payload + ViewType string + SortField *string + SortDir *string + GroupBy *string + Pinned bool + IsDefaultFor *string + CreatedAt time.Time + UpdatedAt time.Time +} + +// ErrViewNotFound surfaces from GetView / SoftDeleteView when no row matches. +var ErrViewNotFound = errors.New("view not found") + +// ViewInput is the writeable subset of View used by Create / Update. +type ViewInput struct { + Name string + Description string + FilterJSON []byte + ViewType string + SortField string + SortDir string + GroupBy string + Pinned bool + IsDefaultFor string // "" → clear default +} + +// ListViews returns every non-deleted view ordered by pinned-first, then name. +func (s *Store) ListViews(ctx context.Context) ([]*View, error) { + rows, err := s.Pool.Query(ctx, ` +SELECT id, name, coalesce(description,''), filter_json, view_type, + sort_field, sort_dir, group_by, pinned, is_default_for, + created_at, updated_at +FROM projax.views +WHERE deleted_at IS NULL +ORDER BY pinned DESC, lower(name) ASC`) + if err != nil { + return nil, fmt.Errorf("list views: %w", err) + } + defer rows.Close() + var out []*View + for rows.Next() { + v, err := scanView(rows) + if err != nil { + return nil, err + } + out = append(out, v) + } + return out, rows.Err() +} + +// GetView returns one view by id. ErrViewNotFound when missing or soft-deleted. +func (s *Store) GetView(ctx context.Context, id string) (*View, error) { + row := s.Pool.QueryRow(ctx, ` +SELECT id, name, coalesce(description,''), filter_json, view_type, + sort_field, sort_dir, group_by, pinned, is_default_for, + created_at, updated_at +FROM projax.views +WHERE id = $1 AND deleted_at IS NULL`, id) + v, err := scanView(row) + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrViewNotFound + } + return v, err +} + +// CreateView inserts a row. When IsDefaultFor is set, the prior default for +// that page is cleared in the same transaction so the partial unique index +// can't fire after a Postgres rewrite. +func (s *Store) CreateView(ctx context.Context, in ViewInput) (*View, error) { + if err := validateViewInput(in); err != nil { + return nil, err + } + if in.FilterJSON == nil { + in.FilterJSON = []byte("{}") + } + var id string + tx, err := s.Pool.BeginTx(ctx, pgx.TxOptions{}) + if err != nil { + return nil, fmt.Errorf("begin: %w", err) + } + defer func() { _ = tx.Rollback(ctx) }() + if in.IsDefaultFor != "" { + if _, err := tx.Exec(ctx, ` +UPDATE projax.views +SET is_default_for = NULL +WHERE is_default_for = $1 AND deleted_at IS NULL`, in.IsDefaultFor); err != nil { + return nil, fmt.Errorf("clear prior default: %w", err) + } + } + err = tx.QueryRow(ctx, ` +INSERT INTO projax.views + (name, description, filter_json, view_type, sort_field, sort_dir, group_by, pinned, is_default_for) +VALUES + ($1, NULLIF($2,''), $3::jsonb, $4, NULLIF($5,''), NULLIF($6,''), NULLIF($7,''), $8, NULLIF($9,'')) +RETURNING id`, + in.Name, in.Description, in.FilterJSON, in.ViewType, + in.SortField, in.SortDir, in.GroupBy, in.Pinned, in.IsDefaultFor, + ).Scan(&id) + if err != nil { + return nil, fmt.Errorf("insert view: %w", err) + } + if err := tx.Commit(ctx); err != nil { + return nil, fmt.Errorf("commit: %w", err) + } + return s.GetView(ctx, id) +} + +// UpdateView replaces every writeable field. Same default-clearing semantics +// as CreateView. +func (s *Store) UpdateView(ctx context.Context, id string, in ViewInput) (*View, error) { + if err := validateViewInput(in); err != nil { + return nil, err + } + if in.FilterJSON == nil { + in.FilterJSON = []byte("{}") + } + tx, err := s.Pool.BeginTx(ctx, pgx.TxOptions{}) + if err != nil { + return nil, fmt.Errorf("begin: %w", err) + } + defer func() { _ = tx.Rollback(ctx) }() + if in.IsDefaultFor != "" { + if _, err := tx.Exec(ctx, ` +UPDATE projax.views +SET is_default_for = NULL +WHERE is_default_for = $1 AND id <> $2 AND deleted_at IS NULL`, + in.IsDefaultFor, id); err != nil { + return nil, fmt.Errorf("clear prior default: %w", err) + } + } + tag, err := tx.Exec(ctx, ` +UPDATE projax.views +SET name = $2, + description = NULLIF($3,''), + filter_json = $4::jsonb, + view_type = $5, + sort_field = NULLIF($6,''), + sort_dir = NULLIF($7,''), + group_by = NULLIF($8,''), + pinned = $9, + is_default_for = NULLIF($10,'') +WHERE id = $1 AND deleted_at IS NULL`, + id, in.Name, in.Description, in.FilterJSON, in.ViewType, + in.SortField, in.SortDir, in.GroupBy, in.Pinned, in.IsDefaultFor, + ) + if err != nil { + return nil, fmt.Errorf("update view: %w", err) + } + if tag.RowsAffected() == 0 { + return nil, ErrViewNotFound + } + if err := tx.Commit(ctx); err != nil { + return nil, fmt.Errorf("commit: %w", err) + } + return s.GetView(ctx, id) +} + +// SoftDeleteView sets deleted_at on the row. Idempotent (returns ErrViewNotFound +// only when the row never existed; subsequent calls on a soft-deleted row +// silently succeed since deleted_at is just refreshed). +func (s *Store) SoftDeleteView(ctx context.Context, id string) error { + tag, err := s.Pool.Exec(ctx, ` +UPDATE projax.views SET deleted_at = now() +WHERE id = $1`, id) + if err != nil { + return fmt.Errorf("delete view: %w", err) + } + if tag.RowsAffected() == 0 { + return ErrViewNotFound + } + return nil +} + +// DefaultViewFor returns the view that should auto-apply on the named page, +// or nil if none is set. +func (s *Store) DefaultViewFor(ctx context.Context, page string) (*View, error) { + row := s.Pool.QueryRow(ctx, ` +SELECT id, name, coalesce(description,''), filter_json, view_type, + sort_field, sort_dir, group_by, pinned, is_default_for, + created_at, updated_at +FROM projax.views +WHERE is_default_for = $1 AND deleted_at IS NULL +LIMIT 1`, page) + v, err := scanView(row) + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + return v, err +} + +// validateViewInput runs the Go-side guards. The DB CHECK constraints provide +// the durable contract; these checks let handlers surface a friendlier error. +func validateViewInput(in ViewInput) error { + if strings.TrimSpace(in.Name) == "" { + return errors.New("view name is required") + } + switch in.ViewType { + case "card", "list", "calendar", "kanban", "timeline": + default: + return fmt.Errorf("invalid view_type %q (allowed: card list calendar kanban timeline)", in.ViewType) + } + if in.SortDir != "" && in.SortDir != "asc" && in.SortDir != "desc" { + return fmt.Errorf("invalid sort_dir %q", in.SortDir) + } + if in.ViewType == "kanban" && strings.TrimSpace(in.GroupBy) == "" { + return errors.New("kanban view_type requires group_by") + } + if in.IsDefaultFor != "" { + switch in.IsDefaultFor { + case "tree", "dashboard", "calendar", "timeline": + default: + return fmt.Errorf("invalid is_default_for %q", in.IsDefaultFor) + } + } + if len(in.FilterJSON) > 0 { + var dummy any + if err := json.Unmarshal(in.FilterJSON, &dummy); err != nil { + return fmt.Errorf("filter_json is not valid JSON: %w", err) + } + } + return nil +} + +type viewScanner interface { + Scan(dest ...any) error +} + +func scanView(s viewScanner) (*View, error) { + v := &View{} + var sortField, sortDir, groupBy, isDefaultFor *string + if err := s.Scan( + &v.ID, &v.Name, &v.Description, &v.FilterJSON, &v.ViewType, + &sortField, &sortDir, &groupBy, &v.Pinned, &isDefaultFor, + &v.CreatedAt, &v.UpdatedAt, + ); err != nil { + return nil, err + } + v.SortField = sortField + v.SortDir = sortDir + v.GroupBy = groupBy + v.IsDefaultFor = isDefaultFor + return v, nil +} + +// pgxRowsCompat keeps the linter quiet about importing pgxpool only for +// type assertions inside views.go. The Pool method on Store already pulls +// pgxpool into the package; nothing to do here, but the unused-import +// shadow doesn't bite. +var _ = pgxpool.Pool{} diff --git a/web/server.go b/web/server.go index eeba124..e4e0783 100644 --- a/web/server.go +++ b/web/server.go @@ -151,7 +151,7 @@ func New(s *store.Store, logger *slog.Logger) (*Server, error) { }, } pages := map[string]*template.Template{} - for _, name := range []string{"new", "classify", "caldav_admin", "caldav_disabled", "error"} { + for _, name := range []string{"new", "classify", "caldav_admin", "caldav_disabled", "error", "views"} { t, err := template.New(name).Funcs(funcs).ParseFS(templatesFS, "templates/layout.tmpl", "templates/"+name+".tmpl", @@ -380,6 +380,10 @@ func (s *Server) Routes() http.Handler { mux.HandleFunc("GET /admin/caldav", s.handleCalDAVAdmin) mux.HandleFunc("POST /admin/caldav/link", s.handleCalDAVLink) mux.HandleFunc("POST /admin/caldav/unlink", s.handleCalDAVUnlink) + mux.HandleFunc("GET /views", s.handleViewsIndex) + mux.HandleFunc("POST /views", s.handleViewCreate) + mux.HandleFunc("GET /views/", s.handleViewRedirect) + mux.HandleFunc("POST /views/", s.handleViewWrite) mux.HandleFunc("GET /login", s.handleLoginForm) mux.HandleFunc("POST /login", s.handleLoginSubmit) mux.HandleFunc("POST /logout", s.handleLogout) @@ -444,6 +448,19 @@ func (s *Server) handleTree(w http.ResponseWriter, r *http.Request) { filter := ParseTreeFilter(r.URL.Query()) viewSet := PageViewTypes("/") view := ParseViewType(r.URL.Query(), viewSet) + // Phase 5i Slice D: ?view= resolves a saved view's filter + + // view_type into the current request, overriding URL-only chip state. + // Resolution failure (deleted view, malformed payload) is logged and + // silently falls back to the URL-derived filter — the page stays + // renderable rather than 500ing. + if saved, err := s.applySavedView(r, &filter, &view); err == nil && saved != nil { + // Re-validate view_type against the route catalog so a saved + // kanban-view URL opened on / (before slice C ships kanban) lands on + // the default with the chip showing the wanted view as locked. + view = viewSet.Resolve(view) + } else if err != nil { + s.Logger.Warn("applySavedView", "id", r.URL.Query().Get("view"), "err", err) + } roots, orphans, total, orphanN, matched := applyTreeFilter(items, filter, linkKinds) counts := computeChipCounts(items, filter, linkKinds, tags) // Phase 5i Slice B: the card view renders a flat grid of matched items diff --git a/web/templates/layout.tmpl b/web/templates/layout.tmpl index 8732fa3..4e239a1 100644 --- a/web/templates/layout.tmpl +++ b/web/templates/layout.tmpl @@ -80,6 +80,15 @@ Graph + + + Views +

Views

+ +

Saved bundles of (filter + view_type + sort + group_by). Page-agnostic — open one to render the saved set on the matching page.

+ +
+ {{if .Views}} + + + + + + + + {{range .Views}} + + + + + + + + + {{end}} + +
NameTypeDefault forGroup by
{{if .Pinned}}★{{end}}{{.Name}}{{if .Description}}
{{.Description}}{{end}}
{{.ViewType}}{{if .IsDefaultFor}}{{deref .IsDefaultFor}}{{else}}{{end}}{{if .GroupBy}}{{deref .GroupBy}}{{else}}{{end}} +
+ +
+
+ {{else}} +

No saved views yet. Create one with the form below or via the "Save view…" link on any Views-supporting page.

+ {{end}} +
+ +
+

New view

+
+ + + + + + + + + + +
+
+{{end}} diff --git a/web/views.go b/web/views.go new file mode 100644 index 0000000..fe540f8 --- /dev/null +++ b/web/views.go @@ -0,0 +1,289 @@ +package web + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "strings" + + "github.com/m/projax/store" +) + +// Phase 5i Slice D — saved views handlers. Page-agnostic: a view bundles a +// filter + view_type + sort/group_by and renders on any page that supports +// that view_type. The sidebar in layout.tmpl lists every saved view; the +// /views index lets m manage them. + +// handleViewsIndex renders the list + create-form page. +func (s *Server) handleViewsIndex(w http.ResponseWriter, r *http.Request) { + views, err := s.Store.ListViews(r.Context()) + if err != nil { + s.fail(w, r, err) + return + } + // Prefill: a save-from-page link can pass ?prefill_filter=&prefill_view_type=&prefill_page= so the form opens + // with the user's current state already typed in. + prefill := map[string]string{ + "filter": r.URL.Query().Get("prefill_filter"), + "view_type": r.URL.Query().Get("prefill_view_type"), + "page": r.URL.Query().Get("prefill_page"), + } + s.render(w, r, "views", map[string]any{ + "Title": "views", + "Views": views, + "Prefill": prefill, + // Catalog of selectable values for the form selects. + "AllViewTypes": allViewTypes, + "DefaultForOptions": []string{"", "tree", "dashboard", "calendar", "timeline"}, + "SortDirOptions": []string{"", "asc", "desc"}, + "GroupByOptions": []string{"", "status", "area", "tag", "management"}, + }) +} + +// handleViewCreate accepts the create-view form POST. +func (s *Server) handleViewCreate(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + s.fail(w, r, err) + return + } + in, err := viewInputFromForm(r.PostForm) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + v, err := s.Store.CreateView(r.Context(), in) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + http.Redirect(w, r, "/views/"+v.ID, http.StatusSeeOther) +} + +// handleViewWrite dispatches the /views/ POST routes: bare path is +// update; /views//delete is soft-delete. +func (s *Server) handleViewWrite(w http.ResponseWriter, r *http.Request) { + path := strings.TrimPrefix(r.URL.Path, "/views/") + if base, ok := strings.CutSuffix(path, "/delete"); ok { + s.handleViewDelete(w, r, base) + return + } + if err := r.ParseForm(); err != nil { + s.fail(w, r, err) + return + } + in, err := viewInputFromForm(r.PostForm) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + if _, err := s.Store.UpdateView(r.Context(), path, in); err != nil { + if errors.Is(err, store.ErrViewNotFound) { + http.NotFound(w, r) + return + } + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + http.Redirect(w, r, "/views", http.StatusSeeOther) +} + +// handleViewDelete soft-deletes by id. +func (s *Server) handleViewDelete(w http.ResponseWriter, r *http.Request, id string) { + if err := s.Store.SoftDeleteView(r.Context(), id); err != nil { + if errors.Is(err, store.ErrViewNotFound) { + http.NotFound(w, r) + return + } + s.fail(w, r, err) + return + } + http.Redirect(w, r, "/views", http.StatusSeeOther) +} + +// handleViewRedirect resolves /views/ GET into a redirect to the +// appropriate Views-supporting page with ?view= appended. The target +// page resolves the saved filter+view_type at render time via +// applySavedView. +func (s *Server) handleViewRedirect(w http.ResponseWriter, r *http.Request) { + id := strings.TrimPrefix(r.URL.Path, "/views/") + if id == "" { + http.NotFound(w, r) + return + } + v, err := s.Store.GetView(r.Context(), id) + if err != nil { + if errors.Is(err, store.ErrViewNotFound) { + http.NotFound(w, r) + return + } + s.fail(w, r, err) + return + } + target := targetRouteForViewType(v.ViewType) + q := url.Values{} + q.Set("view", v.ID) + http.Redirect(w, r, target+"?"+q.Encode(), http.StatusSeeOther) +} + +// targetRouteForViewType picks a sensible landing route given the view's +// view_type. card/list/kanban land on /; calendar on /calendar; timeline on +// /timeline. Slice E will let `is_default_for` override. +func targetRouteForViewType(vt string) string { + switch vt { + case ViewTypeCalendar: + return "/calendar" + case ViewTypeTimeline: + return "/timeline" + case ViewTypeCard, ViewTypeList, ViewTypeKanban: + return "/" + } + return "/" +} + +// viewInputFromForm decodes the create/update form. filter_json is accepted +// as either raw JSON (textarea) OR as an encoded query string under +// `filter_query` so the save-from-page workflow can prefill from a TreeFilter +// the user assembled via chips. +func viewInputFromForm(form url.Values) (store.ViewInput, error) { + in := store.ViewInput{ + Name: strings.TrimSpace(form.Get("name")), + Description: strings.TrimSpace(form.Get("description")), + ViewType: strings.TrimSpace(form.Get("view_type")), + SortField: strings.TrimSpace(form.Get("sort_field")), + SortDir: strings.TrimSpace(form.Get("sort_dir")), + GroupBy: strings.TrimSpace(form.Get("group_by")), + Pinned: form.Get("pinned") == "1", + IsDefaultFor: strings.TrimSpace(form.Get("is_default_for")), + } + // Prefer filter_query when present; otherwise fall back to filter_json. + if fq := strings.TrimSpace(form.Get("filter_query")); fq != "" { + filterJSON, err := filterQueryToJSON(fq) + if err != nil { + return in, fmt.Errorf("filter_query: %w", err) + } + in.FilterJSON = filterJSON + } else if fj := strings.TrimSpace(form.Get("filter_json")); fj != "" { + in.FilterJSON = []byte(fj) + } + return in, nil +} + +// filterQueryToJSON parses a TreeFilter URL query and returns the canonical +// JSON shape stored in `filter_json`. Mirrors the design doc §2 keys. +func filterQueryToJSON(query string) ([]byte, error) { + q, err := url.ParseQuery(strings.TrimPrefix(query, "?")) + if err != nil { + return nil, err + } + f := ParseTreeFilter(q) + payload := map[string]any{} + if f.Q != "" { + payload["q"] = f.Q + } + if len(f.Tags) > 0 { + payload["tags"] = f.Tags + } + if len(f.Management) > 0 { + payload["management"] = f.Management + } + if !(len(f.Status) == 1 && f.Status[0] == "active") { + payload["status"] = f.Status + } + if len(f.HasLinks) > 0 { + payload["has_links"] = f.HasLinks + } + if f.Public != nil { + payload["public"] = *f.Public + } + if f.ShowArchived { + payload["show_archived"] = true + } + if f.ProjectPath != "" { + payload["project_path"] = f.ProjectPath + if !f.IncludeDescendants { + payload["include_descendants"] = false + } + } + return json.Marshal(payload) +} + +// applySavedView resolves a `?view=` reference and folds the persisted +// filter + view_type back into the supplied TreeFilter + view-type slot. +// Called by every Views-supporting page handler at the top of their render +// path. Returns the saved view (for chip labelling) or nil when no `?view=` +// was given. Errors are logged + returned (handlers can choose to ignore). +func (s *Server) applySavedView(r *http.Request, filter *TreeFilter, viewType *string) (*store.View, error) { + id := strings.TrimSpace(r.URL.Query().Get("view")) + if id == "" { + return nil, nil + } + v, err := s.Store.GetView(r.Context(), id) + if err != nil { + return nil, err + } + payload := map[string]any{} + if len(v.FilterJSON) > 0 { + if err := json.Unmarshal(v.FilterJSON, &payload); err != nil { + return v, fmt.Errorf("decode filter_json: %w", err) + } + } + // Replace filter dimensions with persisted values. Empty / missing keys + // reset to TreeFilter defaults so a saved view is the canonical state. + *filter = filterFromJSONPayload(payload) + *viewType = v.ViewType + return v, nil +} + +// filterFromJSONPayload is the inverse of filterQueryToJSON. Keys absent +// from the payload land at their TreeFilter zero value (Status defaults to +// ["active"] to match ParseTreeFilter). +func filterFromJSONPayload(p map[string]any) TreeFilter { + f := TreeFilter{ + Status: []string{"active"}, + IncludeDescendants: true, + } + if v, ok := p["q"].(string); ok { + f.Q = v + } + if v, ok := p["tags"].([]any); ok { + f.Tags = anySliceToStrings(v) + } + if v, ok := p["management"].([]any); ok { + f.Management = anySliceToStrings(v) + } + if v, ok := p["status"].([]any); ok { + f.Status = anySliceToStrings(v) + if len(f.Status) == 0 { + f.Status = []string{"active"} + } + } + if v, ok := p["has_links"].([]any); ok { + f.HasLinks = anySliceToStrings(v) + } + if v, ok := p["public"].(bool); ok { + f.Public = &v + } + if v, ok := p["show_archived"].(bool); ok && v { + f.ShowArchived = true + } + if v, ok := p["project_path"].(string); ok { + f.ProjectPath = v + } + if v, ok := p["include_descendants"].(bool); ok { + f.IncludeDescendants = v + } + return f +} + +func anySliceToStrings(in []any) []string { + out := make([]string, 0, len(in)) + for _, v := range in { + if s, ok := v.(string); ok { + out = append(out, s) + } + } + return out +} diff --git a/web/views_test.go b/web/views_test.go new file mode 100644 index 0000000..e7723ef --- /dev/null +++ b/web/views_test.go @@ -0,0 +1,123 @@ +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/ redirects to the right page with ?view=. + code, _ = get(t, h, "/views/"+id) + if code != 303 { + t.Errorf("GET /views/ 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//delete") + } +} + +// TestSavedViewAppliedOnQueryParam verifies that opening / with ?view= +// 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") + } +}