Compare commits
2 Commits
a9f062a67e
...
0ad610d018
| Author | SHA1 | Date | |
|---|---|---|---|
| 0ad610d018 | |||
| e305f0e0ae |
@@ -152,7 +152,7 @@ func New(s *store.Store, logger *slog.Logger) (*Server, error) {
|
||||
},
|
||||
}
|
||||
pages := map[string]*template.Template{}
|
||||
for _, name := range []string{"new", "classify", "caldav_admin", "caldav_disabled", "error"} {
|
||||
for _, name := range []string{"new", "classify", "caldav_admin", "caldav_disabled", "error", "views_landing", "view_editor"} {
|
||||
t, err := template.New(name).Funcs(funcs).ParseFS(templatesFS,
|
||||
"templates/layout.tmpl",
|
||||
"templates/"+name+".tmpl",
|
||||
@@ -189,6 +189,21 @@ func New(s *store.Store, logger *slog.Logger) (*Server, error) {
|
||||
return nil, fmt.Errorf("parse tree_section: %w", err)
|
||||
}
|
||||
pages["tree_section"] = treeSection
|
||||
// Phase 5j view-render template bundles the tree-section partials so a
|
||||
// rendered view at /views/{slug} can use the same dispatch (list / card
|
||||
// / kanban via .ViewType).
|
||||
viewRender, err := template.New("view_render").Funcs(funcs).ParseFS(templatesFS,
|
||||
"templates/layout.tmpl",
|
||||
"templates/view_render.tmpl",
|
||||
"templates/tree_section.tmpl",
|
||||
"templates/tree_card.tmpl",
|
||||
"templates/tree_kanban.tmpl",
|
||||
"templates/project_chip.tmpl",
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse view_render: %w", err)
|
||||
}
|
||||
pages["view_render"] = viewRender
|
||||
// detail bundles the shared tasks-section + issues-section partials so
|
||||
// HTMX swaps and the initial page render hit the same template definitions.
|
||||
detailTmpl, err := template.New("detail").Funcs(funcs).ParseFS(templatesFS,
|
||||
@@ -383,9 +398,18 @@ func (s *Server) Routes() http.Handler {
|
||||
mux.HandleFunc("GET /admin/caldav", s.handleCalDAVAdmin)
|
||||
mux.HandleFunc("POST /admin/caldav/link", s.handleCalDAVLink)
|
||||
mux.HandleFunc("POST /admin/caldav/unlink", s.handleCalDAVUnlink)
|
||||
// /views routes land in slice B (paliad-shape: GET /views, GET
|
||||
// /views/{slug}, GET /views/new, GET /views/{slug}/edit, plus POST CRUD).
|
||||
// Between slice A and slice B these URLs 404 by design.
|
||||
// Phase 5j paliad-shape views routes (slice B). /views = MRU landing
|
||||
// or onboarding shell; /views/{slug} = render the saved view as its
|
||||
// own page; /views/new + /views/{slug}/edit = editor. POST CRUD
|
||||
// rounds out the family; reorder is wired now for slice G's drag UI.
|
||||
mux.HandleFunc("GET /views", s.handleViewsLanding)
|
||||
mux.HandleFunc("POST /views", s.handleViewCreate)
|
||||
mux.HandleFunc("POST /views/reorder", s.handleViewReorder)
|
||||
mux.HandleFunc("GET /views/new", s.handleViewEditor)
|
||||
mux.HandleFunc("GET /views/{slug}", s.handleViewRender)
|
||||
mux.HandleFunc("GET /views/{slug}/edit", s.handleViewEditor)
|
||||
mux.HandleFunc("POST /views/{slug}", s.handleViewUpdate)
|
||||
mux.HandleFunc("POST /views/{slug}/delete", s.handleViewDelete)
|
||||
mux.HandleFunc("GET /login", s.handleLoginForm)
|
||||
mux.HandleFunc("POST /login", s.handleLoginSubmit)
|
||||
mux.HandleFunc("POST /logout", s.handleLogout)
|
||||
|
||||
53
web/templates/view_editor.tmpl
Normal file
53
web/templates/view_editor.tmpl
Normal file
@@ -0,0 +1,53 @@
|
||||
{{define "content"}}
|
||||
<h1>{{if .View}}Edit {{.View.Name}}{{else}}New view{{end}}</h1>
|
||||
<p class="muted"><a href="/views">← back to views</a></p>
|
||||
|
||||
<form class="view-editor"
|
||||
method="post"
|
||||
action="{{if .View}}/views/{{.View.Slug}}{{else}}/views{{end}}">
|
||||
<label>Name <input type="text" name="name" required maxlength="80" value="{{if .View}}{{.View.Name}}{{end}}"></label>
|
||||
<label>Slug
|
||||
<input type="text" name="slug" required maxlength="63"
|
||||
pattern="^[a-z0-9][a-z0-9-]{0,62}$"
|
||||
value="{{if .View}}{{.View.Slug}}{{end}}">
|
||||
<small class="muted">lowercase letters, digits, dashes. No reserved system slugs.</small>
|
||||
</label>
|
||||
<label>Icon
|
||||
<select name="icon">
|
||||
{{$cur := ""}}
|
||||
{{if and .View .View.Icon}}{{$cur = deref .View.Icon}}{{end}}
|
||||
<option value="">— folder (default)</option>
|
||||
<option value="clock" {{if eq $cur "clock"}}selected{{end}}>clock</option>
|
||||
<option value="star" {{if eq $cur "star"}}selected{{end}}>star</option>
|
||||
<option value="tag" {{if eq $cur "tag"}}selected{{end}}>tag</option>
|
||||
<option value="inbox" {{if eq $cur "inbox"}}selected{{end}}>inbox</option>
|
||||
<option value="box" {{if eq $cur "box"}}selected{{end}}>box</option>
|
||||
<option value="file-text" {{if eq $cur "file-text"}}selected{{end}}>file-text</option>
|
||||
</select>
|
||||
</label>
|
||||
<fieldset class="view-type-radios">
|
||||
<legend>View type</legend>
|
||||
{{range .ViewTypes}}
|
||||
<label><input type="radio" name="view_type" value="{{.}}" {{if eq . $.CurrentVT}}checked{{end}}> {{.}}</label>
|
||||
{{end}}
|
||||
</fieldset>
|
||||
<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" 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="show_count" value="1"
|
||||
{{if and .View .View.ShowCount}}checked{{end}}> Show row-count badge in sidebar</label>
|
||||
<label>Filter (URL query form)
|
||||
<input type="text" name="filter_query" placeholder="tag=work&mgmt=mai" value="{{.FilterQuery}}">
|
||||
</label>
|
||||
<button type="submit">{{if .View}}Save changes{{else}}Create view{{end}}</button>
|
||||
<a class="muted" href="/views">cancel</a>
|
||||
</form>
|
||||
{{end}}
|
||||
14
web/templates/view_render.tmpl
Normal file
14
web/templates/view_render.tmpl
Normal file
@@ -0,0 +1,14 @@
|
||||
{{define "content"}}
|
||||
<section class="view-header">
|
||||
<h1>{{.View.Name}}</h1>
|
||||
<p class="muted view-meta">
|
||||
<code>/views/{{.View.Slug}}</code> ·
|
||||
<a href="/views/{{.View.Slug}}/edit">edit</a> ·
|
||||
<form method="post" action="/views/{{.View.Slug}}/delete" style="display:inline">
|
||||
<button type="submit" class="link-button" onclick="return confirm('Delete view {{.View.Name}}?')">delete</button>
|
||||
</form>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{{template "tree-section" .}}
|
||||
{{end}}
|
||||
26
web/templates/views_landing.tmpl
Normal file
26
web/templates/views_landing.tmpl
Normal file
@@ -0,0 +1,26 @@
|
||||
{{define "content"}}
|
||||
<h1>Views</h1>
|
||||
|
||||
<p class="muted">First-class saved pages. Each view has its own URL and renders on its own.</p>
|
||||
|
||||
{{if .Views}}
|
||||
<section class="views-list">
|
||||
<ul class="views-list-grid">
|
||||
{{range .Views}}
|
||||
<li>
|
||||
<a class="view-card" href="/views/{{.Slug}}">
|
||||
<span class="view-card-name">{{.Name}}</span>
|
||||
<span class="view-card-slug muted">/views/{{.Slug}}</span>
|
||||
</a>
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
</section>
|
||||
{{else}}
|
||||
<section class="views-empty">
|
||||
<p class="muted"><em>No saved views yet.</em></p>
|
||||
</section>
|
||||
{{end}}
|
||||
|
||||
<p><a class="view-create-link" href="/views/new">+ New view</a></p>
|
||||
{{end}}
|
||||
470
web/views.go
470
web/views.go
@@ -1,10 +1,466 @@
|
||||
package web
|
||||
|
||||
// Phase 5j Slice A — paliad-shape redesign. The 5i overlay handlers
|
||||
// (handleViewsIndex / handleViewCreate / handleViewWrite / handleViewEdit
|
||||
// / handleViewRedirect / applySavedView / applyDefaultView / friends)
|
||||
// are deleted here. The new /views/{slug} route family lands in slice B;
|
||||
// system-view migration lands in slice C.
|
||||
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 C–G evolve the render, editor, system-views, sidebar,
|
||||
// and polish layers.
|
||||
//
|
||||
// Between slices A and B the /views URLs return 404 — by design, no real
|
||||
// user data was on the old shape (hours-old after the 5i ship).
|
||||
// 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)
|
||||
counts := computeChipCounts(items, filter, linkKinds, tags)
|
||||
cardItems := flatMatchedItems(items, filter, linkKinds)
|
||||
if groupBy == "" {
|
||||
groupBy = ParseGroupBy(r.URL.Query())
|
||||
}
|
||||
kanban := BuildKanbanBoard(cardItems, groupBy)
|
||||
groupByChips := GroupByChips("/views/"+v.Slug, 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": "/views/" + v.Slug,
|
||||
"ProjectChipTarget": "#tree-section",
|
||||
"ViewType": viewType,
|
||||
"ViewTypeChips": ViewTypeChips("/", 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"},
|
||||
})
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
197
web/views_test.go
Normal file
197
web/views_test.go
Normal file
@@ -0,0 +1,197 @@
|
||||
package web_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestViewsLandingOnboarding asserts that GET /views with no views and no
|
||||
// MRU renders the onboarding shell ("No saved views yet" + "+ New view").
|
||||
func TestViewsLandingOnboarding(t *testing.T) {
|
||||
srv, pool := mustServer(t)
|
||||
defer pool.Close()
|
||||
h := srv.Routes()
|
||||
// Clear any leftover touched views from prior runs so the MRU 302
|
||||
// doesn't fire and steal the response.
|
||||
if _, err := pool.Exec(context.Background(),
|
||||
`UPDATE projax.views SET last_used_at = NULL`); err != nil {
|
||||
t.Fatalf("reset mru: %v", err)
|
||||
}
|
||||
// Also clear ALL views so the onboarding shell renders (othewise the
|
||||
// landing still ListViews-displays them).
|
||||
if _, err := pool.Exec(context.Background(), `DELETE FROM projax.views`); err != nil {
|
||||
t.Fatalf("clear views: %v", err)
|
||||
}
|
||||
code, body := get(t, h, "/views")
|
||||
if code != 200 {
|
||||
t.Fatalf("GET /views status=%d body=%q", code, body)
|
||||
}
|
||||
if !strings.Contains(body, "No saved views yet") {
|
||||
t.Error("onboarding shell should surface the no-views nudge")
|
||||
}
|
||||
if !strings.Contains(body, `href="/views/new"`) {
|
||||
t.Error("onboarding shell should link to /views/new")
|
||||
}
|
||||
}
|
||||
|
||||
// TestViewsLandingMRURedirects asserts that GET /views 302s to the most
|
||||
// recently used view when one exists.
|
||||
func TestViewsLandingMRURedirects(t *testing.T) {
|
||||
srv, pool := mustServer(t)
|
||||
defer pool.Close()
|
||||
h := srv.Routes()
|
||||
stamp := strings.ReplaceAll(time.Now().UTC().Format("150405.000"), ".", "")
|
||||
slug := "p5j-b-landing-" + stamp
|
||||
defer pool.Exec(context.Background(), `DELETE FROM projax.views WHERE slug = $1`, slug)
|
||||
// Seed + touch.
|
||||
if _, err := pool.Exec(context.Background(), `
|
||||
INSERT INTO projax.views (slug, name, filter_json, last_used_at)
|
||||
VALUES ($1, 'P5j B Landing', '{"view_type":"list"}'::jsonb, now())`, slug); err != nil {
|
||||
t.Fatalf("seed view: %v", err)
|
||||
}
|
||||
code, body := get(t, h, "/views")
|
||||
if code != 302 {
|
||||
t.Errorf("GET /views status=%d (want 302 to MRU); body=%q", code, body)
|
||||
}
|
||||
}
|
||||
|
||||
// TestViewRenderShowsSavedView asserts that GET /views/{slug} renders the
|
||||
// view's name + slug in the header and the tree-section body.
|
||||
func TestViewRenderShowsSavedView(t *testing.T) {
|
||||
srv, pool := mustServer(t)
|
||||
defer pool.Close()
|
||||
h := srv.Routes()
|
||||
stamp := strings.ReplaceAll(time.Now().UTC().Format("150405.000"), ".", "")
|
||||
slug := "p5j-b-render-" + stamp
|
||||
defer pool.Exec(context.Background(), `DELETE FROM projax.views WHERE slug = $1`, slug)
|
||||
if _, err := pool.Exec(context.Background(), `
|
||||
INSERT INTO projax.views (slug, name, filter_json)
|
||||
VALUES ($1, 'P5j B Render', '{"view_type":"card"}'::jsonb)`, slug); err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
code, body := get(t, h, "/views/"+slug)
|
||||
if code != 200 {
|
||||
t.Fatalf("GET /views/<slug> status=%d body=%q", code, body)
|
||||
}
|
||||
if !strings.Contains(body, "P5j B Render") {
|
||||
t.Error("render should surface the view's name")
|
||||
}
|
||||
if !strings.Contains(body, `/views/`+slug) {
|
||||
t.Error("render should surface the view's slug in the header")
|
||||
}
|
||||
if !strings.Contains(body, `class="tree-card-grid"`) {
|
||||
t.Error("view_type=card should render the card grid")
|
||||
}
|
||||
}
|
||||
|
||||
// TestViewRender404OnUnknownSlug — an unknown slug returns 404, not a
|
||||
// silent fallback to the tree.
|
||||
func TestViewRender404OnUnknownSlug(t *testing.T) {
|
||||
srv, pool := mustServer(t)
|
||||
defer pool.Close()
|
||||
h := srv.Routes()
|
||||
code, _ := get(t, h, "/views/this-slug-does-not-exist-anywhere-9876")
|
||||
if code != 404 {
|
||||
t.Errorf("unknown slug should 404, got %d", code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestViewCreateAndDelete — POST /views creates; POST /views/<slug>/delete
|
||||
// removes. Verifies the slug-format error path too.
|
||||
func TestViewCreateAndDelete(t *testing.T) {
|
||||
srv, pool := mustServer(t)
|
||||
defer pool.Close()
|
||||
h := srv.Routes()
|
||||
stamp := strings.ReplaceAll(time.Now().UTC().Format("150405.000"), ".", "")
|
||||
slug := "p5j-b-crud-" + stamp
|
||||
defer pool.Exec(context.Background(), `DELETE FROM projax.views WHERE slug = $1`, slug)
|
||||
|
||||
form := url.Values{}
|
||||
form.Set("slug", slug)
|
||||
form.Set("name", "P5j B CRUD")
|
||||
form.Set("view_type", "list")
|
||||
form.Set("filter_query", "tag=work")
|
||||
code, _ := post(t, h, "/views", form)
|
||||
if code != 303 {
|
||||
t.Fatalf("create status=%d want 303", code)
|
||||
}
|
||||
|
||||
// Reserved-slug 400.
|
||||
form2 := url.Values{}
|
||||
form2.Set("slug", "dashboard")
|
||||
form2.Set("name", "Should be rejected")
|
||||
form2.Set("view_type", "list")
|
||||
code, body := post(t, h, "/views", form2)
|
||||
if code != 400 {
|
||||
t.Errorf("reserved-slug create should 400, got %d body=%q", code, body)
|
||||
}
|
||||
|
||||
// Delete.
|
||||
code, _ = post(t, h, "/views/"+slug+"/delete", url.Values{})
|
||||
if code != 303 {
|
||||
t.Errorf("delete status=%d want 303", code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSavedViewFilterOverlay — chip params on /views/<slug>?tag=x narrow
|
||||
// the saved filter. Verifies the slice B render-path overlay.
|
||||
func TestSavedViewFilterOverlay(t *testing.T) {
|
||||
srv, pool := mustServer(t)
|
||||
defer pool.Close()
|
||||
h := srv.Routes()
|
||||
ctx := context.Background()
|
||||
stamp := strings.ReplaceAll(time.Now().UTC().Format("150405.000"), ".", "")
|
||||
slug := "p5j-b-overlay-" + stamp
|
||||
devSlug := "p5j-b-overlay-d-" + stamp
|
||||
homeSlug := "p5j-b-overlay-h-" + stamp
|
||||
defer pool.Exec(context.Background(), `DELETE FROM projax.views WHERE slug = $1`, slug)
|
||||
|
||||
var dev, home string
|
||||
if err := pool.QueryRow(ctx, `select id from projax.items where slug='dev' and cardinality(parent_ids)=0`).Scan(&dev); err != nil {
|
||||
t.Fatalf("dev: %v", err)
|
||||
}
|
||||
if err := pool.QueryRow(ctx, `select id from projax.items where slug='home' and cardinality(parent_ids)=0`).Scan(&home); err != nil {
|
||||
t.Fatalf("home: %v", err)
|
||||
}
|
||||
var devID, homeID string
|
||||
if err := pool.QueryRow(ctx, `
|
||||
INSERT INTO projax.items (kind, title, slug, parent_ids, tags)
|
||||
VALUES (array['project']::text[], 'P5jB Dev', $1, ARRAY[$2]::uuid[], ARRAY['work'])
|
||||
RETURNING id`, devSlug, dev).Scan(&devID); err != nil {
|
||||
t.Fatalf("seed dev item: %v", err)
|
||||
}
|
||||
if err := pool.QueryRow(ctx, `
|
||||
INSERT INTO projax.items (kind, title, slug, parent_ids, tags)
|
||||
VALUES (array['project']::text[], 'P5jB Home', $1, ARRAY[$2]::uuid[], ARRAY['home'])
|
||||
RETURNING id`, homeSlug, home).Scan(&homeID); err != nil {
|
||||
t.Fatalf("seed home item: %v", err)
|
||||
}
|
||||
defer pool.Exec(context.Background(), `DELETE FROM projax.items WHERE id IN ($1,$2)`, devID, homeID)
|
||||
|
||||
if _, err := pool.Exec(ctx, `
|
||||
INSERT INTO projax.views (slug, name, filter_json)
|
||||
VALUES ($1, 'P5jB Overlay', '{"view_type":"list"}'::jsonb)`, slug); err != nil {
|
||||
t.Fatalf("seed view: %v", err)
|
||||
}
|
||||
|
||||
devLink := `href="/i/dev.` + devSlug + `"`
|
||||
homeLink := `href="/i/home.` + homeSlug + `"`
|
||||
|
||||
_, base := get(t, h, "/views/"+slug)
|
||||
if !strings.Contains(base, devLink) {
|
||||
t.Error("saved view without tag should show dev row")
|
||||
}
|
||||
if !strings.Contains(base, homeLink) {
|
||||
t.Error("saved view without tag should show home row")
|
||||
}
|
||||
_, narrowed := get(t, h, "/views/"+slug+"?tag=work")
|
||||
if !strings.Contains(narrowed, devLink) {
|
||||
t.Error("URL chip tag=work should keep dev (work-tagged)")
|
||||
}
|
||||
if strings.Contains(narrowed, homeLink) {
|
||||
t.Error("URL chip tag=work should hide home")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user