feat(views): Phase 5i slice D — saved views table + CRUD + sidebar entry

Persists named bundles of (filter + view_type + sort + group_by). Per m's
Q2 pick (2026-05-26), views are page-agnostic — `is_default_for` lets a
view become the auto-applied default for a page, otherwise views render
on whichever page accepts their view_type.

Schema (db/migrations/0016_views.sql):
- projax.views table with check constraints on view_type (5-value enum),
  sort_dir, is_default_for, and the kanban-needs-group rule.
- Case-insensitive unique name index (live rows only).
- One-default-per-page partial unique index.
- updated_at trigger; projax_admin ownership / grants.

Store (store/views.go):
- View struct + ViewInput; ListViews / GetView / CreateView / UpdateView
  / SoftDeleteView / DefaultViewFor.
- CreateView and UpdateView clear the prior default for a page in the
  same transaction when IsDefaultFor is set — defends against the
  partial unique index outside the SECURITY DEFINER path.
- Validation mirrors the DB check constraints so handlers can surface
  friendlier errors before round-tripping.

Handlers (web/views.go) + routes (web/server.go):
- GET  /views            list + create form (templates/views.tmpl).
- POST /views            create (filter_query form field is parsed into
                         canonical filter_json shape — design.md §2).
- GET  /views/<id>       redirect to the target page + ?view=<id>.
- POST /views/<id>       update.
- POST /views/<id>/delete soft delete.

Resolution path:
- handleTree now calls applySavedView when ?view=<uuid> is present;
  fields the saved filter_json + view_type back into the TreeFilter and
  the view-type slot. view_type then revalidates against the route
  catalog so a saved kanban-view URL on / lands on list with kanban
  shown locked until slice C ships it. Failures fall back gracefully
  (log + URL-derived filter), no 500.

UI:
- Sidebar gains a Views entry (4-square icon) next to Admin in
  layout.tmpl.
- /views renders a flat table + inline create form. The form accepts a
  URL-query filter string (e.g. `tag=work&mgmt=mai`) which is canonised
  into filter_json on save.

Tests:
- TestViewsCRUDRoundTrip — full create / list / open-redirect / soft-
  delete cycle via HTTP, plus filter_json shape assertion.
- TestSavedViewAppliedOnQueryParam — seed a card view scoped to dev,
  hit /?view=<id>, assert the page renders card grid + scoped chip-on.

Out of scope for slice D (per design.md §7):
- HTMX modal save UI from any page (the inline-create-on-/views/ form
  works; a modal lands in a polish pass).
- MCP read tools for views (deferred to a follow-up — m manages views
  via the UI).
This commit is contained in:
mAi
2026-05-26 13:42:51 +02:00
parent 5f712c68d4
commit 2f47b28f39
7 changed files with 851 additions and 1 deletions

View File

@@ -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=<uuid> 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

View File

@@ -80,6 +80,15 @@
</svg>
<span class="nav-label">Graph</span>
</a>
<a href="/views" class="nav-item{{if eq $path "/views"}} active{{end}}" title="Views">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<rect x="3" y="3" width="7" height="7" rx="1"/>
<rect x="14" y="3" width="7" height="7" rx="1"/>
<rect x="3" y="14" width="7" height="7" rx="1"/>
<rect x="14" y="14" width="7" height="7" rx="1"/>
</svg>
<span class="nav-label">Views</span>
</a>
<a href="/admin" class="nav-item{{if eq $path "/admin"}} active{{end}}" title="Admin">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="12" cy="12" r="3"/>

69
web/templates/views.tmpl Normal file
View File

@@ -0,0 +1,69 @@
{{define "content"}}
<h1>Views</h1>
<p class="muted">Saved bundles of (filter + view_type + sort + group_by). Page-agnostic — open one to render the saved set on the matching page.</p>
<section class="views-list">
{{if .Views}}
<table>
<thead>
<tr>
<th>★</th><th>Name</th><th>Type</th><th>Default for</th><th>Group by</th><th></th>
</tr>
</thead>
<tbody>
{{range .Views}}
<tr>
<td>{{if .Pinned}}★{{end}}</td>
<td><a href="/views/{{.ID}}">{{.Name}}</a>{{if .Description}}<br><small class="muted">{{.Description}}</small>{{end}}</td>
<td>{{.ViewType}}</td>
<td>{{if .IsDefaultFor}}{{deref .IsDefaultFor}}{{else}}<span class="muted">—</span>{{end}}</td>
<td>{{if .GroupBy}}{{deref .GroupBy}}{{else}}<span class="muted">—</span>{{end}}</td>
<td>
<form method="post" action="/views/{{.ID}}/delete" style="display:inline">
<button type="submit" class="link-button" onclick="return confirm('Delete view {{.Name}}?')">delete</button>
</form>
</td>
</tr>
{{end}}
</tbody>
</table>
{{else}}
<p class="empty muted"><em>No saved views yet. Create one with the form below or via the "Save view…" link on any Views-supporting page.</em></p>
{{end}}
</section>
<section class="views-create">
<h2>New view</h2>
<form method="post" action="/views">
<label>Name <input type="text" name="name" required maxlength="80"></label>
<label>Description <input type="text" name="description" maxlength="200"></label>
<label>View type
<select name="view_type" required>
{{range .AllViewTypes}}<option value="{{.}}">{{.}}</option>{{end}}
</select>
</label>
<label>Default for
<select name="is_default_for">
{{range .DefaultForOptions}}<option value="{{.}}">{{if eq . ""}}—{{else}}{{.}}{{end}}</option>{{end}}
</select>
</label>
<label>Group by
<select name="group_by">
{{range .GroupByOptions}}<option value="{{.}}">{{if eq . ""}}—{{else}}{{.}}{{end}}</option>{{end}}
</select>
</label>
<label>Sort field <input type="text" name="sort_field" placeholder="title / updated_at / start_time" maxlength="40"></label>
<label>Sort dir
<select name="sort_dir">
{{range .SortDirOptions}}<option value="{{.}}">{{if eq . ""}}—{{else}}{{.}}{{end}}</option>{{end}}
</select>
</label>
<label><input type="checkbox" name="pinned" value="1"> Pinned</label>
<label>Filter (URL query form, e.g. <code>tag=work&amp;mgmt=mai</code>)
<input type="text" name="filter_query" placeholder="tag=work&mgmt=mai" value="{{.Prefill.filter}}">
</label>
<button type="submit">Create view</button>
</form>
</section>
{{end}}

289
web/views.go Normal file
View File

@@ -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=<encoded TreeFilter
// URL query>&prefill_view_type=<vt>&prefill_page=<route> 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/<id> POST routes: bare path is
// update; /views/<id>/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/<uuid> GET into a redirect to the
// appropriate Views-supporting page with ?view=<uuid> 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=<uuid>` 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
}

123
web/views_test.go Normal file
View File

@@ -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/<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")
}
}
// 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")
}
}