Refactor configuration profile editing

This commit is contained in:
Jonathon Leight committed 2026-08-05 15:57:43 -04:00
1 parent b11a7ea1c9
commit d394d229ac
10 files changed
+465 -105

No files matched your search

+19 -8
View File
@@ -31,7 +31,8 @@ func TestOrgConfigProfilesFlow(t *testing.T) {
t.Fatalf("create org: %v", err)
}
// Two profiles, each created on its own page.
// Two profiles, each created through the editor. A save lands back on the
// config page with the new profile selected.
for _, p := range []struct{ name, steps string }{{"ESP32", "# esp base"}, {"nRF52", "# nrf base"}} {
save := post(t, ts, h.app, "/orgs/"+org.Slug+"/config/profiles",
url.Values{"profile_name": {p.name}, "profile_steps": {p.steps}}, sess)
@@ -39,6 +40,9 @@ func TestOrgConfigProfilesFlow(t *testing.T) {
if save.StatusCode != http.StatusSeeOther {
t.Fatalf("create profile %s status = %d, want 303", p.name, save.StatusCode)
}
if got, want := save.Header.Get("Location"), "/orgs/"+org.Slug+"/config?profile="+url.QueryEscape(p.name); got != want {
t.Fatalf("create profile %s redirected to %q, want %q", p.name, got, want)
}
}
// A geofenced region, saved on the region page.
rsave := post(t, ts, h.app, "/orgs/"+org.Slug+"/config/regions", url.Values{
@@ -55,9 +59,13 @@ func TestOrgConfigProfilesFlow(t *testing.T) {
}
body := readBody(t, do(t, ts, h.app, "/orgs/"+org.Slug+"/config", sess))
// Profiles list with a selector; the geofenced region surfaces as the
// click-to-preview location map (regions aren't listed individually).
for _, want := range []string{"ESP32", "nRF52", "<select", "Preview a location", "region-map"} {
// The profile list, with per-row admin actions; the geofenced region surfaces as
// the click-to-preview location map (regions aren't listed individually).
for _, want := range []string{
"ESP32", "nRF52", `data-testid="config-profile-list"`,
`data-testid="config-profile-add"`, `data-testid="config-profile-edit"`, `data-testid="config-profile-delete"`,
`id="config-profile-modal"`, "Preview a location", "region-map",
} {
if !strings.Contains(body, want) {
t.Fatalf("config view missing %q", want)
}
@@ -72,15 +80,18 @@ func TestOrgConfigProfilesFlow(t *testing.T) {
t.Fatalf("?profile=nRF52 should show nRF52's base settings")
}
// The admin editor pages render: the hub lists the profiles + region summary,
// the new-profile page shows the base-settings editor, and the region page has
// the map + a region row.
// The admin editor pages render: the hub summarizes the regions (profiles live
// on the config page now), the new-profile page shows the base-settings editor,
// and the region page has the map + a region row.
hub := readBody(t, do(t, ts, h.app, "/orgs/"+org.Slug+"/config/edit", sess))
for _, want := range []string{"ESP32", "nRF52", "Add profile", "Edit regions", "1 region"} {
for _, want := range []string{"Edit regions", "1 region"} {
if !strings.Contains(hub, want) {
t.Fatalf("config hub missing %q", want)
}
}
if strings.Contains(hub, "Add profile") {
t.Fatalf("config hub should no longer manage profiles")
}
newProf := readBody(t, do(t, ts, h.app, "/orgs/"+org.Slug+"/config/profiles/new", sess))
if !strings.Contains(newProf, "Base settings") || !strings.Contains(newProf, "Create profile") {
t.Fatalf("new-profile page missing its form")
+28 -15
View File
@@ -4,6 +4,7 @@ import (
"errors"
"fmt"
"net/http"
"net/url"
"strconv"
"strings"
@@ -75,9 +76,9 @@ func (s *Handlers) pageOrgConfig(w http.ResponseWriter, r *http.Request) {
s.Render(w, r, "org_config.html", data)
}
// pageConfigHub is the admin configuration overview: a list of profiles (each
// edited on its own page) and a summary of the org's regions (edited on the map
// page). It replaces the old single mega-form.
// pageConfigHub is the admin configuration overview: a summary of the org's
// regions, which are edited on the map page. (Profiles are managed inline on the
// Configuration page itself.)
func (s *Handlers) pageConfigHub(w http.ResponseWriter, r *http.Request) {
orgID, ok := s.requireOrgAdmin(w, r)
if !ok {
@@ -88,11 +89,6 @@ func (s *Handlers) pageConfigHub(w http.ResponseWriter, r *http.Request) {
s.NotFound(w, r)
return
}
profiles, err := s.Store.ListProfiles(r.Context(), orgID)
if err != nil {
s.ServerError(w, r, "could not load config", err)
return
}
regions, err := s.Store.ListRegions(r.Context(), orgID)
if err != nil {
s.ServerError(w, r, "could not load config", err)
@@ -108,7 +104,6 @@ func (s *Handlers) pageConfigHub(w http.ResponseWriter, r *http.Request) {
s.Render(w, r, "config_edit.html", map[string]any{
"Org": org,
"Nav": s.OrgNavFor(r.Context(), web.OrgNavArgs{OrgID: orgID, Name: org.Name, Slug: org.Slug, Active: "config", IsMember: true, IsAdmin: true, Manage: true}),
"Profiles": profiles,
"RegionCount": len(regions),
"PrimaryRegion": primary,
})
@@ -148,18 +143,34 @@ func (s *Handlers) pageProfileEdit(w http.ResponseWriter, r *http.Request) {
s.renderProfileEdit(w, r, org, pid, name, stepsText, nil, nil)
}
// renderProfileEdit renders the profile editor page (shared by the initial GET
// and the error re-render). pid 0 means a new profile.
// renderProfileEdit renders the profile editor (shared by the initial GET and the
// error re-render). pid 0 means a new profile. An htmx request gets the modal
// fragment the Configuration page swaps in place; anything else (no JS, or a
// direct link) gets the standalone page.
func (s *Handlers) renderProfileEdit(w http.ResponseWriter, r *http.Request, org *store.Org, pid int64, name, stepsText string, errs, risky []string) {
s.Render(w, r, "config_profile_edit.html", map[string]any{
data := map[string]any{
"Org": org,
"Nav": s.OrgNavFor(r.Context(), web.OrgNavArgs{OrgID: org.ID, Name: org.Name, Slug: org.Slug, Active: "config", IsMember: true, IsAdmin: true, Manage: true}),
"ProfileID": pid,
"Action": profileFormAction(org.Slug, pid),
"Name": name,
"StepsText": stepsText,
"Errors": errs,
"RiskyWarn": risky,
})
}
if r.Header.Get("HX-Request") != "" {
data["Layout"] = "config-profile-modal"
}
s.Render(w, r, "config_profile_edit.html", data)
}
// profileFormAction is where the editor posts: the collection for a new profile,
// the profile's own URL for an update.
func profileFormAction(slug string, pid int64) string {
if pid == 0 {
return "/orgs/" + slug + "/config/profiles"
}
return "/orgs/" + slug + "/config/profiles/" + strconv.FormatInt(pid, 10)
}
// handleCreateProfile validates and inserts a new profile.
@@ -227,7 +238,9 @@ func (s *Handlers) saveProfile(w http.ResponseWriter, r *http.Request, orgID, pi
s.ServerError(w, r, "could not save profile", err)
return
default:
http.Redirect(w, r, "/orgs/"+orgParam(r)+"/config/edit", http.StatusSeeOther) //nolint:gosec // G710: local path or config-pinned origin
// Back to the config page with the saved profile selected, so the editor
// closes onto what was just changed. hxRedirect closes the modal.
s.hxRedirect(w, r, "/orgs/"+orgParam(r)+"/config?profile="+url.QueryEscape(name))
return
}
}
@@ -249,7 +262,7 @@ func (s *Handlers) handleDeleteProfile(w http.ResponseWriter, r *http.Request) {
s.ServerError(w, r, "could not delete profile", err)
return
}
http.Redirect(w, r, "/orgs/"+orgParam(r)+"/config/edit", http.StatusSeeOther) //nolint:gosec // G710: local path or config-pinned origin
http.Redirect(w, r, "/orgs/"+orgParam(r)+"/config", http.StatusSeeOther) //nolint:gosec // G710: local path or config-pinned origin
}
// pageRegionsEdit renders the region map editor (map + side list).
+167
View File
@@ -0,0 +1,167 @@
package core
import (
"net/http"
"net/url"
"strconv"
"strings"
"testing"
)
// TestConfigProfileEditorSurfaces: the profile editor serves the modal fragment to
// htmx and the standalone page to everything else (the no-JS fallback), a
// validation error re-renders the fragment in place with the entered values kept,
// and a save sends the browser back to the config page with that profile selected.
func TestConfigProfileEditorSurfaces(t *testing.T) {
t.Parallel()
st, ctx, ts, h := splitServer(t)
admin, sess := appLogin(t, ts, st, ctx, h.app, "profmodaladmin")
org, err := st.CreateOrg(ctx, "Modal Org", admin.ID) // creator becomes admin
if err != nil {
t.Fatalf("create org: %v", err)
}
// htmx GET → just the modal fragment (no page chrome), posting to the collection.
frag := readBody(t, doHX(t, ts, h.app, "/orgs/"+org.Slug+"/config/profiles/new", sess))
for _, want := range []string{"modal-header", "Add profile", `hx-post="/orgs/` + org.Slug + `/config/profiles"`} {
if !strings.Contains(frag, want) {
t.Fatalf("new-profile fragment missing %q", want)
}
}
if strings.Contains(frag, "<html") {
t.Fatal("htmx request should get a bare fragment, not the full page")
}
// Plain GET → the full page fallback, with no htmx attributes to hijack it.
page := readBody(t, do(t, ts, h.app, "/orgs/"+org.Slug+"/config/profiles/new", sess))
if !strings.Contains(page, "<html") || !strings.Contains(page, "Create profile") {
t.Fatal("non-htmx request should get the standalone editor page")
}
if strings.Contains(page, "hx-post") {
t.Fatal("the fallback page must not carry hx-post (its target doesn't exist there)")
}
// A validation error on an htmx submit keeps the modal: 200 with the fragment,
// the error, and the entered values — never a redirect that drops the work.
bad := postHX(t, ts, h.app, "/orgs/"+org.Slug+"/config/profiles",
url.Values{"profile_name": {"ESP32"}, "profile_steps": {"definitely not a command"}}, sess)
badBody := readBody(t, bad)
if bad.StatusCode != http.StatusOK || bad.Header.Get("HX-Redirect") != "" {
t.Fatalf("invalid submit = %d (HX-Redirect %q), want 200 with no redirect", bad.StatusCode, bad.Header.Get("HX-Redirect"))
}
for _, want := range []string{"Nothing was saved", "definitely not a command", `value="ESP32"`} {
if !strings.Contains(badBody, want) {
t.Fatalf("re-rendered fragment missing %q", want)
}
}
// A valid htmx submit closes the modal via HX-Redirect onto the saved profile.
ok := postHX(t, ts, h.app, "/orgs/"+org.Slug+"/config/profiles",
url.Values{"profile_name": {"ESP32"}, "profile_steps": {"# esp base"}}, sess)
ok.Body.Close()
if got, want := ok.Header.Get("HX-Redirect"), "/orgs/"+org.Slug+"/config?profile=ESP32"; ok.StatusCode != http.StatusOK || got != want {
t.Fatalf("valid submit = %d HX-Redirect %q, want 200 → %q", ok.StatusCode, got, want)
}
profiles, err := st.ListProfiles(ctx, org.ID)
if err != nil || len(profiles) != 1 {
t.Fatalf("ListProfiles = %v, %v; want one profile", profiles, err)
}
pid := strconv.FormatInt(profiles[0].ID, 10)
// The edit fragment is pre-filled and posts to that profile's own URL.
editFrag := readBody(t, doHX(t, ts, h.app, "/orgs/"+org.Slug+"/config/profiles/"+pid+"/edit", sess))
for _, want := range []string{"Edit profile", `value="ESP32"`, "# esp base",
`hx-post="/orgs/` + org.Slug + `/config/profiles/` + pid + `"`} {
if !strings.Contains(editFrag, want) {
t.Fatalf("edit fragment missing %q", want)
}
}
// Deleting from the list row (a plain POST) returns to the config page.
del := post(t, ts, h.app, "/orgs/"+org.Slug+"/config/profiles/"+pid+"/delete", url.Values{}, sess)
del.Body.Close()
if got, want := del.Header.Get("Location"), "/orgs/"+org.Slug+"/config"; del.StatusCode != http.StatusSeeOther || got != want {
t.Fatalf("delete = %d → %q, want 303 → %q", del.StatusCode, got, want)
}
}
// TestConfigPageProfileEditingIsAdminOnly: the inline profile controls (add, edit,
// delete, and the editor modal itself) render only for an org admin — never for a
// plain member, and never on the anonymous public surface, which shares this
// template.
func TestConfigPageProfileEditingIsAdminOnly(t *testing.T) {
t.Parallel()
st, ctx, ts, h := splitServer(t)
admin, adminSess := appLogin(t, ts, st, ctx, h.app, "cfgroleadmin")
org, err := st.CreateOrg(ctx, "Role Org", admin.ID)
if err != nil {
t.Fatalf("create org: %v", err)
}
if _, err := st.CreateProfile(ctx, org.ID, "ESP32", nil); err != nil {
t.Fatalf("create profile: %v", err)
}
member, memberSess := appLogin(t, ts, st, ctx, h.app, "cfgrolemember")
if err := st.AddOrgMember(ctx, org.ID, member.ID, "member"); err != nil {
t.Fatalf("add member: %v", err)
}
adminView := readBody(t, do(t, ts, h.app, "/orgs/"+org.Slug+"/config", adminSess))
for _, want := range []string{`data-testid="config-profile-add"`, `data-testid="config-profile-edit"`,
`data-testid="config-profile-delete"`, `id="config-profile-modal"`} {
if !strings.Contains(adminView, want) {
t.Fatalf("admin config view missing %q", want)
}
}
// Selecting a profile is a plain link on both surfaces.
if !strings.Contains(adminView, "/config?profile=ESP32") {
t.Fatal("profile rows should link to ?profile=<name>")
}
for _, v := range []struct {
name string
body string
}{
{"member", readBody(t, do(t, ts, h.app, "/orgs/"+org.Slug+"/config", memberSess))},
{"anonymous", readBody(t, do(t, ts, h.root, "/orgs/"+org.Slug+"/config"))},
} {
if !strings.Contains(v.body, "ESP32") {
t.Fatalf("%s config view should still list the profile", v.name)
}
for _, forbidden := range []string{"config-profile-add", "config-profile-edit", "config-profile-delete",
"config-profile-modal", "/config/profiles"} {
if strings.Contains(v.body, forbidden) {
t.Fatalf("%s config view leaked the admin control %q", v.name, forbidden)
}
}
}
}
// TestConfigPageEmptyStates: an admin with no config yet gets the profile card
// (with Add profile) plus an explanation of what a profile is, so there's an entry
// point; everyone else still sees the plain "nothing defined yet" note.
func TestConfigPageEmptyStates(t *testing.T) {
t.Parallel()
st, ctx, ts, h := splitServer(t)
admin, sess := appLogin(t, ts, st, ctx, h.app, "cfgemptyadmin")
org, err := st.CreateOrg(ctx, "Empty Cfg Org", admin.ID)
if err != nil {
t.Fatalf("create org: %v", err)
}
adminView := readBody(t, do(t, ts, h.app, "/orgs/"+org.Slug+"/config", sess))
for _, want := range []string{`data-testid="config-profile-add"`, "What's a profile?",
"A <strong>profile</strong> is", "No profiles yet."} {
if !strings.Contains(adminView, want) {
t.Fatalf("empty admin config view missing %q", want)
}
}
anon := readBody(t, do(t, ts, h.root, "/orgs/"+org.Slug+"/config"))
if !strings.Contains(anon, "hasn't defined any configuration yet") {
t.Fatal("anonymous view of a config-less org should say so")
}
if strings.Contains(anon, "config-profile-add") {
t.Fatal("anonymous view must not offer to add a profile")
}
}
+5 -4
View File
@@ -87,14 +87,15 @@ func TestOrgConfigProfileUpdateDelete(t *testing.T) {
upd := post(t, ts, h.app, base+"/profiles/"+strconv.FormatInt(pid, 10),
url.Values{"profile_name": {"Heltec"}, "profile_steps": {"# base"}}, sess)
upd.Body.Close()
if loc, _ := url.Parse(upd.Header.Get("Location")); upd.StatusCode != http.StatusSeeOther || loc.Path != base+"/edit" {
t.Fatalf("update profile = %d %q, want 303 → config/edit", upd.StatusCode, upd.Header.Get("Location"))
// Both land back on the config page, where profiles are managed.
if loc, _ := url.Parse(upd.Header.Get("Location")); upd.StatusCode != http.StatusSeeOther || loc.Path != base {
t.Fatalf("update profile = %d %q, want 303 → config", upd.StatusCode, upd.Header.Get("Location"))
}
del := post(t, ts, h.app, base+"/profiles/"+strconv.FormatInt(pid, 10)+"/delete", url.Values{}, sess)
del.Body.Close()
if loc, _ := url.Parse(del.Header.Get("Location")); del.StatusCode != http.StatusSeeOther || loc.Path != base+"/edit" {
t.Fatalf("delete profile = %d %q, want 303 → config/edit", del.StatusCode, del.Header.Get("Location"))
if loc, _ := url.Parse(del.Header.Get("Location")); del.StatusCode != http.StatusSeeOther || loc.Path != base {
t.Fatalf("delete profile = %d %q, want 303 → config", del.StatusCode, del.Header.Get("Location"))
}
}
-30
View File
@@ -14,36 +14,6 @@
{{template "org-tabs" .Nav}}
<div class="card">
<div class="card-header">
<div>
<h2 class="card-title">Profiles</h2>
<div class="text-secondary small">Named sets of base settings — e.g. one per board family. Members pick the profile matching their use case.</div>
</div>
<div class="card-actions">
<a class="btn btn-primary" href="/orgs/{{.Org.Slug}}/config/profiles/new">{{template "icon-plus" "me-1"}}Add profile</a>
</div>
</div>
{{if .Profiles}}
<div class="list-group list-group-flush">
{{range .Profiles}}
<div class="list-group-item d-flex align-items-center gap-3">
<div class="flex-fill" style="min-width:0">
<div class="fw-bold text-truncate">{{.Name}}</div>
<div class="text-secondary small">{{len .Steps}} step{{if ne (len .Steps) 1}}s{{end}}</div>
</div>
<a class="btn btn-sm" href="/orgs/{{$.Org.Slug}}/config/profiles/{{.ID}}/edit">Edit</a>
<form method="post" action="/orgs/{{$.Org.Slug}}/config/profiles/{{.ID}}/delete" class="m-0" data-confirm="Delete the profile “{{.Name}}”?">
<button type="submit" class="btn btn-sm btn-ghost-danger">Delete</button>
</form>
</div>
{{end}}
</div>
{{else}}
<div class="card-body"><p class="text-secondary mb-0">No profiles yet. Add one to give members a base-settings preset for their hardware.</p></div>
{{end}}
</div>
<div class="card mt-3">
<div class="card-header">
<div>
<h2 class="card-title">Regions</h2>
@@ -1,3 +1,10 @@
{{/* The profile editor. Admins normally reach it as the config-profile-modal
fragment swapped into #config-profile-modal on the Configuration page; it
posts via htmx so a validation error re-renders the fragment in place (modal
stays open, entered values preserved) and a save sends HX-Redirect back to
the config page. The full page below is the no-JS / direct-link fallback, so
it carries the same form without the htmx attributes. */}}
{{define "title"}}{{if .ProfileID}}Edit profile{{else}}Add profile{{end}} · {{.Org.Name}} · MeshTender{{end}}
{{define "header"}}
<div class="row g-2 align-items-center">
@@ -6,13 +13,50 @@
<h1 class="page-title fs-1">{{if .ProfileID}}Edit profile{{else}}Add profile{{end}}</h1>
</div>
<div class="col-auto ms-auto d-print-none">
<a class="btn" href="/orgs/{{.Org.Slug}}/config/edit">Back to configuration</a>
<a class="btn" href="/orgs/{{.Org.Slug}}/config">Back to configuration</a>
</div>
</div>
{{end}}
{{define "content"}}
{{template "org-tabs" .Nav}}
{{template "profile-editor-alerts" .}}
<div class="card">
<div class="card-body">
<form method="post" action="{{.Action}}">
{{template "profile-editor-fields" .}}
<div class="btn-list mt-3">
<button type="submit" class="btn btn-primary">{{if .ProfileID}}Save profile{{else}}Create profile{{end}}</button>
<a class="btn" href="/orgs/{{.Org.Slug}}/config">Cancel</a>
</div>
</form>
</div>
</div>
{{end}}
{{/* config-profile-modal is the htmx fragment swapped into #config-profile-modal. */}}
{{define "config-profile-modal"}}
<div class="modal-header">
<h2 class="modal-title">{{if .ProfileID}}Edit profile{{else}}Add profile{{end}}</h2>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
{{template "profile-editor-alerts" .}}
<form method="post" action="{{.Action}}" id="config-profile-form"
hx-post="{{.Action}}" hx-target="#config-profile-modal-content" hx-swap="innerHTML">
{{template "profile-editor-fields" .}}
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-link link-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" form="config-profile-form" class="btn btn-primary" data-testid="save-profile">{{if .ProfileID}}Save profile{{else}}Create profile{{end}}</button>
</div>
{{end}}
{{/* profile-editor-alerts renders the validation errors and the risky-command
warning, shared by the page and the modal. */}}
{{define "profile-editor-alerts"}}
{{if .Errors}}
<div class="alert alert-danger">
<div>
@@ -30,35 +74,18 @@
</div>
</div>
{{end}}
{{end}}
<div class="card">
<div class="card-body">
<form method="post" action="{{if .ProfileID}}/orgs/{{.Org.Slug}}/config/profiles/{{.ProfileID}}{{else}}/orgs/{{.Org.Slug}}/config/profiles{{end}}">
<div class="mb-3">
<label class="form-label" for="profile_name">Profile name</label>
<input class="form-control" id="profile_name" name="profile_name" value="{{.Name}}" placeholder="ESP32" maxlength="60" autofocus>
</div>
<div class="mb-1">
<label class="form-label" for="profile_steps">Base settings</label>
<textarea class="form-control font-monospace" id="profile_steps" name="profile_steps" rows="10" spellcheck="false" placeholder="set tx 22&#10;# notes start with #">{{.StepsText}}</textarea>
<small class="form-hint">One command per line; lines starting with <code>#</code> are notes. Put <code>{{"{{ region }}"}}</code> on its own line to insert this org's region settings there — otherwise they run after all steps.</small>
</div>
<div class="btn-list mt-3">
<button type="submit" class="btn btn-primary">{{if .ProfileID}}Save profile{{else}}Create profile{{end}}</button>
<a class="btn" href="/orgs/{{.Org.Slug}}/config/edit">Cancel</a>
</div>
</form>
</div>
{{/* profile-editor-fields are the form controls themselves — the <form> element
around them differs between the page and the modal. */}}
{{define "profile-editor-fields"}}
<div class="mb-3">
<label class="form-label" for="profile_name">Profile name</label>
<input class="form-control" id="profile_name" name="profile_name" value="{{.Name}}" placeholder="ESP32" maxlength="60" autofocus>
</div>
{{if .ProfileID}}
<div class="card mt-3">
<div class="card-body">
<form method="post" action="/orgs/{{.Org.Slug}}/config/profiles/{{.ProfileID}}/delete" data-confirm="Delete this profile?">
<button type="submit" class="btn btn-ghost-danger">Delete profile</button>
<small class="form-hint mt-1">Removes this profile and its base settings.</small>
</form>
</div>
<div class="mb-1">
<label class="form-label" for="profile_steps">Base settings</label>
<textarea class="form-control font-monospace" id="profile_steps" name="profile_steps" rows="10" spellcheck="false" placeholder="set tx 22&#10;# notes start with #">{{.StepsText}}</textarea>
<small class="form-hint">One command per line; lines starting with <code>#</code> are notes. Put <code>{{"{{ region }}"}}</code> on its own line to insert this org's region settings there — otherwise they run after all steps.</small>
</div>
{{end}}
{{end}}
+107
View File
@@ -0,0 +1,107 @@
//go:build browser
package e2e
import (
"strings"
"testing"
cdplog "github.com/chromedp/cdproto/log"
"github.com/chromedp/cdproto/network"
"github.com/chromedp/chromedp"
"github.com/jleight/meshtender/internal/store"
)
// TestE2EConfigProfileSelect: the profile list on the Configuration page selects a
// profile — clicking a row shows that profile's base settings on the left and marks
// the row active — under the strict CSP.
func TestE2EConfigProfileSelect(t *testing.T) {
srv := newE2EServer(t)
user, cookie := srv.login(t, "e2ecfgselect")
org, err := srv.store.CreateOrg(srv.ctx, "Config Select Org", user.ID) // creator = admin
if err != nil {
t.Fatalf("create org: %v", err)
}
for _, p := range []struct{ name, comment string }{{"ESP32", "esp base"}, {"nRF52", "nrf base"}} {
if _, err := srv.store.CreateProfile(srv.ctx, org.ID, p.name, []store.ConfigStep{{Comment: p.comment}}); err != nil {
t.Fatalf("create profile %s: %v", p.name, err)
}
}
bctx, cancel, watch := startBrowser(t)
defer cancel()
var heading, steps string
var rowActive bool
cfgURL := srv.appURL + "/orgs/" + org.Slug + "/config"
if err := chromedp.Run(bctx,
network.Enable(),
cdplog.Enable(),
setSessionCookie(cookie),
chromedp.Navigate(cfgURL),
chromedp.WaitVisible(`[data-testid="config-profile-list"]`, chromedp.ByQuery),
// Click the second row's name to select it.
chromedp.Click(`[data-testid="config-profile-row"]:nth-of-type(2) a`, chromedp.ByQuery),
chromedp.WaitVisible(`.card-title`, chromedp.ByQuery),
chromedp.Text(`.col-lg-8 .card-title`, &heading, chromedp.ByQuery),
chromedp.Text(`.col-lg-8 .card-body pre`, &steps, chromedp.ByQuery),
chromedp.Evaluate(`!!document.querySelector('[data-testid="config-profile-row"]:nth-of-type(2).active')`, &rowActive),
); err != nil {
t.Fatalf("browser run against %s: %v", cfgURL, err)
}
if !strings.Contains(heading, "nRF52") {
t.Fatalf("selected profile heading = %q, want it to name nRF52", heading)
}
if !strings.Contains(steps, "nrf base") {
t.Fatalf("selected profile steps = %q, want nRF52's base settings", steps)
}
if !rowActive {
t.Fatal("the selected profile's row should be marked active")
}
watch.assertClean(t)
}
// TestE2EConfigProfileModalSave: an admin adds a profile from the Configuration page
// itself — the editor loads into the modal via htmx and saving navigates back to the
// config page with the new profile selected (no separate editor page).
func TestE2EConfigProfileModalSave(t *testing.T) {
srv := newE2EServer(t)
user, cookie := srv.login(t, "e2ecfgadd")
org, err := srv.store.CreateOrg(srv.ctx, "Config Add Org", user.ID)
if err != nil {
t.Fatalf("create org: %v", err)
}
bctx, cancel, watch := startBrowser(t)
defer cancel()
var heading, currentURL string
cfgURL := srv.appURL + "/orgs/" + org.Slug + "/config"
if err := chromedp.Run(bctx,
network.Enable(),
cdplog.Enable(),
setSessionCookie(cookie),
chromedp.Navigate(cfgURL),
chromedp.WaitVisible(`[data-testid="config-profile-add"]`, chromedp.ByQuery),
chromedp.Click(`[data-testid="config-profile-add"]`, chromedp.ByQuery),
// The editor arrives in the modal via htmx.
chromedp.WaitVisible(`#config-profile-modal-content input[name=profile_name]`, chromedp.ByQuery),
chromedp.SetValue(`#config-profile-modal-content input[name=profile_name]`, "Heltec", chromedp.ByQuery),
chromedp.SetValue(`#config-profile-modal-content textarea[name=profile_steps]`, "# heltec base", chromedp.ByQuery),
chromedp.Click(`[data-testid="save-profile"]`, chromedp.ByQuery),
// HX-Redirect navigates back to the config page, now showing the new profile.
chromedp.WaitVisible(`[data-testid="config-profile-list"]`, chromedp.ByQuery),
chromedp.Text(`.col-lg-8 .card-title`, &heading, chromedp.ByQuery),
chromedp.Location(&currentURL),
); err != nil {
t.Fatalf("browser run against %s: %v", cfgURL, err)
}
if !strings.Contains(heading, "Heltec") {
t.Fatalf("after saving, left panel heading = %q, want it to name Heltec", heading)
}
if !strings.Contains(currentURL, "/config?profile=Heltec") {
t.Fatalf("after saving, URL = %q, want the config page with Heltec selected", currentURL)
}
watch.assertClean(t)
}
+5 -2
View File
@@ -13,8 +13,11 @@ import (
// root/marketing host (anonymous) can render the same shared templates — the
// same split that lets both surfaces render org_public.html.
// ProfileView is one named base-settings profile for display.
// ProfileView is one named base-settings profile for display. ID is carried so
// the admin surface can build its edit/delete URLs from the same list the
// read-only view renders (the public surface just ignores it).
type ProfileView struct {
ID int64
Name string
Steps []store.ConfigStep
}
@@ -68,7 +71,7 @@ func BuildConfigView(ctx context.Context, st *store.Store, orgID int64, selected
PreviewActive: lat != nil && lon != nil,
}
for _, p := range profiles {
cv.Profiles = append(cv.Profiles, ProfileView{Name: p.Name, Steps: p.Steps})
cv.Profiles = append(cv.Profiles, ProfileView{ID: p.ID, Name: p.Name, Steps: p.Steps})
}
if len(profiles) > 0 {
idx := 0
+17
View File
@@ -209,6 +209,23 @@ code.pubkey {
.leaflet-container .leaflet-control-layers-list label { margin: 0; color: var(--tblr-body-color); }
.leaflet-container .leaflet-control-layers-selector { accent-color: var(--tblr-primary); }
/* Profile list on the org config page: a long list scrolls inside its card
instead of pushing the location-preview map off the page. Rows are compact —
Tabler's default list-group padding plus a full-size icon button makes a ~4.5rem
row, so only a couple fit before scrolling. Both are driven by CSS variables, so
turn those down here rather than overriding the padding itself. */
.profile-list {
max-height: 22rem;
overflow-y: auto;
}
.profile-list .list-group-item {
--tblr-list-group-item-padding-y: 0.375rem;
}
.profile-list .btn-icon {
--tblr-btn-padding-y: 0.25rem;
--tblr-btn-icon-size: 1.25rem;
}
/* Active region block in the config editor: highlight the block currently being
drawn on the shared region map. */
.region-block.region-active {
+61 -17
View File
@@ -3,9 +3,9 @@
{{define "content"}}
{{template "org-tabs" .Nav}}
{{if not .Config.HasConfig}}
{{if and (not .Config.HasConfig) (not .CanEdit)}}
<div class="card"><div class="card-body text-secondary">
This organization hasn't defined any configuration yet.{{if .CanEdit}} Use <strong>Edit</strong> to add a profile or region.{{end}}
This organization hasn't defined any configuration yet.
</div></div>
{{else}}
<div class="row row-cards">
@@ -16,6 +16,15 @@
<div class="card-header"><h2 class="card-title">{{.Config.Selected}} · base settings</h2></div>
<div class="card-body">{{template "step-list" .Config.SelectedSteps}}</div>
</div>
{{else if .CanEdit}}
<!-- Admin, no profiles yet: explain what a profile is and point at Add profile. -->
<div class="card">
<div class="card-header"><h2 class="card-title">What's a profile?</h2></div>
<div class="card-body">
<p>A <strong>profile</strong> is this organization's recommended configuration: the commands a repeater owner runs to bring their repeater in spec, not just onto the mesh. Profiles vary by hardware and use case, so give each one a name that tells members which fits theirs.</p>
<p class="text-secondary mb-0">Use <strong>Add profile</strong> to create your first one.</p>
</div>
</div>
{{end}}
{{if .Config.PreviewActive}}
@@ -31,28 +40,54 @@
{{end}}
</div>
<!-- Sidebar: profile selector (if profiles) + a click-to-preview location map -->
<!-- Sidebar: the profile list (select one to show it on the left; admins edit in
place) + a click-to-preview location map -->
<div class="col-lg-4">
{{if .Config.Profiles}}
{{if or .Config.Profiles .CanEdit}}
<div class="card">
<div class="card-header"><h2 class="card-title">Profile</h2></div>
<div class="card-body">
<form method="get" action="/orgs/{{.Org.Slug}}/config">
<select class="form-select" name="profile" aria-label="Choose a configuration profile" data-autosubmit>
{{range .Config.Profiles}}<option value="{{.Name}}"{{if eq .Name $.Config.Selected}} selected{{end}}>{{.Name}}</option>{{end}}
</select>
{{if .PreviewLat}}<input type="hidden" name="lat" value="{{.PreviewLat}}">{{end}}
{{if .PreviewLon}}<input type="hidden" name="lon" value="{{.PreviewLon}}">{{end}}
</form>
<p class="text-secondary small mt-2 mb-0">
Pick the profile matching your hardware to see its base settings.{{if .Config.HasRegions}} A repeater's regions are assigned separately, by location.{{end}}
</p>
<div class="card-header">
<h2 class="card-title">Profiles</h2>
{{if .CanEdit}}
<div class="card-actions">
<button type="button" class="btn btn-sm btn-primary" data-testid="config-profile-add"
data-bs-toggle="modal" data-bs-target="#config-profile-modal"
hx-get="/orgs/{{.Org.Slug}}/config/profiles/new" hx-target="#config-profile-modal-content" hx-swap="innerHTML">{{template "icon-plus" "me-1"}}Add profile</button>
</div>
{{end}}
</div>
{{if .Config.Profiles}}
<div class="list-group list-group-flush list-group-hoverable profile-list" data-testid="config-profile-list">
{{range .Config.Profiles}}
<div class="list-group-item d-flex align-items-center gap-2{{if eq .Name $.Config.Selected}} active{{end}}" data-testid="config-profile-row">
<a class="flex-fill text-truncate text-reset text-decoration-none"
href="/orgs/{{$.Org.Slug}}/config?profile={{.Name | urlquery}}{{if $.PreviewLat}}&amp;lat={{$.PreviewLat}}&amp;lon={{$.PreviewLon}}{{end}}"
{{if eq .Name $.Config.Selected}}aria-current="true"{{end}}>{{.Name}}</a>
{{if $.CanEdit}}
<button type="button" class="btn btn-icon btn-ghost-secondary" data-testid="config-profile-edit"
title="Edit profile" aria-label="Edit profile {{.Name}}"
data-bs-toggle="modal" data-bs-target="#config-profile-modal"
hx-get="/orgs/{{$.Org.Slug}}/config/profiles/{{.ID}}/edit" hx-target="#config-profile-modal-content" hx-swap="innerHTML">{{template "icon-pencil" ""}}</button>
<form method="post" action="/orgs/{{$.Org.Slug}}/config/profiles/{{.ID}}/delete" class="m-0" data-confirm="Delete the profile “{{.Name}}”?">
<button type="submit" class="btn btn-icon btn-ghost-danger" data-testid="config-profile-delete"
title="Delete profile" aria-label="Delete profile {{.Name}}">{{template "icon-trash" ""}}</button>
</form>
{{end}}
</div>
{{end}}
</div>
{{else}}
<div class="card-body text-secondary">No profiles yet.</div>
{{end}}
{{if or .Config.Profiles .Config.HasRegions}}
<div class="card-footer text-secondary small">
{{if .Config.Profiles}}Select a profile to see its base settings.{{end}}{{if .Config.HasRegions}} A repeater's regions are assigned separately, by location.{{end}}
</div>
{{end}}
</div>
{{end}}
{{if .Config.HasRegionShapes}}
<div class="card{{if .Config.Profiles}} mt-3{{end}}">
<div class="card{{if or .Config.Profiles .CanEdit}} mt-3{{end}}">
<div class="card-header"><h2 class="card-title">Preview a location</h2></div>
<div class="card-body p-2">
<link rel="stylesheet" href="{{ asset "/static/leaflet.css" }}">
@@ -74,6 +109,15 @@
{{end}}
</div>
</div>
{{if .CanEdit}}
<!-- Admin profile editor; the Add/Edit buttons load their fragment here via htmx. -->
<div class="modal modal-blur fade" id="config-profile-modal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered modal-dialog-scrollable modal-lg" role="document">
<div class="modal-content" id="config-profile-modal-content"></div>
</div>
</div>
{{end}}
{{end}}
{{end}}