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).
70 lines
2.7 KiB
Cheetah
70 lines
2.7 KiB
Cheetah
{{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>
|
|
<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}}
|