panel: place drill-down back links under the heading
test / test (push) Has been cancelled

The user form had Back to users at the bottom of the card; all drill-down
pages now use a shared back_link template with a structural test.

Co-Authored-By: Claude <Composer 2.5> <noreply@anthropic.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-11 21:58:08 +03:00
parent f1074eb526
commit 5bd632c8e0
8 changed files with 58 additions and 5 deletions
+7
View File
@@ -5,6 +5,13 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
## [Unreleased] ## [Unreleased]
### Fixed
- panel: the user create/edit form placed «Back to users» at the bottom of the
card instead of under the heading like the delivery, domain, and domain-delete
pages. A shared `back_link` template now renders every drill-down up-link, and
`TestDrillDownPagesPlaceBackLinkAboveContent` guards its position.
### Changed ### Changed
- panel: **Settings** shows panel credentials and DMARC aggregate reports side - panel: **Settings** shows panel credentials and DMARC aggregate reports side
+1 -1
View File
@@ -18,7 +18,7 @@
<span class="st st-{{.Level}}">{{.Row.Status}}</span> <span class="st st-{{.Level}}">{{.Row.Status}}</span>
</p> </p>
<a class="back" href="{{.BackURL}}">&larr; Back to deliveries</a> {{template "back_link" (back .BackURL "Back to deliveries")}}
{{/* The two columns: what was recorded on the left, in what order it happened {{/* The two columns: what was recorded on the left, in what order it happened
on the right. They are a pair — the facts are only worth reading against on the right. They are a pair — the facts are only worth reading against
@@ -1,7 +1,7 @@
{{define "content"}} {{define "content"}}
<h1>Delete {{.Domain.Name}}</h1> <h1>Delete {{.Domain.Name}}</h1>
<a class="back" href="/domains/{{.Domain.ID}}">&larr; Back to {{.Domain.Name}}</a> {{template "back_link" (back (printf "/domains/%d" .Domain.ID) (printf "Back to %s" .Domain.Name))}}
<div class="card"> <div class="card">
<h2>Confirm deletion</h2> <h2>Confirm deletion</h2>
@@ -1,7 +1,7 @@
{{define "content"}} {{define "content"}}
<h1>{{.Domain.Name}}</h1> <h1>{{.Domain.Name}}</h1>
<a class="back" href="/domains">&larr; All domains</a> {{template "back_link" (back "/domains" "All domains")}}
{{if .Flash}}<div class="flash">{{.Flash}}</div>{{end}} {{if .Flash}}<div class="flash">{{.Flash}}</div>{{end}}
{{if .RateLimitErr}}<div class="flash error">{{.RateLimitErr}}</div>{{end}} {{if .RateLimitErr}}<div class="flash error">{{.RateLimitErr}}</div>{{end}}
+8
View File
@@ -107,6 +107,14 @@
so its definition replaces the empty one. */}} so its definition replaces the empty one. */}}
{{define "wide"}}{{end}} {{define "wide"}}{{end}}
{{/* back_link — up-navigation on drill-down pages. Invoke with the "back"
function, e.g. {{template "back_link" (back "/users" "Back to users")}}.
Place it directly under the page <h1> and above the cards (see .back in
panel.css). TestDrillDownPagesPlaceBackLinkAboveContent guards this. */}}
{{define "back_link"}}
<a class="back" href="{{.Href}}">&larr; {{.Label}}</a>
{{end}}
{{/* Navigation icons. Inline SVG rather than an icon font or sprite file: they {{/* Navigation icons. Inline SVG rather than an icon font or sprite file: they
inherit the link's colour through currentColor, cost no extra request, and inherit the link's colour through currentColor, cost no extra request, and
need no exemption from the panel's "default-src 'self'" policy. Each is need no exemption from the panel's "default-src 'self'" policy. Each is
+2 -1
View File
@@ -1,6 +1,8 @@
{{define "content"}} {{define "content"}}
<h1>{{if .IsEdit}}Edit user{{else}}Create user{{end}}</h1> <h1>{{if .IsEdit}}Edit user{{else}}Create user{{end}}</h1>
{{template "back_link" (back "/users" "Back to users")}}
<div class="card narrow"> <div class="card narrow">
{{if .Error}}<p class="error">{{.Error}}</p>{{end}} {{if .Error}}<p class="error">{{.Error}}</p>{{end}}
<form method="post" action="{{if .IsEdit}}/users/{{.UserID}}{{else}}/users/new{{end}}"> <form method="post" action="{{if .IsEdit}}/users/{{.UserID}}{{else}}/users/new{{end}}">
@@ -33,6 +35,5 @@
<button type="submit" name="action" value="delete" class="danger">Delete user</button> <button type="submit" name="action" value="delete" class="danger">Delete user</button>
{{end}} {{end}}
</form> </form>
<p class="muted"><a href="/users">Back to users</a></p>
</div> </div>
{{end}} {{end}}
+26
View File
@@ -279,6 +279,32 @@ func TestOnlyThePagesMadeOfDataDeclareThemselvesWide(t *testing.T) {
} }
} }
// Drill-down pages carry an up-link directly under the heading and above the
// cards. A link at the bottom of a form is easy to miss and drifts from the
// rest of the panel, so the shared back_link template is mandatory on those
// pages and TestDrillDownPagesPlaceBackLinkAboveContent guards its position.
func TestDrillDownPagesPlaceBackLinkAboveContent(t *testing.T) {
drillDown := map[string]bool{
"user_form.html": true,
"domain_detail.html": true,
"domain_delete.html": true,
"delivery.html": true,
}
forEachTemplate(t, func(name, body string) {
if !drillDown[name] {
return
}
if !strings.Contains(body, `template "back_link"`) {
t.Errorf("%s is a drill-down page but does not use the shared back_link template", name)
}
backIdx := strings.Index(body, `template "back_link"`)
cardIdx := strings.Index(body, `class="card`)
if cardIdx >= 0 && backIdx > cardIdx {
t.Errorf("%s places the back link after the first card", name)
}
})
}
// Since the panel root redirects to the status page, a link left pointing at // Since the panel root redirects to the status page, a link left pointing at
// "/" silently lands on the wrong screen instead of failing — so no template may // "/" silently lands on the wrong screen instead of failing — so no template may
// contain one. // contain one.
+12 -1
View File
@@ -65,7 +65,7 @@ func New(version string) (*Engine, error) {
} }
for name, files := range pageFiles { for name, files := range pageFiles {
patterns := append([]string{"templates/layout.html"}, files...) patterns := append([]string{"templates/layout.html"}, files...)
tmpl, err := template.New("layout.html").ParseFS(assetsFS, patterns...) tmpl, err := template.New("layout.html").Funcs(templateFuncs()).ParseFS(assetsFS, patterns...)
if err != nil { if err != nil {
return nil, fmt.Errorf("parse template %s: %w", name, err) return nil, fmt.Errorf("parse template %s: %w", name, err)
} }
@@ -81,6 +81,17 @@ func New(version string) (*Engine, error) {
return e, nil return e, nil
} }
// templateFuncs supplies helpers shared across page templates.
func templateFuncs() template.FuncMap {
return template.FuncMap{
// back builds the map back_link reads; keeps href and label paired at
// the call site instead of repeating the <a class="back"> markup.
"back": func(href, label string) map[string]string {
return map[string]string{"Href": href, "Label": label}
},
}
}
// Page returns a parsed page template by logical name. It is exported for // Page returns a parsed page template by logical name. It is exported for
// template guard tests that assert structural properties across all pages. // template guard tests that assert structural properties across all pages.
func (e *Engine) Page(name string) *template.Template { func (e *Engine) Page(name string) *template.Template {