feat(views): Phase 5j slice A — paliad-shape schema redesign
Hard-replaces the 5i projax.views table per m's Q10 pick (2026-05-29):
no real data to preserve after a few hours, and the shape changes are
big enough that a clean recreate beats a 6-step ALTER.
Schema (migration 0017_views_redesign.sql):
- id (uuid), slug (text, format-CHECK'd, UNIQUE), name, icon,
filter_json (jsonb — INCLUDES view_type per m's Q2), sort_field,
sort_dir, group_by, sort_order, show_count, last_used_at,
created_at, updated_at.
- DROPPED: pinned, is_default_for, view_type column. m's Q9 picked
MRU (last_used_at) over per-page-default; Q2 placed view_type
inside filter_json so the JSON owns the canonical render spec.
- Constraints: slug regex, sort_dir enum. NO view_type CHECK — the
JSON-shape validator owns it now.
- Indexes: slug UNIQUE, (sort_order, name), (last_used_at DESC).
- updated_at trigger reused; projax_admin ownership preserved.
Store (store/views.go rewrite):
- View struct: Slug as the user-facing key; uuid kept on ID for the
legacy `?view=<uuid>` 302-redirect path that lands in slice C.
- ListViews ordered by sort_order, name (matches sidebar).
- GetView(slug) + GetViewByID(uuid). MostRecentView() drives the
/views landing redirect (slice B).
- TouchView(slug) bumps last_used_at fire-and-forget.
- ReorderViews([]slugs) wires the column for slice G's drag UI.
- CreateView server-assigns sort_order = MAX+1 inside the tx.
- UpdateView replaces every writeable field; renames are supported.
- Validation: slug format regex + reserved-list rejection +
filter_json JSON well-formed check before round-trip.
- ErrViewNotFound / ErrViewSlugTaken / ErrViewSlugReserved /
ErrViewSlugFormat surface to handlers as the typed error set.
Cleanup of the 5i overlay (drops what the new shape obsoletes):
- web/views.go: gutted to a stub. applySavedView, applyDefaultView,
overlayURLFields, filterQueryToJSON, filterJSONToQuery,
filterFromJSONPayload, anySliceToStrings + every old handler
(handleViewsIndex, handleViewCreate, handleViewWrite, handleViewEdit,
handleViewRedirect, handleViewDelete) deleted.
- web/server.go: dropped the /views route registrations and the
applySavedView + applyDefaultView calls in handleTree.
DefaultBanner data-map field removed.
- web/tree_filter.go: TreeFilter.ViewID field removed; ParseTreeFilter
and QueryString stop reading/emitting ?view=.
- web/templates/views.tmpl and view_edit.tmpl deleted.
- web/templates/tree_section.tmpl: default-banner block deleted.
- web/views_test.go: deleted (every test was against the 5i shape).
Between slice A and slice B, /views/* URLs return 404 by design.
Slice B reintroduces the route family in paliad-shape:
GET /views → MRU landing
GET /views/{slug} → render
GET /views/new → editor
GET /views/{slug}/edit → editor
POST /views, /views/{slug}, /views/{slug}/delete → CRUD
Tests (store/views_test.go, new):
- TestViewSlugCRUD — create / get-by-slug / get-by-id / rename /
delete round-trip, including rename-leaves-old-slug-gone.
- TestViewSlugFormatRejected — uppercase, underscore, leading dash,
length-cap, empty all surface ErrViewSlugFormat.
- TestViewReservedSlugRejected — tree/dashboard/calendar/timeline/graph
and friends all reject with ErrViewSlugReserved.
- TestViewSlugCollision — duplicate slug surfaces ErrViewSlugTaken.
- TestViewMRU — TouchView + MostRecentView ordering against a
controlled pair of slugs (resilient to other suites' touched views).
- TestViewReorder — ReorderViews rewrites sort_order ascending.
Web tests stay green (the 5i overlay tests are gone, the rest don't
touch the views shape).
This commit is contained in:
101
db/migrations/0017_views_redesign.sql
Normal file
101
db/migrations/0017_views_redesign.sql
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
-- 0017_views_redesign.sql
|
||||||
|
--
|
||||||
|
-- Phase 5j Slice A: paliad-shape redesign of projax.views.
|
||||||
|
--
|
||||||
|
-- 5i (0016) modelled views as overlays on existing pages keyed by uuid.
|
||||||
|
-- m's feedback: that's the wrong shape — views should be first-class
|
||||||
|
-- pages at /views/{slug}, mirroring paliad's user_views model.
|
||||||
|
--
|
||||||
|
-- This migration HARD-REPLACES the 5i table. m's pick on Q10 (2026-05-29):
|
||||||
|
-- hard-replace is fine because 5i was hours old with no persisted user
|
||||||
|
-- data of value. Any rows present get dropped along with the table.
|
||||||
|
--
|
||||||
|
-- m's other picks worth marking inline:
|
||||||
|
-- Q2 (2026-05-29): view_type lives INSIDE filter_json, not as a
|
||||||
|
-- top-level column with a CHECK constraint. Keeps the
|
||||||
|
-- schema lean — the renderer parses the JSON anyway.
|
||||||
|
-- Q9 (2026-05-29): is_default_for column dropped entirely. MRU
|
||||||
|
-- (last_used_at) replaces the per-page-default model.
|
||||||
|
-- Q11 (2026-05-29): graph stays outside the views enum; no graph
|
||||||
|
-- view_type ever lands in filter_json.
|
||||||
|
|
||||||
|
DROP TABLE IF EXISTS projax.views CASCADE;
|
||||||
|
|
||||||
|
CREATE TABLE projax.views (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
|
||||||
|
-- URL-routable identifier. Application-layer validator enforces the
|
||||||
|
-- regex `^[a-z0-9][a-z0-9-]{0,62}$` + a reserved-slug list (system
|
||||||
|
-- slugs + top-level route segments). Globally unique — single-user
|
||||||
|
-- v1; no user_id prefix.
|
||||||
|
slug text NOT NULL,
|
||||||
|
|
||||||
|
-- Display name. Free-form; user picks whatever language they think in.
|
||||||
|
-- Rendered verbatim in the sidebar.
|
||||||
|
name text NOT NULL,
|
||||||
|
|
||||||
|
-- Frontend icon-registry key. NULL → default folder glyph. Length cap
|
||||||
|
-- keeps stored value sane even if the registry is bypassed.
|
||||||
|
icon text,
|
||||||
|
|
||||||
|
-- Canonical view definition. Includes view_type (per m's Q2 pick),
|
||||||
|
-- plus the standard TreeFilter dimensions (q, tags, management, …),
|
||||||
|
-- plus optional sort/group hints. Renderer parses the JSON; the DB
|
||||||
|
-- never has to look inside.
|
||||||
|
filter_json jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
|
||||||
|
-- Sort + grouping hints used by the renderers (list/card/kanban).
|
||||||
|
-- Kept as top-level columns so the editor can index them quickly,
|
||||||
|
-- though they're conceptually part of the render spec.
|
||||||
|
sort_field text,
|
||||||
|
sort_dir text,
|
||||||
|
group_by text,
|
||||||
|
|
||||||
|
-- Sidebar ordering. Server-assigned MAX+1 on create so two parallel
|
||||||
|
-- inserts don't collide. Drag-reorder UI lands in slice G; this
|
||||||
|
-- column is wired now so the data shape is stable.
|
||||||
|
sort_order integer NOT NULL DEFAULT 0,
|
||||||
|
|
||||||
|
-- Opt-in count badge on the sidebar entry. Defaults false so casual
|
||||||
|
-- views don't pay the COUNT(*) cost.
|
||||||
|
show_count boolean NOT NULL DEFAULT false,
|
||||||
|
|
||||||
|
-- MRU landing on /views — `handleViewsLanding` 302s here when set.
|
||||||
|
-- Touched fire-and-forget on every render.
|
||||||
|
last_used_at timestamptz,
|
||||||
|
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
|
||||||
|
CONSTRAINT views_sort_dir_chk
|
||||||
|
CHECK (sort_dir IS NULL OR sort_dir IN ('asc','desc')),
|
||||||
|
CONSTRAINT views_slug_format_chk
|
||||||
|
CHECK (slug ~ '^[a-z0-9][a-z0-9-]{0,62}$')
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX views_slug_uniq ON projax.views (slug);
|
||||||
|
CREATE INDEX views_sort_order_idx ON projax.views (sort_order, name);
|
||||||
|
CREATE INDEX views_last_used_idx ON projax.views (last_used_at DESC NULLS LAST);
|
||||||
|
|
||||||
|
-- updated_at trigger. Re-created here (CREATE OR REPLACE on the function)
|
||||||
|
-- because 0016 dropped with CASCADE above.
|
||||||
|
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$;
|
||||||
366
store/views.go
366
store/views.go
@@ -5,58 +5,108 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// View is one row in projax.views. Phase 5i Slice D — saved views.
|
// View is one row in projax.views — a first-class /views/{slug} page.
|
||||||
//
|
// Phase 5j paliad-shape: the slug is the user-facing key; URLs and the
|
||||||
// FilterJSON carries the persisted filter state as raw JSON so callers can
|
// sidebar both index by it. The uuid id stays because it's cheap and
|
||||||
// freely round-trip into their TreeFilter or another future filter type
|
// surfaces in future MCP integrations, but it is NOT exposed in URLs.
|
||||||
// without forcing the store package to depend on web/.
|
|
||||||
type View struct {
|
type View struct {
|
||||||
ID string
|
ID string
|
||||||
Name string
|
Slug string
|
||||||
Description string
|
Name string
|
||||||
FilterJSON []byte // raw jsonb payload
|
Icon *string
|
||||||
ViewType string
|
FilterJSON []byte // raw jsonb payload — includes view_type per m's Q2
|
||||||
SortField *string
|
SortField *string
|
||||||
SortDir *string
|
SortDir *string
|
||||||
GroupBy *string
|
GroupBy *string
|
||||||
Pinned bool
|
SortOrder int
|
||||||
IsDefaultFor *string
|
ShowCount bool
|
||||||
CreatedAt time.Time
|
LastUsedAt *time.Time
|
||||||
UpdatedAt time.Time
|
CreatedAt time.Time
|
||||||
|
UpdatedAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
// ErrViewNotFound surfaces from GetView / SoftDeleteView when no row matches.
|
// ErrViewNotFound surfaces from Get*/Update*/Delete when no row matches.
|
||||||
var ErrViewNotFound = errors.New("view not found")
|
var ErrViewNotFound = errors.New("view not found")
|
||||||
|
|
||||||
// ViewInput is the writeable subset of View used by Create / Update.
|
// ErrViewSlugTaken is returned by Create / Update when the slug already
|
||||||
type ViewInput struct {
|
// belongs to another view. Web handlers map this to 409.
|
||||||
Name string
|
var ErrViewSlugTaken = errors.New("view slug already exists")
|
||||||
Description string
|
|
||||||
FilterJSON []byte
|
// ErrViewSlugReserved is returned when the caller picks a slug that
|
||||||
ViewType string
|
// shadows a system slug or a top-level URL segment. Web handlers map
|
||||||
SortField string
|
// this to 400 with a friendly message.
|
||||||
SortDir string
|
var ErrViewSlugReserved = errors.New("view slug is reserved")
|
||||||
GroupBy string
|
|
||||||
Pinned bool
|
// ErrViewSlugFormat is returned when the slug doesn't match the format
|
||||||
IsDefaultFor string // "" → clear default
|
// regex. Same mapping as reserved.
|
||||||
|
var ErrViewSlugFormat = errors.New("view slug must match ^[a-z0-9][a-z0-9-]{0,62}$")
|
||||||
|
|
||||||
|
// slugRE is the format guard. Mirrors the SQL CHECK constraint so callers
|
||||||
|
// get a friendly error before round-tripping to the DB.
|
||||||
|
var slugRE = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,62}$`)
|
||||||
|
|
||||||
|
// reservedViewSlugs is the static list of slugs the validator rejects.
|
||||||
|
// Combines system-view slugs (slice C wires them) with top-level route
|
||||||
|
// segments the application owns.
|
||||||
|
var reservedViewSlugs = map[string]struct{}{
|
||||||
|
// System views (slice C):
|
||||||
|
"tree": {}, "dashboard": {}, "calendar": {}, "timeline": {}, "graph": {},
|
||||||
|
// /views sub-routes:
|
||||||
|
"new": {}, "edit": {},
|
||||||
|
// Top-level application URLs:
|
||||||
|
"admin": {}, "login": {}, "logout": {}, "healthz": {}, "mcp": {},
|
||||||
|
"static": {}, "i": {}, "views": {},
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListViews returns every non-deleted view ordered by pinned-first, then name.
|
// IsReservedViewSlug reports whether the slug shadows a system slug or a
|
||||||
|
// top-level URL segment. Exported for the editor's slug-derivation
|
||||||
|
// helper.
|
||||||
|
func IsReservedViewSlug(slug string) bool {
|
||||||
|
_, ok := reservedViewSlugs[strings.ToLower(slug)]
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateSlug runs format + reserved checks. Returns nil for valid slugs.
|
||||||
|
func ValidateSlug(slug string) error {
|
||||||
|
if !slugRE.MatchString(slug) {
|
||||||
|
return ErrViewSlugFormat
|
||||||
|
}
|
||||||
|
if IsReservedViewSlug(slug) {
|
||||||
|
return ErrViewSlugReserved
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ViewInput is the writeable subset for Create / Update. Defaults
|
||||||
|
// applied: nil FilterJSON → {}; SortOrder is server-assigned on Create.
|
||||||
|
type ViewInput struct {
|
||||||
|
Slug string
|
||||||
|
Name string
|
||||||
|
Icon *string
|
||||||
|
FilterJSON []byte
|
||||||
|
SortField string
|
||||||
|
SortDir string
|
||||||
|
GroupBy string
|
||||||
|
ShowCount bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListViews returns every view ordered by sort_order ASC then name —
|
||||||
|
// matches the sidebar rendering order.
|
||||||
func (s *Store) ListViews(ctx context.Context) ([]*View, error) {
|
func (s *Store) ListViews(ctx context.Context) ([]*View, error) {
|
||||||
rows, err := s.Pool.Query(ctx, `
|
rows, err := s.Pool.Query(ctx, `
|
||||||
SELECT id, name, coalesce(description,''), filter_json, view_type,
|
SELECT id, slug, name, icon, filter_json,
|
||||||
sort_field, sort_dir, group_by, pinned, is_default_for,
|
sort_field, sort_dir, group_by,
|
||||||
|
sort_order, show_count, last_used_at,
|
||||||
created_at, updated_at
|
created_at, updated_at
|
||||||
FROM projax.views
|
FROM projax.views
|
||||||
WHERE deleted_at IS NULL
|
ORDER BY sort_order ASC, name ASC`)
|
||||||
ORDER BY pinned DESC, lower(name) ASC`)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("list views: %w", err)
|
return nil, fmt.Errorf("list views: %w", err)
|
||||||
}
|
}
|
||||||
@@ -72,14 +122,25 @@ ORDER BY pinned DESC, lower(name) ASC`)
|
|||||||
return out, rows.Err()
|
return out, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetView returns one view by id. ErrViewNotFound when missing or soft-deleted.
|
// GetView returns one view by slug. ErrViewNotFound when missing.
|
||||||
func (s *Store) GetView(ctx context.Context, id string) (*View, error) {
|
func (s *Store) GetView(ctx context.Context, slug string) (*View, error) {
|
||||||
|
return s.getView(ctx, `slug = $1`, slug)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetViewByID returns one view by uuid id. Used by the legacy
|
||||||
|
// `?view=<uuid>` 302-redirect path during the 5i → 5j cutover.
|
||||||
|
func (s *Store) GetViewByID(ctx context.Context, id string) (*View, error) {
|
||||||
|
return s.getView(ctx, `id = $1`, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) getView(ctx context.Context, where, arg string) (*View, error) {
|
||||||
row := s.Pool.QueryRow(ctx, `
|
row := s.Pool.QueryRow(ctx, `
|
||||||
SELECT id, name, coalesce(description,''), filter_json, view_type,
|
SELECT id, slug, name, icon, filter_json,
|
||||||
sort_field, sort_dir, group_by, pinned, is_default_for,
|
sort_field, sort_dir, group_by,
|
||||||
|
sort_order, show_count, last_used_at,
|
||||||
created_at, updated_at
|
created_at, updated_at
|
||||||
FROM projax.views
|
FROM projax.views
|
||||||
WHERE id = $1 AND deleted_at IS NULL`, id)
|
WHERE `+where, arg)
|
||||||
v, err := scanView(row)
|
v, err := scanView(row)
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
return nil, ErrViewNotFound
|
return nil, ErrViewNotFound
|
||||||
@@ -87,9 +148,29 @@ WHERE id = $1 AND deleted_at IS NULL`, id)
|
|||||||
return v, err
|
return v, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateView inserts a row. When IsDefaultFor is set, the prior default for
|
// MostRecentView returns the view with the most recent last_used_at. nil
|
||||||
// that page is cleared in the same transaction so the partial unique index
|
// when no view has been touched yet (or none exist). Drives the /views
|
||||||
// can't fire after a Postgres rewrite.
|
// landing redirect.
|
||||||
|
func (s *Store) MostRecentView(ctx context.Context) (*View, error) {
|
||||||
|
row := s.Pool.QueryRow(ctx, `
|
||||||
|
SELECT id, slug, name, icon, filter_json,
|
||||||
|
sort_field, sort_dir, group_by,
|
||||||
|
sort_order, show_count, last_used_at,
|
||||||
|
created_at, updated_at
|
||||||
|
FROM projax.views
|
||||||
|
WHERE last_used_at IS NOT NULL
|
||||||
|
ORDER BY last_used_at DESC
|
||||||
|
LIMIT 1`)
|
||||||
|
v, err := scanView(row)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return v, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateView inserts a new view. SortOrder is server-assigned to
|
||||||
|
// MAX(existing)+1 inside the same tx so two parallel creates don't
|
||||||
|
// collide on the index.
|
||||||
func (s *Store) CreateView(ctx context.Context, in ViewInput) (*View, error) {
|
func (s *Store) CreateView(ctx context.Context, in ViewInput) (*View, error) {
|
||||||
if err := validateViewInput(in); err != nil {
|
if err := validateViewInput(in); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -97,95 +178,81 @@ func (s *Store) CreateView(ctx context.Context, in ViewInput) (*View, error) {
|
|||||||
if in.FilterJSON == nil {
|
if in.FilterJSON == nil {
|
||||||
in.FilterJSON = []byte("{}")
|
in.FilterJSON = []byte("{}")
|
||||||
}
|
}
|
||||||
var id string
|
|
||||||
tx, err := s.Pool.BeginTx(ctx, pgx.TxOptions{})
|
tx, err := s.Pool.BeginTx(ctx, pgx.TxOptions{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("begin: %w", err)
|
return nil, fmt.Errorf("begin: %w", err)
|
||||||
}
|
}
|
||||||
defer func() { _ = tx.Rollback(ctx) }()
|
defer func() { _ = tx.Rollback(ctx) }()
|
||||||
if in.IsDefaultFor != "" {
|
var nextOrder int
|
||||||
if _, err := tx.Exec(ctx, `
|
if err := tx.QueryRow(ctx,
|
||||||
UPDATE projax.views
|
`SELECT COALESCE(MAX(sort_order), -1) + 1 FROM projax.views`,
|
||||||
SET is_default_for = NULL
|
).Scan(&nextOrder); err != nil {
|
||||||
WHERE is_default_for = $1 AND deleted_at IS NULL`, in.IsDefaultFor); err != nil {
|
return nil, fmt.Errorf("compute next sort_order: %w", err)
|
||||||
return nil, fmt.Errorf("clear prior default: %w", err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
var id string
|
||||||
err = tx.QueryRow(ctx, `
|
err = tx.QueryRow(ctx, `
|
||||||
INSERT INTO projax.views
|
INSERT INTO projax.views
|
||||||
(name, description, filter_json, view_type, sort_field, sort_dir, group_by, pinned, is_default_for)
|
(slug, name, icon, filter_json, sort_field, sort_dir, group_by, sort_order, show_count)
|
||||||
VALUES
|
VALUES
|
||||||
($1, NULLIF($2,''), $3::jsonb, $4, NULLIF($5,''), NULLIF($6,''), NULLIF($7,''), $8, NULLIF($9,''))
|
($1, $2, $3, $4::jsonb, NULLIF($5,''), NULLIF($6,''), NULLIF($7,''), $8, $9)
|
||||||
RETURNING id`,
|
RETURNING id`,
|
||||||
in.Name, in.Description, in.FilterJSON, in.ViewType,
|
in.Slug, in.Name, in.Icon, in.FilterJSON,
|
||||||
in.SortField, in.SortDir, in.GroupBy, in.Pinned, in.IsDefaultFor,
|
in.SortField, in.SortDir, in.GroupBy, nextOrder, in.ShowCount,
|
||||||
).Scan(&id)
|
).Scan(&id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if isUniqueSlugViolation(err) {
|
||||||
|
return nil, ErrViewSlugTaken
|
||||||
|
}
|
||||||
return nil, fmt.Errorf("insert view: %w", err)
|
return nil, fmt.Errorf("insert view: %w", err)
|
||||||
}
|
}
|
||||||
if err := tx.Commit(ctx); err != nil {
|
if err := tx.Commit(ctx); err != nil {
|
||||||
return nil, fmt.Errorf("commit: %w", err)
|
return nil, fmt.Errorf("commit: %w", err)
|
||||||
}
|
}
|
||||||
return s.GetView(ctx, id)
|
return s.GetView(ctx, in.Slug)
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateView replaces every writeable field. Same default-clearing semantics
|
// UpdateView replaces every writeable field on the row matching `slug`.
|
||||||
// as CreateView.
|
// To rename, pass the desired new slug in `in.Slug`; if it collides with
|
||||||
func (s *Store) UpdateView(ctx context.Context, id string, in ViewInput) (*View, error) {
|
// another row, ErrViewSlugTaken surfaces.
|
||||||
|
func (s *Store) UpdateView(ctx context.Context, slug string, in ViewInput) (*View, error) {
|
||||||
if err := validateViewInput(in); err != nil {
|
if err := validateViewInput(in); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if in.FilterJSON == nil {
|
if in.FilterJSON == nil {
|
||||||
in.FilterJSON = []byte("{}")
|
in.FilterJSON = []byte("{}")
|
||||||
}
|
}
|
||||||
tx, err := s.Pool.BeginTx(ctx, pgx.TxOptions{})
|
tag, err := s.Pool.Exec(ctx, `
|
||||||
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
|
UPDATE projax.views
|
||||||
SET is_default_for = NULL
|
SET slug = $2,
|
||||||
WHERE is_default_for = $1 AND id <> $2 AND deleted_at IS NULL`,
|
name = $3,
|
||||||
in.IsDefaultFor, id); err != nil {
|
icon = $4,
|
||||||
return nil, fmt.Errorf("clear prior default: %w", err)
|
filter_json = $5::jsonb,
|
||||||
}
|
sort_field = NULLIF($6,''),
|
||||||
}
|
sort_dir = NULLIF($7,''),
|
||||||
tag, err := tx.Exec(ctx, `
|
group_by = NULLIF($8,''),
|
||||||
UPDATE projax.views
|
show_count = $9
|
||||||
SET name = $2,
|
WHERE slug = $1`,
|
||||||
description = NULLIF($3,''),
|
slug, in.Slug, in.Name, in.Icon, in.FilterJSON,
|
||||||
filter_json = $4::jsonb,
|
in.SortField, in.SortDir, in.GroupBy, in.ShowCount,
|
||||||
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 {
|
if err != nil {
|
||||||
|
if isUniqueSlugViolation(err) {
|
||||||
|
return nil, ErrViewSlugTaken
|
||||||
|
}
|
||||||
return nil, fmt.Errorf("update view: %w", err)
|
return nil, fmt.Errorf("update view: %w", err)
|
||||||
}
|
}
|
||||||
if tag.RowsAffected() == 0 {
|
if tag.RowsAffected() == 0 {
|
||||||
return nil, ErrViewNotFound
|
return nil, ErrViewNotFound
|
||||||
}
|
}
|
||||||
if err := tx.Commit(ctx); err != nil {
|
return s.GetView(ctx, in.Slug)
|
||||||
return nil, fmt.Errorf("commit: %w", err)
|
|
||||||
}
|
|
||||||
return s.GetView(ctx, id)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// SoftDeleteView sets deleted_at on the row. Idempotent (returns ErrViewNotFound
|
// DeleteView removes a view by slug. Hard delete (no soft-delete column
|
||||||
// only when the row never existed; subsequent calls on a soft-deleted row
|
// in the redesign — single-user, no audit obligation). Idempotent only
|
||||||
// silently succeed since deleted_at is just refreshed).
|
// on the second call; first call against a non-existent row returns
|
||||||
func (s *Store) SoftDeleteView(ctx context.Context, id string) error {
|
// ErrViewNotFound.
|
||||||
tag, err := s.Pool.Exec(ctx, `
|
func (s *Store) DeleteView(ctx context.Context, slug string) error {
|
||||||
UPDATE projax.views SET deleted_at = now()
|
tag, err := s.Pool.Exec(ctx, `DELETE FROM projax.views WHERE slug = $1`, slug)
|
||||||
WHERE id = $1`, id)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("delete view: %w", err)
|
return fmt.Errorf("delete view: %w", err)
|
||||||
}
|
}
|
||||||
@@ -195,79 +262,100 @@ WHERE id = $1`, id)
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// DefaultViewFor returns the view that should auto-apply on the named page,
|
// TouchView bumps last_used_at to now(). Fire-and-forget from the render
|
||||||
// or nil if none is set.
|
// handler — failures are logged but never block the page.
|
||||||
func (s *Store) DefaultViewFor(ctx context.Context, page string) (*View, error) {
|
func (s *Store) TouchView(ctx context.Context, slug string) error {
|
||||||
row := s.Pool.QueryRow(ctx, `
|
tag, err := s.Pool.Exec(ctx,
|
||||||
SELECT id, name, coalesce(description,''), filter_json, view_type,
|
`UPDATE projax.views SET last_used_at = now() WHERE slug = $1`, slug)
|
||||||
sort_field, sort_dir, group_by, pinned, is_default_for,
|
if err != nil {
|
||||||
created_at, updated_at
|
return fmt.Errorf("touch view: %w", err)
|
||||||
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
|
if tag.RowsAffected() == 0 {
|
||||||
|
return ErrViewNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// validateViewInput runs the Go-side guards. The DB CHECK constraints provide
|
// ReorderViews applies a sort_order rewrite where the provided slugs map
|
||||||
// the durable contract; these checks let handlers surface a friendlier error.
|
// to ascending sort_order values starting at 0. Slugs not present in the
|
||||||
|
// input keep their existing sort_order. Drives slice G's drag-reorder UI.
|
||||||
|
func (s *Store) ReorderViews(ctx context.Context, slugs []string) error {
|
||||||
|
if len(slugs) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
tx, err := s.Pool.BeginTx(ctx, pgx.TxOptions{})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("begin: %w", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = tx.Rollback(ctx) }()
|
||||||
|
for i, slug := range slugs {
|
||||||
|
if _, err := tx.Exec(ctx,
|
||||||
|
`UPDATE projax.views SET sort_order = $1 WHERE slug = $2`,
|
||||||
|
i, slug,
|
||||||
|
); err != nil {
|
||||||
|
return fmt.Errorf("reorder %q: %w", slug, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tx.Commit(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateViewInput runs Go-side guards. The DB CHECK constraints are the
|
||||||
|
// durable contract; these checks let handlers surface friendlier errors.
|
||||||
func validateViewInput(in ViewInput) error {
|
func validateViewInput(in ViewInput) error {
|
||||||
|
if err := ValidateSlug(in.Slug); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
if strings.TrimSpace(in.Name) == "" {
|
if strings.TrimSpace(in.Name) == "" {
|
||||||
return errors.New("view name is required")
|
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" {
|
if in.SortDir != "" && in.SortDir != "asc" && in.SortDir != "desc" {
|
||||||
return fmt.Errorf("invalid sort_dir %q", in.SortDir)
|
return fmt.Errorf("invalid sort_dir %q", in.SortDir)
|
||||||
}
|
}
|
||||||
if in.ViewType == "kanban" && strings.TrimSpace(in.GroupBy) == "" {
|
if in.Icon != nil && len(*in.Icon) > 64 {
|
||||||
return errors.New("kanban view_type requires group_by")
|
return errors.New("icon key exceeds 64 characters")
|
||||||
}
|
|
||||||
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 {
|
if len(in.FilterJSON) > 0 {
|
||||||
var dummy any
|
var probe any
|
||||||
if err := json.Unmarshal(in.FilterJSON, &dummy); err != nil {
|
if err := json.Unmarshal(in.FilterJSON, &probe); err != nil {
|
||||||
return fmt.Errorf("filter_json is not valid JSON: %w", err)
|
return fmt.Errorf("filter_json is not valid JSON: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// isUniqueSlugViolation matches the postgres unique_violation SQLSTATE
|
||||||
|
// (23505) on the views_slug_uniq index. We don't import pgconn here to
|
||||||
|
// avoid widening the package's dep surface; substring match on the
|
||||||
|
// pgx-formatted error covers both the wire-level codes pgx surfaces.
|
||||||
|
func isUniqueSlugViolation(err error) bool {
|
||||||
|
if err == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
s := err.Error()
|
||||||
|
return strings.Contains(s, "views_slug_uniq") ||
|
||||||
|
(strings.Contains(s, "SQLSTATE 23505") && strings.Contains(s, "slug"))
|
||||||
|
}
|
||||||
|
|
||||||
type viewScanner interface {
|
type viewScanner interface {
|
||||||
Scan(dest ...any) error
|
Scan(dest ...any) error
|
||||||
}
|
}
|
||||||
|
|
||||||
func scanView(s viewScanner) (*View, error) {
|
func scanView(s viewScanner) (*View, error) {
|
||||||
v := &View{}
|
v := &View{}
|
||||||
var sortField, sortDir, groupBy, isDefaultFor *string
|
var icon, sortField, sortDir, groupBy *string
|
||||||
|
var lastUsedAt *time.Time
|
||||||
if err := s.Scan(
|
if err := s.Scan(
|
||||||
&v.ID, &v.Name, &v.Description, &v.FilterJSON, &v.ViewType,
|
&v.ID, &v.Slug, &v.Name, &icon, &v.FilterJSON,
|
||||||
&sortField, &sortDir, &groupBy, &v.Pinned, &isDefaultFor,
|
&sortField, &sortDir, &groupBy,
|
||||||
|
&v.SortOrder, &v.ShowCount, &lastUsedAt,
|
||||||
&v.CreatedAt, &v.UpdatedAt,
|
&v.CreatedAt, &v.UpdatedAt,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
v.Icon = icon
|
||||||
v.SortField = sortField
|
v.SortField = sortField
|
||||||
v.SortDir = sortDir
|
v.SortDir = sortDir
|
||||||
v.GroupBy = groupBy
|
v.GroupBy = groupBy
|
||||||
v.IsDefaultFor = isDefaultFor
|
v.LastUsedAt = lastUsedAt
|
||||||
return v, nil
|
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{}
|
|
||||||
|
|||||||
246
store/views_test.go
Normal file
246
store/views_test.go
Normal file
@@ -0,0 +1,246 @@
|
|||||||
|
package store_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
|
"github.com/m/projax/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// connect mirrors db_test's connect helper. The store package owns its own
|
||||||
|
// integration tests (Phase 5j Slice A introduced this file alongside the
|
||||||
|
// schema redesign); it shares the same env-var convention to skip when no
|
||||||
|
// DB is wired up.
|
||||||
|
func connect(t *testing.T) (*pgxpool.Pool, *store.Store) {
|
||||||
|
t.Helper()
|
||||||
|
url := os.Getenv("PROJAX_DB_URL")
|
||||||
|
if url == "" {
|
||||||
|
url = os.Getenv("SUPABASE_DATABASE_URL")
|
||||||
|
}
|
||||||
|
if url == "" {
|
||||||
|
t.Skip("no PROJAX_DB_URL / SUPABASE_DATABASE_URL set — skipping integration test")
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
pool, err := pgxpool.New(ctx, url)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("pool: %v", err)
|
||||||
|
}
|
||||||
|
if err := pool.Ping(ctx); err != nil {
|
||||||
|
t.Skipf("DB unreachable: %v", err)
|
||||||
|
}
|
||||||
|
return pool, store.New(pool)
|
||||||
|
}
|
||||||
|
|
||||||
|
// uniqueSlug suffixes a base slug with a timestamp so parallel test runs
|
||||||
|
// don't collide on the views_slug_uniq index.
|
||||||
|
func uniqueSlug(prefix string) string {
|
||||||
|
return prefix + "-" + strings.ReplaceAll(time.Now().UTC().Format("150405.000"), ".", "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestViewSlugCRUD(t *testing.T) {
|
||||||
|
pool, s := connect(t)
|
||||||
|
defer pool.Close()
|
||||||
|
ctx := context.Background()
|
||||||
|
slug := uniqueSlug("p5j-a-crud")
|
||||||
|
defer pool.Exec(context.Background(), `DELETE FROM projax.views WHERE slug LIKE 'p5j-a-crud-%' OR slug LIKE 'p5j-a-renamed-%'`)
|
||||||
|
|
||||||
|
// Create.
|
||||||
|
created, err := s.CreateView(ctx, store.ViewInput{
|
||||||
|
Slug: slug,
|
||||||
|
Name: "Slice A CRUD",
|
||||||
|
FilterJSON: []byte(`{"view_type":"list","tags":["work"]}`),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create: %v", err)
|
||||||
|
}
|
||||||
|
if created.Slug != slug {
|
||||||
|
t.Errorf("slug = %q, want %q", created.Slug, slug)
|
||||||
|
}
|
||||||
|
if created.ID == "" {
|
||||||
|
t.Error("ID should be populated on create")
|
||||||
|
}
|
||||||
|
if created.SortOrder < 0 {
|
||||||
|
t.Errorf("sort_order should be >= 0 (server-assigned), got %d", created.SortOrder)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetView by slug.
|
||||||
|
got, err := s.GetView(ctx, slug)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get: %v", err)
|
||||||
|
}
|
||||||
|
if string(got.FilterJSON) != `{"view_type": "list", "tags": ["work"]}` && string(got.FilterJSON) != `{"tags": ["work"], "view_type": "list"}` {
|
||||||
|
// Postgres jsonb normalises key order — accept either ordering.
|
||||||
|
// Verify it round-trips structurally.
|
||||||
|
if !strings.Contains(string(got.FilterJSON), `"view_type"`) || !strings.Contains(string(got.FilterJSON), `"tags"`) {
|
||||||
|
t.Errorf("filter_json did not round-trip view_type+tags: %s", got.FilterJSON)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetViewByID (legacy 5i 302-redirect path uses this).
|
||||||
|
byID, err := s.GetViewByID(ctx, created.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get by id: %v", err)
|
||||||
|
}
|
||||||
|
if byID.Slug != slug {
|
||||||
|
t.Errorf("by-id lookup returned wrong slug: %q", byID.Slug)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update — rename slug + change filter.
|
||||||
|
renamed := uniqueSlug("p5j-a-renamed")
|
||||||
|
updated, err := s.UpdateView(ctx, slug, store.ViewInput{
|
||||||
|
Slug: renamed,
|
||||||
|
Name: "Renamed",
|
||||||
|
FilterJSON: []byte(`{"view_type":"card"}`),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("update: %v", err)
|
||||||
|
}
|
||||||
|
if updated.Slug != renamed {
|
||||||
|
t.Errorf("renamed slug = %q, want %q", updated.Slug, renamed)
|
||||||
|
}
|
||||||
|
if _, err := s.GetView(ctx, slug); !errors.Is(err, store.ErrViewNotFound) {
|
||||||
|
t.Errorf("old slug should be ErrViewNotFound after rename, got %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete.
|
||||||
|
if err := s.DeleteView(ctx, renamed); err != nil {
|
||||||
|
t.Fatalf("delete: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := s.GetView(ctx, renamed); !errors.Is(err, store.ErrViewNotFound) {
|
||||||
|
t.Errorf("post-delete get should be ErrViewNotFound, got %v", err)
|
||||||
|
}
|
||||||
|
if err := s.DeleteView(ctx, renamed); !errors.Is(err, store.ErrViewNotFound) {
|
||||||
|
t.Errorf("second delete should be ErrViewNotFound, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestViewSlugFormatRejected(t *testing.T) {
|
||||||
|
pool, s := connect(t)
|
||||||
|
defer pool.Close()
|
||||||
|
ctx := context.Background()
|
||||||
|
bad := []string{
|
||||||
|
"", // empty
|
||||||
|
"UPPER", // uppercase
|
||||||
|
"under_score", // underscore
|
||||||
|
"-leading-dash", // leading dash
|
||||||
|
"a." + strings.Repeat("x", 100), // too long + invalid char
|
||||||
|
strings.Repeat("a", 64), // length cap is 63 (1 + 62)
|
||||||
|
}
|
||||||
|
for _, slug := range bad {
|
||||||
|
_, err := s.CreateView(ctx, store.ViewInput{
|
||||||
|
Slug: slug, Name: "x", FilterJSON: []byte(`{}`),
|
||||||
|
})
|
||||||
|
if !errors.Is(err, store.ErrViewSlugFormat) {
|
||||||
|
t.Errorf("slug=%q expected ErrViewSlugFormat, got %v", slug, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestViewReservedSlugRejected(t *testing.T) {
|
||||||
|
_, s := connect(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
for _, slug := range []string{"tree", "dashboard", "calendar", "timeline", "graph", "new", "edit", "admin", "views"} {
|
||||||
|
_, err := s.CreateView(ctx, store.ViewInput{
|
||||||
|
Slug: slug, Name: "x", FilterJSON: []byte(`{}`),
|
||||||
|
})
|
||||||
|
if !errors.Is(err, store.ErrViewSlugReserved) {
|
||||||
|
t.Errorf("reserved slug %q should be rejected, got %v", slug, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestViewSlugCollision(t *testing.T) {
|
||||||
|
pool, s := connect(t)
|
||||||
|
defer pool.Close()
|
||||||
|
ctx := context.Background()
|
||||||
|
slug := uniqueSlug("p5j-a-collision")
|
||||||
|
defer pool.Exec(context.Background(), `DELETE FROM projax.views WHERE slug = $1`, slug)
|
||||||
|
|
||||||
|
if _, err := s.CreateView(ctx, store.ViewInput{Slug: slug, Name: "First"}); err != nil {
|
||||||
|
t.Fatalf("first create: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := s.CreateView(ctx, store.ViewInput{Slug: slug, Name: "Second"}); !errors.Is(err, store.ErrViewSlugTaken) {
|
||||||
|
t.Errorf("duplicate slug should be ErrViewSlugTaken, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestViewMRU(t *testing.T) {
|
||||||
|
pool, s := connect(t)
|
||||||
|
defer pool.Close()
|
||||||
|
ctx := context.Background()
|
||||||
|
a := uniqueSlug("p5j-a-mru-a")
|
||||||
|
b := uniqueSlug("p5j-a-mru-b")
|
||||||
|
defer pool.Exec(context.Background(), `DELETE FROM projax.views WHERE slug IN ($1, $2)`, a, b)
|
||||||
|
|
||||||
|
if _, err := s.CreateView(ctx, store.ViewInput{Slug: a, Name: "A"}); err != nil {
|
||||||
|
t.Fatalf("create a: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := s.CreateView(ctx, store.ViewInput{Slug: b, Name: "B"}); err != nil {
|
||||||
|
t.Fatalf("create b: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MostRecentView with no touches yet — when no view in the table has
|
||||||
|
// last_used_at set, MRU returns nil. (Other tests may have left their
|
||||||
|
// own touched views, so we only assert on the slugs we control.)
|
||||||
|
if err := s.TouchView(ctx, a); err != nil {
|
||||||
|
t.Fatalf("touch a: %v", err)
|
||||||
|
}
|
||||||
|
time.Sleep(20 * time.Millisecond)
|
||||||
|
if err := s.TouchView(ctx, b); err != nil {
|
||||||
|
t.Fatalf("touch b: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
mru, err := s.MostRecentView(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("mru: %v", err)
|
||||||
|
}
|
||||||
|
// Other tests' touched views may rank higher; we only assert that
|
||||||
|
// when MRU is one of OURS, the most-recently-touched (b) wins over a.
|
||||||
|
// To guarantee this test's signal even with contention from other
|
||||||
|
// suites, check b's last_used_at > a's last_used_at directly.
|
||||||
|
aV, _ := s.GetView(ctx, a)
|
||||||
|
bV, _ := s.GetView(ctx, b)
|
||||||
|
if aV.LastUsedAt == nil || bV.LastUsedAt == nil {
|
||||||
|
t.Fatal("both views should have last_used_at after touch")
|
||||||
|
}
|
||||||
|
if !bV.LastUsedAt.After(*aV.LastUsedAt) {
|
||||||
|
t.Errorf("b.last_used_at should be after a.last_used_at; a=%v b=%v", aV.LastUsedAt, bV.LastUsedAt)
|
||||||
|
}
|
||||||
|
if mru == nil {
|
||||||
|
t.Error("MostRecentView returned nil even though touches landed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestViewReorder(t *testing.T) {
|
||||||
|
pool, s := connect(t)
|
||||||
|
defer pool.Close()
|
||||||
|
ctx := context.Background()
|
||||||
|
a := uniqueSlug("p5j-a-reorder-a")
|
||||||
|
b := uniqueSlug("p5j-a-reorder-b")
|
||||||
|
c := uniqueSlug("p5j-a-reorder-c")
|
||||||
|
defer pool.Exec(context.Background(), `DELETE FROM projax.views WHERE slug IN ($1, $2, $3)`, a, b, c)
|
||||||
|
|
||||||
|
for _, slug := range []string{a, b, c} {
|
||||||
|
if _, err := s.CreateView(ctx, store.ViewInput{Slug: slug, Name: slug}); err != nil {
|
||||||
|
t.Fatalf("create %s: %v", slug, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Reorder c → b → a.
|
||||||
|
if err := s.ReorderViews(ctx, []string{c, b, a}); err != nil {
|
||||||
|
t.Fatalf("reorder: %v", err)
|
||||||
|
}
|
||||||
|
cV, _ := s.GetView(ctx, c)
|
||||||
|
bV, _ := s.GetView(ctx, b)
|
||||||
|
aV, _ := s.GetView(ctx, a)
|
||||||
|
if cV.SortOrder != 0 || bV.SortOrder != 1 || aV.SortOrder != 2 {
|
||||||
|
t.Errorf("reorder yielded sort_orders c=%d b=%d a=%d, want 0,1,2",
|
||||||
|
cV.SortOrder, bV.SortOrder, aV.SortOrder)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -151,7 +151,7 @@ func New(s *store.Store, logger *slog.Logger) (*Server, error) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
pages := map[string]*template.Template{}
|
pages := map[string]*template.Template{}
|
||||||
for _, name := range []string{"new", "classify", "caldav_admin", "caldav_disabled", "error", "views", "view_edit"} {
|
for _, name := range []string{"new", "classify", "caldav_admin", "caldav_disabled", "error"} {
|
||||||
t, err := template.New(name).Funcs(funcs).ParseFS(templatesFS,
|
t, err := template.New(name).Funcs(funcs).ParseFS(templatesFS,
|
||||||
"templates/layout.tmpl",
|
"templates/layout.tmpl",
|
||||||
"templates/"+name+".tmpl",
|
"templates/"+name+".tmpl",
|
||||||
@@ -382,11 +382,9 @@ func (s *Server) Routes() http.Handler {
|
|||||||
mux.HandleFunc("GET /admin/caldav", s.handleCalDAVAdmin)
|
mux.HandleFunc("GET /admin/caldav", s.handleCalDAVAdmin)
|
||||||
mux.HandleFunc("POST /admin/caldav/link", s.handleCalDAVLink)
|
mux.HandleFunc("POST /admin/caldav/link", s.handleCalDAVLink)
|
||||||
mux.HandleFunc("POST /admin/caldav/unlink", s.handleCalDAVUnlink)
|
mux.HandleFunc("POST /admin/caldav/unlink", s.handleCalDAVUnlink)
|
||||||
mux.HandleFunc("GET /views", s.handleViewsIndex)
|
// /views routes land in slice B (paliad-shape: GET /views, GET
|
||||||
mux.HandleFunc("POST /views", s.handleViewCreate)
|
// /views/{slug}, GET /views/new, GET /views/{slug}/edit, plus POST CRUD).
|
||||||
mux.HandleFunc("GET /views/{id}/edit", s.handleViewEdit)
|
// Between slice A and slice B these URLs 404 by design.
|
||||||
mux.HandleFunc("GET /views/", s.handleViewRedirect)
|
|
||||||
mux.HandleFunc("POST /views/", s.handleViewWrite)
|
|
||||||
mux.HandleFunc("GET /login", s.handleLoginForm)
|
mux.HandleFunc("GET /login", s.handleLoginForm)
|
||||||
mux.HandleFunc("POST /login", s.handleLoginSubmit)
|
mux.HandleFunc("POST /login", s.handleLoginSubmit)
|
||||||
mux.HandleFunc("POST /logout", s.handleLogout)
|
mux.HandleFunc("POST /logout", s.handleLogout)
|
||||||
@@ -451,30 +449,11 @@ func (s *Server) handleTree(w http.ResponseWriter, r *http.Request) {
|
|||||||
filter := ParseTreeFilter(r.URL.Query())
|
filter := ParseTreeFilter(r.URL.Query())
|
||||||
viewSet := PageViewTypes("/")
|
viewSet := PageViewTypes("/")
|
||||||
view := ParseViewType(r.URL.Query(), viewSet)
|
view := ParseViewType(r.URL.Query(), viewSet)
|
||||||
var defaultBanner *store.View
|
// Phase 5j: ?view= overlay + is_default_for resolution deleted with the
|
||||||
// Phase 5i Slice D: ?view=<uuid> resolves a saved view's filter +
|
// 5i shape. /views/{slug} (slice B+) renders saved views as their own
|
||||||
// view_type into the current request, overriding URL-only chip state.
|
// pages; legacy ?view=<uuid> URLs are 302-redirected from a dedicated
|
||||||
// Resolution failure (deleted view, malformed payload) is logged and
|
// handler (slice C). handleTree stays focused on the tree-as-tree
|
||||||
// silently falls back to the URL-derived filter — the page stays
|
// surface and no longer hijacks itself based on a query param.
|
||||||
// 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)
|
|
||||||
} else {
|
|
||||||
// Phase 5i Slice E: no explicit ?view= → check for a page default.
|
|
||||||
// applyDefaultView returns nil unless the URL is "clean" (no chip
|
|
||||||
// state) AND a default exists for this page.
|
|
||||||
if def, err := s.applyDefaultView(r, "tree", &filter, &view); err == nil && def != nil {
|
|
||||||
view = viewSet.Resolve(view)
|
|
||||||
defaultBanner = def
|
|
||||||
} else if err != nil {
|
|
||||||
s.Logger.Warn("applyDefaultView", "page", "tree", "err", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
roots, orphans, total, orphanN, matched := applyTreeFilter(items, filter, linkKinds)
|
roots, orphans, total, orphanN, matched := applyTreeFilter(items, filter, linkKinds)
|
||||||
counts := computeChipCounts(items, filter, linkKinds, tags)
|
counts := computeChipCounts(items, filter, linkKinds, tags)
|
||||||
// Phase 5i Slice B: the card view renders a flat grid of matched items
|
// Phase 5i Slice B: the card view renders a flat grid of matched items
|
||||||
@@ -504,7 +483,6 @@ func (s *Server) handleTree(w http.ResponseWriter, r *http.Request) {
|
|||||||
"Kanban": kanban,
|
"Kanban": kanban,
|
||||||
"GroupBy": groupBy,
|
"GroupBy": groupBy,
|
||||||
"GroupByChips": groupByChips,
|
"GroupByChips": groupByChips,
|
||||||
"DefaultBanner": defaultBanner,
|
|
||||||
// ActiveTags kept for backwards-compat with the old template path; removed
|
// ActiveTags kept for backwards-compat with the old template path; removed
|
||||||
// after the template migrates fully.
|
// after the template migrates fully.
|
||||||
"ActiveTags": filter.Tags,
|
"ActiveTags": filter.Tags,
|
||||||
|
|||||||
@@ -1,12 +1,5 @@
|
|||||||
{{define "tree-section"}}
|
{{define "tree-section"}}
|
||||||
<section id="tree-section" class="tree-section">
|
<section id="tree-section" class="tree-section">
|
||||||
{{if .DefaultBanner}}
|
|
||||||
<p class="default-banner muted">
|
|
||||||
Showing default view: <strong>{{.DefaultBanner.Name}}</strong> ·
|
|
||||||
<a href="/?nodefault=1"
|
|
||||||
hx-get="/?nodefault=1" hx-target="#tree-section" hx-swap="outerHTML" hx-push-url="true">clear</a>
|
|
||||||
</p>
|
|
||||||
{{end}}
|
|
||||||
<p class="counts">
|
<p class="counts">
|
||||||
<strong>{{.Matched}}</strong> / <strong>{{.Total}}</strong> items match
|
<strong>{{.Matched}}</strong> / <strong>{{.Total}}</strong> items match
|
||||||
{{if .OrphanN}} · <strong>{{.OrphanN}}</strong> unclassified mai-managed roots <a href="/admin/classify">→ classify</a>{{end}}
|
{{if .OrphanN}} · <strong>{{.OrphanN}}</strong> unclassified mai-managed roots <a href="/admin/classify">→ classify</a>{{end}}
|
||||||
|
|||||||
@@ -1,42 +0,0 @@
|
|||||||
{{define "content"}}
|
|
||||||
<h1>Edit view</h1>
|
|
||||||
<p class="muted"><a href="/views">← back to views</a></p>
|
|
||||||
|
|
||||||
<section class="views-create">
|
|
||||||
<form method="post" action="/views/{{.View.ID}}">
|
|
||||||
<label>Name <input type="text" name="name" required maxlength="80" value="{{.View.Name}}"></label>
|
|
||||||
<label>Description <input type="text" name="description" maxlength="200" value="{{.View.Description}}"></label>
|
|
||||||
<label>View type
|
|
||||||
<select name="view_type" required>
|
|
||||||
{{$cur := .View.ViewType}}
|
|
||||||
{{range .AllViewTypes}}<option value="{{.}}"{{if eq . $cur}} selected{{end}}>{{.}}</option>{{end}}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<label>Default for
|
|
||||||
<select name="is_default_for">
|
|
||||||
{{$d := deref .View.IsDefaultFor}}
|
|
||||||
{{range .DefaultForOptions}}<option value="{{.}}"{{if eq . $d}} selected{{end}}>{{if eq . ""}}—{{else}}{{.}}{{end}}</option>{{end}}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<label>Group by
|
|
||||||
<select name="group_by">
|
|
||||||
{{$g := deref .View.GroupBy}}
|
|
||||||
{{range .GroupByOptions}}<option value="{{.}}"{{if eq . $g}} selected{{end}}>{{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" value="{{deref .View.SortField}}"></label>
|
|
||||||
<label>Sort dir
|
|
||||||
<select name="sort_dir">
|
|
||||||
{{$sd := deref .View.SortDir}}
|
|
||||||
{{range .SortDirOptions}}<option value="{{.}}"{{if eq . $sd}} selected{{end}}>{{if eq . ""}}—{{else}}{{.}}{{end}}</option>{{end}}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<label><input type="checkbox" name="pinned" value="1"{{if .View.Pinned}} checked{{end}}> Pinned</label>
|
|
||||||
<label>Filter (URL query form)
|
|
||||||
<input type="text" name="filter_query" placeholder="tag=work&mgmt=mai" value="{{.FilterQuery}}">
|
|
||||||
</label>
|
|
||||||
<button type="submit">Save changes</button>
|
|
||||||
<a class="muted" href="/views">cancel</a>
|
|
||||||
</form>
|
|
||||||
</section>
|
|
||||||
{{end}}
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
{{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>
|
|
||||||
<a href="/views/{{.ID}}/edit">edit</a>
|
|
||||||
<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&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}}
|
|
||||||
@@ -26,12 +26,6 @@ type TreeFilter struct {
|
|||||||
// exposes an explicit on/off toggle.
|
// exposes an explicit on/off toggle.
|
||||||
ProjectPath string
|
ProjectPath string
|
||||||
IncludeDescendants bool
|
IncludeDescendants bool
|
||||||
// Phase 5i fix-shift — saved-view anchor. When set, the URL was
|
|
||||||
// `?view=<uuid>`; chip clicks need to round-trip the value so the user
|
|
||||||
// stays inside the saved view while narrowing further. Not a "filter"
|
|
||||||
// dimension in the matching sense — Matches ignores it — but it lives
|
|
||||||
// in the URL state and on the struct so QueryString preserves it.
|
|
||||||
ViewID string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Active reports whether any filter dimension is set to something other than
|
// Active reports whether any filter dimension is set to something other than
|
||||||
@@ -70,7 +64,6 @@ func ParseTreeFilter(q url.Values) TreeFilter {
|
|||||||
ShowArchived: q.Get("show-archived") == "1",
|
ShowArchived: q.Get("show-archived") == "1",
|
||||||
ProjectPath: strings.TrimSpace(q.Get("project")),
|
ProjectPath: strings.TrimSpace(q.Get("project")),
|
||||||
IncludeDescendants: true,
|
IncludeDescendants: true,
|
||||||
ViewID: strings.TrimSpace(q.Get("view")),
|
|
||||||
}
|
}
|
||||||
if v := strings.TrimSpace(q.Get("public")); v != "" {
|
if v := strings.TrimSpace(q.Get("public")); v != "" {
|
||||||
// Treat 1/true/yes/on as true; 0/false/no/off as false; anything else nil.
|
// Treat 1/true/yes/on as true; 0/false/no/off as false; anything else nil.
|
||||||
@@ -132,9 +125,6 @@ func (f TreeFilter) QueryString() string {
|
|||||||
v.Set("project_descendants", "0")
|
v.Set("project_descendants", "0")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if f.ViewID != "" {
|
|
||||||
v.Set("view", f.ViewID)
|
|
||||||
}
|
|
||||||
return v.Encode()
|
return v.Encode()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
426
web/views.go
426
web/views.go
@@ -1,422 +1,10 @@
|
|||||||
package web
|
package web
|
||||||
|
|
||||||
import (
|
// Phase 5j Slice A — paliad-shape redesign. The 5i overlay handlers
|
||||||
"encoding/json"
|
// (handleViewsIndex / handleViewCreate / handleViewWrite / handleViewEdit
|
||||||
"errors"
|
// / handleViewRedirect / applySavedView / applyDefaultView / friends)
|
||||||
"fmt"
|
// are deleted here. The new /views/{slug} route family lands in slice B;
|
||||||
"net/http"
|
// system-view migration lands in slice C.
|
||||||
"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)
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleViewEdit renders the edit form for an existing view, pre-populated
|
|
||||||
// with the row's current values. Submit posts back to /views/<id>.
|
|
||||||
func (s *Server) handleViewEdit(w http.ResponseWriter, r *http.Request) {
|
|
||||||
id := strings.TrimSuffix(strings.TrimPrefix(r.URL.Path, "/views/"), "/edit")
|
|
||||||
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
|
|
||||||
}
|
|
||||||
filterQuery, err := filterJSONToQuery(v.FilterJSON)
|
|
||||||
if err != nil {
|
|
||||||
s.Logger.Warn("filterJSONToQuery", "id", id, "err", err)
|
|
||||||
}
|
|
||||||
s.render(w, r, "view_edit", map[string]any{
|
|
||||||
"Title": "edit view",
|
|
||||||
"View": v,
|
|
||||||
"FilterQuery": filterQuery,
|
|
||||||
"AllViewTypes": allViewTypes,
|
|
||||||
"DefaultForOptions": []string{"", "tree", "dashboard", "calendar", "timeline"},
|
|
||||||
"SortDirOptions": []string{"", "asc", "desc"},
|
|
||||||
"GroupByOptions": []string{"", "status", "area", "tag", "management"},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// filterJSONToQuery rebuilds a URL-query representation of a stored
|
|
||||||
// filter_json so the edit form can pre-populate the `filter_query` input
|
|
||||||
// field. Inverse of filterQueryToJSON.
|
|
||||||
func filterJSONToQuery(filterJSON []byte) (string, error) {
|
|
||||||
if len(filterJSON) == 0 {
|
|
||||||
return "", nil
|
|
||||||
}
|
|
||||||
payload := map[string]any{}
|
|
||||||
if err := json.Unmarshal(filterJSON, &payload); err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
f := filterFromJSONPayload(payload)
|
|
||||||
// QueryString re-emits the canonical URL query form; that's exactly the
|
|
||||||
// shape the form's `filter_query` input expects on round-trip.
|
|
||||||
return f.QueryString(), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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. /views/<id>/edit is dispatched separately via the more
|
|
||||||
// specific route registered first; this handler ignores the edit suffix
|
|
||||||
// defensively when the routing pattern doesn't match for some reason.
|
|
||||||
func (s *Server) handleViewRedirect(w http.ResponseWriter, r *http.Request) {
|
|
||||||
id := strings.TrimPrefix(r.URL.Path, "/views/")
|
|
||||||
if id == "" {
|
|
||||||
http.NotFound(w, r)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if strings.HasSuffix(id, "/edit") {
|
|
||||||
s.handleViewEdit(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)
|
|
||||||
}
|
|
||||||
|
|
||||||
// applyDefaultView resolves the saved view marked is_default_for=<page>
|
|
||||||
// when the request URL carries no filter/view-specific params and the user
|
|
||||||
// has not opted out via ?nodefault=1. Returns the applied view (for banner
|
|
||||||
// labelling) or nil when no default exists / was applied.
|
|
||||||
//
|
//
|
||||||
// Per design.md §7 Slice E: defaults are a polish layer. They only kick in
|
// Between slices A and B the /views URLs return 404 — by design, no real
|
||||||
// on a "clean" landing — the moment the user types a chip click, the URL
|
// user data was on the old shape (hours-old after the 5i ship).
|
||||||
// gains a filter param and the default no longer auto-applies. Same with
|
|
||||||
// an explicit ?view=<uuid>.
|
|
||||||
func (s *Server) applyDefaultView(r *http.Request, page string, filter *TreeFilter, viewType *string) (*store.View, error) {
|
|
||||||
q := r.URL.Query()
|
|
||||||
if q.Get("nodefault") == "1" {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
// Any filter-affecting param means "user is driving" — skip the default.
|
|
||||||
for _, key := range []string{"q", "tag", "mgmt", "status", "has", "show-archived", "public", "project", "project_id", "project_descendants", "view", "view_type", "group_by"} {
|
|
||||||
if q.Get(key) != "" {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
v, err := s.Store.DefaultViewFor(r.Context(), page)
|
|
||||||
if err != nil || v == nil {
|
|
||||||
return v, err
|
|
||||||
}
|
|
||||||
payload := map[string]any{}
|
|
||||||
if len(v.FilterJSON) > 0 {
|
|
||||||
if err := json.Unmarshal(v.FilterJSON, &payload); err != nil {
|
|
||||||
return v, fmt.Errorf("decode default filter_json: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
*filter = filterFromJSONPayload(payload)
|
|
||||||
*viewType = v.ViewType
|
|
||||||
return v, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// applySavedView resolves a `?view=<uuid>` reference and folds the persisted
|
|
||||||
// filter + view_type back into the supplied TreeFilter + view-type slot.
|
|
||||||
// URL chip params OVERLAY the saved filter — a saved view scoped to
|
|
||||||
// `dev` with `?tag=work` added narrows further. Transient overlays don't
|
|
||||||
// auto-save back to the view (the URL is bookmarkable, but to persist the
|
|
||||||
// drift the user opens /views/<id>/edit).
|
|
||||||
//
|
|
||||||
// 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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
saved := filterFromJSONPayload(payload)
|
|
||||||
saved.ViewID = id
|
|
||||||
q := r.URL.Query()
|
|
||||||
overlayURLFields(&saved, *filter, q)
|
|
||||||
*filter = saved
|
|
||||||
// view_type: URL wins when explicitly set, otherwise the saved value.
|
|
||||||
if strings.TrimSpace(q.Get("view_type")) == "" {
|
|
||||||
*viewType = v.ViewType
|
|
||||||
}
|
|
||||||
return v, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// overlayURLFields lets URL-provided chip values override the saved-view
|
|
||||||
// baseline. The URL filter is the parsed-from-query TreeFilter; q is the
|
|
||||||
// raw url.Values so we can detect "field was set in the URL" distinct from
|
|
||||||
// "field's value happens to equal the zero value".
|
|
||||||
func overlayURLFields(base *TreeFilter, urlFilter TreeFilter, q url.Values) {
|
|
||||||
if q.Get("q") != "" {
|
|
||||||
base.Q = urlFilter.Q
|
|
||||||
}
|
|
||||||
if _, ok := q["tag"]; ok {
|
|
||||||
base.Tags = urlFilter.Tags
|
|
||||||
}
|
|
||||||
if _, ok := q["mgmt"]; ok {
|
|
||||||
base.Management = urlFilter.Management
|
|
||||||
}
|
|
||||||
if _, ok := q["status"]; ok {
|
|
||||||
base.Status = urlFilter.Status
|
|
||||||
}
|
|
||||||
if _, ok := q["has"]; ok {
|
|
||||||
base.HasLinks = urlFilter.HasLinks
|
|
||||||
}
|
|
||||||
if q.Get("show-archived") != "" {
|
|
||||||
base.ShowArchived = urlFilter.ShowArchived
|
|
||||||
}
|
|
||||||
if q.Get("public") != "" {
|
|
||||||
base.Public = urlFilter.Public
|
|
||||||
}
|
|
||||||
if q.Get("project") != "" {
|
|
||||||
base.ProjectPath = urlFilter.ProjectPath
|
|
||||||
}
|
|
||||||
if q.Get("project_descendants") != "" {
|
|
||||||
base.IncludeDescendants = urlFilter.IncludeDescendants
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,327 +0,0 @@
|
|||||||
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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user