Files
projax/web/view_type.go
mAi 5f712c68d4 feat(views): Phase 5i slice B — view_type URL param + card view on /tree
m's Q1+Q3 picks (2026-05-26): five canonical view_types
(card/list/calendar/kanban/timeline). Slice B introduces the parameter and
the first non-default rendering: card view on /tree shows the filtered set
as a flat tile grid alongside the existing tree forest.

New web/view_type.go owns the enum, per-route allowed set, parser, and
the chip-strip builder. Per the design note, view_type is RENDER state,
not filter state — kept off TreeFilter so the same filter can render as
card or list.

PageViewTypes("/") = {default: list, allowed: [list, card]}.
Dashboard / calendar / timeline are LOCKED to their native shape in
slice B; switching templates on /dashboard for card vs list is mostly
already done via fuller's 5h tabbed-tiles surface and stays as-is for
now (the chip strip surfaces card as the only allowed value there).
Kanban + cross-page list/card swaps land in slice C onwards.

Render:
- handleTree parses `?view_type=` with the per-route catalog, builds
  flatMatchedItems for the card consumer alongside the existing forest.
- tree_section.tmpl gains a view-type chip strip (locked entries shown
  greyed-out with title tooltip) + branches into either `tree-card` or
  the forest based on .ViewType.
- New templates/tree_card.tmpl renders a flat grid of tiles for the
  matched set; per-item field set mirrors the list rendering.
- Hidden `view_type` input added to the search form so chip clicks
  preserve the view choice.

Tests:
- view_type_test.go: parser fallback, per-route catalog, chip strip
  active/locked flags, filter preservation in chip URLs.
- server_test.go: end-to-end dispatch — GET /?view_type=card renders
  tree-card-grid, GET / renders forest, unknown values fall back to
  list. Chip strip present on both views.
2026-05-26 13:36:28 +02:00

154 lines
4.8 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package web
import (
"net/url"
"strings"
)
// View type enum — Phase 5i Slice B. Five values per m's Q1 + Q3 picks
// (2026-05-26): timeline is a first-class view_type alongside the four m
// originally named.
const (
ViewTypeCard = "card"
ViewTypeList = "list"
ViewTypeCalendar = "calendar"
ViewTypeKanban = "kanban"
ViewTypeTimeline = "timeline"
)
// allViewTypes is the canonical ordered set used by validators and template
// rendering. Adding a value here is one of the few places that needs to stay
// in lockstep with the `view_type` CHECK constraint in migration 0016
// (lands in slice D).
var allViewTypes = []string{
ViewTypeCard,
ViewTypeList,
ViewTypeCalendar,
ViewTypeKanban,
ViewTypeTimeline,
}
// ViewTypeSet is the per-route catalog: which view types each Views-supporting
// page accepts. Tree supports list (default) + card today; kanban joins in
// slice C. Dashboard, calendar, and timeline are locked to their native shape
// for slice B — accepting a different view_type silently falls back to the
// default (no errors; the chip-strip surface signals "this view is locked").
type ViewTypeSet struct {
Default string
Allowed []string
}
// Has reports whether vt is part of the route's allowed set.
func (s ViewTypeSet) Has(vt string) bool {
for _, v := range s.Allowed {
if v == vt {
return true
}
}
return false
}
// Resolve returns vt if it is in the allowed set, otherwise the default. Used
// by handlers when parsing `?view_type=`; unknown / forbidden values fall back
// gracefully without 4xx.
func (s ViewTypeSet) Resolve(vt string) string {
if s.Has(vt) {
return vt
}
return s.Default
}
// PageViewTypes returns the catalog for the named route. Routes outside the
// Views system (graph, admin/*) get an empty set; their handlers don't call
// this. The narrow tree/dashboard set is the seed; slices CE grow it.
func PageViewTypes(route string) ViewTypeSet {
switch route {
case "/", "tree":
return ViewTypeSet{
Default: ViewTypeList,
// Card joins in slice B; kanban lands in slice C — until then,
// `?view_type=kanban` falls back to list with the chip strip
// labelling it "coming soon" via the template.
Allowed: []string{ViewTypeList, ViewTypeCard},
}
case "/dashboard", "dashboard":
// Dashboard is locked to its Phase 5h tabbed-tiles surface in slice B.
// The view_type chip is informational only here; switching templates
// for card vs list on /dashboard is a follow-up slice (the tabbed
// tiles ARE the card view conceptually, so the work is mostly
// renaming labels).
return ViewTypeSet{
Default: ViewTypeCard,
Allowed: []string{ViewTypeCard},
}
case "/timeline", "timeline":
return ViewTypeSet{
Default: ViewTypeTimeline,
Allowed: []string{ViewTypeTimeline},
}
case "/calendar", "calendar":
return ViewTypeSet{
Default: ViewTypeCalendar,
Allowed: []string{ViewTypeCalendar},
}
}
return ViewTypeSet{}
}
// ParseViewType pulls `view_type` from q and falls back to the route's
// default. Unknown values map to the default (no error path for the user).
func ParseViewType(q url.Values, set ViewTypeSet) string {
raw := strings.ToLower(strings.TrimSpace(q.Get("view_type")))
if raw == "" {
return set.Default
}
return set.Resolve(raw)
}
// ViewTypeChip is one entry in the view-type chip strip rendered above the
// section. Active marks the currently-rendered view; URL is the toggle target.
type ViewTypeChip struct {
Label string
URL string
Active bool
// Locked is true for view types that aren't in the route's allowed set
// today (e.g. kanban on /tree before slice C). Rendered greyed-out with a
// "coming soon" title attribute. Clicking still navigates (so the URL
// remains shareable), but lands on the default with the chip strip
// showing the desired view as un-toggled.
Locked bool
}
// ViewTypeChips builds the chip strip for `route` given the current filter
// and view. Currently emits chips for every value in allViewTypes; entries
// outside the route's allowed set surface as Locked.
func ViewTypeChips(route string, filter TreeFilter, current string) []ViewTypeChip {
set := PageViewTypes(route)
base := route
if base == "tree" {
base = "/"
}
out := make([]ViewTypeChip, 0, len(allViewTypes))
for _, vt := range allViewTypes {
urlStr := filter.URLOn(base)
// Embed the chosen view_type into the URL. We use a tiny query
// rewrite because the filter does NOT carry the view_type — keeping
// it out of TreeFilter (the design doc's call: render state, not
// filter state).
if vt != set.Default {
if strings.Contains(urlStr, "?") {
urlStr += "&view_type=" + vt
} else {
urlStr += "?view_type=" + vt
}
}
out = append(out, ViewTypeChip{
Label: vt,
URL: urlStr,
Active: vt == current,
Locked: !set.Has(vt),
})
}
return out
}