mirror of
https://github.com/MeshTender/MeshTender.git
synced 2026-10-22 21:22:04 +00:00
Refactor configuration profile editing
This commit is contained in:
1 parent
b11a7ea1c9
commit
d394d229ac
10 files changed
+465
-105
No files matched your search
@@ -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")
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 # 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 # 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}}
|
||||
@@ -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(¤tURL),
|
||||
); 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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}}&lat={{$.PreviewLat}}&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}}
|
||||
|
||||
|
||||
Reference in new issue
Block a user