m's bug (verbatim from /views): "we cant edit views yet. and the filters
on custom views dont seem to work. No apply button and no instant apply"
Two distinct gaps, both surgically fixed.
## Gap 1 — edit UI missing
Slice D shipped POST /views/<id> (update) but no GET form to drive it.
The index page had delete + redirect-open links only.
Fix:
- New handleViewEdit serves GET /views/<id>/edit with the form pre-filled
from the persisted row.
- New templates/view_edit.tmpl mirrors the create form, selecting the
current values on each <select>, populating each <input value="">.
- filterJSONToQuery rebuilds the URL-query representation of filter_json
so the `filter_query` text input round-trips on edit.
- /views index row gets an "edit" link next to delete.
- Route registered before the catch-all GET /views/ so the more specific
pattern wins. handleViewRedirect also defensively forwards /edit
suffix in case routing falls through.
## Gap 2 — URL chips clobbered by saved-view filter
applySavedView did `*filter = filterFromJSONPayload(payload)` — wholesale
replace. URL chip params parsed earlier in handleTree were thrown away.
Compounded by chip URLs not preserving `?view=<id>`, so even if the
overlay had worked, chip clicks would have stripped the saved view.
Fix:
- TreeFilter grows a `ViewID` field that round-trips through
ParseTreeFilter + QueryString. Not a "filter dimension" in the
matching sense (Matches ignores it); just a URL anchor that
every chip URL emits forward.
- applySavedView builds the saved filter, then overlayURLFields()
selectively replaces any dimension the user set via URL chip on top
(q/tag/mgmt/status/has/show-archived/public/project/project_descendants).
- view_type: URL wins when explicitly set, saved value otherwise.
- Drift is transient — URL bookmarkable as a "narrowed saved view"
without auto-saving back to the row. To persist, user opens /edit.
## Tests
- TestViewEditFlow — GET /<id>/edit pre-fills name + filter_query; POST
/<id> updates name + view_type + filter_json round-trip in DB.
- TestSavedViewPageFilterApply — seed two items + an empty saved view;
/?view=<id> shows both; /?view=<id>&tag=work shows only the work
one. Also asserts chip URLs contain view=<id> so navigation stays in
the saved view.
Out of scope (per brief):
- No schema changes.
- No view sharing / multi-user.
- HTMX modal save UI deferred — the existing inline edit page is the
surgical fix m's bug actually needs.
Closes the Phase 5i implementation chain. When `views.is_default_for=<page>`
is set, opening that page with a "clean" URL (no chip params, no
?view=) auto-applies the saved filter + view_type. A "Showing default
view: <name> · clear" banner makes the swap visible and gives the user
a one-click out. Adding any chip param to the URL bypasses the default;
?nodefault=1 is the explicit opt-out for "I want the bare default tree".
New web/views.go: applyDefaultView gates on the param-cleanness check
+ Store.DefaultViewFor lookup. Resolution + view_type revalidation
mirror the slice D ?view=<uuid> path so a kanban-default opened on a
route that doesn't allow kanban falls back cleanly.
handleTree wires it into the existing slice D else-branch (no default
when ?view= is set). DefaultBanner field passes the applied view to
the template for the banner.
Test:
- TestDefaultViewAppliedOnCleanURL — seeds a tree default with
filter_json={tags:[work]} + view_type=card, then asserts: clean GET /
applies (card grid + banner with the view's name); ?tag=dev bypasses
(forest, no banner); ?nodefault=1 opt-out (forest, no banner).
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).