fix(new): populate Parents <select> and pre-select ?parent= match

m's report: /new?parent=admin doesn't pre-select admin. Root cause is
worse than the report — the Parents <select> was COMPLETELY EMPTY: the
handler never passed ParentOptions to the template, so the
`{{range .ParentOptions}}` block iterated nil. There was nothing to
pre-select.

handleNewForm now calls s.parentOptions(r.Context()) the same way
handleClassify already did, and threads the result through the data
map as "ParentOptions". The template's existing pre-select expression
`{{if and $.Parent (eq .ID $.Parent.ID)}}selected{{end}}` already
handles id/path resolution — once the options exist, the `selected`
attribute lands on the right one.

Regression test (web/new_form_test.go):

- TestNewFormPreselectsParent — probes /new?parent=admin against the
  HTTP integration server, asserts (1) <option> tags are rendered in
  the Parents <select>, (2) the admin <option> exists with `selected`
  on its opening tag, (3) other root options (dev) do NOT carry
  `selected`. Confirmed failing pre-fix (no admin option at all),
  passing post-fix.

- TestNewFormNoParentParamRendersAllOptions — bare /new with no
  ?parent= still populates the Parents <select> so the user can pick
  any parent. Belt-and-braces guard.

Full web suite green. Pre-existing db/TestBackfillTagsFromArea failure
unchanged.

Net: +105 / -0.
This commit is contained in:
mAi
2026-05-27 14:04:14 +02:00
parent d0e0669fff
commit b15c222727
2 changed files with 105 additions and 0 deletions

96
web/new_form_test.go Normal file
View File

@@ -0,0 +1,96 @@
package web_test
import (
"strings"
"testing"
)
// TestNewFormPreselectsParent reproduces m's bug report: GET /new?parent=admin
// must render the Parents <select> populated with the full project list AND
// pre-select the option whose value matches admin's item id. Pre-fix the
// handler passed no ParentOptions to the template, so the <select> was empty
// and there was nothing to pre-select.
func TestNewFormPreselectsParent(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
code, body := get(t, h, "/new?parent=admin")
if code != 200 {
t.Fatalf("GET /new?parent=admin → %d body=%s", code, body)
}
// The Parents <select> must be populated. admin is a root area present
// in every projax instance — its option should be there.
if !strings.Contains(body, `<option value="`) {
t.Fatalf("Parents <select> is empty — no <option> rendered. Body excerpt: %s",
body[strings.Index(body, "parent_ids"):min(len(body), strings.Index(body, "parent_ids")+800)])
}
if !strings.Contains(body, `>admin</option>`) {
t.Errorf("expected an <option>...>admin</option> in the Parents <select>")
}
// The admin option must be the selected one — that's the prefill contract.
// We anchor on the path (rendered as the option label) since the id is a
// uuid we'd otherwise have to look up.
adminIdx := strings.Index(body, `>admin</option>`)
if adminIdx < 0 {
t.Fatalf("admin option not found in rendered Parents select")
}
// Look back ~200 chars to the <option ... selected> opening tag.
from := adminIdx - 200
if from < 0 {
from = 0
}
openingTag := body[from:adminIdx]
if !strings.Contains(openingTag, "selected") {
t.Errorf("admin <option> not marked selected; opening tag was: %s", openingTag)
}
// And other unrelated options must NOT be selected. Pick `dev` (another
// root area) as the counter-anchor.
devIdx := strings.Index(body, `>dev</option>`)
if devIdx >= 0 {
from := devIdx - 200
if from < 0 {
from = 0
}
devTag := body[from:devIdx]
if strings.Contains(devTag, "selected") {
t.Errorf("dev <option> should NOT be selected when ?parent=admin; opening tag was: %s", devTag)
}
}
}
// TestNewFormNoParentParamRendersAllOptions confirms the Parents <select>
// is populated even when no ?parent= is supplied — clicking "+ New" from the
// nav should still let the user pick any parent.
func TestNewFormNoParentParamRendersAllOptions(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
code, body := get(t, h, "/new")
if code != 200 {
t.Fatalf("GET /new → %d", code)
}
// At least one option exists.
if !strings.Contains(body, `<option value="`) {
t.Fatalf("Parents <select> is empty on /new (no ?parent= param)")
}
// Nothing pre-selected.
if strings.Contains(body, `<option value="`) && strings.Contains(body, `" selected>`) {
// Make sure no Parents <select> option is selected — Status options
// might use selected for the default, so anchor on parent_ids context.
pIdx := strings.Index(body, `name="parent_ids"`)
if pIdx >= 0 {
selectClose := strings.Index(body[pIdx:], `</select>`)
if selectClose > 0 {
parentBlock := body[pIdx : pIdx+selectClose]
if strings.Contains(parentBlock, "selected") {
t.Errorf("no Parents option should be selected on bare /new, but block contains 'selected': %s", parentBlock)
}
}
}
}
}

View File

@@ -860,9 +860,18 @@ func (s *Server) handleNewForm(w http.ResponseWriter, r *http.Request) {
}
parent = p
}
// new.tmpl iterates {{range .ParentOptions}} to render the Parents
// <select>. Without this the dropdown was empty and `?parent=admin`
// had nothing to pre-select — the symptom m hit.
parents, err := s.parentOptions(r.Context())
if err != nil {
s.fail(w, r, err)
return
}
s.render(w, r, "new", map[string]any{
"Title": "new",
"Parent": parent,
"ParentOptions": parents,
"StatusOptions": []string{"active", "done", "archived"},
})
}