Files
projax/db/migrations/0016_views.sql
mAi 2f47b28f39 feat(views): Phase 5i slice D — saved views table + CRUD + sidebar entry
Persists named bundles of (filter + view_type + sort + group_by). Per m's
Q2 pick (2026-05-26), views are page-agnostic — `is_default_for` lets a
view become the auto-applied default for a page, otherwise views render
on whichever page accepts their view_type.

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

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

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

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

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

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

Out of scope for slice D (per design.md §7):
- HTMX modal save UI from any page (the inline-create-on-/views/ form
  works; a modal lands in a polish pass).
- MCP read tools for views (deferred to a follow-up — m manages views
  via the UI).
2026-05-26 13:42:51 +02:00

71 lines
2.7 KiB
PL/PgSQL

-- 0016_views.sql
--
-- Phase 5i Slice D: persistent saved views.
--
-- A saved view bundles (filter + view_type + sort + group_by) under a
-- name. Page-agnostic per m's Q2 pick (2026-05-26) — the view doesn't
-- own a route; `is_default_for` lets one view become the auto-applied
-- default for a given page.
--
-- Singleton user; no `user_id` column. If multi-user ever lands, the
-- two partial unique indexes below need a `(user_id, …)` prefix.
CREATE TABLE IF NOT EXISTS projax.views (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
name text NOT NULL,
description text,
filter_json jsonb NOT NULL DEFAULT '{}'::jsonb,
view_type text NOT NULL,
sort_field text,
sort_dir text,
group_by text,
pinned boolean NOT NULL DEFAULT false,
is_default_for text,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz,
CONSTRAINT views_view_type_chk
CHECK (view_type IN ('card','list','calendar','kanban','timeline')),
CONSTRAINT views_sort_dir_chk
CHECK (sort_dir IS NULL OR sort_dir IN ('asc','desc')),
CONSTRAINT views_kanban_needs_group
CHECK (view_type <> 'kanban' OR group_by IS NOT NULL),
CONSTRAINT views_default_for_chk
CHECK (is_default_for IS NULL OR is_default_for IN ('tree','dashboard','calendar','timeline'))
);
-- Case-insensitive uniqueness on the visible name. Soft-deleted rows are
-- exempt so a re-create after delete doesn't collide.
CREATE UNIQUE INDEX IF NOT EXISTS views_name_uniq
ON projax.views (lower(name))
WHERE deleted_at IS NULL;
-- One default view per page. The handler should clear the prior default in
-- the same transaction as setting a new one; the index defends against any
-- code path that forgets.
CREATE UNIQUE INDEX IF NOT EXISTS views_default_for_uniq
ON projax.views (is_default_for)
WHERE is_default_for IS NOT NULL AND deleted_at IS NULL;
-- updated_at trigger mirrors the items table pattern.
CREATE OR REPLACE FUNCTION projax.views_touch_updated_at()
RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
NEW.updated_at := now();
RETURN NEW;
END;
$$;
DROP TRIGGER IF EXISTS views_touch_updated_at ON projax.views;
CREATE TRIGGER views_touch_updated_at
BEFORE UPDATE ON projax.views
FOR EACH ROW EXECUTE FUNCTION projax.views_touch_updated_at();
DO $own$ BEGIN
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'projax_admin') THEN
EXECUTE 'ALTER TABLE projax.views OWNER TO projax_admin';
EXECUTE 'ALTER FUNCTION projax.views_touch_updated_at() OWNER TO projax_admin';
EXECUTE 'GRANT SELECT, INSERT, UPDATE, DELETE ON projax.views TO projax_admin';
END IF;
END $own$;