Files
projax/web/views.go
mAi 9a8ea8f31e feat(views): Phase 5j slice G — show_count badges + icon registry
Per m's v1 picks (2026-05-29):
- Q6 (icon picker): yes, with curated keys + SVG registry.
- Q8 (show_count badge): yes, opt-in checkbox + sidebar badge.

Icon registry (web/icons.go):
- 7 curated keys: folder (default), clock, star, tag, inbox, box,
  file-text. Each maps to a Feather-style 24x24 SVG matching the rest
  of the projax sidebar aesthetic. Returns template.HTML so layout.tmpl
  emits markup verbatim. Unknown / nil keys fall back to folder.
- RenderViewIcon(*string) is template-callable; IconRegistryKeys()
  feeds the editor's <select>.
- Funcs map in web/server.go gains a "renderIcon" entry.

show_count badge (web/server.go + web/templates/layout.tmpl):
- render() now computes per-saved-view counts when ANY view in the
  list has ShowCount=true. One ListAll per render, shared across all
  show-count views; for each opted-in view the persisted filter_json
  is decoded into a TreeFilter and matched against every item.
- Counts pass to the template as UserViewCounts (slug → count). The
  template renders {{index $counts $slug}} inside a nav-badge span
  next to the view's name.

Template updates:
- layout.tmpl: replaces the diamond-glyph placeholder with
  {{renderIcon .Icon}}; show_count views emit a .nav-badge next to
  their name.
- view_editor.tmpl: icon <select> now sourced from IconKeys data
  (the editor handler passes IconRegistryKeys()).

CSS additions:
- nav-badge: muted-color, surface-background, pill-shaped, pushed to
  the right via margin-left:auto so the badge aligns with the row's
  end regardless of name length.
- nav-item-user-view.active .nav-badge: switches to accent border +
  color so the active row's badge stays legible.

Tests:
- TestSidebarShowCountBadge — seeds show_count=true view, asserts
  .nav-badge markup in the sidebar.
- TestSidebarIconRenders — seeds icon=star view, asserts the
  distinctive star polygon path lands in the sidebar SVG.

Drag-reorder UI stays parked (m's Q7=(b) v2). sort_order column is
server-assigned MAX+1 on create; the column was wired in slice A and
ReorderViews is ready for slice G's followup.
2026-05-29 12:07:54 +02:00

469 lines
14 KiB
Go
Raw Permalink 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 (
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"strings"
"github.com/m/projax/store"
)
// Phase 5j paliad-shape views handlers. Slice B introduces the route
// family; slices CG evolve the render, editor, system-views, sidebar,
// and polish layers.
//
// Route table:
// GET /views → handleViewsLanding (MRU 302 or shell)
// GET /views/{slug} → handleViewRender (saved or system)
// GET /views/new → handleViewEditor (blank)
// GET /views/{slug}/edit → handleViewEditor (existing)
// POST /views → handleViewCreate
// POST /views/{slug} → handleViewUpdate
// POST /views/{slug}/delete → handleViewDelete
// POST /views/reorder → handleViewReorder (slice G — wired now,
// used in v2)
// handleViewsLanding implements m's Q5 pick: 302 to the most-recently-used
// view if any, else render the onboarding shell listing every saved view.
func (s *Server) handleViewsLanding(w http.ResponseWriter, r *http.Request) {
if r.URL.Query().Get("nodefault") != "1" {
mr, err := s.Store.MostRecentView(r.Context())
if err != nil {
s.Logger.Warn("views landing: mru", "err", err)
} else if mr != nil {
http.Redirect(w, r, "/views/"+mr.Slug, http.StatusFound)
return
}
}
views, err := s.Store.ListViews(r.Context())
if err != nil {
s.fail(w, r, err)
return
}
s.render(w, r, "views_landing", map[string]any{
"Title": "views",
"Views": views,
})
}
// handleViewRender resolves a slug into either a user view (Slice A
// schema) or a system view (Slice C), then renders the appropriate
// template. The render path also fire-and-forgets a TouchView so the
// view climbs the MRU ladder for the next /views landing redirect.
//
// Slice B implementation: only user views are wired; system views
// resolve via LookupSystemView (added in Slice C) and 404 in this slice
// when the slug is unknown.
func (s *Server) handleViewRender(w http.ResponseWriter, r *http.Request) {
slug := r.PathValue("slug")
if slug == "" {
http.NotFound(w, r)
return
}
v, err := s.Store.GetView(r.Context(), slug)
if errors.Is(err, store.ErrViewNotFound) {
http.NotFound(w, r)
return
}
if err != nil {
s.fail(w, r, err)
return
}
if err := s.Store.TouchView(r.Context(), slug); err != nil {
s.Logger.Warn("touch view", "slug", slug, "err", err)
}
// Parse the saved spec.
filter, viewType, groupBy := decodeViewSpec(v.FilterJSON)
// Allow URL chip overlay so chip clicks inside a saved view narrow
// further. The page chip URLs round-trip ?view= via the URL anchor
// added in slice E's sidebar wiring; here we just respect anything
// the user typed in the query.
urlFilter := ParseTreeFilter(r.URL.Query())
overlayURLOntoSavedFilter(&filter, urlFilter, r.URL.Query())
if raw := strings.TrimSpace(r.URL.Query().Get("view_type")); raw != "" {
viewType = raw
}
if raw := strings.TrimSpace(r.URL.Query().Get("group_by")); raw != "" {
groupBy = raw
}
s.renderViewPage(w, r, v, filter, viewType, groupBy)
}
// renderViewPage runs the shared render path for a resolved view (user
// view or future system view). Slice B reuses the tree handler's
// rendering pieces — list / card / kanban share the tree-section
// dispatch shape. Calendar / timeline view_types fall back to list in
// slice B; slice D wires their dedicated templates.
func (s *Server) renderViewPage(w http.ResponseWriter, r *http.Request, v *store.View, filter TreeFilter, viewType, groupBy string) {
items, err := s.Store.ListAll(r.Context())
if err != nil {
s.fail(w, r, err)
return
}
tags, err := s.Store.AllTags(r.Context())
if err != nil {
s.fail(w, r, err)
return
}
linkKinds, err := s.linkKindsByItem(r.Context())
if err != nil {
s.fail(w, r, err)
return
}
viewSet := PageViewTypes("/")
if viewType == "" {
viewType = viewSet.Default
}
viewType = viewSet.Resolve(viewType)
roots, orphans, total, orphanN, matched := applyTreeFilter(items, filter, linkKinds)
base := "/views/" + v.Slug
counts := computeChipCounts(items, filter, linkKinds, tags, base)
cardItems := flatMatchedItems(items, filter, linkKinds)
if groupBy == "" {
groupBy = ParseGroupBy(r.URL.Query())
}
kanban := BuildKanbanBoard(cardItems, groupBy)
groupByChips := GroupByChips(base, filter, groupBy)
data := map[string]any{
"Title": v.Name,
"View": v,
"Roots": roots,
"Orphans": orphans,
"Total": total,
"OrphanN": orphanN,
"Matched": matched,
"AllTags": tags,
"Filter": filter,
"Counts": counts,
"Projects": parentOptionsFromItems(items),
"BasePath": base,
"ProjectChipTarget": "#tree-section",
"ViewType": viewType,
"ViewTypeChips": ViewTypeChips(base, filter, viewType),
"CardItems": cardItems,
"Kanban": kanban,
"GroupBy": groupBy,
"GroupByChips": groupByChips,
"ActiveTags": filter.Tags,
}
if r.Header.Get("HX-Request") == "true" {
s.render(w, r, "tree_section", data)
return
}
s.render(w, r, "view_render", data)
}
// handleViewEditor renders the create / edit form. Slice B ships a
// minimal placeholder; Slice D rebuilds the form with the chip strip
// + slug derivation + icon picker.
func (s *Server) handleViewEditor(w http.ResponseWriter, r *http.Request) {
slug := r.PathValue("slug")
var (
view *store.View
err error
title = "new view"
)
if slug != "" {
view, err = s.Store.GetView(r.Context(), slug)
if errors.Is(err, store.ErrViewNotFound) {
http.NotFound(w, r)
return
}
if err != nil {
s.fail(w, r, err)
return
}
title = "edit " + view.Name
}
filterQuery := ""
currentViewType := "list"
if view != nil {
f, vt, _ := decodeViewSpec(view.FilterJSON)
filterQuery = f.QueryString()
if vt != "" {
currentViewType = vt
}
}
s.render(w, r, "view_editor", map[string]any{
"Title": title,
"View": view,
"FilterQuery": filterQuery,
"ViewTypes": []string{ViewTypeList, ViewTypeCard, ViewTypeKanban},
"CurrentVT": currentViewType,
"GroupByOptions": []string{"", "status", "area", "tag", "management"},
"SortDirOptions": []string{"", "asc", "desc"},
"IconKeys": IconRegistryKeys(),
})
}
// handleViewCreate accepts the create form POST.
func (s *Server) handleViewCreate(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
s.fail(w, r, err)
return
}
in, err := viewInputFromForm(r.PostForm)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
v, err := s.Store.CreateView(r.Context(), in)
if err != nil {
s.writeViewError(w, err)
return
}
http.Redirect(w, r, "/views/"+v.Slug, http.StatusSeeOther)
}
// handleViewUpdate accepts the edit form POST.
func (s *Server) handleViewUpdate(w http.ResponseWriter, r *http.Request) {
slug := r.PathValue("slug")
if err := r.ParseForm(); err != nil {
s.fail(w, r, err)
return
}
in, err := viewInputFromForm(r.PostForm)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
v, err := s.Store.UpdateView(r.Context(), slug, in)
if err != nil {
if errors.Is(err, store.ErrViewNotFound) {
http.NotFound(w, r)
return
}
s.writeViewError(w, err)
return
}
http.Redirect(w, r, "/views/"+v.Slug, http.StatusSeeOther)
}
// handleViewDelete soft-… nope. New schema is hard-delete (no
// deleted_at). One POST removes the row.
func (s *Server) handleViewDelete(w http.ResponseWriter, r *http.Request) {
slug := r.PathValue("slug")
if err := s.Store.DeleteView(r.Context(), slug); err != nil {
if errors.Is(err, store.ErrViewNotFound) {
http.NotFound(w, r)
return
}
s.fail(w, r, err)
return
}
http.Redirect(w, r, "/views", http.StatusSeeOther)
}
// handleViewReorder takes a comma-separated slug list and applies new
// sort_order values. Wired now so slice G's drag UI has a target.
func (s *Server) handleViewReorder(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
s.fail(w, r, err)
return
}
raw := strings.TrimSpace(r.PostForm.Get("slugs"))
if raw == "" {
http.Error(w, "slugs is required", http.StatusBadRequest)
return
}
slugs := strings.Split(raw, ",")
for i, slug := range slugs {
slugs[i] = strings.TrimSpace(slug)
}
if err := s.Store.ReorderViews(r.Context(), slugs); err != nil {
s.fail(w, r, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// writeViewError maps the typed store errors to friendly HTTP status +
// banner copy. Falls back to 400 for anything else.
func (s *Server) writeViewError(w http.ResponseWriter, err error) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
switch {
case errors.Is(err, store.ErrViewSlugFormat):
http.Error(w, "slug must match ^[a-z0-9][a-z0-9-]{0,62}$ (lowercase, no underscores, no leading dash)", http.StatusBadRequest)
case errors.Is(err, store.ErrViewSlugReserved):
http.Error(w, "slug is reserved (system views and top-level routes shadow it)", http.StatusBadRequest)
case errors.Is(err, store.ErrViewSlugTaken):
http.Error(w, "slug already exists — pick a different one", http.StatusConflict)
default:
http.Error(w, err.Error(), http.StatusBadRequest)
}
}
// viewInputFromForm decodes the create/edit form. Slug + name are
// required; the rest defaults sensibly. filter_query is optional and
// canonicalises into filter_json on save (URL-query form is what the
// editor's chip strip emits in slice D).
func viewInputFromForm(form url.Values) (store.ViewInput, error) {
in := store.ViewInput{
Slug: strings.TrimSpace(form.Get("slug")),
Name: strings.TrimSpace(form.Get("name")),
SortField: strings.TrimSpace(form.Get("sort_field")),
SortDir: strings.TrimSpace(form.Get("sort_dir")),
GroupBy: strings.TrimSpace(form.Get("group_by")),
ShowCount: form.Get("show_count") == "1",
}
if iconRaw := strings.TrimSpace(form.Get("icon")); iconRaw != "" {
in.Icon = &iconRaw
}
viewType := strings.TrimSpace(form.Get("view_type"))
if viewType == "" {
viewType = ViewTypeList
}
fq := strings.TrimSpace(form.Get("filter_query"))
filterJSON, err := encodeFilterToJSON(fq, viewType)
if err != nil {
return in, fmt.Errorf("filter_query: %w", err)
}
in.FilterJSON = filterJSON
return in, nil
}
// encodeFilterToJSON turns a URL-query-form filter + view_type into the
// canonical filter_json shape stored on the view. view_type lives inside
// the JSON per m's Q2 pick.
func encodeFilterToJSON(query, viewType string) ([]byte, error) {
q, err := url.ParseQuery(strings.TrimPrefix(query, "?"))
if err != nil {
return nil, err
}
f := ParseTreeFilter(q)
payload := map[string]any{
"view_type": viewType,
}
if f.Q != "" {
payload["q"] = f.Q
}
if len(f.Tags) > 0 {
payload["tags"] = f.Tags
}
if len(f.Management) > 0 {
payload["management"] = f.Management
}
if !(len(f.Status) == 1 && f.Status[0] == "active") {
payload["status"] = f.Status
}
if len(f.HasLinks) > 0 {
payload["has_links"] = f.HasLinks
}
if f.Public != nil {
payload["public"] = *f.Public
}
if f.ShowArchived {
payload["show_archived"] = true
}
if f.ProjectPath != "" {
payload["project_path"] = f.ProjectPath
if !f.IncludeDescendants {
payload["include_descendants"] = false
}
}
return json.Marshal(payload)
}
// decodeViewSpec parses filter_json into a TreeFilter + view_type +
// group_by. Inverse of encodeFilterToJSON.
func decodeViewSpec(filterJSON []byte) (TreeFilter, string, string) {
f := TreeFilter{
Status: []string{"active"},
IncludeDescendants: true,
}
viewType := ""
groupBy := ""
if len(filterJSON) == 0 {
return f, viewType, groupBy
}
payload := map[string]any{}
if err := json.Unmarshal(filterJSON, &payload); err != nil {
return f, viewType, groupBy
}
if v, ok := payload["view_type"].(string); ok {
viewType = v
}
if v, ok := payload["group_by"].(string); ok {
groupBy = v
}
if v, ok := payload["q"].(string); ok {
f.Q = v
}
if v, ok := payload["tags"].([]any); ok {
f.Tags = anySliceToStrings(v)
}
if v, ok := payload["management"].([]any); ok {
f.Management = anySliceToStrings(v)
}
if v, ok := payload["status"].([]any); ok {
f.Status = anySliceToStrings(v)
if len(f.Status) == 0 {
f.Status = []string{"active"}
}
}
if v, ok := payload["has_links"].([]any); ok {
f.HasLinks = anySliceToStrings(v)
}
if v, ok := payload["public"].(bool); ok {
f.Public = &v
}
if v, ok := payload["show_archived"].(bool); ok && v {
f.ShowArchived = true
}
if v, ok := payload["project_path"].(string); ok {
f.ProjectPath = v
}
if v, ok := payload["include_descendants"].(bool); ok {
f.IncludeDescendants = v
}
return f, viewType, groupBy
}
// overlayURLOntoSavedFilter applies URL-query chip values on top of the
// saved-view baseline. Same pattern the 5i fix-shift had (URL overrides
// saved); slice B reintroduces it here on the /views/{slug} render path.
func overlayURLOntoSavedFilter(base *TreeFilter, urlFilter TreeFilter, q url.Values) {
if q.Get("q") != "" {
base.Q = urlFilter.Q
}
if _, ok := q["tag"]; ok {
base.Tags = urlFilter.Tags
}
if _, ok := q["mgmt"]; ok {
base.Management = urlFilter.Management
}
if _, ok := q["status"]; ok {
base.Status = urlFilter.Status
}
if _, ok := q["has"]; ok {
base.HasLinks = urlFilter.HasLinks
}
if q.Get("show-archived") != "" {
base.ShowArchived = urlFilter.ShowArchived
}
if q.Get("public") != "" {
base.Public = urlFilter.Public
}
if q.Get("project") != "" {
base.ProjectPath = urlFilter.ProjectPath
}
if q.Get("project_descendants") != "" {
base.IncludeDescendants = urlFilter.IncludeDescendants
}
}
func anySliceToStrings(in []any) []string {
out := make([]string, 0, len(in))
for _, v := range in {
if s, ok := v.(string); ok {
out = append(out, s)
}
}
return out
}