m's request: typing "Mallorca 2026" into the new-item Title should
suggest "mallorca-2026" in the Slug field. Surface-only — server still
validates per itemwrite (^[a-z0-9][a-z0-9-]{0,62}$).
Inline ~25-line vanilla-JS handler on /new:
- normalize('NFD') + strip combining diacritics → ä→a, ñ→n, São→sao
- ß → ss (German sharp-s)
- non-alphanum run → single hyphen
- trim leading/trailing hyphens, collapse runs of hyphens
- slice(0, 63) to match the validator's length cap
Behavioural contract per m's brief:
- Slug syncs from Title on every Title input event UNTIL the user
edits the slug manually. After that the slug field is locked in
(`slug.dataset.userEdited === '1'`).
- A pre-filled slug counts as user-edited too — defensive against any
future flow that lands on /new with a slug already populated.
Scoped to /new only — the detail-page edit form intentionally keeps
manual slug control because auto-sync there would silently rename
existing items.
Template additions:
- Added `id="new-item-form"`, `id="new-title"`, `id="new-slug"` to the
form + inputs so the script can grab them by id rather than name
(name="slug" exists on the detail page too and we don't want to
cross-bind).
Test (web/new_form_test.go):
- TestNewFormHasSlugSuggestScript — asserts the inline script's
signature fragments (`normalize('NFD')`, `replace(/ß/g, 'ss')`,
`slice(0, 63)`, `dataset.userEdited`, the input ids) all render on
/new. Guards against a "harmless cleanup" pass silently stripping
the script.
Manual verification: typing "Mallorca 2026" updates slug to
"mallorca-2026"; typing in the slug field locks further sync.
Full web suite green.
124 lines
4.2 KiB
Go
124 lines
4.2 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestNewFormHasSlugSuggestScript pins the Phase 5k slug auto-suggest:
|
|
// the new-item template ships an inline <script> that derives a
|
|
// kebab-case slug from the title as the user types and stops syncing
|
|
// once the slug is edited manually. Without this guard a future
|
|
// template refactor could silently strip the script.
|
|
func TestNewFormHasSlugSuggestScript(t *testing.T) {
|
|
srv, pool := mustServer(t)
|
|
defer pool.Close()
|
|
h := srv.Routes()
|
|
_, body := get(t, h, "/new")
|
|
for _, want := range []string{
|
|
`id="new-title"`,
|
|
`id="new-slug"`,
|
|
// Algorithm signatures we don't want a "harmless cleanup" pass
|
|
// to drop quietly.
|
|
"normalize('NFD')",
|
|
"replace(/ß/g, 'ss')",
|
|
"replace(/[^a-z0-9]+/g, '-')",
|
|
"slice(0, 63)",
|
|
"dataset.userEdited",
|
|
} {
|
|
if !strings.Contains(body, want) {
|
|
t.Errorf("new-item template missing slug-suggest fragment %q", want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|