Files
projax/web/new_form_test.go
mAi 157c4e659b feat(new): auto-suggest kebab slug from title
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.
2026-05-27 14:30:23 +02:00

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)
}
}
}
}
}