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).
71 lines
2.7 KiB
PL/PgSQL
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$;
|