Merge branch 'mai/knuth/detail-page-order' (feat: detail-page field ordering + auxiliary section break)
This commit is contained in:
134
web/detail_order_test.go
Normal file
134
web/detail_order_test.go
Normal file
@@ -0,0 +1,134 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -1304,3 +1304,42 @@ html[data-sidebar-collapsed="true"] .projax-sidebar .collapse-icon {
|
||||
padding-bottom: calc(56px + 1rem + env(safe-area-inset-bottom, 0px));
|
||||
}
|
||||
}
|
||||
|
||||
/* --- Detail page: form-group ordering polish (Phase 5i, detail-page-order) --- */
|
||||
.detail-form { display: flex; flex-direction: column; gap: 20px; max-width: 720px; }
|
||||
.detail-form .form-group { display: flex; flex-direction: column; gap: 12px; margin: 0; padding: 0; }
|
||||
.detail-form .form-group-heading {
|
||||
margin: 0;
|
||||
font-size: 0.78em;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted);
|
||||
border-bottom: 1px dotted var(--border);
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
.detail-form .form-group-flags { flex-direction: row; flex-wrap: wrap; gap: 18px 24px; align-items: baseline; }
|
||||
.detail-form .form-group-flags .form-group-heading { flex-basis: 100%; }
|
||||
.detail-form .form-group-content-label > textarea { font-family: ui-monospace, SFMono-Regular, monospace; }
|
||||
.detail-form .form-group-content-label { gap: 0; }
|
||||
.detail-form > details.proj-section { margin-top: 4px; }
|
||||
|
||||
/* Divider between the editable form and the read-only auxiliary
|
||||
collapsibles. <hr> is semantically a thematic break — matches the
|
||||
intent. The "Related" heading below it makes the change-of-mode
|
||||
obvious without leaning on the line alone. */
|
||||
.aux-divider {
|
||||
border: 0;
|
||||
border-top: 1px solid var(--border);
|
||||
margin: 32px 0 16px;
|
||||
}
|
||||
.aux-sections { display: flex; flex-direction: column; gap: 4px; max-width: 720px; }
|
||||
.aux-heading {
|
||||
margin: 0 0 8px;
|
||||
font-size: 0.95em;
|
||||
font-weight: 600;
|
||||
color: var(--muted);
|
||||
}
|
||||
.aux-reset { margin: 12px 0 0; font-size: 0.85em; }
|
||||
.aux-reset .proj-section-reset { color: var(--muted); }
|
||||
.aux-reset .proj-section-reset:hover { color: var(--bad); }
|
||||
|
||||
@@ -13,111 +13,109 @@
|
||||
<p class="meta muted">Also at: {{range $i, $p := .Item.OtherPaths}}{{if $i}}, {{end}}<a href="/i/{{$p}}">{{$p}}</a>{{end}}</p>
|
||||
{{end}}
|
||||
|
||||
{{/*
|
||||
Phase 4e: collapsible sections. Each detail-page section is wrapped in a
|
||||
<details> element with a smart default for the `open` attribute based on
|
||||
the count of items inside. The inline JS at the bottom of the page
|
||||
overrides those defaults from localStorage so m's per-item collapse
|
||||
state survives reloads. Data-section keys are stable strings; data-count
|
||||
surfaces the count so the JS doesn't have to re-walk children to label
|
||||
the summary.
|
||||
*/}}
|
||||
{{$itemID := .Item.ID}}
|
||||
|
||||
{{if .CalDAVOn}}
|
||||
{{/* Tasks section opens by default when any linked calendar has at least
|
||||
one open VTODO. hasOpenTasks template helper would be cleaner but the
|
||||
range-with-flag style avoids registering a new func. */}}
|
||||
{{$tasksOpen := false}}
|
||||
{{range .Tasks}}{{if .Open}}{{$tasksOpen = true}}{{end}}{{end}}
|
||||
<details class="proj-section" data-section="tasks" data-item-id="{{$itemID}}"{{if $tasksOpen}} open{{end}}>
|
||||
<summary class="proj-section-summary">Tasks {{if $tasksOpen}}<small class="muted">(open)</small>{{end}}</summary>
|
||||
{{template "tasks-section" .}}
|
||||
</details>
|
||||
{{end}}
|
||||
{{/*
|
||||
Phase 5i: reordered general → specific. Form first (Title → Slug →
|
||||
Parents → Status → Classification → Flags → Content → form-bound
|
||||
collapsibles) so the always-edit fields sit at the top, then a divider
|
||||
before the auxiliary read-only collapsibles (Tasks / Issues /
|
||||
Documents). Field grouping (General / Classification / Flags) reads as
|
||||
three groups instead of nine flat labels per m's "pimped a bit" ask.
|
||||
|
||||
{{if and .GiteaOn .Issues}}
|
||||
{{$open := le .IssuesOpenTotal 10}}
|
||||
<details class="proj-section" data-section="issues" data-item-id="{{$itemID}}"{{if $open}} open{{end}}>
|
||||
<summary class="proj-section-summary">Issues <small class="muted">({{.IssuesOpenTotal}} open)</small></summary>
|
||||
{{template "issues-section" .}}
|
||||
</details>
|
||||
{{end}}
|
||||
Public listing + Timeline behaviour stay INSIDE the form so they save
|
||||
with the main Save button — moving them out would require a separate
|
||||
POST endpoint, which is out of scope for this pass.
|
||||
|
||||
{{$docOpen := le (len .Documents) 5}}
|
||||
<details class="proj-section" data-section="documents" data-item-id="{{$itemID}}"{{if $docOpen}} open{{end}}>
|
||||
<summary class="proj-section-summary">Documents <small class="muted">({{len .Documents}})</small></summary>
|
||||
{{template "documents-section" .}}
|
||||
</details>
|
||||
Phase 4e collapsibles smart-default + localStorage state preserved
|
||||
exactly: same data-section keys, same proj-section CSS class, same
|
||||
inline JS.
|
||||
*/}}
|
||||
|
||||
<form method="post" action="/i/{{.Item.PrimaryPath}}" class="edit">
|
||||
<label>Title <input name="title" value="{{.Item.Title}}" required></label>
|
||||
<label>Slug <input name="slug" value="{{.Item.Slug}}" required pattern="[^.]+"></label>
|
||||
<label>Parents <small class="muted">(hold Ctrl/Cmd to pick multiple — same row can live under several branches)</small>
|
||||
<select name="parent_ids" multiple size="6">
|
||||
{{range .ParentOptions}}
|
||||
<option value="{{.ID}}" {{if contains $.Item.ParentIDs .ID}}selected{{end}}>{{.Path}}</option>
|
||||
{{end}}
|
||||
</select>
|
||||
</label>
|
||||
<label>Status
|
||||
<select name="status">
|
||||
{{range $opt := .StatusOptions}}
|
||||
<option value="{{$opt}}" {{if eq $opt $.Item.Status}}selected{{end}}>{{$opt}}</option>
|
||||
{{end}}
|
||||
</select>
|
||||
</label>
|
||||
<label>Tags
|
||||
<input name="tags" value="{{join "," .Item.Tags}}" placeholder="comma-separated, e.g. work, dev">
|
||||
</label>
|
||||
<label>Management
|
||||
<input name="management" value="{{join "," .Item.Management}}" placeholder="comma-separated: self, mai, external">
|
||||
</label>
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" name="pinned" value="1" {{if .Item.Pinned}}checked{{end}}> pinned
|
||||
</label>
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" name="archived" value="1" {{if .Item.Archived}}checked{{end}}> archived
|
||||
</label>
|
||||
<label>Content
|
||||
<textarea name="content_md" rows="14">{{.Item.ContentMD}}</textarea>
|
||||
</label>
|
||||
<form method="post" action="/i/{{.Item.PrimaryPath}}" class="edit detail-form">
|
||||
|
||||
<section class="form-group" aria-labelledby="hdr-general">
|
||||
<h2 id="hdr-general" class="form-group-heading">General</h2>
|
||||
<label>Title <input name="title" value="{{.Item.Title}}" required></label>
|
||||
<label>Slug <input name="slug" value="{{.Item.Slug}}" required pattern="[^.]+"></label>
|
||||
<label>Parents <small class="muted">(hold Ctrl/Cmd to pick multiple — same row can live under several branches)</small>
|
||||
<select name="parent_ids" multiple size="6">
|
||||
{{range .ParentOptions}}
|
||||
<option value="{{.ID}}" {{if contains $.Item.ParentIDs .ID}}selected{{end}}>{{.Path}}</option>
|
||||
{{end}}
|
||||
</select>
|
||||
</label>
|
||||
<label>Status
|
||||
<select name="status">
|
||||
{{range $opt := .StatusOptions}}
|
||||
<option value="{{$opt}}" {{if eq $opt $.Item.Status}}selected{{end}}>{{$opt}}</option>
|
||||
{{end}}
|
||||
</select>
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<section class="form-group" aria-labelledby="hdr-classification">
|
||||
<h2 id="hdr-classification" class="form-group-heading">Classification</h2>
|
||||
<label>Tags
|
||||
<input name="tags" value="{{join "," .Item.Tags}}" placeholder="comma-separated, e.g. work, dev">
|
||||
</label>
|
||||
<label>Management
|
||||
<input name="management" value="{{join "," .Item.Management}}" placeholder="comma-separated: self, mai, external">
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<section class="form-group form-group-flags" aria-labelledby="hdr-flags">
|
||||
<h2 id="hdr-flags" class="form-group-heading">Flags</h2>
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" name="pinned" value="1" {{if .Item.Pinned}}checked{{end}}> pinned
|
||||
</label>
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" name="archived" value="1" {{if .Item.Archived}}checked{{end}}> archived
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<section class="form-group" aria-labelledby="hdr-content">
|
||||
<h2 id="hdr-content" class="form-group-heading">Content</h2>
|
||||
<label class="form-group-content-label">
|
||||
<textarea name="content_md" rows="14">{{.Item.ContentMD}}</textarea>
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<details class="proj-section" data-section="public" data-item-id="{{$itemID}}">
|
||||
<summary class="proj-section-summary">Public listing {{if .Item.Public}}<small class="muted">(on)</small>{{end}}</summary>
|
||||
<fieldset class="public-listing">
|
||||
<legend class="visually-hidden">Public listing</legend>
|
||||
<p class="muted">When public is on, flexsiebels.de (and any other portfolio
|
||||
consumer) can pull these fields via the projax MCP. The values are
|
||||
preserved when public is off — toggling never destroys them.</p>
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" name="public" value="1" {{if .Item.Public}}checked{{end}}> Make this public
|
||||
</label>
|
||||
<label>Public description
|
||||
<textarea name="public_description" rows="4" placeholder="What visitors see on flexsiebels. Markdown allowed.">{{.Item.PublicDescription}}</textarea>
|
||||
</label>
|
||||
<label>Live URL
|
||||
<input name="public_live_url" type="url" value="{{.Item.PublicLiveURL}}" placeholder="https://racetrack.dev">
|
||||
</label>
|
||||
<label>Source URL
|
||||
<input name="public_source_url" type="url" value="{{.Item.PublicSourceURL}}" placeholder="https://mgit.msbls.de/m/racetrack">
|
||||
</label>
|
||||
<label>Screenshots <small class="muted">(one URL per row; order is the display order)</small>
|
||||
<div class="public-screenshots" id="public-screenshots">
|
||||
{{range .Item.PublicScreenshots}}
|
||||
<summary class="proj-section-summary">Public listing {{if .Item.Public}}<small class="muted">(on)</small>{{end}}</summary>
|
||||
<fieldset class="public-listing">
|
||||
<legend class="visually-hidden">Public listing</legend>
|
||||
<p class="muted">When public is on, flexsiebels.de (and any other portfolio
|
||||
consumer) can pull these fields via the projax MCP. The values are
|
||||
preserved when public is off — toggling never destroys them.</p>
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" name="public" value="1" {{if .Item.Public}}checked{{end}}> Make this public
|
||||
</label>
|
||||
<label>Public description
|
||||
<textarea name="public_description" rows="4" placeholder="What visitors see on flexsiebels. Markdown allowed.">{{.Item.PublicDescription}}</textarea>
|
||||
</label>
|
||||
<label>Live URL
|
||||
<input name="public_live_url" type="url" value="{{.Item.PublicLiveURL}}" placeholder="https://racetrack.dev">
|
||||
</label>
|
||||
<label>Source URL
|
||||
<input name="public_source_url" type="url" value="{{.Item.PublicSourceURL}}" placeholder="https://mgit.msbls.de/m/racetrack">
|
||||
</label>
|
||||
<label>Screenshots <small class="muted">(one URL per row; order is the display order)</small>
|
||||
<div class="public-screenshots" id="public-screenshots">
|
||||
{{range .Item.PublicScreenshots}}
|
||||
<div class="public-screenshot-row">
|
||||
<input name="public_screenshots" type="url" value="{{.}}" placeholder="https://…">
|
||||
<button type="button" class="public-screenshot-remove" aria-label="Remove screenshot">×</button>
|
||||
</div>
|
||||
{{end}}
|
||||
<div class="public-screenshot-row">
|
||||
<input name="public_screenshots" type="url" value="{{.}}" placeholder="https://…">
|
||||
<input name="public_screenshots" type="url" value="" placeholder="https://…">
|
||||
<button type="button" class="public-screenshot-remove" aria-label="Remove screenshot">×</button>
|
||||
</div>
|
||||
{{end}}
|
||||
<div class="public-screenshot-row">
|
||||
<input name="public_screenshots" type="url" value="" placeholder="https://…">
|
||||
<button type="button" class="public-screenshot-remove" aria-label="Remove screenshot">×</button>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" id="public-screenshot-add" class="public-screenshot-add">+ Add screenshot</button>
|
||||
</label>
|
||||
</fieldset>
|
||||
<button type="button" id="public-screenshot-add" class="public-screenshot-add">+ Add screenshot</button>
|
||||
</label>
|
||||
</fieldset>
|
||||
</details>
|
||||
|
||||
<details class="proj-section" data-section="timeline-behaviour" data-item-id="{{$itemID}}"{{if .Item.TimelineExclude}} open{{end}}>
|
||||
@@ -136,9 +134,44 @@
|
||||
<div class="actions">
|
||||
<button type="submit">Save</button>
|
||||
<a class="cancel" href="/">Cancel</a>
|
||||
<a class="proj-section-reset" href="#" data-item-id="{{$itemID}}">reset section state</a>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<hr class="aux-divider" aria-hidden="true">
|
||||
|
||||
<section class="aux-sections" aria-labelledby="hdr-aux">
|
||||
<h2 id="hdr-aux" class="aux-heading">Related</h2>
|
||||
|
||||
{{if .CalDAVOn}}
|
||||
{{/* Tasks section opens by default when any linked calendar has at least
|
||||
one open VTODO. */}}
|
||||
{{$tasksOpen := false}}
|
||||
{{range .Tasks}}{{if .Open}}{{$tasksOpen = true}}{{end}}{{end}}
|
||||
<details class="proj-section" data-section="tasks" data-item-id="{{$itemID}}"{{if $tasksOpen}} open{{end}}>
|
||||
<summary class="proj-section-summary">Tasks {{if $tasksOpen}}<small class="muted">(open)</small>{{end}}</summary>
|
||||
{{template "tasks-section" .}}
|
||||
</details>
|
||||
{{end}}
|
||||
|
||||
{{if and .GiteaOn .Issues}}
|
||||
{{$open := le .IssuesOpenTotal 10}}
|
||||
<details class="proj-section" data-section="issues" data-item-id="{{$itemID}}"{{if $open}} open{{end}}>
|
||||
<summary class="proj-section-summary">Issues <small class="muted">({{.IssuesOpenTotal}} open)</small></summary>
|
||||
{{template "issues-section" .}}
|
||||
</details>
|
||||
{{end}}
|
||||
|
||||
{{$docOpen := le (len .Documents) 5}}
|
||||
<details class="proj-section" data-section="documents" data-item-id="{{$itemID}}"{{if $docOpen}} open{{end}}>
|
||||
<summary class="proj-section-summary">Documents <small class="muted">({{len .Documents}})</small></summary>
|
||||
{{template "documents-section" .}}
|
||||
</details>
|
||||
|
||||
<p class="aux-reset">
|
||||
<a class="proj-section-reset muted" href="#" data-item-id="{{$itemID}}">reset section state</a>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<script>
|
||||
// Phase 4e collapsible-section persistence. Each <details data-section data-item-id>
|
||||
// reads its open state from localStorage on boot (user choice wins over the
|
||||
|
||||
Reference in New Issue
Block a user