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).
274 lines
8.0 KiB
Go
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{}
|