Files
projax/store/views.go
mAi 2f47b28f39 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).
2026-05-26 13:42:51 +02:00

274 lines
8.0 KiB
Go

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{}