- migration 0012: one-shot populate empty tags from each item's area-roots (so chips on /?tag=work etc. actually filter the 40+ mai-backfilled rows) - migration 0013: cleanup 12 orphan item_links + BEFORE-UPDATE trigger that cascades soft-delete to item_links going forward — closes the data drift that made TestItemsUnifiedSurfacesMaiPointer fail since 3c - /admin/bulk page: flat filter+checkbox list with one-tx Apply for add/ remove tag, set management, set status. Per-row inline chip add/remove via /admin/bulk/chip. Reuses tree_filter URL params 1:1. - design.md §3.2 + §4.1 updated; tag+management section notes 0012 - bulk + tag-backfill + soft-delete-cascade tests cover the new surface
372 lines
11 KiB
Go
372 lines
11 KiB
Go
package web
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"fmt"
|
||
"net/http"
|
||
"sort"
|
||
"strings"
|
||
|
||
"github.com/jackc/pgx/v5"
|
||
|
||
"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, "bulk_section", data)
|
||
return
|
||
}
|
||
s.render(w, "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"
|
||
}
|
||
|
||
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"),
|
||
}
|
||
}
|
||
|
||
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
|
||
}
|
||
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"])
|
||
if len(ids) == 0 {
|
||
http.Error(w, "no rows selected", http.StatusBadRequest)
|
||
return
|
||
}
|
||
action := parseBulkAction(r)
|
||
if action.describe() == "" {
|
||
http.Error(w, "no action chosen", http.StatusBadRequest)
|
||
return
|
||
}
|
||
|
||
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.
|
||
if r.Header.Get("HX-Request") == "true" {
|
||
// Re-derive filter from referrer query so the result list matches the
|
||
// view the user is on. The HTMX form sends hx-include=closest #bulk-filter
|
||
// so filter inputs are in the form body — read those.
|
||
s.renderBulkList(w, r)
|
||
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)
|
||
default:
|
||
return errors.New("bulk: empty action")
|
||
}
|
||
if err != nil {
|
||
return fmt.Errorf("bulk %s: %w", a.describe(), err)
|
||
}
|
||
return tx.Commit(ctx)
|
||
}
|
||
|
||
// renderBulkList re-renders only the rows list + summary line after a bulk
|
||
// apply. Builds a fresh r-equivalent that re-uses the filter posted alongside
|
||
// the form (the page includes filter inputs in the apply submission).
|
||
func (s *Server) renderBulkList(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
|
||
}
|
||
// The apply form ships the same filter fields as the bulk page itself, so
|
||
// ParseTreeFilter against r.Form gives us the right view.
|
||
filter := TreeFilter{
|
||
Q: strings.TrimSpace(r.FormValue("q")),
|
||
Tags: parseCSV(r.FormValue("tag")),
|
||
Management: parseCSV(r.FormValue("mgmt")),
|
||
Status: parseCSV(r.FormValue("status")),
|
||
HasLinks: parseCSV(r.FormValue("has")),
|
||
ShowArchived: r.FormValue("show-archived") == "1",
|
||
}
|
||
rows := filterFlat(items, filter, linkKinds)
|
||
s.render(w, "bulk_section", map[string]any{
|
||
"Title": "bulk edit",
|
||
"Rows": rows,
|
||
"AllTags": allTags,
|
||
"Filter": filter,
|
||
"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)
|
||
}
|
||
}
|