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.
154 lines
4.8 KiB
Go
154 lines
4.8 KiB
Go
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 C–E 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
|
||
}
|