Phase 5c slice B. Three web write paths now pre-validate via the itemwrite package before calling store.Create / Update / Reparent. - handleDetailWrite: ValidateFormat + ValidateAgainstStore on (title, slug, status, parent_ids) before the store.Update call. - handleNewSubmit: same pair, scoped to a new item (no ID yet). - handleReparent: format + DB-aware checks; validator catches self-parent, unknown-parent, cycle. The existing "parent_ids required" guard stays as a separate fast-fail. - handleBulkApply: set_status pre-flight against the validator. Other bulk actions (add_tag / set_mgmt / set_public / timeline_todos) don't mutate validated fields so they pass through unchanged. On ValidationError the handler responds 400 + a human banner keyed on err.Kind via the new s.itemWriteFailure helper. itemWriteBannerCopy centralises the Kind→copy mapping so web/server.go and web/bulk.go share one phrasing. No web test source touched — all web/*_test.go assert on observable behaviour (HTTP status, response body) and the new validator path preserves both for valid AND invalid inputs the SQL trigger would have rejected anyway. Tests stay green unmodified. Task: t-projax-5c-itemwrite
456 lines
14 KiB
Go
456 lines
14 KiB
Go
package web
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"fmt"
|
||
"net/http"
|
||
"sort"
|
||
"strings"
|
||
|
||
"github.com/jackc/pgx/v5"
|
||
|
||
"github.com/m/projax/internal/itemwrite"
|
||
"github.com/m/projax/store"
|
||
)
|
||
|
||
// handleBulk renders the bulk-edit admin page: a filter bar (reusing the
|
||
// tree-page TreeFilter so URL params translate 1:1), a checkbox list of every
|
||
// matching item, and an action bar that posts to /admin/bulk/apply. The page
|
||
// is intentionally desktop-only — m bulk-edits from a keyboard.
|
||
func (s *Server) handleBulk(w http.ResponseWriter, r *http.Request) {
|
||
items, err := s.Store.ListAll(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
|
||
}
|
||
allTags, err := s.Store.AllTags(r.Context())
|
||
if err != nil {
|
||
s.fail(w, r, err)
|
||
return
|
||
}
|
||
filter := ParseTreeFilter(r.URL.Query())
|
||
// Bulk filters never default to status=active; the page is for editing,
|
||
// and m wants to see done/archived rows too. Override the implicit default.
|
||
if r.URL.Query().Get("status") == "" {
|
||
filter.Status = nil
|
||
}
|
||
rows := filterFlat(items, filter, linkKinds)
|
||
|
||
data := map[string]any{
|
||
"Title": "bulk edit",
|
||
"Rows": rows,
|
||
"AllTags": allTags,
|
||
"Filter": filter,
|
||
"Total": len(items),
|
||
"Matched": len(rows),
|
||
}
|
||
if r.Header.Get("HX-Request") == "true" {
|
||
s.render(w, r, "bulk_section", data)
|
||
return
|
||
}
|
||
s.render(w, r, "bulk", data)
|
||
}
|
||
|
||
// filterFlat returns every item matching the filter as a flat sorted list
|
||
// (no tree shape — bulk-edit treats items as a flat working set). Sort by
|
||
// primary path so siblings cluster together.
|
||
func filterFlat(items []*store.Item, f TreeFilter, linkKinds map[string]map[string]struct{}) []*store.Item {
|
||
out := []*store.Item{}
|
||
for _, it := range items {
|
||
// Bulk filter is "match this item directly" — no descendant credit.
|
||
// Override status default: if Status is nil treat as "any".
|
||
if !bulkMatches(f, it, linkKinds[it.ID]) {
|
||
continue
|
||
}
|
||
out = append(out, it)
|
||
}
|
||
sort.Slice(out, func(i, j int) bool { return out[i].PrimaryPath() < out[j].PrimaryPath() })
|
||
return out
|
||
}
|
||
|
||
// bulkMatches is a near-clone of TreeFilter.Matches but with Status treated
|
||
// as "any" when the slice is empty (the bulk-edit page deliberately surfaces
|
||
// every status when m has not picked one). Archived is still honoured
|
||
// through ShowArchived.
|
||
func bulkMatches(f TreeFilter, it *store.Item, itemLinkKinds map[string]struct{}) bool {
|
||
if len(f.Status) > 0 && !contains(f.Status, it.Status) {
|
||
return false
|
||
}
|
||
if it.Archived && !f.ShowArchived {
|
||
// "any status" mode still hides archived rows unless ShowArchived is
|
||
// explicit; otherwise a single archived item would dominate the list.
|
||
// When the user filters to status=archived the previous branch already
|
||
// accepted it, and ShowArchived stays default-off — which means we'd
|
||
// still hide it here. Honour status=archived as an implicit ShowArchived.
|
||
if !contains(f.Status, "archived") {
|
||
return false
|
||
}
|
||
}
|
||
for _, t := range f.Tags {
|
||
if !it.HasTag(t) {
|
||
return false
|
||
}
|
||
}
|
||
if len(f.Management) > 0 {
|
||
ok := false
|
||
for _, m := range f.Management {
|
||
if m == "unmanaged" && len(it.Management) == 0 {
|
||
ok = true
|
||
break
|
||
}
|
||
if it.HasManagement(m) {
|
||
ok = true
|
||
break
|
||
}
|
||
}
|
||
if !ok {
|
||
return false
|
||
}
|
||
}
|
||
for _, h := range f.HasLinks {
|
||
if _, ok := itemLinkKinds[h]; !ok {
|
||
return false
|
||
}
|
||
}
|
||
if f.Q != "" {
|
||
q := strings.ToLower(f.Q)
|
||
hit := strings.Contains(strings.ToLower(it.Title), q) ||
|
||
strings.Contains(strings.ToLower(it.Slug), q) ||
|
||
strings.Contains(strings.ToLower(it.ContentMD), q)
|
||
if !hit {
|
||
for _, p := range it.Paths {
|
||
if strings.Contains(strings.ToLower(p), q) {
|
||
hit = true
|
||
break
|
||
}
|
||
}
|
||
}
|
||
if !hit {
|
||
return false
|
||
}
|
||
}
|
||
return true
|
||
}
|
||
|
||
// bulkAction parses the action descriptor sent by the form. Exactly one field
|
||
// is non-zero per request; the dispatch picks the matching code path.
|
||
type bulkAction struct {
|
||
AddTag string // free-text input
|
||
RemoveTag string
|
||
SetMgmt string // "mai" / "self" / "external" / "clear"
|
||
SetStatus string // "active" / "done" / "archived"
|
||
SetPublic string // "" / "make_public" / "make_private" — Phase 4d
|
||
TimelineTodos string // "" / "exclude" / "include" — Phase 4f
|
||
}
|
||
|
||
func parseBulkAction(r *http.Request) bulkAction {
|
||
get := func(k string) string { return strings.TrimSpace(r.FormValue(k)) }
|
||
return bulkAction{
|
||
AddTag: strings.ToLower(get("add_tag")),
|
||
RemoveTag: strings.ToLower(get("remove_tag")),
|
||
SetMgmt: get("set_mgmt"),
|
||
SetStatus: get("set_status"),
|
||
SetPublic: get("set_public"),
|
||
TimelineTodos: get("timeline_todos"),
|
||
}
|
||
}
|
||
|
||
func (a bulkAction) describe() string {
|
||
switch {
|
||
case a.AddTag != "":
|
||
return "add tag " + a.AddTag
|
||
case a.RemoveTag != "":
|
||
return "remove tag " + a.RemoveTag
|
||
case a.SetMgmt != "":
|
||
return "set management " + a.SetMgmt
|
||
case a.SetStatus != "":
|
||
return "set status " + a.SetStatus
|
||
case a.SetPublic == "make_public":
|
||
return "make public"
|
||
case a.SetPublic == "make_private":
|
||
return "make private"
|
||
case a.TimelineTodos == "exclude":
|
||
return "exclude todos from timeline"
|
||
case a.TimelineTodos == "include":
|
||
return "re-include todos on timeline"
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// handleBulkApply applies one action to every selected item in a single
|
||
// transaction. The form posts:
|
||
// ids=<uuid>&ids=<uuid>&… plus one action field (add_tag / remove_tag /
|
||
// set_mgmt / set_status). The action is validated here, dispatched to the
|
||
// matching SQL, and returns the re-rendered list partial.
|
||
func (s *Server) handleBulkApply(w http.ResponseWriter, r *http.Request) {
|
||
if err := r.ParseForm(); err != nil {
|
||
s.fail(w, r, err)
|
||
return
|
||
}
|
||
ids := dedupeStrings(r.Form["ids"])
|
||
action := parseBulkAction(r)
|
||
|
||
// Validation banners: re-render the section instead of replacing it with
|
||
// a plain-text 400. The previous behaviour caused HTMX swaps to wipe out
|
||
// the page chrome when m clicked Apply without ticking a row or filling
|
||
// an action field. Two distinct messages so m can tell what he missed.
|
||
var banner string
|
||
switch {
|
||
case len(ids) == 0 && action.describe() == "":
|
||
banner = "Pick at least one row and choose an action before clicking Apply."
|
||
case len(ids) == 0:
|
||
banner = "No rows selected — tick the checkboxes for the rows you want to change."
|
||
case action.describe() == "":
|
||
banner = "No action chosen — type a tag, pick a management mode, or pick a status before clicking Apply."
|
||
default:
|
||
// Pre-flight bulk action via itemwrite where applicable. set_status
|
||
// is the only bulk action today that mutates a validated field
|
||
// (status enum); the others (add_tag / set_mgmt / set_public /
|
||
// timeline_todos) operate outside the validator's rule set.
|
||
if action.SetStatus != "" {
|
||
if ve := itemwrite.ValidateFormat(itemwrite.Input{Title: "x", Slug: "x", Status: action.SetStatus}); ve != nil {
|
||
banner = "Cannot apply: " + itemWriteBannerCopy(ve)
|
||
break
|
||
}
|
||
}
|
||
if err := s.applyBulk(r.Context(), ids, action); err != nil {
|
||
s.fail(w, r, err)
|
||
return
|
||
}
|
||
}
|
||
|
||
// HTMX: re-render the list partial with the same filter context (and
|
||
// banner, if any). The form sends every filter input alongside the
|
||
// action via hx-include="#bulk-filter".
|
||
if r.Header.Get("HX-Request") == "true" {
|
||
s.renderBulkList(w, r, banner)
|
||
return
|
||
}
|
||
// Non-HTMX fallback: redirect back to the bulk page preserving filters.
|
||
dest := "/admin/bulk"
|
||
if q := r.FormValue("filter_query"); q != "" {
|
||
dest = "/admin/bulk?" + q
|
||
}
|
||
http.Redirect(w, r, dest, http.StatusSeeOther)
|
||
}
|
||
|
||
// applyBulk runs the action against every id in a single transaction so
|
||
// partial failures roll back. Each branch is its own UPDATE because Postgres
|
||
// array operators cannot be parameterised cleanly across different operations.
|
||
func (s *Server) applyBulk(ctx context.Context, ids []string, a bulkAction) error {
|
||
tx, err := s.Store.Pool.BeginTx(ctx, pgx.TxOptions{})
|
||
if err != nil {
|
||
return fmt.Errorf("begin bulk tx: %w", err)
|
||
}
|
||
defer tx.Rollback(ctx)
|
||
|
||
switch {
|
||
case a.AddTag != "":
|
||
// array_append guards against duplicate tag with a CASE: only append
|
||
// when the tag isn't already present.
|
||
_, err = tx.Exec(ctx, `
|
||
update projax.items
|
||
set tags = case when $2 = any(tags) then tags else array_append(tags, $2) end
|
||
where id = any($1::uuid[]) and deleted_at is null`,
|
||
ids, a.AddTag)
|
||
case a.RemoveTag != "":
|
||
_, err = tx.Exec(ctx, `
|
||
update projax.items
|
||
set tags = array_remove(tags, $2)
|
||
where id = any($1::uuid[]) and deleted_at is null`,
|
||
ids, a.RemoveTag)
|
||
case a.SetMgmt != "":
|
||
if a.SetMgmt == "clear" {
|
||
_, err = tx.Exec(ctx, `
|
||
update projax.items set management = '{}'::text[]
|
||
where id = any($1::uuid[]) and deleted_at is null`,
|
||
ids)
|
||
} else {
|
||
// Replace management entirely — single-mode semantics matches
|
||
// the chip group on detail.tmpl.
|
||
_, err = tx.Exec(ctx, `
|
||
update projax.items set management = ARRAY[$2]::text[]
|
||
where id = any($1::uuid[]) and deleted_at is null`,
|
||
ids, a.SetMgmt)
|
||
}
|
||
case a.SetStatus != "":
|
||
_, err = tx.Exec(ctx, `
|
||
update projax.items set status = $2
|
||
where id = any($1::uuid[]) and deleted_at is null`,
|
||
ids, a.SetStatus)
|
||
case a.SetPublic == "make_public":
|
||
_, err = tx.Exec(ctx, `
|
||
update projax.items set public = true
|
||
where id = any($1::uuid[]) and deleted_at is null`,
|
||
ids)
|
||
case a.SetPublic == "make_private":
|
||
_, err = tx.Exec(ctx, `
|
||
update projax.items set public = false
|
||
where id = any($1::uuid[]) and deleted_at is null`,
|
||
ids)
|
||
case a.TimelineTodos == "exclude":
|
||
// Idempotent: append 'todos' only when not already in the array.
|
||
_, err = tx.Exec(ctx, `
|
||
update projax.items
|
||
set timeline_exclude = case
|
||
when 'todos' = any(timeline_exclude) then timeline_exclude
|
||
else array_append(timeline_exclude, 'todos')
|
||
end
|
||
where id = any($1::uuid[]) and deleted_at is null`,
|
||
ids)
|
||
case a.TimelineTodos == "include":
|
||
_, err = tx.Exec(ctx, `
|
||
update projax.items
|
||
set timeline_exclude = array_remove(timeline_exclude, 'todos')
|
||
where id = any($1::uuid[]) and deleted_at is null`,
|
||
ids)
|
||
default:
|
||
return errors.New("bulk: empty action")
|
||
}
|
||
if err != nil {
|
||
return fmt.Errorf("bulk %s: %w", a.describe(), err)
|
||
}
|
||
return tx.Commit(ctx)
|
||
}
|
||
|
||
// normaliseFormStrings deduplicates, lowercases, and trims the slice of
|
||
// values a multi-value form key submitted. Mirrors what parseCSV would do
|
||
// to a single comma-joined string — but takes the already-split slice that
|
||
// r.Form[k] returns for multi-select inputs. Drops empties.
|
||
func normaliseFormStrings(in []string) []string {
|
||
if len(in) == 0 {
|
||
return []string{}
|
||
}
|
||
seen := map[string]struct{}{}
|
||
out := make([]string, 0, len(in))
|
||
for _, v := range in {
|
||
t := strings.ToLower(strings.TrimSpace(v))
|
||
if t == "" {
|
||
continue
|
||
}
|
||
if _, ok := seen[t]; ok {
|
||
continue
|
||
}
|
||
seen[t] = struct{}{}
|
||
out = append(out, t)
|
||
}
|
||
return out
|
||
}
|
||
|
||
// renderBulkList re-renders the bulk section after a bulk apply or a
|
||
// validation failure. The apply form ships every filter input alongside the
|
||
// action via hx-include, so this reads them straight off r.Form (which the
|
||
// caller has already ParseForm'd). banner, when non-empty, surfaces an
|
||
// inline note above the action bar.
|
||
//
|
||
// Multi-value selects (tag, mgmt, status, has) submit ONE name=value pair
|
||
// per selected option. r.Form[k] is []string{...}; r.FormValue returns only
|
||
// the first. Must use the slice form here or the second+ values silently
|
||
// drop on every Apply round-trip.
|
||
func (s *Server) renderBulkList(w http.ResponseWriter, r *http.Request, banner string) {
|
||
items, err := s.Store.ListAll(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
|
||
}
|
||
allTags, err := s.Store.AllTags(r.Context())
|
||
if err != nil {
|
||
s.fail(w, r, err)
|
||
return
|
||
}
|
||
filter := TreeFilter{
|
||
Q: strings.TrimSpace(r.FormValue("q")),
|
||
Tags: normaliseFormStrings(r.Form["tag"]),
|
||
Management: normaliseFormStrings(r.Form["mgmt"]),
|
||
Status: normaliseFormStrings(r.Form["status"]),
|
||
HasLinks: normaliseFormStrings(r.Form["has"]),
|
||
ShowArchived: r.FormValue("show-archived") == "1",
|
||
}
|
||
rows := filterFlat(items, filter, linkKinds)
|
||
s.render(w, r, "bulk_section", map[string]any{
|
||
"Title": "bulk edit",
|
||
"Rows": rows,
|
||
"AllTags": allTags,
|
||
"Filter": filter,
|
||
"Banner": banner,
|
||
"Total": len(items),
|
||
"Matched": len(rows),
|
||
})
|
||
}
|
||
|
||
// handleBulkChip handles per-row inline chip edits — the small +/× buttons
|
||
// next to a tag chip on the bulk page. POST /admin/bulk/chip with id + op +
|
||
// kind + value. op ∈ {add, remove}; kind ∈ {tag, mgmt}. Returns the re-rendered
|
||
// chip cell.
|
||
func (s *Server) handleBulkChip(w http.ResponseWriter, r *http.Request) {
|
||
if err := r.ParseForm(); err != nil {
|
||
s.fail(w, r, err)
|
||
return
|
||
}
|
||
id := strings.TrimSpace(r.FormValue("id"))
|
||
op := strings.TrimSpace(r.FormValue("op"))
|
||
kind := strings.TrimSpace(r.FormValue("kind"))
|
||
val := strings.ToLower(strings.TrimSpace(r.FormValue("value")))
|
||
if id == "" || op == "" || kind == "" || val == "" {
|
||
http.Error(w, "id, op, kind, value all required", http.StatusBadRequest)
|
||
return
|
||
}
|
||
a := bulkAction{}
|
||
switch {
|
||
case kind == "tag" && op == "add":
|
||
a.AddTag = val
|
||
case kind == "tag" && op == "remove":
|
||
a.RemoveTag = val
|
||
case kind == "mgmt" && op == "set":
|
||
a.SetMgmt = val
|
||
default:
|
||
http.Error(w, "unsupported op/kind", http.StatusBadRequest)
|
||
return
|
||
}
|
||
if err := s.applyBulk(r.Context(), []string{id}, a); err != nil {
|
||
s.fail(w, r, err)
|
||
return
|
||
}
|
||
it, err := s.Store.GetByID(r.Context(), id)
|
||
if err != nil {
|
||
s.fail(w, r, err)
|
||
return
|
||
}
|
||
// Return just the chip cell's inner HTML (HTMX hx-swap=innerHTML on the <td>).
|
||
// The chip templates render against the Item directly, not a map — we pass
|
||
// it through .Item-keyed data so the same fragment-render path works.
|
||
switch kind {
|
||
case "tag":
|
||
s.renderChip(w, "bulk_chip_tags", it)
|
||
case "mgmt":
|
||
s.renderChip(w, "bulk_chip_mgmt", it)
|
||
default:
|
||
http.Error(w, "unknown chip kind", http.StatusBadRequest)
|
||
}
|
||
}
|
||
|
||
// renderChip renders a chip-cell template directly against an *Item.
|
||
func (s *Server) renderChip(w http.ResponseWriter, name string, it *store.Item) {
|
||
t, ok := s.pages[name]
|
||
if !ok {
|
||
http.Error(w, "unknown page: "+name, http.StatusInternalServerError)
|
||
return
|
||
}
|
||
entry := strings.ReplaceAll(name, "_", "-")
|
||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||
if err := t.ExecuteTemplate(w, entry, it); err != nil {
|
||
s.Logger.Error("render chip", "page", name, "err", err)
|
||
}
|
||
}
|