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).
122 lines
5.5 KiB
Cheetah
122 lines
5.5 KiB
Cheetah
{{define "tree-section"}}
|
||
<section id="tree-section" class="tree-section">
|
||
<p class="counts">
|
||
<strong>{{.Matched}}</strong> / <strong>{{.Total}}</strong> items match
|
||
{{if .OrphanN}} · <strong>{{.OrphanN}}</strong> unclassified mai-managed roots <a href="/admin/classify">→ classify</a>{{end}}
|
||
{{if .Filter.Active}} · <a class="clear" href="/">clear filters</a>{{end}}
|
||
</p>
|
||
|
||
<section class="tagbar" id="tree-filterbar">
|
||
<form class="search" hx-get="/" hx-target="#tree-section" hx-swap="outerHTML"
|
||
hx-trigger="keyup changed delay:200ms from:input[name=q], change from:input[type=hidden]"
|
||
hx-push-url="true">
|
||
<input type="search" name="q" value="{{.Filter.Q}}" placeholder="search title, slug, content…" autocomplete="off">
|
||
{{if .Filter.Tags}}<input type="hidden" name="tag" value="{{join "," .Filter.Tags}}">{{end}}
|
||
{{if .Filter.Management}}<input type="hidden" name="mgmt" value="{{join "," .Filter.Management}}">{{end}}
|
||
{{if ne (join "," .Filter.Status) "active"}}<input type="hidden" name="status" value="{{join "," .Filter.Status}}">{{end}}
|
||
{{if .Filter.HasLinks}}<input type="hidden" name="has" value="{{join "," .Filter.HasLinks}}">{{end}}
|
||
{{if .Filter.ShowArchived}}<input type="hidden" name="show-archived" value="1">{{end}}
|
||
{{if .Filter.ProjectPath}}<input type="hidden" name="project" value="{{.Filter.ProjectPath}}">{{end}}
|
||
{{if and .Filter.ProjectPath (not .Filter.IncludeDescendants)}}<input type="hidden" name="project_descendants" value="0">{{end}}
|
||
{{if ne .ViewType "list"}}<input type="hidden" name="view_type" value="{{.ViewType}}">{{end}}
|
||
</form>
|
||
|
||
{{template "view-project-chip" .}}
|
||
|
||
<div class="chip-row view-type-chip-row">
|
||
<span class="muted">view:</span>
|
||
{{range .ViewTypeChips}}
|
||
<a class="view-type-chip{{if .Active}} chip-on{{end}}{{if .Locked}} chip-locked{{end}}"
|
||
href="{{.URL}}"
|
||
hx-get="{{.URL}}" hx-target="#tree-section" hx-swap="outerHTML" hx-push-url="true"
|
||
{{if .Locked}}title="{{.Label}} view lands in a future slice; clicks fall back to the default."{{end}}>{{.Label}}</a>
|
||
{{end}}
|
||
</div>
|
||
|
||
{{if .AllTags}}
|
||
<div class="chip-row">
|
||
<span class="muted">tag:</span>
|
||
{{range .Counts.Tags}}
|
||
<a class="tag {{if .Active}}tag-on{{end}}"
|
||
href="{{.URL}}"
|
||
hx-get="{{.URL}}" hx-target="#tree-section" hx-swap="outerHTML" hx-push-url="true">{{.Label}} <small>({{.Count}})</small></a>
|
||
{{end}}
|
||
</div>
|
||
{{end}}
|
||
|
||
<div class="chip-row">
|
||
<span class="muted">mgmt:</span>
|
||
{{range .Counts.Management}}
|
||
<a class="mgmt-chip {{if .Active}}chip-on{{end}}"
|
||
href="{{.URL}}"
|
||
hx-get="{{.URL}}" hx-target="#tree-section" hx-swap="outerHTML" hx-push-url="true">{{.Label}} <small>({{.Count}})</small></a>
|
||
{{end}}
|
||
</div>
|
||
|
||
<div class="chip-row">
|
||
<span class="muted">status:</span>
|
||
{{range .Counts.Status}}
|
||
<a class="status-chip {{if .Active}}chip-on{{end}}"
|
||
href="{{.URL}}"
|
||
hx-get="{{.URL}}" hx-target="#tree-section" hx-swap="outerHTML" hx-push-url="true">{{.Label}} <small>({{.Count}})</small></a>
|
||
{{end}}
|
||
<a class="status-chip {{if .Filter.ShowArchived}}chip-on{{end}}"
|
||
href="{{.Counts.ShowArchived.URL}}"
|
||
hx-get="{{.Counts.ShowArchived.URL}}" hx-target="#tree-section" hx-swap="outerHTML" hx-push-url="true"
|
||
title="When off, archived rows hide even when status=archived is selected">show archived <small>({{.Counts.ShowArchived.Count}})</small></a>
|
||
</div>
|
||
|
||
<div class="chip-row">
|
||
<span class="muted">has:</span>
|
||
{{range .Counts.Has}}
|
||
<a class="has-chip {{if .Active}}chip-on{{end}}"
|
||
href="{{.URL}}"
|
||
hx-get="{{.URL}}" hx-target="#tree-section" hx-swap="outerHTML" hx-push-url="true">{{.Label}} <small>({{.Count}})</small></a>
|
||
{{end}}
|
||
</div>
|
||
</section>
|
||
|
||
{{if eq .ViewType "card"}}
|
||
{{template "tree-card" .}}
|
||
{{else if eq .ViewType "kanban"}}
|
||
{{template "tree-kanban" .}}
|
||
{{else}}
|
||
<section class="tree">
|
||
<ul class="forest">
|
||
{{range .Roots}}
|
||
<li class="node root">
|
||
<a href="/i/{{.Item.PrimaryPath}}">{{.Item.Title}}</a>
|
||
<span class="slug">{{.Item.PrimaryPath}}</span>
|
||
{{range .Item.Management}}<span class="mgmt mgmt-{{.}}">{{.}}</span>{{end}}
|
||
{{range .Item.Tags}}<span class="tag">{{.}}</span>{{end}}
|
||
<a class="add" href="/new?parent={{.Item.PrimaryPath}}">+</a>
|
||
{{template "children" .}}
|
||
</li>
|
||
{{else}}
|
||
<li class="empty"><em>No items match. Try fewer filters or <a href="/">clear all</a>.</em></li>
|
||
{{end}}
|
||
</ul>
|
||
</section>
|
||
{{end}}
|
||
</section>
|
||
{{end}}
|
||
|
||
{{define "children"}}
|
||
{{if .Children}}
|
||
<ul>
|
||
{{range .Children}}
|
||
<li class="node project">
|
||
<a href="/i/{{.Item.PrimaryPath}}">{{.Item.Title}}</a>
|
||
<span class="slug">{{.Item.PrimaryPath}}</span>
|
||
<span class="status status-{{.Item.Status}}">{{.Item.Status}}</span>
|
||
{{if gt (len .Item.Paths) 1}}<span class="muted multi-parent" title="appears under multiple parents">×{{len .Item.Paths}}</span>{{end}}
|
||
{{range .Item.Management}}<span class="mgmt mgmt-{{.}}">{{.}}</span>{{end}}
|
||
{{range .Item.Tags}}<span class="tag">{{.}}</span>{{end}}
|
||
<a class="add" href="/new?parent={{.Item.PrimaryPath}}">+</a>
|
||
{{template "children" .}}
|
||
</li>
|
||
{{end}}
|
||
</ul>
|
||
{{end}}
|
||
{{end}}
|