m's report: detail page (/i/{path}) shows Tasks / Issues / Documents
above the edit form, and the form's 9 flat fields read as a wall of
labels rather than a flow. He wants the form first, fields grouped, then
auxiliary read-only sections below a clear visual break.
Reordered top-to-bottom flow:
h1 + meta
▸ form
General — Title → Slug → Parents → Status
Classification — Tags → Management
Flags — pinned + archived (inline pair)
Content — markdown textarea
Public listing <details> (stays inside form: save coherence)
Timeline behaviour <details> (stays inside form: save coherence)
Save / Cancel actions
◂ /form
<hr class="aux-divider">
▸ section.aux-sections "Related"
Tasks <details> (was above form)
Issues <details> (was above form)
Documents <details> (was above form)
reset section state link
web/templates/detail.tmpl:
- Three <section class="form-group"> blocks each with a <h2
class="form-group-heading"> ID-anchored for aria-labelledby + the
ordering test. The headings render as small uppercase muted labels —
visual hierarchy without screaming "FORM".
- Form-bound collapsibles (Public Listing + Timeline behaviour) stay
inside the form; moving them out would require a separate POST
endpoint, which the brief explicitly puts out of scope.
- Tasks / Issues / Documents collapsibles moved out of the form, into a
new <section class="aux-sections"> after a thematic <hr>.
- Reset-section-state link relocated to .aux-reset under the auxiliary
section since that's where most collapsible state lives now.
- All data-section / data-item-id / proj-section class hooks preserved
exactly — Phase 4e smart-default + localStorage state semantics
unchanged.
web/static/style.css:
- .detail-form: column flex, gap 20px between groups for breathing room.
- .form-group-heading: 0.78em uppercase muted with dotted-border-bottom
separator — looks like an admin-form group header without being
shouty.
- .form-group-flags: row-flex so pinned + archived sit inline.
- .aux-divider: full-width 1px solid border-top with 32px margin above,
16px below — the explicit "this is where editable ends" break.
- .aux-sections + .aux-heading + .aux-reset: matched flex layout +
small "Related" header so the change-of-mode reads without
squinting.
Tests:
- TestDetailFieldsRenderInOrder (new) — strict-greater index walk
through every documented anchor: General → Title → Slug → Parents →
Status → Classification → Tags → Management → Flags → pinned →
archived → Content → content_md → Save → aux-divider → Related →
Documents. Catches any future regression that re-tangles the order.
- TestDetailFormGroupHeadings (new) — pins the five visible group
headings (General / Classification / Flags / Content / Related) so
a string-cleanup pass can't silently strip them.
- TestDetailAuxSectionsAfterForm (new) — Documents <details> lives
AFTER the detail form's </form>, while Public listing stays INSIDE
the form for save-coherence. Skips the sidebar's logout-form </form>
by anchoring on the detail-form's action="/i/dev" start tag.
- TestDetailIncludesSectionToggleScript / TestDetailSectionsWrappedInDetails /
TestDetailDocumentsClosedDefaultsWhenManyItems still pass — the
Phase 4e collapsible semantics are untouched.
Net: +298 / -92.
135 lines
4.6 KiB
Go
135 lines
4.6 KiB
Go
package web_test
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// TestDetailFieldsRenderInOrder pins m's requested top-to-bottom flow on
|
|
// the /i/{path} edit form: form-then-auxiliaries, with form fields
|
|
// grouped general → specific. The test slices the rendered body into the
|
|
// documented anchor strings (form labels, group headings, divider, aux
|
|
// heading, first auxiliary <details>) and confirms each anchor's first
|
|
// index is strictly greater than the previous one.
|
|
//
|
|
// Anchors deliberately picked to be robust:
|
|
// - Form field markup (name="title" / name="slug" / etc.) — won't drift
|
|
// unless the form is re-architected.
|
|
// - Group-heading IDs (hdr-general / hdr-classification / hdr-flags /
|
|
// hdr-content / hdr-aux) — emitted by the Phase-5i template.
|
|
// - The aux-divider <hr> — the explicit visual break m asked for.
|
|
//
|
|
// If a future refactor moves fields around inside a group, this test
|
|
// still passes as long as the cross-group order holds.
|
|
func TestDetailFieldsRenderInOrder(t *testing.T) {
|
|
srv, pool := mustServer(t)
|
|
defer pool.Close()
|
|
h := srv.Routes()
|
|
code, body := get(t, h, "/i/dev")
|
|
if code != 200 {
|
|
t.Fatalf("GET /i/dev → %d", code)
|
|
}
|
|
|
|
anchors := []struct {
|
|
label string
|
|
needle string
|
|
}{
|
|
{"General heading", `id="hdr-general"`},
|
|
{"Title field", `name="title"`},
|
|
{"Slug field", `name="slug"`},
|
|
{"Parents field", `name="parent_ids"`},
|
|
{"Status field", `name="status"`},
|
|
{"Classification heading", `id="hdr-classification"`},
|
|
{"Tags field", `name="tags"`},
|
|
{"Management field", `name="management"`},
|
|
{"Flags heading", `id="hdr-flags"`},
|
|
{"pinned field", `name="pinned"`},
|
|
{"archived field", `name="archived"`},
|
|
{"Content heading", `id="hdr-content"`},
|
|
{"Content textarea", `name="content_md"`},
|
|
{"Save button", `<button type="submit">Save</button>`},
|
|
{"Auxiliary divider", `class="aux-divider"`},
|
|
{"Auxiliary heading", `id="hdr-aux"`},
|
|
{"Documents section", `data-section="documents"`},
|
|
}
|
|
|
|
prevIdx := -1
|
|
prevLabel := "(start)"
|
|
for _, a := range anchors {
|
|
idx := strings.Index(body, a.needle)
|
|
if idx < 0 {
|
|
t.Errorf("%s anchor %q not found in body", a.label, a.needle)
|
|
continue
|
|
}
|
|
if idx <= prevIdx {
|
|
t.Errorf("order violation: %s (idx %d) must come AFTER %s (idx %d)",
|
|
a.label, idx, prevLabel, prevIdx)
|
|
}
|
|
prevIdx = idx
|
|
prevLabel = a.label
|
|
}
|
|
}
|
|
|
|
// TestDetailFormGroupHeadings proves the three group subheadings render
|
|
// with the expected human-readable copy. Hardcoded so a future "clean
|
|
// up the strings" pass doesn't silently strip the visual hierarchy m
|
|
// asked for.
|
|
func TestDetailFormGroupHeadings(t *testing.T) {
|
|
srv, pool := mustServer(t)
|
|
defer pool.Close()
|
|
h := srv.Routes()
|
|
_, body := get(t, h, "/i/dev")
|
|
for _, want := range []string{
|
|
`>General</h2>`,
|
|
`>Classification</h2>`,
|
|
`>Flags</h2>`,
|
|
`>Content</h2>`,
|
|
`>Related</h2>`,
|
|
} {
|
|
if !strings.Contains(body, want) {
|
|
t.Errorf("detail page missing group heading %q", want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestDetailAuxSectionsAfterForm proves the read-only auxiliary
|
|
// <details> sections (Tasks / Issues / Documents) live BELOW the
|
|
// form's </form> tag — that's the load-bearing visual contract from
|
|
// m's report. Public-listing + Timeline-behaviour stay INSIDE the form
|
|
// (form-bound, saved by the main Save) — this test asserts only the
|
|
// purely read-only sections moved.
|
|
func TestDetailAuxSectionsAfterForm(t *testing.T) {
|
|
srv, pool := mustServer(t)
|
|
defer pool.Close()
|
|
h := srv.Routes()
|
|
_, body := get(t, h, "/i/dev")
|
|
// The layout has a <form action="/logout"> in the sidebar — its </form>
|
|
// would match first. Anchor on the detail form's start tag, then look
|
|
// for </form> AFTER that point so we're measuring the right boundary.
|
|
formStart := strings.Index(body, `<form method="post" action="/i/dev"`)
|
|
if formStart < 0 {
|
|
t.Fatalf("detail form start tag not found in body")
|
|
}
|
|
formEnd := strings.Index(body[formStart:], "</form>")
|
|
if formEnd < 0 {
|
|
t.Fatalf("</form> tag not found after detail form start")
|
|
}
|
|
formEnd += formStart
|
|
docs := strings.Index(body, `data-section="documents"`)
|
|
if docs < 0 {
|
|
t.Fatalf("documents section not found")
|
|
}
|
|
if docs <= formEnd {
|
|
t.Errorf("Documents section (idx %d) must appear AFTER </form> (idx %d)", docs, formEnd)
|
|
}
|
|
// Public listing stays inside the form — confirm the contract holds.
|
|
publicSection := strings.Index(body, `data-section="public"`)
|
|
if publicSection < 0 {
|
|
t.Fatalf("public section not found")
|
|
}
|
|
if publicSection >= formEnd {
|
|
t.Errorf("Public listing section (idx %d) should remain INSIDE the form (before </form> at idx %d) for save coherence",
|
|
publicSection, formEnd)
|
|
}
|
|
}
|