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