Refactor region editing

This commit is contained in:
Jonathon Leight committed 2026-08-05 20:10:51 -04:00
1 parent d394d229ac
commit abc0dbaee8
26 files changed
+2730 -918

No files matched your search

+81 -24
View File
@@ -3,6 +3,7 @@ package core
import (
"net/http"
"net/url"
"strconv"
"strings"
"testing"
)
@@ -44,64 +45,120 @@ func TestOrgConfigProfilesFlow(t *testing.T) {
t.Fatalf("create profile %s redirected to %q, want %q", p.name, got, want)
}
}
// A geofenced region, saved on the region page.
// A region, created in two steps the way the editor does it: the attribute form
// creates it as a draft, then the area page saves its drawn shape.
rsave := post(t, ts, h.app, "/orgs/"+org.Slug+"/config/regions", url.Values{
"region_display": {"Mountains"},
"region_token": {"mtns"},
"region_layer": {"2"},
"region_allow_flood": {"1"},
"root_allow_flood": {"1"},
"region_geojson": {`{"type":"Polygon","coordinates":[[[30,10],[40,10],[40,20],[30,20],[30,10]]]}`},
}, sess)
rsave.Body.Close()
if rsave.StatusCode != http.StatusSeeOther {
t.Fatalf("save regions status = %d, want 303", rsave.StatusCode)
t.Fatalf("create region status = %d, want 303", rsave.StatusCode)
}
regions, err := st.ListRegions(ctx, org.ID)
if err != nil || len(regions) != 1 {
t.Fatalf("ListRegions = %v, %v; want one region", regions, err)
}
rid := strconv.FormatInt(regions[0].ID, 10)
// Before an area is drawn the region is a draft: flagged on the config page and
// contributing nothing to a location's region def.
draftView := readBody(t, do(t, ts, h.app, "/orgs/"+org.Slug+"/config?lat=15&lon=35", sess))
if !strings.Contains(draftView, `data-testid="config-region-draft"`) {
t.Fatal("a region with no area should be flagged as needing one")
}
if strings.Contains(draftView, "region def") {
t.Fatal("a draft region must not produce region def commands")
}
asave := post(t, ts, h.app, "/orgs/"+org.Slug+"/config/regions/"+rid+"/area", url.Values{
"region_geojson": {`{"type":"Polygon","coordinates":[[[30,10],[40,10],[40,20],[30,20],[30,10]]]}`},
}, sess)
asave.Body.Close()
if asave.StatusCode != http.StatusSeeOther {
t.Fatalf("save area status = %d, want 303", asave.StatusCode)
}
body := readBody(t, do(t, ts, h.app, "/orgs/"+org.Slug+"/config", sess))
// The profile list, with per-row admin actions; the geofenced region surfaces as
// the click-to-preview location map (regions aren't listed individually).
// The profile list and the region list, each with per-row admin actions, plus the
// click-to-preview location map the drawn geofence enables.
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",
`id="config-profile-modal"`, `data-testid="config-regions"`, "region-map",
"Mountains", "mtns", `data-testid="config-region-row"`, `data-testid="config-region-add"`,
`data-testid="config-region-area"`, `id="config-region-modal"`,
} {
if !strings.Contains(body, want) {
t.Fatalf("config view missing %q", want)
}
}
// Default selection shows the first profile's base settings.
if !strings.Contains(body, "ESP32 · base settings") {
t.Fatalf("default config view should show the first profile's base settings")
// With an area drawn, the region is no longer flagged as a draft.
if strings.Contains(body, `data-testid="config-region-draft"`) {
t.Fatal("a region with an area should not be flagged as needing one")
}
// And it now applies at a location inside it.
preview := readBody(t, do(t, ts, h.app, "/orgs/"+org.Slug+"/config?lat=15&lon=35", sess))
if !strings.Contains(preview, "region def mtns") {
t.Fatal("a drawn region should apply at a location inside it")
}
// Default selection shows the first profile's steps, not the second's.
if !strings.Contains(body, "esp base") || strings.Contains(body, "nrf base") {
t.Fatal("default config view should show the first profile's base settings")
}
// The profile selector works: ?profile= switches the shown base settings.
sel := readBody(t, do(t, ts, h.app, "/orgs/"+org.Slug+"/config?profile=nRF52", sess))
if !strings.Contains(sel, "nRF52 · base settings") {
t.Fatalf("?profile=nRF52 should show nRF52's base settings")
if !strings.Contains(sel, "nrf base") || strings.Contains(sel, "esp base") {
t.Fatal("?profile=nRF52 should show nRF52's base settings")
}
// 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{"Edit regions", "1 region"} {
if !strings.Contains(hub, want) {
t.Fatalf("config hub missing %q", want)
// The preview is assembled: with a location picked, the selected profile's steps
// and that location's region commands appear in one block, which is what a
// repeater owner actually runs.
assembled := readBody(t, do(t, ts, h.app, "/orgs/"+org.Slug+"/config?profile=ESP32&lat=15&lon=35", sess))
block := assembled[strings.Index(assembled, `data-testid="config-commands"`):]
block = block[:strings.Index(block, "</code>")]
for _, want := range []string{"esp base", "region def mtns", "region save"} {
if !strings.Contains(block, want) {
t.Fatalf("assembled preview missing %q; got %q", want, block)
}
}
if strings.Contains(hub, "Add profile") {
t.Fatalf("config hub should no longer manage profiles")
// Region-contributed lines are marked so the page can show where they came from.
if !strings.Contains(block, `data-testid="config-region-line"`) {
t.Fatal("region commands in the assembled preview should be marked as region lines")
}
// And the legend marks the regions that cover the picked location.
if !strings.Contains(assembled, `data-testid="config-region-match"`) {
t.Fatal("the legend should mark which regions apply at the previewed location")
}
// The standalone editor pages render (the no-JS fallbacks): the new-profile page
// shows the base-settings editor, and the region area page has the map.
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")
}
rgn := readBody(t, do(t, ts, h.app, "/orgs/"+org.Slug+"/config/regions", sess))
for _, want := range []string{"region-map", "Mountains", "mtns", "Add region", "everywhere", "Allow flood"} {
rgn := readBody(t, do(t, ts, h.app, "/orgs/"+org.Slug+"/config/regions/"+rid+"/area", sess))
for _, want := range []string{"region-map", "Mountains", "mtns", "Save area", "Clear area"} {
if !strings.Contains(rgn, want) {
t.Fatalf("regions editor missing %q", want)
t.Fatalf("region area editor missing %q", want)
}
}
// The old config hub is gone entirely (404), and the old bulk region editor's
// path is now POST-only — the collection you create a region through — so a GET
// there is a 405 rather than a page.
hub := do(t, ts, h.app, "/orgs/"+org.Slug+"/config/edit", sess)
hub.Body.Close()
if hub.StatusCode != http.StatusNotFound {
t.Errorf("GET /config/edit = %d, want 404 (route removed)", hub.StatusCode)
}
bulk := do(t, ts, h.app, "/orgs/"+org.Slug+"/config/regions", sess)
bulk.Body.Close()
if bulk.StatusCode != http.StatusMethodNotAllowed {
t.Errorf("GET /config/regions = %d, want 405 (POST-only collection)", bulk.StatusCode)
}
// A non-admin member of a config-less org does NOT see the Configuration tab.
plain, _ := st.CreateUser(ctx, "plainmember", "")
+1 -236
View File
@@ -10,27 +10,10 @@ import (
"github.com/go-chi/chi/v5"
"github.com/jleight/meshtender/internal/geo"
"github.com/jleight/meshtender/internal/store"
"github.com/jleight/meshtender/internal/web"
)
// Bound on how many regions one save may define, so a malformed or abusive
// submission can't insert unbounded rows. (Profiles are added one at a time.)
const maxConfigRegions = 50
// configRegionView is a region in the admin editor: its display name, MeshCore
// token, layer (depth / region def order), and the raw GeoJSON geofence the map
// editor reads and writes (empty = applies everywhere).
type configRegionView struct {
DisplayName string
Token string
Layer int
Primary bool
AllowFlood bool
GeofenceJSON string
}
// pageOrgConfig renders an org's configuration read-only: a selected profile's
// base settings plus the regions (location steps). Visible to any signed-in user;
// admins get an Edit button. ?profile= chooses which profile to show; ?lat=&lon=
@@ -76,39 +59,6 @@ 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 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 {
return
}
org, err := s.Store.GetOrg(r.Context(), orgID)
if err != nil {
s.NotFound(w, r)
return
}
regions, err := s.Store.ListRegions(r.Context(), orgID)
if err != nil {
s.ServerError(w, r, "could not load config", err)
return
}
var primary string
for _, z := range regions {
if z.Primary {
primary = z.DisplayName
break
}
}
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}),
"RegionCount": len(regions),
"PrimaryRegion": primary,
})
}
// pageProfileEdit renders the single-profile editor: blank for the /new route, or
// pre-filled when a {pid} is present. 404s if the profile isn't this org's.
func (s *Handlers) pageProfileEdit(w http.ResponseWriter, r *http.Request) {
@@ -150,7 +100,7 @@ func (s *Handlers) pageProfileEdit(w http.ResponseWriter, r *http.Request) {
func (s *Handlers) renderProfileEdit(w http.ResponseWriter, r *http.Request, org *store.Org, pid int64, name, stepsText string, errs, risky []string) {
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}),
"Nav": s.orgAdminNav(r, org),
"ProfileID": pid,
"Action": profileFormAction(org.Slug, pid),
"Name": name,
@@ -265,178 +215,6 @@ func (s *Handlers) handleDeleteProfile(w http.ResponseWriter, r *http.Request) {
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).
func (s *Handlers) pageRegionsEdit(w http.ResponseWriter, r *http.Request) {
orgID, ok := s.requireOrgAdmin(w, r)
if !ok {
return
}
org, err := s.Store.GetOrg(r.Context(), orgID)
if err != nil {
s.NotFound(w, r)
return
}
regions, err := s.Store.ListRegions(r.Context(), orgID)
if err != nil {
s.ServerError(w, r, "could not load regions", err)
return
}
s.Render(w, r, "config_regions_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}),
"EmptyRegion": configRegionView{AllowFlood: true},
"Regions": regionViews(regions),
"RootAllowFlood": org.RootAllowFlood,
})
}
// handleSaveRegions validates and atomically replaces just the org's regions
// (profiles are left untouched). On error the editor is re-rendered with input
// preserved.
func (s *Handlers) handleSaveRegions(w http.ResponseWriter, r *http.Request) {
orgID, ok := s.requireOrgAdmin(w, r)
if !ok {
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, "bad form", http.StatusBadRequest)
return
}
rootAllowFlood := r.FormValue("root_allow_flood") == "1"
var errs []string
regions, regionVs := s.parseRegions(r, &errs)
if len(errs) > 0 {
org, gerr := s.Store.GetOrg(r.Context(), orgID)
if gerr != nil {
s.NotFound(w, r)
return
}
s.Render(w, r, "config_regions_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}),
"EmptyRegion": configRegionView{AllowFlood: true},
"Errors": errs,
"Regions": regionVs,
"RootAllowFlood": rootAllowFlood,
})
return
}
if err := s.Store.ReplaceRegions(r.Context(), orgID, regions, rootAllowFlood); err != nil {
s.ServerError(w, r, "could not save", err)
return
}
http.Redirect(w, r, "/orgs/"+orgParam(r)+"/config", http.StatusSeeOther) //nolint:gosec // G710: local path or config-pinned origin
}
// parseRegions reads the repeated region blocks from the form into store inputs
// and editor views. A region needs a token (its MeshCore name); the display name
// defaults to the token when left blank. Tokens must be unique and use only
// letters, digits, hyphens, or underscores (they are space-joined into a single
// region def line, so spaces and the |/, separators are not allowed).
func (s *Handlers) parseRegions(r *http.Request, errs *[]string) ([]store.RegionInput, []configRegionView) {
tokens := r.Form["region_token"]
if len(tokens) > maxConfigRegions {
*errs = append(*errs, fmt.Sprintf("Too many regions (max %d).", maxConfigRegions))
}
seen := map[string]bool{}
primaryTaken := false
var ins []store.RegionInput
var views []configRegionView
for i := range tokens {
token := strings.TrimSpace(formAt(r, "region_token", i))
display := strings.TrimSpace(formAt(r, "region_display", i))
geojson := strings.TrimSpace(formAt(r, "region_geojson", i))
layer, _ := strconv.Atoi(formAt(r, "region_layer", i))
primary := formAt(r, "region_primary", i) == "1"
allowFlood := formAt(r, "region_allow_flood", i) == "1"
if token == "" && display == "" && geojson == "" {
continue // empty block
}
zv := configRegionView{DisplayName: display, Token: token, Layer: layer, Primary: primary, AllowFlood: allowFlood, GeofenceJSON: geojson}
if token == "" {
*errs = append(*errs, "A region is missing its short name.")
views = append(views, zv)
continue
}
if !validRegionToken(token) {
*errs = append(*errs, fmt.Sprintf("Region name %q may only contain letters, digits, hyphens, or underscores.", token))
views = append(views, zv)
continue
}
if seen[strings.ToLower(token)] {
*errs = append(*errs, fmt.Sprintf("Duplicate region name %q.", token))
views = append(views, zv)
continue
}
seen[strings.ToLower(token)] = true
if display == "" {
display = token
zv.DisplayName = display
}
// Non-root regions must have a drawn area; the root region (*) is the only
// "applies everywhere" region and lives on the org, not in this list.
if geojson == "" {
*errs = append(*errs, fmt.Sprintf("Region %q needs a drawn area — outline it on the map, or rely on the root region.", token))
views = append(views, zv)
continue
}
geofence, ok := regionGeofence(zv, token, errs)
if !ok {
views = append(views, zv)
continue
}
// Only one region may be primary; keep the first and clear any later ones.
if primary && primaryTaken {
primary, zv.Primary = false, false
} else if primary {
primaryTaken = true
}
views = append(views, zv)
ins = append(ins, store.RegionInput{Token: token, DisplayName: display, Layer: layer, Primary: primary, AllowFlood: allowFlood, GeofenceJSON: geofence})
}
return ins, views
}
// validRegionToken reports whether s is a usable MeshCore region token: non-empty
// and limited to letters, digits, hyphens, and underscores.
func validRegionToken(s string) bool {
if s == "" {
return false
}
for _, c := range s {
switch {
case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c >= '0' && c <= '9', c == '-', c == '_':
default:
return false
}
}
return true
}
// formAt returns the i-th value of a repeated form field, or "".
func formAt(r *http.Request, field string, i int) string {
v := r.Form[field]
if i < len(v) {
return v[i]
}
return ""
}
// regionGeofence validates the GeoJSON drawn for a region. An empty shape is a
// region that applies everywhere (nil geofence); anything else must parse as a
// GeoJSON Polygon/MultiPolygon.
func regionGeofence(zv configRegionView, name string, errs *[]string) ([]byte, bool) {
gj := strings.TrimSpace(zv.GeofenceJSON)
if gj == "" {
return nil, true // everywhere
}
if _, err := geo.Parse([]byte(gj)); err != nil {
*errs = append(*errs, fmt.Sprintf("Region %q has an invalid map shape — redraw it on the map.", name))
return nil, false
}
return []byte(gj), true
}
// parseConfigSteps turns textarea lines into config steps, validating each command
// against the catalog with the same resolver the console uses. Blank lines are
// skipped; "# ..." lines become comment steps; unknown commands are recorded in
@@ -517,16 +295,3 @@ func stepsToText(steps []store.ConfigStep) string {
}
return b.String()
}
// regionViews converts stored regions into editor views, carrying each geofence's
// raw GeoJSON through verbatim so the map editor round-trips arbitrary polygons.
func regionViews(regions []store.Region) []configRegionView {
out := make([]configRegionView, 0, len(regions))
for _, z := range regions {
out = append(out, configRegionView{
DisplayName: z.DisplayName, Token: z.Token, Layer: z.Layer, Primary: z.Primary,
AllowFlood: z.AllowFlood, GeofenceJSON: string(z.GeofenceJSON),
})
}
return out
}
-31
View File
@@ -4,7 +4,6 @@ import (
"strings"
"testing"
"github.com/jleight/meshtender/internal/geo"
"github.com/jleight/meshtender/internal/store"
)
@@ -80,33 +79,3 @@ func TestParseConfigStepsRegionMarker(t *testing.T) {
}
_ = risky
}
func TestRegionGeofence(t *testing.T) {
t.Parallel()
// Empty shape → everywhere (nil geofence, ok).
if gf, ok := regionGeofence(configRegionView{}, "z", new([]string)); !ok || gf != nil {
t.Fatalf("empty shape: got (%v,%v), want (nil,true)", gf, ok)
}
// A drawn polygon → carried through verbatim, parseable, contains its center.
drawn := `{"type":"Polygon","coordinates":[[[30,10],[40,10],[40,20],[30,20],[30,10]]]}`
var errs []string
gf, ok := regionGeofence(configRegionView{GeofenceJSON: drawn}, "z", &errs)
if !ok || gf == nil {
t.Fatalf("drawn polygon: got (%v,%v), want non-nil geofence ok", gf, ok)
}
shape, err := geo.Parse(gf)
if err != nil {
t.Fatalf("parse geofence: %v", err)
}
if !shape.Contains(15, 35) {
t.Fatalf("geofence should contain its center")
}
// Malformed GeoJSON → error.
errs = nil
if _, ok := regionGeofence(configRegionView{GeofenceJSON: "{not geojson"}, "z", &errs); ok || len(errs) == 0 {
t.Fatalf("invalid shape should be rejected with an error")
}
}
+404
View File
@@ -0,0 +1,404 @@
package core
import (
"errors"
"fmt"
"net/http"
"strconv"
"strings"
"github.com/go-chi/chi/v5"
"github.com/jleight/meshtender/internal/geo"
"github.com/jleight/meshtender/internal/store"
"github.com/jleight/meshtender/internal/web"
)
// Regions are edited one at a time, split by what each part needs from the screen:
//
// - Attributes (name, token, level, primary, flood) are a small form, edited in a
// modal on the Configuration page — the same pattern as profiles.
// - The area is drawn on a map, which needs the width and the spatial context of
// the neighboring regions, so it gets its own workspace page.
//
// The two save through separate endpoints, so a rejected attribute edit can never
// discard a drawn polygon (and vice versa).
// configRegionView is a region in the admin editor: its display name, MeshCore
// token, layer (depth / region def order), flags, and the raw GeoJSON geofence the
// map editor reads and writes (empty = a draft that applies nowhere yet).
type configRegionView struct {
ID int64
DisplayName string
Token string
Layer int
Primary bool
AllowFlood bool
GeofenceJSON string
}
// regionAreaSibling is one *other* region of the org, drawn as read-only context on
// the area map so an admin can see how the shape they're editing sits against its
// neighbors.
type regionAreaSibling struct {
Name string `json:"name"`
GeoJSON string `json:"geojson"`
}
// pageRegionEdit renders the region attribute editor: blank for the /new route, or
// pre-filled when a {rid} is present. 404s if the region isn't this org's.
func (s *Handlers) pageRegionEdit(w http.ResponseWriter, r *http.Request) {
orgID, ok := s.requireOrgAdmin(w, r)
if !ok {
return
}
org, err := s.Store.GetOrg(r.Context(), orgID)
if err != nil {
s.NotFound(w, r)
return
}
// A new region defaults to allowing flood, matching the column default.
zv := configRegionView{AllowFlood: true}
if raw := chi.URLParam(r, "rid"); raw != "" {
rid, err := strconv.ParseInt(raw, 10, 64)
if err != nil {
s.NotFound(w, r)
return
}
z, err := s.Store.GetRegion(r.Context(), orgID, rid)
if errors.Is(err, store.ErrNotFound) {
s.NotFound(w, r)
return
}
if err != nil {
s.ServerError(w, r, "could not load region", err)
return
}
zv = regionView(*z)
}
s.renderRegionEdit(w, r, org, zv, nil)
}
// renderRegionEdit renders the attribute editor (shared by the initial GET and the
// error re-render). A zero ID means a new region. 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) renderRegionEdit(w http.ResponseWriter, r *http.Request, org *store.Org, zv configRegionView, errs []string) {
data := map[string]any{
"Org": org,
"Nav": s.orgAdminNav(r, org),
"Region": zv,
"Action": regionFormAction(org.Slug, zv.ID),
"Errors": errs,
}
if r.Header.Get("HX-Request") != "" {
data["Layout"] = "config-region-modal"
}
s.Render(w, r, "config_region_edit.html", data)
}
// regionFormAction is where the attribute editor posts: the collection for a new
// region, the region's own URL for an update.
func regionFormAction(slug string, rid int64) string {
if rid == 0 {
return "/orgs/" + slug + "/config/regions"
}
return "/orgs/" + slug + "/config/regions/" + strconv.FormatInt(rid, 10)
}
// handleCreateRegion validates and inserts a new region.
func (s *Handlers) handleCreateRegion(w http.ResponseWriter, r *http.Request) {
orgID, ok := s.requireOrgAdmin(w, r)
if !ok {
return
}
s.saveRegion(w, r, orgID, 0)
}
// handleUpdateRegion validates and updates an existing region's attributes.
func (s *Handlers) handleUpdateRegion(w http.ResponseWriter, r *http.Request) {
orgID, ok := s.requireOrgAdmin(w, r)
if !ok {
return
}
rid, ok := s.regionID(r)
if !ok {
s.NotFound(w, r)
return
}
s.saveRegion(w, r, orgID, rid)
}
// saveRegion parses the attribute form and creates (rid 0) or updates a region. On
// a validation error it re-renders the editor with the entered values preserved; a
// duplicate token is reported the same way rather than as a server error.
func (s *Handlers) saveRegion(w http.ResponseWriter, r *http.Request, orgID, rid int64) {
if err := r.ParseForm(); err != nil {
http.Error(w, "bad form", http.StatusBadRequest)
return
}
org, err := s.Store.GetOrg(r.Context(), orgID)
if err != nil {
s.NotFound(w, r)
return
}
layer, _ := strconv.Atoi(strings.TrimSpace(r.FormValue("region_layer")))
zv := configRegionView{
ID: rid,
DisplayName: strings.TrimSpace(r.FormValue("region_display")),
Token: strings.TrimSpace(r.FormValue("region_token")),
Layer: layer,
Primary: r.FormValue("region_primary") == "1",
AllowFlood: r.FormValue("region_allow_flood") == "1",
}
var errs []string
switch {
case zv.Token == "":
errs = append(errs, "Give the region a short name — it's the name MeshCore uses.")
case !validRegionToken(zv.Token):
errs = append(errs, fmt.Sprintf("Region name %q may only contain letters, digits, hyphens, or underscores.", zv.Token))
}
// The display name is optional; fall back to the token so a row always has a
// label to show.
display := zv.DisplayName
if display == "" {
display = zv.Token
}
if len(errs) == 0 {
in := store.RegionInput{
Token: zv.Token, DisplayName: display, Layer: zv.Layer,
Primary: zv.Primary, AllowFlood: zv.AllowFlood,
}
// Only the area endpoint writes a geofence, so neither branch here can drop a
// shape that's already been drawn.
if rid == 0 {
_, err = s.Store.CreateRegion(r.Context(), orgID, in)
} else {
err = s.Store.UpdateRegion(r.Context(), orgID, rid, in)
}
switch {
case errors.Is(err, store.ErrDuplicate):
errs = append(errs, fmt.Sprintf("A region named %q already exists.", zv.Token))
case errors.Is(err, store.ErrNotFound):
s.NotFound(w, r)
return
case err != nil:
s.ServerError(w, r, "could not save region", err)
return
default:
// hxRedirect closes the modal and reloads the config page.
s.hxRedirect(w, r, "/orgs/"+orgParam(r)+"/config")
return
}
}
s.renderRegionEdit(w, r, org, zv, errs)
}
// handleDeleteRegion removes a region and returns to the config page.
func (s *Handlers) handleDeleteRegion(w http.ResponseWriter, r *http.Request) {
orgID, ok := s.requireOrgAdmin(w, r)
if !ok {
return
}
rid, ok := s.regionID(r)
if !ok {
s.NotFound(w, r)
return
}
if err := s.Store.DeleteRegion(r.Context(), orgID, rid); err != nil && !errors.Is(err, store.ErrNotFound) {
s.ServerError(w, r, "could not delete region", err)
return
}
http.Redirect(w, r, "/orgs/"+orgParam(r)+"/config", http.StatusSeeOther) //nolint:gosec // G710: local path or config-pinned origin
}
// pageRegionArea renders the area workspace for one region: a full-width map with
// this region's shape editable and its siblings drawn as read-only context.
func (s *Handlers) pageRegionArea(w http.ResponseWriter, r *http.Request) {
orgID, ok := s.requireOrgAdmin(w, r)
if !ok {
return
}
org, err := s.Store.GetOrg(r.Context(), orgID)
if err != nil {
s.NotFound(w, r)
return
}
rid, ok := s.regionID(r)
if !ok {
s.NotFound(w, r)
return
}
regions, err := s.Store.ListRegions(r.Context(), orgID)
if err != nil {
s.ServerError(w, r, "could not load regions", err)
return
}
zv, siblings, found := splitRegionAndSiblings(regions, rid)
if !found {
s.NotFound(w, r)
return
}
s.renderRegionArea(w, r, org, zv, siblings, nil)
}
// renderRegionArea renders the area workspace (shared by the initial GET and the
// error re-render, which keeps the submitted shape so a bad polygon isn't lost).
func (s *Handlers) renderRegionArea(w http.ResponseWriter, r *http.Request, org *store.Org, zv configRegionView, siblings []regionAreaSibling, errs []string) {
s.Render(w, r, "config_region_area.html", map[string]any{
"Org": org,
"Nav": s.orgAdminNav(r, org),
"Region": zv,
"Siblings": siblings,
"Errors": errs,
})
}
// handleSaveRegionArea saves just this region's drawn area. An empty shape clears it
// back to a draft (the "Clear area" path), which is allowed — a region with no area
// simply applies nowhere until one is drawn.
func (s *Handlers) handleSaveRegionArea(w http.ResponseWriter, r *http.Request) {
orgID, ok := s.requireOrgAdmin(w, r)
if !ok {
return
}
rid, ok := s.regionID(r)
if !ok {
s.NotFound(w, r)
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, "bad form", http.StatusBadRequest)
return
}
gj := strings.TrimSpace(r.FormValue("region_geojson"))
var errs []string
geofence, valid := regionGeofence(gj, &errs)
if valid {
err := s.Store.UpdateRegionGeofence(r.Context(), orgID, rid, geofence)
switch {
case errors.Is(err, store.ErrNotFound):
s.NotFound(w, r)
return
case err != nil:
s.ServerError(w, r, "could not save area", err)
return
}
http.Redirect(w, r, "/orgs/"+orgParam(r)+"/config", http.StatusSeeOther) //nolint:gosec // G710: local path or config-pinned origin
return
}
// Re-render with the rejected shape still in hand so the admin can fix it.
org, err := s.Store.GetOrg(r.Context(), orgID)
if err != nil {
s.NotFound(w, r)
return
}
regions, err := s.Store.ListRegions(r.Context(), orgID)
if err != nil {
s.ServerError(w, r, "could not load regions", err)
return
}
zv, siblings, found := splitRegionAndSiblings(regions, rid)
if !found {
s.NotFound(w, r)
return
}
zv.GeofenceJSON = gj
s.renderRegionArea(w, r, org, zv, siblings, errs)
}
// handleSetRootFlood toggles the org's root (*) flood policy. The root isn't a
// region row, so it saves on its own from the Configuration page.
func (s *Handlers) handleSetRootFlood(w http.ResponseWriter, r *http.Request) {
orgID, ok := s.requireOrgAdmin(w, r)
if !ok {
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, "bad form", http.StatusBadRequest)
return
}
if err := s.Store.SetRootAllowFlood(r.Context(), orgID, r.FormValue("root_allow_flood") == "1"); err != nil {
s.ServerError(w, r, "could not save flood policy", err)
return
}
http.Redirect(w, r, "/orgs/"+orgParam(r)+"/config", http.StatusSeeOther) //nolint:gosec // G710: local path or config-pinned origin
}
// regionID reads the {rid} URL param.
func (s *Handlers) regionID(r *http.Request) (int64, bool) {
rid, err := strconv.ParseInt(chi.URLParam(r, "rid"), 10, 64)
return rid, err == nil
}
// orgAdminNav builds the org sub-navigation for the admin config screens, which are
// all admin-only members-area pages on the Configuration tab.
func (s *Handlers) orgAdminNav(r *http.Request, org *store.Org) map[string]any {
return s.OrgNavFor(r.Context(), web.OrgNavArgs{
OrgID: org.ID, Name: org.Name, Slug: org.Slug, Active: "config",
IsMember: true, IsAdmin: true, Manage: true,
})
}
// splitRegionAndSiblings picks the target region out of an org's regions and turns
// the rest into read-only map context. Only siblings with a drawn shape are
// included — a draft has nothing to render.
func splitRegionAndSiblings(regions []store.Region, rid int64) (configRegionView, []regionAreaSibling, bool) {
var zv configRegionView
found := false
var siblings []regionAreaSibling
for _, z := range regions {
if z.ID == rid {
zv, found = regionView(z), true
continue
}
if len(z.GeofenceJSON) > 0 {
siblings = append(siblings, regionAreaSibling{Name: z.DisplayName, GeoJSON: string(z.GeofenceJSON)})
}
}
return zv, siblings, found
}
// regionView converts a stored region into an editor view, carrying its geofence's
// raw GeoJSON through verbatim so the map editor round-trips arbitrary polygons.
func regionView(z store.Region) configRegionView {
return configRegionView{
ID: z.ID, DisplayName: z.DisplayName, Token: z.Token, Layer: z.Layer,
Primary: z.Primary, AllowFlood: z.AllowFlood, GeofenceJSON: string(z.GeofenceJSON),
}
}
// validRegionToken reports whether s is a usable MeshCore region token: non-empty
// and limited to letters, digits, hyphens, and underscores. Tokens are space-joined
// into a single `region def` line, so spaces and the |/, separators aren't allowed.
func validRegionToken(s string) bool {
if s == "" {
return false
}
for _, c := range s {
switch {
case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c >= '0' && c <= '9', c == '-', c == '_':
default:
return false
}
}
return true
}
// regionGeofence validates the GeoJSON drawn for a region. An empty shape is a
// draft (nil geofence, applies nowhere); anything else must parse as a GeoJSON
// Polygon/MultiPolygon.
func regionGeofence(gj string, errs *[]string) ([]byte, bool) {
gj = strings.TrimSpace(gj)
if gj == "" {
return nil, true // draft — no area yet
}
if _, err := geo.Parse([]byte(gj)); err != nil {
*errs = append(*errs, "That map shape isn't valid — redraw the area and try again.")
return nil, false
}
return []byte(gj), true
}
+364
View File
@@ -0,0 +1,364 @@
package core
import (
"net/http"
"net/url"
"strconv"
"strings"
"testing"
"github.com/jleight/meshtender/internal/geo"
"github.com/jleight/meshtender/internal/store"
)
// TestConfigRegionEditorSurfaces: the region attribute 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.
func TestConfigRegionEditorSurfaces(t *testing.T) {
t.Parallel()
st, ctx, ts, h := splitServer(t)
admin, sess := appLogin(t, ts, st, ctx, h.app, "rgnmodaladmin")
org, err := st.CreateOrg(ctx, "Region 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/regions/new", sess))
for _, want := range []string{"modal-header", "Add region", `hx-post="/orgs/` + org.Slug + `/config/regions"`} {
if !strings.Contains(frag, want) {
t.Fatalf("new-region fragment missing %q", want)
}
}
if strings.Contains(frag, "<html") {
t.Fatal("htmx request should get a bare fragment, not the full page")
}
// The area is drawn on its own page, so the attribute form must not carry it.
if strings.Contains(frag, "region_geojson") {
t.Fatal("the attribute modal must not carry the geofence field")
}
// 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/regions/new", sess))
if !strings.Contains(page, "<html") || !strings.Contains(page, "Create region") {
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/regions",
url.Values{"region_display": {"Buffalo"}, "region_token": {"not a token"}}, 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", "letters, digits", `value="Buffalo"`, `value="not a token"`} {
if !strings.Contains(badBody, want) {
t.Fatalf("re-rendered fragment missing %q", want)
}
}
// A valid htmx submit closes the modal via HX-Redirect back to the config page.
ok := postHX(t, ts, h.app, "/orgs/"+org.Slug+"/config/regions",
url.Values{"region_display": {"Buffalo"}, "region_token": {"buf"}, "region_layer": {"3"}, "region_allow_flood": {"1"}}, sess)
ok.Body.Close()
if got, want := ok.Header.Get("HX-Redirect"), "/orgs/"+org.Slug+"/config"; ok.StatusCode != http.StatusOK || got != want {
t.Fatalf("valid submit = %d HX-Redirect %q, want 200 → %q", ok.StatusCode, got, want)
}
regions, err := st.ListRegions(ctx, org.ID)
if err != nil || len(regions) != 1 {
t.Fatalf("ListRegions = %v, %v; want one region", regions, err)
}
z := regions[0]
if z.Token != "buf" || z.DisplayName != "Buffalo" || z.Layer != 3 || !z.AllowFlood {
t.Fatalf("saved region = %+v", z)
}
// Created without an area: a draft, which applies nowhere.
if z.Geofence != nil {
t.Fatalf("a region created from the modal should have no area yet: %+v", z)
}
rid := strconv.FormatInt(z.ID, 10)
// The edit fragment is pre-filled and posts to that region's own URL.
editFrag := readBody(t, doHX(t, ts, h.app, "/orgs/"+org.Slug+"/config/regions/"+rid+"/edit", sess))
for _, want := range []string{"Edit region", `value="Buffalo"`, `value="buf"`,
`hx-post="/orgs/` + org.Slug + `/config/regions/` + rid + `"`} {
if !strings.Contains(editFrag, want) {
t.Fatalf("edit fragment missing %q", want)
}
}
// A duplicate token is reported as a validation error, not a 500.
dup := postHX(t, ts, h.app, "/orgs/"+org.Slug+"/config/regions",
url.Values{"region_token": {"buf"}}, sess)
dupBody := readBody(t, dup)
if dup.StatusCode != http.StatusOK || !strings.Contains(dupBody, "already exists") {
t.Fatalf("duplicate token = %d, body missing the friendly message: %q", dup.StatusCode, dupBody)
}
// Deleting from the list row (a plain POST) returns to the config page.
del := post(t, ts, h.app, "/orgs/"+org.Slug+"/config/regions/"+rid+"/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)
}
if left, _ := st.ListRegions(ctx, org.ID); len(left) != 0 {
t.Fatalf("regions after delete = %+v, want none", left)
}
}
// TestConfigRegionAreaSaves: the area workspace edits only the geofence. Saving a
// drawn shape promotes a draft; saving an empty one clears it back to a draft; the
// region's attributes are untouched either way, and a malformed shape re-renders the
// page with the shape still in hand rather than discarding it.
func TestConfigRegionAreaSaves(t *testing.T) {
t.Parallel()
st, ctx, ts, h := splitServer(t)
admin, sess := appLogin(t, ts, st, ctx, h.app, "rgnareaadmin")
org, err := st.CreateOrg(ctx, "Region Area Org", admin.ID)
if err != nil {
t.Fatalf("create org: %v", err)
}
rid, err := st.CreateRegion(ctx, org.ID, store.RegionInput{
Token: "buf", DisplayName: "Buffalo", Layer: 3, Primary: true, AllowFlood: true,
})
if err != nil {
t.Fatalf("create region: %v", err)
}
// A sibling with a shape, which the workspace draws as read-only context.
if _, err := st.CreateRegion(ctx, org.ID, store.RegionInput{
Token: "us", DisplayName: "United States", Layer: 1, AllowFlood: true,
GeofenceJSON: geo.Rectangle(20, -130, 50, -60),
}); err != nil {
t.Fatalf("create sibling: %v", err)
}
area := "/orgs/" + org.Slug + "/config/regions/" + strconv.FormatInt(rid, 10) + "/area"
// The page renders the map, the region's identity, and the sibling for context.
page := readBody(t, do(t, ts, h.app, area, sess))
for _, want := range []string{"region-map", "region_geojson", "Buffalo", "buf", "United States", "initRegionArea"} {
if !strings.Contains(page, want) {
t.Fatalf("area page missing %q", want)
}
}
// Saving a drawn shape promotes the draft, leaving its attributes alone.
drawn := `{"type":"Polygon","coordinates":[[[-80,40],[-75,40],[-75,45],[-80,45],[-80,40]]]}`
resp := post(t, ts, h.app, area, url.Values{"region_geojson": {drawn}}, sess)
resp.Body.Close()
if got, want := resp.Header.Get("Location"), "/orgs/"+org.Slug+"/config"; resp.StatusCode != http.StatusSeeOther || got != want {
t.Fatalf("save area = %d → %q, want 303 → %q", resp.StatusCode, got, want)
}
z, err := st.GetRegion(ctx, org.ID, rid)
if err != nil {
t.Fatalf("get region: %v", err)
}
if z.Geofence == nil || !z.Geofence.Contains(42, -78) {
t.Fatalf("area not saved: %+v", z)
}
if z.Token != "buf" || z.DisplayName != "Buffalo" || z.Layer != 3 || !z.Primary || !z.AllowFlood {
t.Fatalf("saving an area disturbed the region's attributes: %+v", z)
}
// A malformed shape is rejected, keeps the stored area, and re-renders with the
// submitted GeoJSON so it isn't silently lost.
badResp := post(t, ts, h.app, area, url.Values{"region_geojson": {"{not geojson"}}, sess)
badBody := readBody(t, badResp)
if badResp.StatusCode != http.StatusOK {
t.Fatalf("invalid area = %d, want 200 with the editor re-rendered", badResp.StatusCode)
}
if !strings.Contains(badBody, "isn&#39;t valid") && !strings.Contains(badBody, "isn't valid") {
t.Fatalf("invalid area should explain itself: %q", badBody)
}
if !strings.Contains(badBody, "not geojson") {
t.Fatal("the rejected shape should be preserved in the re-rendered form")
}
if z, _ = st.GetRegion(ctx, org.ID, rid); z.Geofence == nil {
t.Fatal("a rejected save must not clear the stored area")
}
// An empty shape is a deliberate "Clear area" — back to a draft.
clear := post(t, ts, h.app, area, url.Values{"region_geojson": {""}}, sess)
clear.Body.Close()
if clear.StatusCode != http.StatusSeeOther {
t.Fatalf("clear area = %d, want 303", clear.StatusCode)
}
if z, _ = st.GetRegion(ctx, org.ID, rid); z.Geofence != nil {
t.Fatalf("clearing the area should return the region to a draft: %+v", z)
}
}
// TestConfigRegionEditingIsAdminOnly: the region controls render only for an org
// admin — never for a plain member, and never on the anonymous public surface, which
// shares this template. The endpoints themselves 404 for non-admins.
func TestConfigRegionEditingIsAdminOnly(t *testing.T) {
t.Parallel()
st, ctx, ts, h := splitServer(t)
admin, adminSess := appLogin(t, ts, st, ctx, h.app, "rgnroleadmin")
org, err := st.CreateOrg(ctx, "Region Role Org", admin.ID)
if err != nil {
t.Fatalf("create org: %v", err)
}
rid, err := st.CreateRegion(ctx, org.ID, store.RegionInput{
Token: "buf", DisplayName: "Buffalo", Layer: 3, AllowFlood: true,
GeofenceJSON: geo.Rectangle(40, -80, 45, -75),
})
if err != nil {
t.Fatalf("create region: %v", err)
}
member, memberSess := appLogin(t, ts, st, ctx, h.app, "rgnrolemember")
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-region-add"`, `data-testid="config-region-edit"`,
`data-testid="config-region-delete"`, `data-testid="config-region-area"`,
`data-testid="config-region-root"`, `id="config-region-modal"`, "Buffalo"} {
if !strings.Contains(adminView, want) {
t.Fatalf("admin config view missing %q", want)
}
}
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"))},
} {
// Regions are public information — a member or visitor needs to see which
// areas an org defines and where they apply. Only the controls are admin-only.
for _, want := range []string{`data-testid="config-regions"`, `data-testid="config-region-row"`,
"Buffalo", "buf", "region-map"} {
if !strings.Contains(v.body, want) {
t.Errorf("%s config view should still show the regions; missing %q", v.name, want)
}
}
for _, forbidden := range []string{"config-region-add", "config-region-edit", "config-region-delete",
"config-region-modal", "config-region-root", "/config/regions"} {
if strings.Contains(v.body, forbidden) {
t.Fatalf("%s config view leaked the admin control %q", v.name, forbidden)
}
}
}
// The endpoints are admin-gated, not just hidden: a member gets 404 everywhere.
ridStr := strconv.FormatInt(rid, 10)
for _, path := range []string{
"/orgs/" + org.Slug + "/config/regions/new",
"/orgs/" + org.Slug + "/config/regions/" + ridStr + "/edit",
"/orgs/" + org.Slug + "/config/regions/" + ridStr + "/area",
} {
resp := do(t, ts, h.app, path, memberSess)
resp.Body.Close()
if resp.StatusCode != http.StatusNotFound {
t.Errorf("member GET %s = %d, want 404", path, resp.StatusCode)
}
}
for _, path := range []string{
"/orgs/" + org.Slug + "/config/regions",
"/orgs/" + org.Slug + "/config/regions/" + ridStr,
"/orgs/" + org.Slug + "/config/regions/" + ridStr + "/delete",
"/orgs/" + org.Slug + "/config/regions/" + ridStr + "/area",
"/orgs/" + org.Slug + "/config/root-flood",
} {
resp := post(t, ts, h.app, path, url.Values{"region_token": {"x"}}, memberSess)
resp.Body.Close()
if resp.StatusCode != http.StatusNotFound {
t.Errorf("member POST %s = %d, want 404", path, resp.StatusCode)
}
}
// And nothing a member submitted took effect.
if z, err := st.GetRegion(ctx, org.ID, rid); err != nil || z.Token != "buf" {
t.Fatalf("region changed under a non-admin: %+v (err %v)", z, err)
}
}
// TestConfigMapMarksPrimaryRegion: the config map's payload flags the primary
// region, which is what it opens framed on. Without that flag the map falls back to
// containing every region, so a nationwide parent would force a continental view of
// an org that operates in one county.
func TestConfigMapMarksPrimaryRegion(t *testing.T) {
t.Parallel()
st, ctx, ts, h := splitServer(t)
admin, sess := appLogin(t, ts, st, ctx, h.app, "cfgmapprimary")
org, err := st.CreateOrg(ctx, "Map Primary Org", admin.ID)
if err != nil {
t.Fatalf("create org: %v", err)
}
for _, z := range []store.RegionInput{
{Token: "us", DisplayName: "US", Layer: 1, AllowFlood: true, GeofenceJSON: geo.Rectangle(25, -125, 49, -67)},
{Token: "wny", DisplayName: "Western New York", Layer: 3, Primary: true, AllowFlood: true,
GeofenceJSON: geo.Rectangle(42, -79.8, 43.5, -77.5)},
} {
if _, err := st.CreateRegion(ctx, org.ID, z); err != nil {
t.Fatalf("create region %s: %v", z.Token, err)
}
}
body := readBody(t, do(t, ts, h.app, "/orgs/"+org.Slug+"/config", sess))
// Exactly one region in the map payload is flagged primary, and it's the one the
// org marked — not simply the first drawn.
if got := strings.Count(body, `"primary":true`); got != 1 {
t.Fatalf("map payload has %d primary regions, want exactly 1", got)
}
// Regions ship ordered by (layer, token), so Western New York (layer 3) is last:
// the flag must fall after its name and nowhere before it. Slicing on braces
// would cut inside the escaped GeoJSON, which is full of them.
at := strings.Index(body, `"name":"Western New York"`)
if at < 0 {
t.Fatal("the primary region is missing from the map payload")
}
if strings.Contains(body[:at], `"primary":true`) {
t.Fatal("a region before the primary one carries the primary flag")
}
if !strings.Contains(body[at:], `"primary":true`) {
t.Fatal("the primary flag is not on Western New York")
}
// Every drawn region still ships with a color, so the legend swatches match.
if got := strings.Count(body, `"color":"#`); got != 2 {
t.Fatalf("map payload carries %d colors, want one per drawn region (2)", got)
}
}
// TestConfigRootFloodToggle: the root (*) flood policy saves on its own from the
// config page — it isn't a region row, so it must not require any region to exist.
func TestConfigRootFloodToggle(t *testing.T) {
t.Parallel()
st, ctx, ts, h := splitServer(t)
admin, sess := appLogin(t, ts, st, ctx, h.app, "rootfloodadmin")
org, err := st.CreateOrg(ctx, "Root Flood Org", admin.ID)
if err != nil {
t.Fatalf("create org: %v", err)
}
path := "/orgs/" + org.Slug + "/config/root-flood"
// An unchecked switch submits nothing → deny.
resp := post(t, ts, h.app, path, url.Values{}, sess)
resp.Body.Close()
if got, want := resp.Header.Get("Location"), "/orgs/"+org.Slug+"/config"; resp.StatusCode != http.StatusSeeOther || got != want {
t.Fatalf("deny flood = %d → %q, want 303 → %q", resp.StatusCode, got, want)
}
if allow, err := st.RootAllowFlood(ctx, org.ID); err != nil || allow {
t.Fatalf("root flood after deny = %v (err %v), want false", allow, err)
}
// A checked switch submits "1" → allow.
resp = post(t, ts, h.app, path, url.Values{"root_allow_flood": {"1"}}, sess)
resp.Body.Close()
if allow, err := st.RootAllowFlood(ctx, org.ID); err != nil || !allow {
t.Fatalf("root flood after allow = %v (err %v), want true", allow, err)
}
// The switch renders checked, reflecting the stored value.
page := readBody(t, do(t, ts, h.app, "/orgs/"+org.Slug+"/config", sess))
if !strings.Contains(page, `name="root_allow_flood" value="1"`) {
t.Fatal("config page should render the root flood switch")
}
}
+74
View File
@@ -0,0 +1,74 @@
package core
import (
"testing"
"github.com/jleight/meshtender/internal/geo"
)
func TestRegionGeofence(t *testing.T) {
t.Parallel()
// No shape → a draft (nil geofence, accepted). The region simply applies
// nowhere until an area is drawn.
if gf, ok := regionGeofence("", new([]string)); !ok || gf != nil {
t.Fatalf("empty shape: got (%v,%v), want (nil,true)", gf, ok)
}
// Whitespace-only is the same thing (a cleared hidden input).
if gf, ok := regionGeofence(" ", new([]string)); !ok || gf != nil {
t.Fatalf("blank shape: got (%v,%v), want (nil,true)", gf, ok)
}
// A drawn polygon → carried through verbatim, parseable, contains its center.
drawn := `{"type":"Polygon","coordinates":[[[30,10],[40,10],[40,20],[30,20],[30,10]]]}`
var errs []string
gf, ok := regionGeofence(drawn, &errs)
if !ok || gf == nil {
t.Fatalf("drawn polygon: got (%v,%v), want non-nil geofence ok", gf, ok)
}
shape, err := geo.Parse(gf)
if err != nil {
t.Fatalf("parse geofence: %v", err)
}
if !shape.Contains(15, 35) {
t.Fatal("geofence should contain its center")
}
// Malformed GeoJSON → error.
errs = nil
if _, ok := regionGeofence("{not geojson", &errs); ok || len(errs) == 0 {
t.Fatal("invalid shape should be rejected with an error")
}
// A non-area geometry is rejected too — geofences are Polygon/MultiPolygon.
errs = nil
if _, ok := regionGeofence(`{"type":"Point","coordinates":[30,10]}`, &errs); ok || len(errs) == 0 {
t.Fatal("a Point should be rejected as a region area")
}
}
func TestValidRegionToken(t *testing.T) {
t.Parallel()
for _, tok := range []string{"buf", "us-ny", "us_ny", "L3", "0"} {
if !validRegionToken(tok) {
t.Errorf("validRegionToken(%q) = false, want true", tok)
}
}
// Tokens are space-joined into one `region def` line, so separators and spaces
// would corrupt the command.
for _, tok := range []string{"", "two words", "a|b", "a,b", "a/b", "né"} {
if validRegionToken(tok) {
t.Errorf("validRegionToken(%q) = true, want false", tok)
}
}
}
func TestRegionFormAction(t *testing.T) {
t.Parallel()
if got, want := regionFormAction("acme", 0), "/orgs/acme/config/regions"; got != want {
t.Errorf("new region action = %q, want %q", got, want)
}
if got, want := regionFormAction("acme", 7), "/orgs/acme/config/regions/7"; got != want {
t.Errorf("existing region action = %q, want %q", got, want)
}
}
+15 -20
View File
@@ -118,29 +118,24 @@ func (s *Handlers) consoleConfigJSON(w http.ResponseWriter, r *http.Request) {
// Profile base settings (verbatim; comment-only steps are notes, not commands),
// with the location-derived region commands spliced at the profile's
// {{ region }} marker or appended after all steps when it has none. The marker
// itself is dropped by SplitAtRegionMarker.
emitStep := func(step store.ConfigStep) {
c := consoleConfigCommand{Line: step.CommandLine, Comment: step.Comment}
if step.IsComment() {
c.Reason = "note"
} else {
c.Runnable, c.Reason = runnable(step.CommandLine)
// {{ region }} marker or appended after all steps when it has none.
//
// AssembledLines owns that ordering, shared with the org Configuration page, so
// what an owner previews there is exactly what the console offers to run.
for _, l := range cv.AssembledLines() {
if l.IsMarker {
continue // a placeholder for the page; never sent to a device
}
c := consoleConfigCommand{}
switch {
case l.IsComment:
c.Comment, c.Reason = l.Text, "note"
default:
c.Line = l.Text
c.Runnable, c.Reason = runnable(l.Text)
}
resp.Commands = append(resp.Commands, c)
}
before, after := store.SplitAtRegionMarker(cv.SelectedSteps)
for _, step := range before {
emitStep(step)
}
for _, line := range cv.RegionDef {
c := consoleConfigCommand{Line: line}
c.Runnable, c.Reason = runnable(line)
resp.Commands = append(resp.Commands, c)
}
for _, step := range after {
emitStep(step)
}
resp.Location = consoleConfigLocation{
Known: lat != nil && lon != nil,
+39 -4
View File
@@ -7,6 +7,8 @@ import (
"strconv"
"strings"
"testing"
"github.com/jleight/meshtender/internal/store"
)
// Black-box coverage for the org-management POST endpoints (create/edit/links/
@@ -68,8 +70,10 @@ func TestOrgManagementPosts(t *testing.T) {
}
}
// #56 update config profile, #57 delete config profile. (Create #54 and regions
// #59 are covered by TestOrgConfigProfilesFlow.)
// #56 update config profile, #57 delete config profile, and the matching region
// writes (#107 update, #108 delete, #110 area, #111 root flood). (Profile create #54
// and region create #59 are covered by TestOrgConfigProfilesFlow; the modal/area
// surfaces in config_region_modal_test.go.)
func TestOrgConfigProfileUpdateDelete(t *testing.T) {
t.Parallel()
st, ctx, ts, h := splitServer(t)
@@ -97,6 +101,29 @@ func TestOrgConfigProfileUpdateDelete(t *testing.T) {
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"))
}
// Regions mirror the same shape: every write lands back on the config page.
rid, err := st.CreateRegion(ctx, org.ID, store.RegionInput{Token: "buf", DisplayName: "Buffalo", AllowFlood: true})
if err != nil {
t.Fatal(err)
}
region := base + "/regions/" + strconv.FormatInt(rid, 10)
for _, step := range []struct {
name string
path string
form url.Values
}{
{"update region", region, url.Values{"region_token": {"buf"}, "region_display": {"Buffalo NY"}}},
{"save area", region + "/area", url.Values{"region_geojson": {`{"type":"Polygon","coordinates":[[[30,10],[40,10],[40,20],[30,20],[30,10]]]}`}}},
{"root flood", base + "/root-flood", url.Values{"root_allow_flood": {"1"}}},
{"delete region", region + "/delete", url.Values{}},
} {
resp := post(t, ts, h.app, step.path, step.form, sess)
resp.Body.Close()
if loc, _ := url.Parse(resp.Header.Get("Location")); resp.StatusCode != http.StatusSeeOther || loc.Path != base {
t.Errorf("%s = %d %q, want 303 → config", step.name, resp.StatusCode, resp.Header.Get("Location"))
}
}
}
// #46 join, #47 leave — against an org owned by someone else.
@@ -251,7 +278,7 @@ func TestOrgEditModals(t *testing.T) {
// TestOrgTabsHeaderConsistent: the shared org-header renders the Actions menu on
// EVERY member tab (not just Home) — the fix for the menu vanishing when you switch
// tabs. Admins also get "Edit configuration" in it.
// tabs.
func TestOrgTabsHeaderConsistent(t *testing.T) {
t.Parallel()
st, ctx, ts, h := splitServer(t)
@@ -262,10 +289,18 @@ func TestOrgTabsHeaderConsistent(t *testing.T) {
}
for _, tab := range []string{"", "/members", "/repeaters", "/config"} {
body := readBody(t, do(t, ts, h.app, "/orgs/"+org.Slug+tab, sess))
for _, want := range []string{">Actions<", "Leave organization", "View public page", "Edit configuration"} {
for _, want := range []string{">Actions<", "Leave organization", "View public page"} {
if !strings.Contains(body, want) {
t.Errorf("tab %q missing %q from the shared Actions header", tab, want)
}
}
// Configuration is reached by its own tab, which an admin always sees — even
// before the org has any config — so the Actions menu doesn't duplicate it.
if strings.Contains(body, "Edit configuration") {
t.Errorf("tab %q still offers Edit configuration in the Actions menu", tab)
}
if !strings.Contains(body, "/orgs/"+org.Slug+"/config") {
t.Errorf("tab %q does not link to the Configuration tab", tab)
}
}
}
+11 -12
View File
@@ -64,18 +64,17 @@ func TestSerialSetupFlow(t *testing.T) {
if save.StatusCode != http.StatusSeeOther {
t.Fatalf("profile save status = %d, want 303", save.StatusCode)
}
// Scoped-flooding pattern: allow flood inside the zone, deny at the root
// (root_allow_flood omitted = unchecked = deny).
rsave := post(t, ts, h.app, "/orgs/"+org.Slug+"/config/regions", url.Values{
"region_display": {"Zone"},
"region_token": {"zone"},
"region_layer": {"1"},
"region_allow_flood": {"1"},
"region_geojson": {`{"type":"Polygon","coordinates":[[[30,10],[40,10],[40,20],[30,20],[30,10]]]}`},
}, sess)
rsave.Body.Close()
if rsave.StatusCode != http.StatusSeeOther {
t.Fatalf("region save status = %d, want 303", rsave.StatusCode)
// Scoped-flooding pattern: allow flood inside the zone, deny at the root. The
// region itself is fixture here — the region editor's own endpoints are covered
// in config_region_modal_test.go — so it's written straight to the store.
if _, err := st.CreateRegion(ctx, org.ID, store.RegionInput{
Token: "zone", DisplayName: "Zone", Layer: 1, AllowFlood: true,
GeofenceJSON: []byte(`{"type":"Polygon","coordinates":[[[30,10],[40,10],[40,20],[30,20],[30,10]]]}`),
}); err != nil {
t.Fatalf("create region: %v", err)
}
if err := st.SetRootAllowFlood(ctx, org.ID, false); err != nil {
t.Fatalf("deny root flood: %v", err)
}
// Build the command list for a point inside the region.
-34
View File
@@ -1,34 +0,0 @@
{{define "title"}}Configuration · {{.Org.Name}} · MeshTender{{end}}
{{define "header"}}
<div class="row g-2 align-items-center">
<div class="col">
<div class="page-pretitle">{{.Org.Name}}</div>
<h1 class="page-title fs-1">Edit configuration</h1>
</div>
<div class="col-auto ms-auto d-print-none">
<a class="btn" href="/orgs/{{.Org.Slug}}/config">Back to configuration</a>
</div>
</div>
{{end}}
{{define "content"}}
{{template "org-tabs" .Nav}}
<div class="card">
<div class="card-header">
<div>
<h2 class="card-title">Regions</h2>
<div class="text-secondary small">Named areas drawn on a map. A repeater is auto-assigned to every region whose area contains it, forming its <code>region def</code>.</div>
</div>
<div class="card-actions">
<a class="btn btn-primary" href="/orgs/{{.Org.Slug}}/config/regions">{{template "icon-map-pin" "me-1"}}Edit regions</a>
</div>
</div>
<div class="card-body">
{{if .RegionCount}}
<div class="text-secondary">{{.RegionCount}} region{{if ne .RegionCount 1}}s{{end}} defined{{if .PrimaryRegion}} · primary: <span class="text-body">{{.PrimaryRegion}}</span>{{end}}.</div>
{{else}}
<p class="text-secondary mb-0">No regions yet. Add regions to apply location-based settings automatically.</p>
{{end}}
</div>
</div>
{{end}}
@@ -0,0 +1,96 @@
{{/* The region area workspace: one region's geofence, drawn on a full-width map with
its siblings shown as read-only context. Attributes live in the modal on the
Configuration page — this page saves only the shape, through its own endpoint.
The side panel is deliberately roomy: it's where boundary import (load a real
administrative border and pick the areas you want) will land next to the draw
tools. */}}
{{define "title"}}{{.Region.DisplayName}} area · {{.Org.Name}} · MeshTender{{end}}
{{define "header"}}
<div class="row g-2 align-items-center">
<div class="col">
<div class="page-pretitle">{{.Org.Name}} · Configuration · {{.Region.DisplayName}}</div>
<h1 class="page-title fs-1">Edit area</h1>
</div>
<div class="col-auto ms-auto d-print-none">
<a class="btn" href="/orgs/{{.Org.Slug}}/config">Back to configuration</a>
</div>
</div>
{{end}}
{{define "content"}}
{{template "org-tabs" .Nav}}
{{if .Errors}}
<div class="alert alert-danger" role="alert">
<div>
<div class="fw-bold">The area wasn't saved — fix this first:</div>
<ul class="mb-0">{{range .Errors}}<li>{{.}}</li>{{end}}</ul>
</div>
</div>
{{end}}
<link rel="stylesheet" href="{{ asset "/static/leaflet.css" }}">
<link rel="stylesheet" href="{{ asset "/static/leaflet-geoman.css" }}">
<form method="post" action="/orgs/{{.Org.Slug}}/config/regions/{{.Region.ID}}/area" id="region-area-form">
<div class="row g-3">
<div class="col-lg-8">
<div id="region-map" style="height:600px; border-radius:var(--tblr-border-radius)"
role="region" aria-label="Map for drawing this region's boundary"></div>
</div>
<div class="col-lg-4">
<div class="card">
<div class="card-header">
<div>
<h2 class="card-title">{{.Region.DisplayName}}</h2>
<div class="text-secondary small">
<code>{{.Region.Token}}</code> · level {{.Region.Layer}}
</div>
</div>
</div>
<div class="card-body">
<div class="mb-3" data-testid="region-area-status">
<span class="text-secondary">Area:</span>
<span class="fw-bold" id="region-area-status">—</span>
</div>
<p class="text-secondary small">
Draw with the tools at the top-left of the map. A region needs an area
before it applies to anything — until then it's a draft and no repeater
picks it up.
</p>
{{if .Siblings}}
<p class="text-secondary small mb-0">
The org's other regions are outlined for reference. They can't be edited
here — open each one's own area editor.
</p>
{{end}}
</div>
<div class="card-footer">
<div class="btn-list">
<button type="submit" class="btn btn-primary" data-testid="save-area">Save area</button>
<a class="btn" href="/orgs/{{.Org.Slug}}/config">Cancel</a>
<button type="button" class="btn btn-ghost-danger ms-auto" id="region-area-clear">Clear area</button>
</div>
</div>
</div>
</div>
</div>
<input type="hidden" name="region_geojson" id="region_geojson" value="{{.Region.GeofenceJSON}}">
</form>
<script src="{{ asset "/static/leaflet.js" }}"></script>
<script src="{{ asset "/static/leaflet-geoman.js" }}"></script>
<script src="{{ asset "/static/regionmap.js" }}"></script>
{{/* Values are interpolated in a JS context, so html/template JSON-encodes and
escapes them — the region names and shapes are user data. */}}
<script nonce="{{.Nonce}}">
initRegionArea('region-map', {
input: 'region_geojson',
status: 'region-area-status',
clear: 'region-area-clear',
name: {{.Region.DisplayName}},
siblings: {{.Siblings}}
});
</script>
{{end}}
@@ -0,0 +1,106 @@
{{/* The region attribute editor — everything about a region except its drawn area,
which is saved separately on the area workspace page so a rejected edit here
can never discard a polygon. Admins normally reach it as the config-region-modal
fragment swapped into #config-region-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 .Region.ID}}Edit region{{else}}Add region{{end}} · {{.Org.Name}} · MeshTender{{end}}
{{define "header"}}
<div class="row g-2 align-items-center">
<div class="col">
<div class="page-pretitle">{{.Org.Name}} · Configuration</div>
<h1 class="page-title fs-1">{{if .Region.ID}}Edit region{{else}}Add region{{end}}</h1>
</div>
<div class="col-auto ms-auto d-print-none">
<a class="btn" href="/orgs/{{.Org.Slug}}/config">Back to configuration</a>
</div>
</div>
{{end}}
{{define "content"}}
{{template "org-tabs" .Nav}}
{{template "region-editor-alerts" .}}
<div class="card">
<div class="card-body">
<form method="post" action="{{.Action}}">
{{template "region-editor-fields" .}}
<div class="btn-list mt-3">
<button type="submit" class="btn btn-primary">{{if .Region.ID}}Save region{{else}}Create region{{end}}</button>
<a class="btn" href="/orgs/{{.Org.Slug}}/config">Cancel</a>
</div>
</form>
</div>
</div>
{{end}}
{{/* config-region-modal is the htmx fragment swapped into #config-region-modal. */}}
{{define "config-region-modal"}}
<div class="modal-header">
<h2 class="modal-title">{{if .Region.ID}}Edit region{{else}}Add region{{end}}</h2>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
{{template "region-editor-alerts" .}}
<form method="post" action="{{.Action}}" id="config-region-form"
hx-post="{{.Action}}" hx-target="#config-region-modal-content" hx-swap="innerHTML">
{{template "region-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-region-form" class="btn btn-primary" data-testid="save-region">{{if .Region.ID}}Save region{{else}}Create region{{end}}</button>
</div>
{{end}}
{{/* region-editor-alerts renders the validation errors, shared by page and modal. */}}
{{define "region-editor-alerts"}}
{{if .Errors}}
<div class="alert alert-danger" role="alert">
<div>
<div class="fw-bold">Nothing was saved — fix these first:</div>
<ul class="mb-0">{{range .Errors}}<li>{{.}}</li>{{end}}</ul>
</div>
</div>
{{end}}
{{end}}
{{/* region-editor-fields are the form controls themselves — the <form> element
around them differs between the page and the modal. The area is deliberately
absent: it's drawn on the workspace page. */}}
{{define "region-editor-fields"}}
<div class="mb-3">
<label class="form-label" for="region_display">Display name</label>
<input class="form-control" id="region_display" name="region_display" value="{{.Region.DisplayName}}"
placeholder="Buffalo" maxlength="60" autofocus>
<small class="form-hint">The human label. Defaults to the short name if you leave it blank.</small>
</div>
<div class="row g-3">
<div class="col">
<label class="form-label" for="region_token">Short name</label>
<input class="form-control font-monospace" id="region_token" name="region_token" value="{{.Region.Token}}"
placeholder="buf" maxlength="30" spellcheck="false">
<small class="form-hint">Used by MeshCore in <code>region def</code>. Letters, digits, <code>-</code>, <code>_</code>.</small>
</div>
<div class="col-auto">
<label class="form-label" for="region_layer">Level</label>
<input class="form-control" type="number" id="region_layer" name="region_layer" value="{{.Region.Layer}}" style="width:6rem">
<small class="form-hint">Depth</small>
</div>
</div>
<div class="mt-3">
<label class="form-check form-switch">
<input class="form-check-input" type="checkbox" name="region_allow_flood" value="1" {{if .Region.AllowFlood}}checked{{end}}>
<span class="form-check-label">Allow flood</span>
<small class="form-hint">Whether flooding is permitted inside this region.</small>
</label>
<label class="form-check form-switch">
<input class="form-check-input" type="checkbox" name="region_primary" value="1" {{if .Region.Primary}}checked{{end}}>
<span class="form-check-label">Primary region</span>
<small class="form-hint">Frames the map on the Configuration page. Turning this on clears whichever region held it.</small>
</label>
</div>
{{end}}
@@ -1,168 +0,0 @@
{{define "title"}}Edit regions · {{.Org.Name}} · MeshTender{{end}}
{{define "header"}}
<div class="row g-2 align-items-center">
<div class="col">
<div class="page-pretitle">{{.Org.Name}} · Configuration</div>
<h1 class="page-title fs-1">Edit regions</h1>
</div>
<div class="col-auto ms-auto d-print-none">
<a class="btn" href="/orgs/{{.Org.Slug}}/config/edit">Back to configuration</a>
</div>
</div>
{{end}}
{{define "content"}}
{{template "org-tabs" .Nav}}
{{if .Errors}}
<div class="alert alert-danger">
<div>
<div class="fw-bold">Nothing was saved — fix these first:</div>
<ul class="mb-0">{{range .Errors}}<li>{{.}}</li>{{end}}</ul>
</div>
</div>
{{end}}
<link rel="stylesheet" href="{{ asset "/static/leaflet.css" }}">
<link rel="stylesheet" href="{{ asset "/static/leaflet-geoman.css" }}">
<style>
@media (min-width: 992px) { .region-map-sticky { position: sticky; top: 1rem; } }
</style>
<p class="text-secondary">Each region has a <strong>display name</strong> (e.g. Buffalo), a short <strong>name</strong> used by MeshCore (e.g. <code>buf</code>), and a <strong>level</strong> for its depth (lower = broader, e.g. country 1 → state 2 → city 3). Pick a region in the list, then use the map tools to outline where it applies — every region needs an area (the root region at the top of the list is the one that applies everywhere).</p>
<form method="post" action="/orgs/{{.Org.Slug}}/config/regions" id="regions-form">
<div class="row g-3">
<div class="col-lg-7">
<div class="region-map-sticky">
<div id="region-map" style="height:520px; border-radius:var(--tblr-border-radius)" role="region" aria-label="Map for drawing region boundaries"></div>
<p class="text-secondary small mt-2 mb-0">Draw with the tools at the top-left of the map. New shapes fill the selected region; drawing again adds a new region.</p>
</div>
</div>
<div class="col-lg-5">
{{/* The root region (*) is pinned first in the list. It is not a .region-block,
so the map editor ignores it; its only setting is flood policy. */}}
<div class="card card-sm mb-2">
<div class="card-body d-flex align-items-center gap-3">
<div class="flex-fill" style="min-width:0">
<div class="fw-bold">Root <span class="text-secondary fw-normal">· <code>*</code> · everywhere</span></div>
<div class="text-secondary small">Applies to every repeater. Deny flood to scope flooding to your defined regions.</div>
</div>
<label class="form-check form-switch m-0 flex-shrink-0 text-nowrap">
<input type="hidden" name="root_allow_flood" value="{{if .RootAllowFlood}}1{{end}}">
<input class="form-check-input" type="checkbox" {{if .RootAllowFlood}}checked{{end}} data-flood>
<span class="form-check-label">Allow flood</span>
</label>
</div>
</div>
<div id="regions">
{{range .Regions}}{{template "region-block" .}}{{end}}
</div>
<button type="button" class="btn btn-outline-primary w-100" id="add-region">{{template "icon-plus" "me-1"}}Add region</button>
<div class="btn-list mt-3">
<button type="submit" class="btn btn-primary">Save regions</button>
<a class="btn" href="/orgs/{{.Org.Slug}}/config">Cancel</a>
</div>
</div>
</div>
</form>
{{/* Hidden prototype cloned by addBlock(); kept out of the form via the template tag. */}}
<template id="region-proto">{{template "region-block" .EmptyRegion}}</template>
<script nonce="{{.Nonce}}">
// addBlock stays global: regionmap.js calls it when a freshly drawn shape needs a
// new region row (then it reconciles the map). The rest is delegated off the form
// so cloned rows work without inline handlers (CSP-friendly).
function addBlock(kind) {
const proto = document.getElementById(kind + '-proto');
document.getElementById(kind + 's').appendChild(proto.content.cloneNode(true));
}
(function () {
const form = document.getElementById('regions-form');
const rows = document.getElementById('regions');
if (!form || !rows) return;
const addBtn = document.getElementById('add-region');
if (addBtn) addBtn.addEventListener('click', function () { addBlock('region'); });
// The flood and primary switches each shadow a hidden input (submitted with the
// form). Flood is independent per region; primary is exclusive — turning one on
// clears the rest.
form.addEventListener('change', function (e) {
if (!e.target.matches) return;
if (e.target.matches('[data-flood]')) {
e.target.previousElementSibling.value = e.target.checked ? '1' : '';
} else if (e.target.matches('[data-primary]')) {
setPrimaryRegion(e.target);
}
});
form.addEventListener('click', function (e) {
if (!e.target.closest) return;
const removeBtn = e.target.closest('.region-remove-btn');
if (removeBtn) { const b = removeBtn.closest('.region-block'); if (b) b.remove(); }
});
// Only one region may be primary: reflect the toggled switch onto every block's
// switch + hidden input, clearing the others.
function setPrimaryRegion(cb) {
const block = cb.closest('.region-block');
const on = cb.checked;
rows.querySelectorAll('.region-block').forEach(function (b) {
const isThis = b === block && on;
const box = b.querySelector('[data-primary]');
const hidden = b.querySelector('input[name="region_primary"]');
if (box) box.checked = isThis;
if (hidden) hidden.value = isThis ? '1' : '';
});
}
})();
</script>
<script src="{{ asset "/static/leaflet.js" }}"></script>
<script src="{{ asset "/static/leaflet-geoman.js" }}"></script>
<script src="{{ asset "/static/regionmap.js" }}"></script>
<script nonce="{{.Nonce}}">initRegionEditor('region-map', 'regions');</script>
{{end}}
{{/* region-block is one node of the region hierarchy, shown as a card in the side
list. Its area is drawn on the map; region_geojson carries the GeoJSON the map
writes. regionmap.js keys off .region-block / region_geojson /
.region-clear / .region-active. */}}
{{define "region-block"}}
<div class="region-block card card-sm mb-2">
<div class="card-body">
<div class="row g-2 align-items-end">
<div class="col-12"><label class="form-label mb-1">Display name</label>
<input class="form-control" name="region_display" value="{{.DisplayName}}" placeholder="Buffalo"
aria-label="Region display name"></div>
<div class="col"><label class="form-label mb-1">Name</label>
<input class="form-control font-monospace" name="region_token" value="{{.Token}}" placeholder="buf"
aria-label="Region name (token used in commands)"></div>
<div class="col-auto"><label class="form-label mb-1">Level</label>
<input class="form-control" type="number" name="region_layer" value="{{.Layer}}" style="width:5rem"
aria-label="Region level"></div>
</div>
{{/* Settings/state: the region's own properties, shown as switches (matching
the root's flood switch) so each toggle reads clearly with its label,
distinct from the action buttons below. */}}
<div class="d-flex align-items-center flex-wrap gap-3 mt-2">
<label class="form-check form-switch m-0">
<input type="hidden" name="region_allow_flood" value="{{if .AllowFlood}}1{{end}}">
<input class="form-check-input" type="checkbox" {{if .AllowFlood}}checked{{end}} data-flood>
<span class="form-check-label">Allow flood</span>
</label>
<label class="form-check form-switch m-0">
{{/* Only one region may be primary; toggling one on clears the rest (see JS). */}}
<input type="hidden" name="region_primary" value="{{if .Primary}}1{{end}}">
<input class="form-check-input" type="checkbox" {{if .Primary}}checked{{end}} data-primary>
<span class="form-check-label">Primary</span>
</label>
</div>
{{/* Actions on the region's map area, plus Remove (danger) pushed to the end. */}}
<div class="d-flex align-items-center flex-wrap gap-2 mt-2 pt-2 border-top">
<button type="button" class="btn btn-sm region-edit-btn">Edit area</button>
<button type="button" class="btn btn-sm btn-ghost-secondary region-clear">Clear area</button>
<button type="button" class="btn btn-sm btn-ghost-danger ms-auto region-remove-btn">Remove</button>
</div>
<input type="hidden" name="region_geojson" value="{{.GeofenceJSON}}">
</div>
</div>
{{end}}
+11 -3
View File
@@ -246,14 +246,22 @@ func (s *Handlers) appRouter() chi.Router {
r.Post("/orgs/{id}/leave", s.handleLeaveOrg)
r.Post("/orgs/{id}/members/{userID}", s.handleSetOrgMember)
r.Get("/orgs/{id}/config", s.pageOrgConfig)
r.Get("/orgs/{id}/config/edit", s.pageConfigHub)
r.Get("/orgs/{id}/config/profiles/new", s.pageProfileEdit)
r.Post("/orgs/{id}/config/profiles", s.handleCreateProfile)
r.Get("/orgs/{id}/config/profiles/{pid}/edit", s.pageProfileEdit)
r.Post("/orgs/{id}/config/profiles/{pid}", s.handleUpdateProfile)
r.Post("/orgs/{id}/config/profiles/{pid}/delete", s.handleDeleteProfile)
r.Get("/orgs/{id}/config/regions", s.pageRegionsEdit)
r.Post("/orgs/{id}/config/regions", s.handleSaveRegions)
// Regions: attributes edit in a modal (mirroring profiles), the drawn area
// on its own workspace page. The root (*) flood policy isn't a region row,
// so it toggles on its own.
r.Get("/orgs/{id}/config/regions/new", s.pageRegionEdit)
r.Post("/orgs/{id}/config/regions", s.handleCreateRegion)
r.Get("/orgs/{id}/config/regions/{rid}/edit", s.pageRegionEdit)
r.Post("/orgs/{id}/config/regions/{rid}", s.handleUpdateRegion)
r.Post("/orgs/{id}/config/regions/{rid}/delete", s.handleDeleteRegion)
r.Get("/orgs/{id}/config/regions/{rid}/area", s.pageRegionArea)
r.Post("/orgs/{id}/config/regions/{rid}/area", s.handleSaveRegionArea)
r.Post("/orgs/{id}/config/root-flood", s.handleSetRootFlood)
// Custom org domains are hidden for now — the hosting/TLS infrastructure
// isn't in place yet. Leave the handlers in tree but don't expose them.
// r.Post("/orgs/{id}/domains", s.handleAddOrgDomain)
+4 -4
View File
@@ -43,9 +43,9 @@ func TestE2EConfigProfileSelect(t *testing.T) {
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.WaitVisible(`[data-testid="config-preview"]`, chromedp.ByQuery),
chromedp.Text(`[data-testid="config-preview"] .card-title`, &heading, chromedp.ByQuery),
chromedp.Text(`[data-testid="config-commands"]`, &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)
@@ -92,7 +92,7 @@ func TestE2EConfigProfileModalSave(t *testing.T) {
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.Text(`[data-testid="config-preview"] .card-title`, &heading, chromedp.ByQuery),
chromedp.Location(&currentURL),
); err != nil {
t.Fatalf("browser run against %s: %v", cfgURL, err)
+6 -3
View File
@@ -17,8 +17,7 @@ import (
func TestE2EOrgActionsConsistent(t *testing.T) {
srv := newE2EServer(t)
user, cookie := srv.login(t, "e2eorgtabs")
// CreateOrg makes the creator an admin member, so the Actions menu (with
// Edit configuration) renders.
// CreateOrg makes the creator an admin member, so the Actions menu renders.
org, err := srv.store.CreateOrg(srv.ctx, "Tabs Org", user.ID)
if err != nil {
t.Fatalf("create org: %v", err)
@@ -43,10 +42,14 @@ func TestE2EOrgActionsConsistent(t *testing.T) {
); err != nil {
t.Fatalf("browser run against %s: %v", membersURL, err)
}
for _, want := range []string{"Edit configuration", "View public page", "Leave organization"} {
for _, want := range []string{"View public page", "Leave organization"} {
if !strings.Contains(items, want) {
t.Errorf("Actions menu on the Members tab missing %q; got %q", want, items)
}
}
// Configuration has its own tab, so it isn't duplicated in this menu.
if strings.Contains(items, "Edit configuration") {
t.Errorf("Actions menu still offers Edit configuration; got %q", items)
}
watch.assertClean(t)
}
+413 -77
View File
@@ -3,8 +3,12 @@
package e2e
import (
"strconv"
"strings"
"testing"
"time"
cdplog "github.com/chromedp/cdproto/log"
"github.com/chromedp/cdproto/network"
"github.com/chromedp/chromedp"
@@ -12,98 +16,430 @@ import (
"github.com/jleight/meshtender/internal/store"
)
// TestE2ERegionEditorControls renders the org region editor and checks the
// clarified controls: Allow flood and Primary are labeled switches, the map-area
// button reads "Edit area", and Primary is exclusive — turning one region's
// Primary switch on clears the others. Runs under the strict CSP.
func TestE2ERegionEditorControls(t *testing.T) {
// TestE2ERegionModalSave: an admin adds a region from the Configuration page — the
// attribute editor loads into the modal via htmx, saving navigates back, and the new
// region is listed flagged as needing an area (it has no shape yet). Runs under the
// strict CSP.
func TestE2ERegionModalSave(t *testing.T) {
srv := newE2EServer(t)
owner, cookie := srv.login(t, "region-admin")
org, err := srv.store.CreateOrg(srv.ctx, "Region Org", owner.ID) // owner is an admin member
user, cookie := srv.login(t, "e2ergnadd")
org, err := srv.store.CreateOrg(srv.ctx, "Region Add Org", user.ID) // creator = admin
if err != nil {
t.Fatalf("create org: %v", err)
}
regions := []store.RegionInput{
{Token: "us", DisplayName: "US", Layer: 1, Primary: false,
GeofenceJSON: geo.Rectangle(30, -90, 48, -70)},
{Token: "us-ny", DisplayName: "New York", Layer: 2, Primary: true,
GeofenceJSON: geo.Rectangle(40, -80, 45, -73)},
}
if err := srv.store.ReplaceOrgConfig(srv.ctx, org.ID, nil, regions); err != nil {
t.Fatalf("replace config: %v", err)
}
bctx, cancel, watch := startBrowser(t)
defer cancel()
url := srv.appURL + "/orgs/" + org.Slug + "/config/regions"
// Read one region's control state by its token (row order isn't guaranteed).
stateJS := func(token string) string {
return `(function () {
var blocks = document.querySelectorAll('.region-block');
for (var i = 0; i < blocks.length; i++) {
var t = blocks[i].querySelector('input[name="region_token"]');
if (t && t.value === ` + "`" + token + "`" + `) {
return {
floodIsSwitch: !!blocks[i].querySelector('.form-switch [data-flood]'),
primaryIsSwitch: !!blocks[i].querySelector('.form-switch [data-primary]'),
primaryChecked: blocks[i].querySelector('[data-primary]').checked,
primaryHidden: blocks[i].querySelector('input[name="region_primary"]').value,
editText: blocks[i].querySelector('.region-edit-btn').textContent.trim(),
};
}
}
return null;
})()`
}
type ctrl struct {
FloodIsSwitch bool `json:"floodIsSwitch"`
PrimaryIsSwitch bool `json:"primaryIsSwitch"`
PrimaryChecked bool `json:"primaryChecked"`
PrimaryHidden string `json:"primaryHidden"`
EditText string `json:"editText"`
}
var us0, ny0, us1, ny1 ctrl
var rowText string
var draftFlagged bool
cfgURL := srv.appURL + "/orgs/" + org.Slug + "/config"
if err := chromedp.Run(bctx,
network.Enable(),
cdplog.Enable(),
setSessionCookie(cookie),
chromedp.Navigate(url),
chromedp.WaitVisible(`.region-block`, chromedp.ByQuery),
chromedp.Evaluate(stateJS("us"), &us0),
chromedp.Evaluate(stateJS("us-ny"), &ny0),
// Turn on US's Primary switch; NY (currently primary) must clear.
chromedp.Evaluate(`(function(){
var b = Array.prototype.slice.call(document.querySelectorAll('.region-block'))
.find(function(x){ return x.querySelector('input[name="region_token"]').value === 'us'; });
b.querySelector('[data-primary]').click();
return true;
})()`, nil),
chromedp.Evaluate(stateJS("us"), &us1),
chromedp.Evaluate(stateJS("us-ny"), &ny1),
chromedp.Navigate(cfgURL),
chromedp.WaitVisible(`[data-testid="config-region-add"]`, chromedp.ByQuery),
chromedp.Click(`[data-testid="config-region-add"]`, chromedp.ByQuery),
// The attribute editor arrives in the modal via htmx.
chromedp.WaitVisible(`#config-region-modal-content input[name=region_display]`, chromedp.ByQuery),
chromedp.SetValue(`#config-region-modal-content input[name=region_display]`, "Buffalo", chromedp.ByQuery),
chromedp.SetValue(`#config-region-modal-content input[name=region_token]`, "buf", chromedp.ByQuery),
chromedp.SetValue(`#config-region-modal-content input[name=region_layer]`, "3", chromedp.ByQuery),
chromedp.Click(`[data-testid="save-region"]`, chromedp.ByQuery),
// HX-Redirect navigates back to the config page, now listing the region.
chromedp.WaitVisible(`[data-testid="config-region-row"]`, chromedp.ByQuery),
chromedp.Text(`[data-testid="config-region-row"]`, &rowText, chromedp.ByQuery),
chromedp.Evaluate(`!!document.querySelector('[data-testid="config-region-draft"]')`, &draftFlagged),
); err != nil {
t.Fatalf("browser run against %s: %v", url, err)
t.Fatalf("browser run against %s: %v", cfgURL, err)
}
for _, want := range []string{"Buffalo", "buf"} {
if !strings.Contains(rowText, want) {
t.Errorf("region row = %q, want it to mention %q", rowText, want)
}
}
if !draftFlagged {
t.Error("a region created without an area should be flagged as needing one")
}
// Controls are switches, and the map-area button is relabeled.
if !us0.FloodIsSwitch || !us0.PrimaryIsSwitch {
t.Errorf("US controls not switches: %+v", us0)
// It really is a draft in the database — no geofence, so it applies nowhere.
regions, err := srv.store.ListRegions(srv.ctx, org.ID)
if err != nil || len(regions) != 1 {
t.Fatalf("ListRegions = %v, %v; want one region", regions, err)
}
if us0.EditText != "Edit area" {
t.Errorf("edit button = %q, want %q", us0.EditText, "Edit area")
if regions[0].Geofence != nil {
t.Errorf("region should have no area yet: %+v", regions[0])
}
watch.assertClean(t)
}
// TestE2EConfigMapShowsRegionsAndPicksLocation: the Configuration page draws every
// shaped region on its map, and clicking the map picks a location — after which the
// regions covering that point are marked in the legend and their commands appear in
// the assembled preview. This is the read path anonymous visitors and members use,
// so it runs signed-out against the root host.
func TestE2EConfigMapShowsRegionsAndPicksLocation(t *testing.T) {
srv := newE2EServer(t)
user, _ := srv.login(t, "e2ecfgmap")
org, err := srv.store.CreateOrg(srv.ctx, "Config Map Org", user.ID)
if err != nil {
t.Fatalf("create org: %v", err)
}
if _, err := srv.store.CreateProfile(srv.ctx, org.ID, "ESP32",
[]store.ConfigStep{{CommandLine: "set tx 22"}}); err != nil {
t.Fatalf("create profile: %v", err)
}
// Two nested regions plus one far away, so a pin inside the inner pair marks
// exactly two and leaves the third alone.
for _, z := range []store.RegionInput{
{Token: "us", DisplayName: "US", Layer: 1, AllowFlood: true, GeofenceJSON: geo.Rectangle(30, -90, 48, -70)},
{Token: "us-ny", DisplayName: "New York", Layer: 2, AllowFlood: true, GeofenceJSON: geo.Rectangle(40, -80, 45, -73)},
{Token: "far", DisplayName: "Far Away", Layer: 2, AllowFlood: true, GeofenceJSON: geo.Rectangle(0, 0, 5, 5)},
} {
if _, err := srv.store.CreateRegion(srv.ctx, org.ID, z); err != nil {
t.Fatalf("create region %s: %v", z.Token, err)
}
}
bctx, cancel, watch := startBrowser(t)
defer cancel()
// Signed out, on the public root host.
cfgURL := srv.rootURL + "/orgs/" + org.Slug + "/config"
var polygons, rows int
if err := chromedp.Run(bctx,
network.Enable(),
cdplog.Enable(),
chromedp.Navigate(cfgURL),
chromedp.WaitVisible(`#region-map .leaflet-overlay-pane path`, chromedp.ByQuery),
chromedp.Evaluate(`document.querySelectorAll('#region-map .leaflet-overlay-pane path').length`, &polygons),
chromedp.Evaluate(`document.querySelectorAll('[data-testid="config-region-row"]').length`, &rows),
); err != nil {
t.Fatalf("browser run against %s: %v", cfgURL, err)
}
if polygons != 3 {
t.Errorf("drew %d polygons, want 3 (one per shaped region)", polygons)
}
if rows != 3 {
t.Errorf("legend has %d rows, want 3", rows)
}
// Picking a location: the preview page marks the covering regions and assembles
// their commands with the profile's steps.
var matches int
var commands string
pinned := cfgURL + "?profile=ESP32&lat=42&lon=-78"
if err := chromedp.Run(bctx,
chromedp.Navigate(pinned),
chromedp.WaitVisible(`[data-testid="config-commands"]`, chromedp.ByQuery),
chromedp.Evaluate(`document.querySelectorAll('[data-testid="config-region-match"]').length`, &matches),
chromedp.Text(`[data-testid="config-commands"]`, &commands, chromedp.ByQuery),
); err != nil {
t.Fatalf("browser run against %s: %v", pinned, err)
}
if matches != 2 {
t.Errorf("%d regions marked as applying, want 2 (us and us-ny, not far)", matches)
}
for _, want := range []string{"set tx 22", "region def us us-ny"} {
if !strings.Contains(commands, want) {
t.Errorf("assembled preview %q, want it to contain %q", commands, want)
}
}
// Signed out, no editing controls anywhere on the page.
var controls int
if err := chromedp.Run(bctx, chromedp.Evaluate(
`document.querySelectorAll('[data-testid="config-region-add"],[data-testid="config-region-edit"],[data-testid="config-region-delete"]').length`,
&controls)); err != nil {
t.Fatalf("control count: %v", err)
}
if controls != 0 {
t.Errorf("anonymous view exposed %d region editing controls, want 0", controls)
}
watch.assertClean(t)
}
// TestE2ERegionIconTooltips: the region row's controls are icon-only, so each needs
// to say what it does on hover/focus. Bootstrap tooltips are opt-in and initialized
// once in ui.js against [data-tooltip]; without that wiring these silently fall back
// to the browser's slow native tooltip, which is what prompted this.
func TestE2ERegionIconTooltips(t *testing.T) {
srv := newE2EServer(t)
user, cookie := srv.login(t, "e2ergntip")
org, err := srv.store.CreateOrg(srv.ctx, "Region Tooltip Org", user.ID)
if err != nil {
t.Fatalf("create org: %v", err)
}
if _, err := srv.store.CreateRegion(srv.ctx, org.ID, store.RegionInput{
Token: "buf", DisplayName: "Buffalo", Layer: 3, AllowFlood: true,
GeofenceJSON: geo.Rectangle(40, -80, 45, -75),
}); err != nil {
t.Fatalf("create region: %v", err)
}
bctx, cancel, watch := startBrowser(t)
defer cancel()
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-region-row"]`, chromedp.ByQuery),
); err != nil {
t.Fatalf("browser run against %s: %v", cfgURL, err)
}
// Focus is the reliable trigger headless (Bootstrap listens for hover *and*
// focus); the tooltip body is rendered into .tooltip-inner.
for _, c := range []struct{ testid, want string }{
{"config-region-area", "Edit area"},
{"config-region-edit", "Edit region"},
{"config-region-delete", "Delete region"},
} {
var got string
if err := chromedp.Run(bctx,
chromedp.Focus(`[data-testid="`+c.testid+`"]`, chromedp.ByQuery),
chromedp.WaitVisible(`.tooltip .tooltip-inner`, chromedp.ByQuery),
chromedp.Text(`.tooltip .tooltip-inner`, &got, chromedp.ByQuery),
chromedp.Blur(`[data-testid="`+c.testid+`"]`, chromedp.ByQuery),
// A dismissed tooltip lingers through its fade-out; without waiting for
// it to leave the DOM the next read picks up the previous one.
chromedp.WaitNotPresent(`.tooltip`, chromedp.ByQuery),
); err != nil {
t.Fatalf("%s tooltip: %v", c.testid, err)
}
if got != c.want {
t.Errorf("%s tooltip = %q, want %q", c.testid, got, c.want)
}
}
watch.assertClean(t)
}
// TestE2ERootFloodSwitchAutosubmits: the root (*) flood switch on the Configuration
// page saves by itself — it's a data-autosubmit checkbox inside its own form nested
// in a list row, so this proves the delegated handler reaches it and that the POST
// round-trips (no inline handler, strict CSP).
func TestE2ERootFloodSwitchAutosubmits(t *testing.T) {
srv := newE2EServer(t)
user, cookie := srv.login(t, "e2erootflood")
org, err := srv.store.CreateOrg(srv.ctx, "Root Flood Org", user.ID)
if err != nil {
t.Fatalf("create org: %v", err)
}
// Default is allow, so one click must land on deny.
if allow, err := srv.store.RootAllowFlood(srv.ctx, org.ID); err != nil || !allow {
t.Fatalf("root flood starts at (%v, %v), want (true, nil)", allow, err)
}
bctx, cancel, watch := startBrowser(t)
defer cancel()
sw := `[data-testid="config-region-root"] input[name="root_allow_flood"]`
var checked bool
cfgURL := srv.appURL + "/orgs/" + org.Slug + "/config"
if err := chromedp.Run(bctx,
network.Enable(),
cdplog.Enable(),
setSessionCookie(cookie),
chromedp.Navigate(cfgURL),
chromedp.WaitVisible(sw, chromedp.ByQuery),
// Clicking it submits the form; the page reloads with the switch off.
chromedp.Click(sw, chromedp.ByQuery),
chromedp.WaitVisible(sw, chromedp.ByQuery),
chromedp.Evaluate(`document.querySelector('`+sw+`').checked`, &checked),
); err != nil {
t.Fatalf("browser run against %s: %v", cfgURL, err)
}
if checked {
t.Error("the root flood switch should render unchecked after toggling it off")
}
// Polling is necessary rather than sloppy: form.submit() navigates
// asynchronously, so chromedp.Run can return before the POST has been handled.
// Wait for the write to land instead of assuming it already has.
denied := false
for i := 0; i < 25 && !denied; i++ {
allow, err := srv.store.RootAllowFlood(srv.ctx, org.ID)
if err != nil {
t.Fatalf("read root flood: %v", err)
}
if denied = !allow; !denied {
time.Sleep(200 * time.Millisecond)
}
}
if !denied {
t.Error("toggling the root flood switch did not persist a deny")
}
watch.assertClean(t)
}
// TestE2ERegionAreaDraftEnablesDrawing: the area workspace for a draft region — no
// shape yet, which is how every region starts — initializes with the draw tools
// ENABLED, where a region that already has an area has them disabled (one region,
// one polygon). Covers the toolbar-toggling path in both directions.
func TestE2ERegionAreaDraftEnablesDrawing(t *testing.T) {
srv := newE2EServer(t)
user, cookie := srv.login(t, "e2ergndraft")
org, err := srv.store.CreateOrg(srv.ctx, "Region Draft Org", user.ID)
if err != nil {
t.Fatalf("create org: %v", err)
}
draft, err := srv.store.CreateRegion(srv.ctx, org.ID, store.RegionInput{
Token: "buf", DisplayName: "Buffalo", Layer: 3, AllowFlood: true,
})
if err != nil {
t.Fatalf("create draft region: %v", err)
}
shaped, err := srv.store.CreateRegion(srv.ctx, org.ID, store.RegionInput{
Token: "us", DisplayName: "United States", Layer: 1, AllowFlood: true,
GeofenceJSON: geo.Rectangle(20, -130, 50, -60),
})
if err != nil {
t.Fatalf("create shaped region: %v", err)
}
bctx, cancel, watch := startBrowser(t)
defer cancel()
// Geoman marks a disabled toolbar button on the anchor wrapping the icon, with
// both .pm-disabled and aria-disabled="true" (see _updateDisabled in
// leaflet-geoman.js). Reading aria-disabled keeps the assertion unambiguous.
drawDisabled := `(function () {
var icon = document.querySelector('.leaflet-pm-icon-polygon');
if (!icon) return 'missing';
var btn = icon.closest('a');
return btn ? btn.getAttribute('aria-disabled') : 'no-anchor';
})()`
areaURL := func(rid int64) string {
return srv.appURL + "/orgs/" + org.Slug + "/config/regions/" + strconv.FormatInt(rid, 10) + "/area"
}
// The draft: no area, drawing available.
var draftStatus string
var draftDrawDisabled string
if err := chromedp.Run(bctx,
network.Enable(),
cdplog.Enable(),
setSessionCookie(cookie),
chromedp.Navigate(areaURL(draft)),
chromedp.WaitVisible(`.leaflet-pm-icon-polygon`, chromedp.ByQuery),
chromedp.Text(`#region-area-status`, &draftStatus, chromedp.ByQuery),
chromedp.Evaluate(drawDisabled, &draftDrawDisabled),
); err != nil {
t.Fatalf("browser run against the draft's area page: %v", err)
}
if draftStatus != "No area yet" {
t.Errorf("draft area status = %q, want %q", draftStatus, "No area yet")
}
if draftDrawDisabled != "false" {
t.Errorf("draft draw button aria-disabled = %q, want %q — a draft's area page must leave the draw tools usable",
draftDrawDisabled, "false")
}
// The one that already has an area: drawing is off until it's cleared.
var shapedDrawDisabled string
if err := chromedp.Run(bctx,
chromedp.Navigate(areaURL(shaped)),
chromedp.WaitVisible(`#region-map .leaflet-overlay-pane path`, chromedp.ByQuery),
chromedp.Evaluate(drawDisabled, &shapedDrawDisabled),
); err != nil {
t.Fatalf("browser run against the shaped region's area page: %v", err)
}
if shapedDrawDisabled != "true" {
t.Errorf("shaped draw button aria-disabled = %q, want %q — one region holds one polygon, so drawing waits for a clear",
shapedDrawDisabled, "true")
}
watch.assertClean(t)
}
// TestE2ERegionAreaEditor: the area workspace loads the region's shape into the map,
// draws its siblings as context, and saves the geometry the map produces — proving
// the Leaflet/Geoman wiring works under the strict CSP (no inline handlers).
func TestE2ERegionAreaEditor(t *testing.T) {
srv := newE2EServer(t)
user, cookie := srv.login(t, "e2ergnarea")
org, err := srv.store.CreateOrg(srv.ctx, "Region Area Org", user.ID)
if err != nil {
t.Fatalf("create org: %v", err)
}
// The region under edit already has an area, plus a sibling drawn for context.
rid, err := srv.store.CreateRegion(srv.ctx, org.ID, store.RegionInput{
Token: "buf", DisplayName: "Buffalo", Layer: 3, AllowFlood: true,
GeofenceJSON: geo.Rectangle(40, -80, 45, -75),
})
if err != nil {
t.Fatalf("create region: %v", err)
}
if _, err := srv.store.CreateRegion(srv.ctx, org.ID, store.RegionInput{
Token: "us", DisplayName: "United States", Layer: 1, AllowFlood: true,
GeofenceJSON: geo.Rectangle(20, -130, 50, -60),
}); err != nil {
t.Fatalf("create sibling: %v", err)
}
bctx, cancel, watch := startBrowser(t)
defer cancel()
var status, hidden string
var paths int
// countPaths counts rendered vector layers: this region plus its one sibling. It
// is scoped to the overlay pane because Leaflet's attribution control contains a
// decorative SVG of its own.
countPaths := `document.querySelectorAll('#region-map .leaflet-overlay-pane path').length`
areaURL := srv.appURL + "/orgs/" + org.Slug + "/config/regions/" + strconv.FormatInt(rid, 10) + "/area"
if err := chromedp.Run(bctx,
network.Enable(),
cdplog.Enable(),
setSessionCookie(cookie),
chromedp.Navigate(areaURL),
// The map initializes and reports the existing area.
chromedp.WaitVisible(`#region-map .leaflet-container, #region-map.leaflet-container`, chromedp.ByQuery),
chromedp.WaitVisible(`#region-map .leaflet-overlay-pane path`, chromedp.ByQuery),
chromedp.Text(`#region-area-status`, &status, chromedp.ByQuery),
chromedp.Evaluate(countPaths, &paths),
chromedp.Value(`#region_geojson`, &hidden, chromedp.ByQuery),
); err != nil {
t.Fatalf("browser run against %s: %v", areaURL, err)
}
if status != "Custom area" {
t.Errorf("area status = %q, want %q", status, "Custom area")
}
if paths != 2 {
t.Errorf("rendered %d map polygons, want exactly 2 (the region + its sibling)", paths)
}
if !strings.Contains(hidden, "Polygon") {
t.Errorf("hidden geofence field = %q, want it to hold the polygon geometry", hidden)
}
// Clearing the area empties the field and flips the status, then saving persists
// the cleared state — the round trip the "Clear area" button exists for.
var clearedStatus, clearedValue string
if err := chromedp.Run(bctx,
chromedp.Click(`#region-area-clear`, chromedp.ByQuery),
chromedp.Text(`#region-area-status`, &clearedStatus, chromedp.ByQuery),
chromedp.Value(`#region_geojson`, &clearedValue, chromedp.ByQuery),
chromedp.Click(`[data-testid="save-area"]`, chromedp.ByQuery),
chromedp.WaitVisible(`[data-testid="config-region-row"]`, chromedp.ByQuery),
); err != nil {
t.Fatalf("clear-and-save run: %v", err)
}
if clearedStatus != "No area yet" {
t.Errorf("status after clear = %q, want %q", clearedStatus, "No area yet")
}
if clearedValue != "" {
t.Errorf("geofence field after clear = %q, want empty", clearedValue)
}
z, err := srv.store.GetRegion(srv.ctx, org.ID, rid)
if err != nil {
t.Fatalf("get region: %v", err)
}
if z.Geofence != nil {
t.Errorf("clearing the area should have persisted as a draft: %+v", z)
}
// The attributes survived a geometry-only save.
if z.Token != "buf" || z.DisplayName != "Buffalo" || z.Layer != 3 {
t.Errorf("saving the area disturbed the region's attributes: %+v", z)
}
// Initial: NY is primary, US is not.
if !ny0.PrimaryChecked || us0.PrimaryChecked {
t.Errorf("initial primary wrong: us=%v ny=%v", us0.PrimaryChecked, ny0.PrimaryChecked)
}
// After toggling US on, exactly US is primary (switch + hidden input), NY cleared.
if !us1.PrimaryChecked || us1.PrimaryHidden != "1" {
t.Errorf("US not primary after toggle: %+v", us1)
}
if ny1.PrimaryChecked || ny1.PrimaryHidden != "" {
t.Errorf("NY still primary after toggling US: %+v", ny1)
}
watch.assertClean(t)
}
+158 -27
View File
@@ -75,9 +75,10 @@ type Profile struct {
// Region is one node in an org's region hierarchy. Token is the MeshCore region
// name used in `region def` (e.g. "buf"); DisplayName is the human label (e.g.
// "Buffalo"); Layer is its depth and ordering in the chain (lower = nearer the
// root). Geofence is nil for a region that applies everywhere. GeofenceJSON is the
// raw stored GeoJSON (nil for a match-all region), carried verbatim so the editor
// can round-trip an arbitrary polygon without collapsing it to its bounding box.
// root). Geofence is nil for a draft — a region whose area hasn't been drawn yet,
// which applies nowhere (see RegionMatches). GeofenceJSON is the raw stored GeoJSON,
// carried verbatim so the editor can round-trip an arbitrary polygon without
// collapsing it to its bounding box.
type Region struct {
ID int64
Token string
@@ -89,8 +90,9 @@ type Region struct {
GeofenceJSON []byte
}
// ProfileInput / RegionInput are an org's config as submitted by the editor for a
// full replace. GeofenceJSON is raw GeoJSON (nil/empty = everywhere).
// ProfileInput / RegionInput are an org's config as submitted by the editor.
// GeofenceJSON is raw GeoJSON; nil/empty leaves the region a draft (applies
// nowhere) until an area is drawn.
type ProfileInput struct {
Name string
Steps []ConfigStep
@@ -248,20 +250,6 @@ func (s *Store) ReplaceOrgConfig(ctx context.Context, orgID int64, profiles []Pr
})
}
// ReplaceRegions replaces just an org's regions (profiles untouched) and sets the
// org's root (*) flood policy, in one transaction. Regions are spatially
// interdependent — parenting is derived from overlap — so the region editor sends
// the full desired set each save.
func (s *Store) ReplaceRegions(ctx context.Context, orgID int64, regions []RegionInput, rootAllowFlood bool) error {
return s.inTx(ctx, func(tx pgx.Tx) error {
if _, err := tx.Exec(ctx,
`UPDATE organizations SET root_allow_flood = $2 WHERE id = $1`, orgID, rootAllowFlood); err != nil {
return fmt.Errorf("set root flood: %w", err)
}
return replaceRegionsTx(ctx, tx, orgID, regions)
})
}
// RootAllowFlood reports whether flooding is allowed at the org's root region (*).
func (s *Store) RootAllowFlood(ctx context.Context, orgID int64) (bool, error) {
var allow bool
@@ -356,6 +344,146 @@ func (s *Store) DeleteProfile(ctx context.Context, orgID, profileID int64) error
return nil
}
// Regions are edited one at a time (attributes in a modal, geometry on the area
// page), so each of the following touches exactly one row. Contrast
// ReplaceOrgConfig above, which still replaces an org's whole config wholesale for
// seeding and imports.
// CreateRegion inserts one region for an org and returns its id. GeofenceJSON may
// be nil — that's a draft region whose area hasn't been drawn yet, which applies
// nowhere until it has a shape (see RegionMatches). Returns ErrDuplicate if the org
// already has a region with that token.
func (s *Store) CreateRegion(ctx context.Context, orgID int64, z RegionInput) (int64, error) {
var id int64
err := s.inTx(ctx, func(tx pgx.Tx) error {
if err := clearOtherPrimaries(ctx, tx, orgID, 0, z.Primary); err != nil {
return err
}
return tx.QueryRow(ctx,
`INSERT INTO config_regions (org_id, token, display_name, layer, is_primary, allow_flood, geofence)
VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id`,
orgID, z.Token, z.DisplayName, z.Layer, z.Primary, z.AllowFlood, nilIfEmpty(z.GeofenceJSON)).Scan(&id)
})
if isUniqueViolation(err) {
return 0, ErrDuplicate
}
if err != nil {
return 0, fmt.Errorf("insert region %q: %w", z.Token, err)
}
return id, nil
}
// GetRegion returns a single region scoped to its org, or ErrNotFound if no such
// region belongs to the org.
func (s *Store) GetRegion(ctx context.Context, orgID, regionID int64) (*Region, error) {
var z Region
var raw []byte
err := s.pool.QueryRow(ctx,
`SELECT id, token, display_name, layer, is_primary, allow_flood, geofence
FROM config_regions WHERE id = $1 AND org_id = $2`, regionID, orgID).
Scan(&z.ID, &z.Token, &z.DisplayName, &z.Layer, &z.Primary, &z.AllowFlood, &raw)
if err != nil {
return nil, notFoundOr(err, "get region")
}
if z.Geofence, err = geo.Parse(raw); err != nil {
return nil, fmt.Errorf("get region %d: %w", regionID, err)
}
z.GeofenceJSON = raw
return &z, nil
}
// UpdateRegion replaces a region's attributes, leaving its geofence alone — the
// area is saved separately by UpdateRegionGeofence, so a failed attribute edit can
// never discard a drawn shape. Returns ErrNotFound if the region isn't the org's,
// or ErrDuplicate if the new token collides with another of its regions.
func (s *Store) UpdateRegion(ctx context.Context, orgID, regionID int64, z RegionInput) error {
err := s.inTx(ctx, func(tx pgx.Tx) error {
if err := clearOtherPrimaries(ctx, tx, orgID, regionID, z.Primary); err != nil {
return err
}
tag, err := tx.Exec(ctx,
`UPDATE config_regions SET token = $3, display_name = $4, layer = $5, is_primary = $6, allow_flood = $7
WHERE id = $1 AND org_id = $2`,
regionID, orgID, z.Token, z.DisplayName, z.Layer, z.Primary, z.AllowFlood)
if err != nil {
return fmt.Errorf("update region: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return nil
})
if isUniqueViolation(err) {
return ErrDuplicate
}
return err
}
// UpdateRegionGeofence saves just a region's drawn area. A nil/empty geofence
// clears it back to a draft. Returns ErrNotFound if the region isn't the org's.
func (s *Store) UpdateRegionGeofence(ctx context.Context, orgID, regionID int64, geofence []byte) error {
tag, err := s.pool.Exec(ctx,
`UPDATE config_regions SET geofence = $3 WHERE id = $1 AND org_id = $2`,
regionID, orgID, nilIfEmpty(geofence))
if err != nil {
return fmt.Errorf("update region geofence: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}
// DeleteRegion removes a region, scoped to its org. Returns ErrNotFound if no such
// region belongs to the org.
func (s *Store) DeleteRegion(ctx context.Context, orgID, regionID int64) error {
tag, err := s.pool.Exec(ctx,
`DELETE FROM config_regions WHERE id = $1 AND org_id = $2`, regionID, orgID)
if err != nil {
return fmt.Errorf("delete region: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}
// SetRootAllowFlood sets the org's root (*) flood policy on its own — the root
// isn't a config_regions row, so it's toggled independently of any region.
func (s *Store) SetRootAllowFlood(ctx context.Context, orgID int64, allow bool) error {
if _, err := s.pool.Exec(ctx,
`UPDATE organizations SET root_allow_flood = $2 WHERE id = $1`, orgID, allow); err != nil {
return fmt.Errorf("set root flood: %w", err)
}
return nil
}
// clearOtherPrimaries demotes the org's other primary regions so the one being
// written can take the flag. keepID is excluded (0 when inserting). A no-op when
// the region isn't becoming primary — turning the flag off never touches siblings.
// This runs before the write, so config_regions_one_primary_idx never actually
// fires; the index is there to keep the invariant if anything else writes the table.
func clearOtherPrimaries(ctx context.Context, tx pgx.Tx, orgID, keepID int64, primary bool) error {
if !primary {
return nil
}
if _, err := tx.Exec(ctx,
`UPDATE config_regions SET is_primary = false WHERE org_id = $1 AND id <> $2 AND is_primary`,
orgID, keepID); err != nil {
return fmt.Errorf("clear other primary regions: %w", err)
}
return nil
}
// nilIfEmpty maps an empty geofence to nil so it lands as SQL NULL (a draft region)
// rather than an empty JSONB value.
func nilIfEmpty(b []byte) []byte {
if len(b) == 0 {
return nil
}
return b
}
// insertProfile inserts one profile row plus its steps, returning the new id.
func insertProfile(ctx context.Context, tx pgx.Tx, orgID int64, name string, pos int, steps []ConfigStep) (int64, error) {
var pid int64
@@ -376,13 +504,9 @@ func replaceRegionsTx(ctx context.Context, tx pgx.Tx, orgID int64, regions []Reg
return fmt.Errorf("clear regions: %w", err)
}
for _, z := range regions {
var geofence []byte // nil → SQL NULL → everywhere
if len(z.GeofenceJSON) > 0 {
geofence = z.GeofenceJSON
}
if _, err := tx.Exec(ctx,
`INSERT INTO config_regions (org_id, token, display_name, layer, is_primary, allow_flood, geofence) VALUES ($1, $2, $3, $4, $5, $6, $7)`,
orgID, z.Token, z.DisplayName, z.Layer, z.Primary, z.AllowFlood, geofence); err != nil {
orgID, z.Token, z.DisplayName, z.Layer, z.Primary, z.AllowFlood, nilIfEmpty(z.GeofenceJSON)); err != nil {
return fmt.Errorf("insert region %q: %w", z.Token, err)
}
}
@@ -546,11 +670,18 @@ func floodCommand(name string, allow bool) string {
return "region denyf " + name
}
// RegionMatches reports whether a region applies at (lat, lon): a match-all region
// always does; a geofenced one only when the location is known and inside it.
// RegionMatches reports whether a region applies at (lat, lon): only when the
// location is known and inside the region's geofence.
//
// A region with no geofence applies *nowhere*, not everywhere. Every row in
// config_regions is a bounded area by definition — the only "applies everywhere"
// region is the org root (*), which lives on the organizations row and is never a
// region row. So a NULL geofence means a draft: a region created before its area
// has been drawn. It stays out of every `region def` chain until it has a shape,
// rather than silently applying to every repeater on the mesh.
func RegionMatches(z Region, lat, lon *float64) bool {
if z.Geofence == nil {
return true
return false
}
return lat != nil && lon != nil && z.Geofence.Contains(*lat, *lon)
}
+302 -20
View File
@@ -153,7 +153,9 @@ func TestProfileCRUD(t *testing.T) {
}
}
func TestReplaceRegionsKeepsProfiles(t *testing.T) {
// TestRegionWritesKeepProfiles: profiles and regions are independent halves of an
// org's config, so writing regions (or the root flood policy) never disturbs them.
func TestRegionWritesKeepProfiles(t *testing.T) {
t.Parallel()
st, ctx := orgTestStore(t)
owner, err := st.CreateUser(ctx, "rgnowner", "")
@@ -168,37 +170,276 @@ func TestReplaceRegionsKeepsProfiles(t *testing.T) {
t.Fatal(err)
}
if err := st.ReplaceRegions(ctx, org.ID, []RegionInput{
{Token: "country", DisplayName: "Country", Layer: 1, AllowFlood: true},
{Token: "metro", DisplayName: "Metro", Layer: 2, Primary: true, AllowFlood: true, GeofenceJSON: geo.Rectangle(10, 30, 20, 40)},
}, false); err != nil {
t.Fatalf("replace regions: %v", err)
country, err := st.CreateRegion(ctx, org.ID, RegionInput{Token: "country", DisplayName: "Country", Layer: 1, AllowFlood: true})
if err != nil {
t.Fatalf("create country: %v", err)
}
if _, err := st.CreateRegion(ctx, org.ID, RegionInput{
Token: "metro", DisplayName: "Metro", Layer: 2, Primary: true, AllowFlood: true,
GeofenceJSON: geo.Rectangle(10, 30, 20, 40),
}); err != nil {
t.Fatalf("create metro: %v", err)
}
if err := st.SetRootAllowFlood(ctx, org.ID, false); err != nil {
t.Fatalf("set root flood: %v", err)
}
// The root (*) flood policy round-trips (we passed deny above).
if allow, err := st.RootAllowFlood(ctx, org.ID); err != nil || allow {
t.Fatalf("root allow flood = %v (err %v), want false", allow, err)
}
// Regions saved and profiles untouched.
gotR, _ := st.ListRegions(ctx, org.ID)
if len(gotR) != 2 {
t.Fatalf("regions = %d, want 2", len(gotR))
}
gotP, _ := st.ListProfiles(ctx, org.ID)
if len(gotP) != 1 || gotP[0].Name != "Keep" {
t.Fatalf("profiles after region replace = %+v, want [Keep]", gotP)
t.Fatalf("profiles after region writes = %+v, want [Keep]", gotP)
}
// Replacing with an empty set clears regions but still keeps profiles.
if err := st.ReplaceRegions(ctx, org.ID, nil, true); err != nil {
t.Fatalf("clear regions: %v", err)
// Deleting every region likewise leaves the profiles alone.
if err := st.DeleteRegion(ctx, org.ID, country); err != nil {
t.Fatal(err)
}
gotR, _ = st.ListRegions(ctx, org.ID)
if len(gotR) != 0 {
t.Fatalf("regions after clear = %d, want 0", len(gotR))
for _, z := range gotR {
_ = st.DeleteRegion(ctx, org.ID, z.ID)
}
gotP, _ = st.ListProfiles(ctx, org.ID)
if len(gotP) != 1 {
t.Fatalf("profiles after clear = %d, want 1", len(gotP))
if gotR, _ = st.ListRegions(ctx, org.ID); len(gotR) != 0 {
t.Fatalf("regions after delete = %d, want 0", len(gotR))
}
if gotP, _ = st.ListProfiles(ctx, org.ID); len(gotP) != 1 {
t.Fatalf("profiles after region delete = %d, want 1", len(gotP))
}
}
// TestRegionCRUD covers the per-region write path the region editor uses: one row
// at a time, with the geofence saved separately from the attributes.
func TestRegionCRUD(t *testing.T) {
t.Parallel()
st, ctx := orgTestStore(t)
owner, err := st.CreateUser(ctx, "rgncrud", "")
if err != nil {
t.Fatal(err)
}
org, err := st.CreateOrg(ctx, "RgnCrudOrg", owner.ID)
if err != nil {
t.Fatal(err)
}
// Created without an area: a draft. Geofence stays NULL rather than becoming an
// empty shape, so RegionMatches keeps it out of every region def.
id, err := st.CreateRegion(ctx, org.ID, RegionInput{
Token: "buf", DisplayName: "Buffalo", Layer: 3, AllowFlood: true,
})
if err != nil {
t.Fatalf("create: %v", err)
}
got, err := st.GetRegion(ctx, org.ID, id)
if err != nil {
t.Fatalf("get: %v", err)
}
if got.Token != "buf" || got.DisplayName != "Buffalo" || got.Layer != 3 || !got.AllowFlood {
t.Fatalf("get region = %+v", got)
}
if got.Geofence != nil || got.GeofenceJSON != nil {
t.Fatalf("draft region has a shape: %+v", got)
}
// Token collides within the org.
if _, err := st.CreateRegion(ctx, org.ID, RegionInput{Token: "buf", DisplayName: "Dup"}); err != ErrDuplicate {
t.Fatalf("duplicate token err = %v, want ErrDuplicate", err)
}
// Another org can't see or write it.
other, err := st.CreateOrg(ctx, "OtherRgnOrg", owner.ID)
if err != nil {
t.Fatal(err)
}
if _, err := st.GetRegion(ctx, other.ID, id); err != ErrNotFound {
t.Fatalf("cross-org get err = %v, want ErrNotFound", err)
}
if err := st.UpdateRegion(ctx, other.ID, id, RegionInput{Token: "x"}); err != ErrNotFound {
t.Fatalf("cross-org update err = %v, want ErrNotFound", err)
}
if err := st.UpdateRegionGeofence(ctx, other.ID, id, geo.Rectangle(0, 0, 1, 1)); err != ErrNotFound {
t.Fatalf("cross-org geofence update err = %v, want ErrNotFound", err)
}
// Draw the area, then edit the attributes: the shape must survive an attribute
// edit, since the two are saved by separate requests.
box := geo.Rectangle(40, -80, 45, -75)
if err := st.UpdateRegionGeofence(ctx, org.ID, id, box); err != nil {
t.Fatalf("save geofence: %v", err)
}
if err := st.UpdateRegion(ctx, org.ID, id, RegionInput{
Token: "buf", DisplayName: "Buffalo NY", Layer: 4, AllowFlood: false,
}); err != nil {
t.Fatalf("update: %v", err)
}
got, _ = st.GetRegion(ctx, org.ID, id)
if got.DisplayName != "Buffalo NY" || got.Layer != 4 || got.AllowFlood {
t.Fatalf("after attribute update = %+v", got)
}
if got.Geofence == nil || !got.Geofence.Contains(42, -78) {
t.Fatalf("attribute update dropped the drawn area: %+v", got)
}
// Clearing the area returns it to a draft.
if err := st.UpdateRegionGeofence(ctx, org.ID, id, nil); err != nil {
t.Fatalf("clear geofence: %v", err)
}
if got, _ = st.GetRegion(ctx, org.ID, id); got.Geofence != nil {
t.Fatalf("cleared area still present: %+v", got)
}
// Delete, then the idempotency guard.
if err := st.DeleteRegion(ctx, org.ID, id); err != nil {
t.Fatalf("delete: %v", err)
}
if err := st.DeleteRegion(ctx, org.ID, id); err != ErrNotFound {
t.Fatalf("re-delete err = %v, want ErrNotFound", err)
}
}
// TestRegionPrimaryIsExclusive: at most one region per org is primary. This used to
// be enforced by the bulk editor's JavaScript clearing the other switches; now the
// write clears siblings in the same transaction, backed by a partial unique index.
func TestRegionPrimaryIsExclusive(t *testing.T) {
t.Parallel()
st, ctx := orgTestStore(t)
owner, err := st.CreateUser(ctx, "rgnprimary", "")
if err != nil {
t.Fatal(err)
}
org, err := st.CreateOrg(ctx, "RgnPrimaryOrg", owner.ID)
if err != nil {
t.Fatal(err)
}
// A second org's primary must be untouched by any of this.
otherOrg, err := st.CreateOrg(ctx, "RgnPrimaryOther", owner.ID)
if err != nil {
t.Fatal(err)
}
if _, err := st.CreateRegion(ctx, otherOrg.ID, RegionInput{Token: "oth", DisplayName: "Other", Primary: true}); err != nil {
t.Fatal(err)
}
primaryToken := func(orgID int64) string {
t.Helper()
regions, err := st.ListRegions(ctx, orgID)
if err != nil {
t.Fatalf("list: %v", err)
}
var found []string
for _, z := range regions {
if z.Primary {
found = append(found, z.Token)
}
}
if len(found) > 1 {
t.Fatalf("org %d has %d primary regions: %v", orgID, len(found), found)
}
if len(found) == 0 {
return ""
}
return found[0]
}
a, err := st.CreateRegion(ctx, org.ID, RegionInput{Token: "a", DisplayName: "A", Primary: true})
if err != nil {
t.Fatal(err)
}
if got := primaryToken(org.ID); got != "a" {
t.Fatalf("primary after first create = %q, want a", got)
}
// Creating a second primary demotes the first.
if _, err := st.CreateRegion(ctx, org.ID, RegionInput{Token: "b", DisplayName: "B", Primary: true}); err != nil {
t.Fatalf("create second primary: %v", err)
}
if got := primaryToken(org.ID); got != "b" {
t.Fatalf("primary after second create = %q, want b", got)
}
// So does promoting one by update.
if err := st.UpdateRegion(ctx, org.ID, a, RegionInput{Token: "a", DisplayName: "A", Primary: true}); err != nil {
t.Fatalf("promote a: %v", err)
}
if got := primaryToken(org.ID); got != "a" {
t.Fatalf("primary after promoting a = %q, want a", got)
}
// Turning the flag off leaves the org with none — it must not promote a sibling.
if err := st.UpdateRegion(ctx, org.ID, a, RegionInput{Token: "a", DisplayName: "A", Primary: false}); err != nil {
t.Fatalf("demote a: %v", err)
}
if got := primaryToken(org.ID); got != "" {
t.Fatalf("primary after demoting a = %q, want none", got)
}
// The other org kept its own primary throughout.
if got := primaryToken(otherOrg.ID); got != "oth" {
t.Fatalf("other org's primary = %q, want oth", got)
}
}
// TestRegionPrimaryIndexIsEnforced checks that one-primary-per-org survives a
// writer that doesn't go through CreateRegion/UpdateRegion, by inserting a second
// primary with raw SQL. CreateRegion clears siblings first, so this partial unique
// index (migration 0044) is otherwise never exercised — without this test nothing
// would notice if the migration were dropped.
func TestRegionPrimaryIndexIsEnforced(t *testing.T) {
t.Parallel()
st, ctx := orgTestStore(t)
owner, err := st.CreateUser(ctx, "rgnindex", "")
if err != nil {
t.Fatal(err)
}
org, err := st.CreateOrg(ctx, "RgnIndexOrg", owner.ID)
if err != nil {
t.Fatal(err)
}
raw := func(token string) error {
_, err := st.pool.Exec(ctx,
`INSERT INTO config_regions (org_id, token, display_name, layer, is_primary, allow_flood)
VALUES ($1, $2, $2, 1, true, true)`, org.ID, token)
return err
}
if err := raw("first"); err != nil {
t.Fatalf("first raw primary insert: %v", err)
}
if err := raw("second"); !isUniqueViolation(err) {
t.Fatalf("second raw primary insert err = %v, want a unique violation", err)
}
}
// TestSetRootAllowFlood: the root (*) flood policy lives on the org, not as a
// region row, so it toggles independently of any region.
func TestSetRootAllowFlood(t *testing.T) {
t.Parallel()
st, ctx := orgTestStore(t)
owner, err := st.CreateUser(ctx, "rootflood", "")
if err != nil {
t.Fatal(err)
}
org, err := st.CreateOrg(ctx, "RootFloodOrg", owner.ID)
if err != nil {
t.Fatal(err)
}
// Default is allow.
if allow, err := st.RootAllowFlood(ctx, org.ID); err != nil || !allow {
t.Fatalf("default root flood = (%v, %v), want (true, nil)", allow, err)
}
if err := st.SetRootAllowFlood(ctx, org.ID, false); err != nil {
t.Fatalf("deny: %v", err)
}
if allow, err := st.RootAllowFlood(ctx, org.ID); err != nil || allow {
t.Fatalf("after deny = (%v, %v), want (false, nil)", allow, err)
}
if err := st.SetRootAllowFlood(ctx, org.ID, true); err != nil {
t.Fatalf("allow: %v", err)
}
if allow, _ := st.RootAllowFlood(ctx, org.ID); !allow {
t.Fatal("root flood did not toggle back to allow")
}
}
@@ -247,6 +488,48 @@ func TestRegionDefCommands(t *testing.T) {
}
}
// TestRegionDefIgnoresDraftRegions: a draft region — one created but whose area
// hasn't been drawn yet, so its geofence is NULL — must apply nowhere. Regression
// test: RegionMatches used to report a nil geofence as a match, which made a
// shapeless row apply to every repeater everywhere and inject its token into every
// region def chain. "Everywhere" is the org root (*), which lives on the
// organizations row, never as a config_regions row.
func TestRegionDefIgnoresDraftRegions(t *testing.T) {
t.Parallel()
mk := func(token string, layer int, box []byte) Region {
shape, err := geo.Parse(box)
if err != nil {
t.Fatalf("parse %s: %v", token, err)
}
return Region{Token: token, Layer: layer, AllowFlood: true, Geofence: shape}
}
// draft sits at layer 2 with no shape — between us (L1) and buf (L3), so if it
// matched it would also wedge itself into the middle of the chain.
regions := []Region{
mk("us", 1, geo.Rectangle(0, 0, 100, 100)),
mk("draft", 2, nil),
mk("buf", 3, geo.Rectangle(12, 12, 18, 18)),
}
// Inside us + buf: the draft must not appear, and must not re-parent buf.
got := RegionDefCommands(regions, true, ptr(15.0), ptr(15.0))
if len(got) == 0 || got[0] != "region def us buf" {
t.Fatalf("inside = %v, want first line %q", got, "region def us buf")
}
for _, line := range got {
if strings.Contains(line, "draft") {
t.Errorf("draft region leaked into %q (full %v)", line, got)
}
}
// Outside every drawn area: no commands at all. A draft must not be the reason
// a config gets emitted — that's the `denyf *` safety guard.
if got := RegionDefCommands(regions, true, ptr(-5.0), ptr(-5.0)); got != nil {
t.Fatalf("outside with a draft present = %v, want nil", got)
}
}
func TestRegionDefFloodCommands(t *testing.T) {
t.Parallel()
mk := func(token string, layer int, allow bool, box []byte) Region {
@@ -346,8 +629,7 @@ func TestListRepeaterConfigOrgs(t *testing.T) {
}
// B: owner is a member, region-only config (no profiles) → included, empty profiles.
orgB, _ := st.CreateOrg(ctx, "Bravo", owner.ID)
if err := st.ReplaceRegions(ctx, orgB.ID,
[]RegionInput{{Token: "x", DisplayName: "X", Layer: 1}}, true); err != nil {
if _, err := st.CreateRegion(ctx, orgB.ID, RegionInput{Token: "x", DisplayName: "X", Layer: 1}); err != nil {
t.Fatal(err)
}
// C: owner is a member but the org has NO config → excluded.
@@ -0,0 +1,19 @@
-- +goose Up
-- Regions are edited one at a time now (per-region CRUD) rather than replaced as a
-- whole set, so "at most one primary region per org" can no longer be enforced by
-- the editor's JavaScript clearing the other switches. CreateRegion/UpdateRegion
-- clear any other primary in the same transaction; this index is the backstop that
-- keeps the invariant true no matter who writes the table.
--
-- Existing data can hold more than one primary (the old bulk editor only enforced
-- exclusivity client-side), so demote all but the lowest-id primary per org before
-- the unique index goes on.
UPDATE config_regions SET is_primary = false
WHERE is_primary
AND id NOT IN (SELECT min(id) FROM config_regions WHERE is_primary GROUP BY org_id);
CREATE UNIQUE INDEX config_regions_one_primary_idx
ON config_regions (org_id) WHERE is_primary;
-- +goose Down
DROP INDEX config_regions_one_primary_idx;
+164 -47
View File
@@ -22,34 +22,155 @@ type ProfileView struct {
Steps []store.ConfigStep
}
// RegionPalette is the set of translucent fills used to tell regions apart, both as
// the legend's swatches and as the polygon colors on every region map. It lives here
// rather than in regionmap.js so one ordering drives both — a swatch can't drift
// from the shape it labels. Templates pass each region's Color through to the map.
var RegionPalette = []string{
"#4dabf7", "#f783ac", "#69db7c", "#ffa94d",
"#9775fa", "#ffd43b", "#3bc9db", "#ff8787",
}
// RegionColor returns the palette entry for the i-th region, wrapping around.
func RegionColor(i int) string { return RegionPalette[i%len(RegionPalette)] }
// RegionView is one region for display, mirroring ProfileView: it carries ID so the
// admin surface can build its edit/area/delete URLs from the same list the
// read-only view renders. HasShape is false for a draft — a region created before
// its area was drawn, which applies nowhere until it has one. Matches reports
// whether the region covers the previewed location (always false without one), which
// drives the highlighting that explains why a token appears in the assembled config.
type RegionView struct {
ID int64
DisplayName string
Token string
Layer int
Primary bool
AllowFlood bool
HasShape bool
Matches bool
Color string
Depth int
}
// maxRegionDepth caps legend indentation. Layers are arbitrary integers, so a deep
// tree would otherwise indent a row off the side of a narrow column.
const maxRegionDepth = 4
// assignRegionDepths sets each row's Depth to the rank of its layer among the
// distinct layers present, not the layer number itself: an org whose only regions
// are layers 2 and 7 reads as two nested levels rather than seven. Regions arrive
// ordered by (layer, token) from ListRegions, so a single pass suffices.
func assignRegionDepths(rows []RegionView) {
depth := -1
prev := 0
for i := range rows {
if depth < 0 || rows[i].Layer != prev {
depth++
prev = rows[i].Layer
}
rows[i].Depth = min(depth, maxRegionDepth)
}
}
// ConfigView is an org's configuration for display: its named profiles (with one
// selected for its base settings) and its regions, kept independent. The regions
// themselves aren't listed here — they surface only through the location picker —
// so this carries just what that map needs. PreviewActive is true when a location
// was supplied.
// selected for its base settings) and its regions, kept independent. Regions are
// listed as the map's legend and drawn on the map itself, so this carries both the
// rows and the geometry. PreviewActive is true when a location was supplied.
//
// The map frames itself from the shapes it draws (and the previewed point), so there
// is no server-computed bounding box.
type ConfigView struct {
HasConfig bool
Profiles []ProfileView
Selected string
SelectedSteps []store.ConfigStep
HasRegions bool // org defines at least one region
HasRegionShapes bool // some region has a geofence (so the picker map is useful)
MapBounds []float64 // {minLat, minLon, maxLat, maxLon} framing all geofences, or nil
RegionDef []string // region def/save commands for the previewed location
Regions []RegionView
MapRegions []MapRegion // the shaped regions, as the read-only map draws them
HasRegions bool // org defines at least one region
HasRegionShapes bool // some region has a geofence (so the picker map is useful)
RootAllowFlood bool // the org root (*) flood policy — not a region row
RegionDef []string // region def/save commands for the previewed location
PreviewActive bool
}
// bbox accumulates a lat/lon bounding box. A nil *bbox is empty, so extend can be
// chained from nil to fold in the first point/box.
type bbox struct{ minLat, minLon, maxLat, maxLon float64 }
// MapRegion is one region as the read-only config map draws it — the payload handed
// to regionMapView. Only regions with an area appear; a draft has nothing to draw.
// Color comes from RegionPalette by the same index the legend swatch uses, so the
// two always agree.
//
// This ships every region's geometry with the page. That is fine for the handful of
// hand-drawn polygons an org defines; if imported administrative boundaries ever
// make these large, serve them from their own endpoint (or simplify for display)
// rather than growing the HTML.
type MapRegion struct {
Name string `json:"name"`
GeoJSON string `json:"geojson"`
Matched bool `json:"matched"`
Color string `json:"color"`
// Primary marks the org's primary region, which the map opens framed on — that
// is where the org actually operates, so a nationwide parent region shouldn't
// force a continental view.
Primary bool `json:"primary"`
}
func (b *bbox) extend(minLat, minLon, maxLat, maxLon float64) *bbox {
if b == nil {
return &bbox{minLat, minLon, maxLat, maxLon}
// ConfigLine is one line of the assembled config preview. FromRegion marks the lines
// contributed by the previewed location's regions rather than by the profile, so the
// page can show which came from where. IsMarker is the {{ region }} placeholder,
// rendered as a hint when no location is picked instead of leaking its literal text.
type ConfigLine struct {
Text string
IsComment bool
IsMarker bool
FromRegion bool
}
// AssembledLines renders the selected profile and the previewed location's region
// commands as the single ordered list a repeater owner would actually run: the
// profile's steps with the region block spliced in at its {{ region }} marker, or
// appended after every step when it has none.
//
// This is the same assembly the console (console_config.go) and serial setup
// (repeater_setup.go) perform via store.SplitAtRegionMarker — the page used to show
// the two halves as separate blocks and leave the merge to the reader, which meant
// it displayed something subtly different from what those surfaces emit.
//
// With no location picked there are no region lines, and the marker survives as
// IsMarker so the page can say where they will land.
func (cv ConfigView) AssembledLines() []ConfigLine {
before, after := store.SplitAtRegionMarker(cv.SelectedSteps)
// No marker means SplitAtRegionMarker put everything in before; the region block
// then follows all of it, which is the documented default.
hasMarker := len(after) > 0 || len(before) != len(cv.SelectedSteps)
out := make([]ConfigLine, 0, len(cv.SelectedSteps)+len(cv.RegionDef))
emit := func(steps []store.ConfigStep) {
for _, st := range steps {
if st.IsComment() {
out = append(out, ConfigLine{Text: st.Comment, IsComment: true})
continue
}
out = append(out, ConfigLine{Text: st.CommandLine})
}
}
b.minLat, b.minLon = min(b.minLat, minLat), min(b.minLon, minLon)
b.maxLat, b.maxLon = max(b.maxLat, maxLat), max(b.maxLon, maxLon)
return b
region := func() {
if len(cv.RegionDef) == 0 {
// Nothing to splice: keep the marker visible so the page can explain that
// region commands land here once a location is picked.
if hasMarker {
out = append(out, ConfigLine{IsMarker: true})
}
return
}
for _, line := range cv.RegionDef {
out = append(out, ConfigLine{Text: line, FromRegion: true})
}
}
emit(before)
region()
emit(after)
return out
}
// BuildConfigView loads an org's profiles and regions for read-only display.
@@ -88,40 +209,36 @@ func BuildConfigView(ctx context.Context, st *store.Store, orgID int64, selected
// none is set (or it has no shape), fall back to the org's public repeaters;
// failing that, the union of all geofences. (The picker only renders when
// HasRegionShapes, so a usable box almost always exists.)
var union *bbox
for _, z := range regions {
for i, z := range regions {
matched := lat != nil && lon != nil && store.RegionMatches(z, lat, lon)
if len(z.GeofenceJSON) > 0 {
cv.MapRegions = append(cv.MapRegions, MapRegion{
Name: z.DisplayName, GeoJSON: string(z.GeofenceJSON),
Matched: matched, Color: RegionColor(i), Primary: z.Primary,
})
}
cv.Regions = append(cv.Regions, RegionView{
ID: z.ID, DisplayName: z.DisplayName, Token: z.Token, Layer: z.Layer,
Primary: z.Primary, AllowFlood: z.AllowFlood, HasShape: len(z.GeofenceJSON) > 0,
// RegionMatches is the same predicate RegionDefCommands uses to decide
// which tokens ship, so the highlight can't disagree with the commands.
Matches: matched,
Color: RegionColor(i),
})
if len(z.GeofenceJSON) > 0 {
cv.HasRegionShapes = true
}
if a, b, c, d, ok := z.Geofence.Bounds(); ok {
union = union.extend(a, b, c, d)
if z.Primary {
cv.MapBounds = []float64{a, b, c, d}
}
}
}
if cv.MapBounds == nil {
if reps, err := st.ListPublicRepeaters(ctx, orgID); err == nil {
var rb *bbox
for _, rp := range reps {
if rp.HasLocation {
rb = rb.extend(rp.Lat, rp.Lon, rp.Lat, rp.Lon)
}
}
if rb != nil {
cv.MapBounds = []float64{rb.minLat, rb.minLon, rb.maxLat, rb.maxLon}
}
}
}
if cv.MapBounds == nil && union != nil {
cv.MapBounds = []float64{union.minLat, union.minLon, union.maxLat, union.maxLon}
assignRegionDepths(cv.Regions)
// The root (*) flood policy isn't a region row, so it's fetched separately — one
// PK lookup on the org, needed both by the admin's root switch and by the region
// def preview.
cv.RootAllowFlood, err = st.RootAllowFlood(ctx, orgID)
if err != nil {
return ConfigView{}, err
}
if cv.PreviewActive {
rootAllow, err := st.RootAllowFlood(ctx, orgID)
if err != nil {
return ConfigView{}, err
}
cv.RegionDef = store.RegionDefCommands(regions, rootAllow, lat, lon)
cv.RegionDef = store.RegionDefCommands(regions, cv.RootAllowFlood, lat, lon)
}
return cv, nil
}
@@ -134,8 +251,8 @@ type OrgNavArgs struct {
Slug string
Active string // "home" | "members" | "repeaters" | "config"
// IsMember gates the Members tab (personal info), so it's false on public views
// even when a member is previewing. IsAdmin gates the "Edit configuration"
// action.
// even when a member is previewing. IsAdmin keeps the Configuration tab visible
// on an org that hasn't defined any config yet, so an admin can go and create it.
IsMember bool
IsAdmin bool
// Manage selects the header's right-side controls: true renders the member
+137
View File
@@ -0,0 +1,137 @@
package web
import (
"strings"
"testing"
"github.com/jleight/meshtender/internal/store"
)
// lines flattens an assembled preview into "kind:text" tokens, so a test can assert
// ordering and provenance in one comparison.
func lines(t *testing.T, cv ConfigView) []string {
t.Helper()
var out []string
for _, l := range cv.AssembledLines() {
switch {
case l.IsMarker:
out = append(out, "marker:")
case l.IsComment:
out = append(out, "note:"+l.Text)
case l.FromRegion:
out = append(out, "region:"+l.Text)
default:
out = append(out, "cmd:"+l.Text)
}
}
return out
}
func eq(t *testing.T, got, want []string, label string) {
t.Helper()
if strings.Join(got, "|") != strings.Join(want, "|") {
t.Errorf("%s:\n got %v\nwant %v", label, got, want)
}
}
// TestAssembledLinesSplicesAtMarker: region commands land where the profile's
// {{ region }} marker sits, and the marker itself never reaches the page as literal
// text.
func TestAssembledLinesSplicesAtMarker(t *testing.T) {
t.Parallel()
cv := ConfigView{
SelectedSteps: []store.ConfigStep{
{CommandLine: "set tx 22"},
{Comment: "tune later"},
{CommandLine: store.RegionMarker},
{CommandLine: "set repeat on"},
},
RegionDef: []string{"region def us", "region save"},
}
eq(t, lines(t, cv), []string{
"cmd:set tx 22",
"note:tune later",
"region:region def us",
"region:region save",
"cmd:set repeat on",
}, "spliced at marker")
for _, l := range cv.AssembledLines() {
if strings.Contains(l.Text, "{{") {
t.Errorf("the region marker leaked into the preview as %q", l.Text)
}
}
}
// TestAssembledLinesAppendsWithoutMarker: with no marker the region block follows
// every profile step — the documented default.
func TestAssembledLinesAppendsWithoutMarker(t *testing.T) {
t.Parallel()
cv := ConfigView{
SelectedSteps: []store.ConfigStep{{CommandLine: "set tx 22"}, {CommandLine: "set repeat on"}},
RegionDef: []string{"region def us", "region save"},
}
eq(t, lines(t, cv), []string{
"cmd:set tx 22",
"cmd:set repeat on",
"region:region def us",
"region:region save",
}, "appended without marker")
}
// TestAssembledLinesWithoutLocation: with no location picked there are no region
// commands. A profile that has a marker keeps it as a placeholder the page can
// explain; one without a marker simply shows its steps.
func TestAssembledLinesWithoutLocation(t *testing.T) {
t.Parallel()
withMarker := ConfigView{SelectedSteps: []store.ConfigStep{
{CommandLine: "set tx 22"},
{CommandLine: store.RegionMarker},
{CommandLine: "set repeat on"},
}}
eq(t, lines(t, withMarker), []string{"cmd:set tx 22", "marker:", "cmd:set repeat on"},
"no location, marker present")
noMarker := ConfigView{SelectedSteps: []store.ConfigStep{{CommandLine: "set tx 22"}}}
eq(t, lines(t, noMarker), []string{"cmd:set tx 22"}, "no location, no marker")
// Region-only org: nothing selected, but a picked location still previews.
regionOnly := ConfigView{RegionDef: []string{"region def us", "region save"}}
eq(t, lines(t, regionOnly), []string{"region:region def us", "region:region save"},
"regions with no profile")
// Empty config assembles to nothing rather than a stray marker.
var empty ConfigView
if got := empty.AssembledLines(); len(got) != 0 {
t.Errorf("empty config assembled to %v, want nothing", got)
}
}
// TestAssembledLinesMarkerOnlyProfile: a profile that is nothing but the marker
// yields exactly the region block.
func TestAssembledLinesMarkerOnlyProfile(t *testing.T) {
t.Parallel()
cv := ConfigView{
SelectedSteps: []store.ConfigStep{{CommandLine: store.RegionMarker}},
RegionDef: []string{"region def us"},
}
eq(t, lines(t, cv), []string{"region:region def us"}, "marker-only profile")
}
// TestRegionColorWrapsAround: every region gets a color, and the palette repeats
// rather than running out, so the legend swatch always matches a drawn polygon.
func TestRegionColorWrapsAround(t *testing.T) {
t.Parallel()
if RegionColor(0) != RegionPalette[0] {
t.Fatalf("RegionColor(0) = %q, want %q", RegionColor(0), RegionPalette[0])
}
n := len(RegionPalette)
if RegionColor(n) != RegionPalette[0] || RegionColor(n+2) != RegionPalette[2] {
t.Fatalf("palette did not wrap: %q, %q", RegionColor(n), RegionColor(n+2))
}
for i := 0; i < n*2; i++ {
if RegionColor(i) == "" {
t.Fatalf("RegionColor(%d) is empty", i)
}
}
}
+146 -153
View File
@@ -1,15 +1,27 @@
// regionmap.js — Leaflet + Geoman map for org config regions.
//
// A region's geofence is a GeoJSON Polygon/MultiPolygon geometry; an empty
// geometry means "applies everywhere" (no shape on the map). Two entry points:
// A region's geofence is a GeoJSON Polygon/MultiPolygon geometry; no geometry means
// the region is a draft that applies nowhere until an area is drawn. Two entry
// points:
//
// initRegionEditor(mapId, listId) — the admin editor: draw/edit one polygon
// per region block, with every region shown at once so overlaps are visible.
// regionMapView(mapId, regions, preview) — read-only: render every region's
// polygon (and an optional previewed location marker).
// initRegionArea(mapId, opts) — the admin area workspace: draw/edit exactly one
// region's polygon, with the org's other regions outlined as read-only context.
// regionMapView(mapId, opts) — read-only: a location picker (with an optional
// previewed location marker).
(function () {
// Distinct translucent fills so overlapping regions read as layered colors.
var PALETTE = ["#4dabf7", "#f783ac", "#69db7c", "#ffa94d", "#9775fa", "#ffd43b", "#3bc9db", "#ff8787"];
// Colors come from the server (web.RegionPalette) so a legend swatch and the
// polygon it labels can never drift apart. FALLBACK covers a caller that passes
// none.
var FALLBACK = "#4dabf7";
// Breathing room when framing a map. FIT_PADDING is in screen pixels, so it stays
// a consistent visual margin no matter how large the area is — a geographic pad
// would scale with the region and zoom a big one right back out. PRIMARY_CONTEXT
// is the small geographic nudge that shows a little of what surrounds the primary
// region; keep it low, because fitBounds then rounds *down* to an integer zoom and
// any slack here gets magnified by that rounding.
var FIT_PADDING = [24, 24];
var PRIMARY_CONTEXT = 0.08;
// baseLayers adds the dark (default) and a light basemap with a layers control to
// toggle between them, remembering the choice in localStorage (shared with
@@ -44,14 +56,36 @@
});
}
function styleFor(i, active) {
var c = PALETTE[i % PALETTE.length];
// editableStyle is the one shape the current page can modify: drawn prominently so
// it always reads which polygon the tools will act on.
function editableStyle(color) {
var c = color || FALLBACK;
return { color: c, weight: 3, opacity: 1, fillColor: c, fillOpacity: 0.25 };
}
// Region fills stack: nested regions overlap, so N of them at opacity a cover
// 1-(1-a)^N of the basemap. Orgs run deep hierarchies (3 levels is common, 5-6 is
// planned), and at a=0.25 six levels reach 82% — the map underneath disappears.
// These are chosen so six levels stay readable (~43% matched, ~22% context) while
// three land at ~25%, the weight a single region used to carry. Raise them and
// deep hierarchies turn to mud; the stroke, not the fill, is what identifies a
// region.
var FILL_MATCHED = 0.09;
var FILL_CONTEXT = 0.04;
// outlineStyle is a region drawn for reference — a neighbor on the area editor, or
// any region on the read-only config map. Dimmed by default; a region that applies
// at the previewed location is brought forward, which is what explains why its
// token shows up in the assembled config.
function outlineStyle(color, matched) {
var c = color || FALLBACK;
return {
color: c,
weight: active ? 3 : 1.5,
opacity: active ? 1 : 0.6,
weight: matched ? 3 : 1.5,
opacity: matched ? 1 : 0.55,
fillColor: c,
fillOpacity: active ? 0.25 : 0.1,
fillOpacity: matched ? FILL_MATCHED : FILL_CONTEXT,
interactive: false,
};
}
@@ -82,15 +116,31 @@
}
}
// ---- Editor -------------------------------------------------------------
// ---- Area editor (one region) -------------------------------------------
window.initRegionEditor = function (mapId, listId) {
var listEl = document.getElementById(listId);
if (!listEl) return;
// initRegionArea drives the per-region area workspace. It edits exactly one
// shape — the region whose page this is — and renders the org's other regions as
// non-interactive outlines for spatial reference. opts:
// input — id of the hidden field carrying the GeoJSON geometry (required);
// status — id of an element to show "Custom area" / "No area yet" in;
// clear — id of a button that removes the shape;
// name — this region's label, used in the shape's tooltip;
// color — this region's palette color (see web.RegionPalette);
// siblings — [{name, geojson, color}] drawn read-only.
//
// Everything the form submits lives in the hidden input, so the server sees the
// same shape whether it was drawn, edited, or cleared.
window.initRegionArea = function (mapId, opts) {
opts = opts || {};
var input = document.getElementById(opts.input);
if (!input) return;
var statusEl = opts.status ? document.getElementById(opts.status) : null;
var map = L.map(mapId);
baseLayers(map);
// Geoman toolbar: only the tools that make sense for area geofences.
// Geoman toolbar: only the tools that make sense for an area geofence. Drawing
// is enabled only while there's no shape — one region, one polygon, so a second
// draw would be ambiguous.
map.pm.addControls({
position: "topleft",
drawMarker: false,
@@ -108,157 +158,76 @@
});
map.pm.setGlobalOptions({ allowSelfIntersection: false });
var blocks = []; // { el, hidden, status, layer }
var active = null;
var banner = document.getElementById(mapId + "-active");
var layer = null;
// While a polygon is being drawn, clicks place vertices — they must not be
// treated as region selections. drawTarget locks onto the region that was
// selected when drawing began, captured here so a vertex landing on top of
// another region's polygon can't hijack the selection mid-draw.
var drawing = false;
var drawTarget = null;
map.on("pm:drawstart", function () { drawing = true; drawTarget = active; });
map.on("pm:drawend", function () { drawing = false; });
function setStatus(b) {
if (b.status) b.status.textContent = b.layer ? "Custom area" : "No area yet";
}
function serialize(b) {
b.hidden.value = b.layer ? geomString(b.layer) : "";
setStatus(b);
}
function restyle() {
blocks.forEach(function (b, i) {
if (b.layer && b.layer.setStyle) b.layer.setStyle(styleFor(i, b === active));
});
}
function setActive(b) {
active = b;
blocks.forEach(function (x) {
var on = x === b;
x.el.classList.toggle("region-active", on);
// The Edit-area button is the visible selection affordance: it lights up
// and reads "Editing area" for the active region, "Edit area" otherwise.
if (x.edit) {
x.edit.classList.toggle("btn-primary", on);
x.edit.textContent = on ? "Editing area" : "Edit area";
}
});
if (banner) banner.textContent = b ? regionName(b) : "—";
restyle();
}
function regionName(b) {
var display = b.el.querySelector('input[name="region_display"]');
var token = b.el.querySelector('input[name="region_token"]');
return (display && display.value.trim()) || (token && token.value.trim()) || "this region";
function serialize() {
input.value = layer ? geomString(layer) : "";
if (statusEl) statusEl.textContent = layer ? "Custom area" : "No area yet";
// With a shape present, drawing is off (edit/drag/remove stay on); once it's
// cleared, the draw tools come back.
map.pm.Toolbar.setButtonDisabled("drawPolygon", !!layer);
map.pm.Toolbar.setButtonDisabled("drawRectangle", !!layer);
}
function bindLayer(b, layer) {
layer.addTo(map);
function bind(l) {
layer = l;
l.setStyle(editableStyle(opts.color));
if (opts.name) l.bindTooltip(String(opts.name), { sticky: true });
l.addTo(map);
// Re-serialize after any vertex edit or whole-shape drag.
layer.on("pm:edit pm:update pm:dragend pm:markerdragend", function () { serialize(b); });
layer.on("click", function () { if (!drawing) setActive(b); });
b.layer = layer;
l.on("pm:edit pm:update pm:dragend pm:markerdragend", serialize);
serialize();
}
function attach(b, layer) {
if (b.layer) map.removeLayer(b.layer); // one shape per region
bindLayer(b, layer);
serialize(b);
restyle();
}
function clearShape(b) {
if (b.layer) {
map.removeLayer(b.layer);
b.layer = null;
function clear() {
if (layer) {
map.removeLayer(layer);
layer = null;
}
serialize(b);
serialize();
}
// reconcile() syncs our block list with the DOM, which the page mutates as
// the admin adds/removes region blocks (addBlock/removeBlock in the template).
function reconcile() {
var els = Array.prototype.slice.call(listEl.querySelectorAll(".region-block"));
// Drop blocks whose DOM node is gone.
blocks = blocks.filter(function (b) {
if (els.indexOf(b.el) === -1) {
if (b.layer) map.removeLayer(b.layer);
if (active === b) active = null;
return false;
}
return true;
});
// Register newly added blocks.
var added = [];
els.forEach(function (el) {
if (blocks.some(function (b) { return b.el === el; })) return;
var b = {
el: el,
hidden: el.querySelector('input[name="region_geojson"]'),
status: el.querySelector(".region-shape-status"),
edit: el.querySelector(".region-edit-btn"),
layer: null,
};
var layer = layerFromGeoJSON(b.hidden && b.hidden.value);
if (layer) bindLayer(b, layer);
setStatus(b);
// Selection is driven by the explicit Edit button (and clicking the shape on
// the map) — the card itself isn't clickable, which read as unintuitive.
if (b.edit) b.edit.addEventListener("click", function () { if (!drawing) setActive(b); });
var clearBtn = el.querySelector(".region-clear");
if (clearBtn) clearBtn.addEventListener("click", function (e) {
e.stopPropagation();
setActive(b);
clearShape(b);
});
blocks.push(b);
added.push(b);
});
// Reorder to match DOM order (keeps palette colors stable by position).
blocks.sort(function (a, b) {
return els.indexOf(a.el) - els.indexOf(b.el);
});
restyle();
// A newly added block becomes the active one (so clicking "Add region"
// selects it); otherwise keep the current selection, defaulting to the last.
if (added.length) setActive(added[added.length - 1]);
else if (!active && blocks.length) setActive(blocks[blocks.length - 1]);
}
// A freshly drawn shape fills the region that was selected when drawing began
// (drawTarget) — but only if that region has no shape yet. If it already has
// one, or nothing was selected, the shape goes into a brand-new region, so
// drawing never overwrites an existing area (e.g. drawing the US inside an
// existing North America region adds the US rather than replacing NA).
map.on("pm:create", function (e) {
var target = drawTarget;
if (!target || target.layer) {
if (typeof addBlock === "function") {
addBlock("region");
reconcile(); // registers + selects the new block synchronously
}
target = active || (blocks.length ? blocks[blocks.length - 1] : null);
}
if (!target) { map.removeLayer(e.layer); return; } // nothing to attach to
setActive(target);
attach(target, e.layer);
if (target.el.scrollIntoView) target.el.scrollIntoView({ block: "nearest" });
// Belt and braces: the draw buttons are disabled while a shape exists, but if
// one is somehow drawn anyway, replace rather than orphan a second polygon.
if (layer) map.removeLayer(layer);
bind(e.layer);
});
// Toolbar removal: clear whichever region owned the removed layer.
map.on("pm:remove", function (e) {
var b = blocks.filter(function (x) { return x.layer === e.layer; })[0];
if (b) { b.layer = null; serialize(b); }
if (e.layer === layer) clear();
});
new MutationObserver(reconcile).observe(listEl, { childList: true });
reconcile();
fitToLayers(map, blocks.map(function (b) { return b.layer; }).filter(Boolean));
// Siblings first, so the edited shape sits on top of them.
var context = [];
(opts.siblings || []).forEach(function (sib) {
if (!sib) return;
var l = layerFromGeoJSON(sib.geojson);
if (!l) return;
l.setStyle(outlineStyle(sib.color));
if (sib.name) l.bindTooltip(String(sib.name));
l.addTo(map);
context.push(l);
});
var existing = layerFromGeoJSON(input.value);
if (existing) bind(existing);
else serialize();
var clearBtn = opts.clear ? document.getElementById(opts.clear) : null;
if (clearBtn) clearBtn.addEventListener("click", clear);
// Frame the edited shape when there is one, otherwise the siblings — a brand-new
// region should open looking at where its neighbors are.
fitToLayers(map, layer ? [layer] : context);
};
// ---- Read-only viewer ---------------------------------------------------
// regionMapView renders a location-picker map (no region outlines — those are
// just noise here). opts:
// regionMapView renders a read-only region map. opts:
// regions — [{name, geojson, matched, color, primary}] drawn as non-interactive
// outlines, the ones matching the previewed location brought forward.
// Omit for a plain location picker (the console and serial-setup
// callers do);
// pickURL — clicking the map navigates here with lat/lon appended so the
// server resolves the region def for that point (must end "?"/"&");
// preview — {lat, lon} of an already-picked point, shown as a marker;
@@ -267,6 +236,22 @@
opts = opts || {};
var map = L.map(mapId, { scrollWheelZoom: false });
baseLayers(map);
// Region outlines go on first so the picked-location marker stays on top. They
// are non-interactive (see outlineStyle), so a click still reaches the map and
// drops a pin rather than being swallowed by a polygon.
var drawn = [];
var primary = null;
(opts.regions || []).forEach(function (r) {
if (!r) return;
var l = layerFromGeoJSON(r.geojson);
if (!l) return;
l.setStyle(outlineStyle(r.color, r.matched));
if (r.name) l.bindTooltip(String(r.name));
l.addTo(map);
drawn.push(l);
if (r.primary) primary = l;
});
if (opts.pickURL) {
map.on("click", function (e) {
window.location = opts.pickURL + "lat=" + e.latlng.lat.toFixed(6) + "&lon=" + e.latlng.lng.toFixed(6);
@@ -287,6 +272,14 @@
}
var fit = [];
if (opts.bounds) fit.push(opts.bounds[0], opts.bounds[1]);
// With no explicit bounds, open framed on the primary region — where the org
// actually operates — with room around it for context, rather than zooming out
// to contain a nationwide parent. Without a primary, frame everything drawn.
if (!opts.bounds && drawn.length) {
var rb = L.featureGroup(primary ? [primary] : drawn).getBounds();
if (primary) rb = rb.pad(PRIMARY_CONTEXT);
fit.push(rb.getSouthWest(), rb.getNorthEast());
}
if (opts.preview) {
L.circleMarker([opts.preview.lat, opts.preview.lon], {
radius: 7, color: "#fff", weight: 2, fillColor: "#fff", fillOpacity: 0.9,
@@ -298,7 +291,7 @@
// A zero-size box (single point — one repeater, or a preview with no
// region box) can't be fit; center on it at a neighborhood zoom instead.
if (b.getNorthEast().equals(b.getSouthWest())) map.setView(b.getCenter(), 11, { animate: false });
else map.fitBounds(b.pad(0.2), { animate: false });
else map.fitBounds(b, { animate: false, padding: FIT_PADDING });
} else {
map.setView([20, 0], 2, { animate: false });
}
+16
View File
@@ -19,6 +19,10 @@
// [data-risky] (checkbox) — confirm before enabling; unchecks on cancel.
// Delegated so it works in htmx-swapped content
// (a modal fragment can't run inline script).
// [data-tooltip] — show this element's title= as a styled tooltip
// on hover/focus, instead of the browser's slow
// native one. Use it on icon-only controls,
// where the icon alone doesn't say what it does.
//
// It also localizes timestamps: any <time data-fmt="…"> element (emitted by the
// `ts` template func) is rewritten from its machine-readable datetime attribute
@@ -26,6 +30,18 @@
(function () {
"use strict";
// --- tooltips ------------------------------------------------------------
// Bootstrap tooltips are opt-in, so wire them once here rather than per page.
// Delegating through the `selector` option means a control swapped in later by
// htmx (a modal fragment, a re-rendered list) gets one with no re-initialization.
//
// It reads the element's title=, which stays as the no-JS fallback: without this,
// hovering still surfaces the native tooltip. Note data-bs-toggle is NOT the hook
// — an element can only carry one, and these controls already use it for modals.
if (window.tabler && window.tabler.Tooltip) {
new window.tabler.Tooltip(document.body, { selector: "[data-tooltip]" });
}
// --- timestamp localization ---------------------------------------------
// The server emits <time datetime="<RFC3339 UTC>" data-fmt="date|datetime|
// time|time-seconds">UTC fallback</time>. We reformat each into the viewer's
+163 -54
View File
@@ -8,16 +8,35 @@
This organization hasn't defined any configuration yet.
</div></div>
{{else}}
{{/* Section 1 — the configuration a chosen profile produces, with the profile list
in the right rail above the region legend (the sidebar position the rest of the
site uses). The main pane shows the *assembled* list — profile steps with the
location's region commands spliced in — which is what a repeater owner actually
runs; see ConfigView.AssembledLines. Main comes first in the DOM so a narrow
screen reads content before its picker, matching the other org pages. */}}
{{if or .Config.Profiles .Config.PreviewActive .CanEdit}}
<div class="row row-cards">
<!-- Main: selected profile's base settings + regions (only what's defined) -->
<div class="col-lg-8">
{{if .Config.Profiles}}
<div class="card">
<div class="card-header"><h2 class="card-title">{{.Config.Selected}} · base settings</h2></div>
<div class="card-body">{{template "step-list" .Config.SelectedSteps}}</div>
{{if or .Config.Profiles .Config.PreviewActive}}
<div class="card" data-testid="config-preview">
<div class="card-header">
<h2 class="card-title">
{{if .Config.Profiles}}{{.Config.Selected}}{{else}}Region commands{{end}}
<span class="text-secondary fw-normal">{{if .Config.PreviewActive}} · at {{.PreviewLat}}, {{.PreviewLon}}{{else}} · base settings{{end}}</span>
</h2>
</div>
<div class="card-body">{{template "config-lines" .Config.AssembledLines}}</div>
{{if .Config.PreviewActive}}
{{if not .Config.RegionDef}}
<div class="card-footer text-secondary small">No regions cover this location.</div>
{{end}}
{{else if .Config.HasRegionShapes}}
<div class="card-footer text-secondary small">Click a spot on the map below to see the region commands a repeater there would also run.</div>
{{end}}
</div>
{{else if .CanEdit}}
<!-- Admin, no profiles yet: explain what a profile is and point at Add profile. -->
{{/* 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">
@@ -26,22 +45,7 @@
</div>
</div>
{{end}}
{{if .Config.PreviewActive}}
<div class="card{{if .Config.Profiles}} mt-3{{end}}">
<div class="card-header"><h2 class="card-title">Regions</h2></div>
{{if .Config.RegionDef}}
<div class="card-body"><pre class="mb-0"><code>{{range .Config.RegionDef}}{{.}}
{{end}}</code></pre></div>
{{else}}
<div class="card-body text-secondary">No regions cover this location.</div>
{{end}}
</div>
{{end}}
</div>
<!-- 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 or .Config.Profiles .CanEdit}}
<div class="card">
@@ -63,12 +67,12 @@
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"
<button type="button" class="btn btn-icon btn-ghost-secondary" data-testid="config-profile-edit" data-tooltip
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"
<button type="submit" class="btn btn-icon btn-ghost-danger" data-testid="config-profile-delete" data-tooltip
title="Delete profile" aria-label="Delete profile {{.Name}}">{{template "icon-trash" ""}}</button>
</form>
{{end}}
@@ -78,51 +82,156 @@
{{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 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" }}">
<div id="region-map" style="height:240px" role="region" aria-label="Click the map to preview a repeater's region config"></div>
<script src="{{ asset "/static/leaflet.js" }}"></script>
<script src="{{ asset "/static/regionmap.js" }}"></script>
<script nonce="{{.Nonce}}">
regionMapView('region-map', {
pickURL: '/orgs/{{.Org.Slug}}/config?{{if .Config.Profiles}}profile={{.Config.Selected | urlquery}}&{{end}}',
{{if .Config.PreviewActive}}preview: {lat: {{.PreviewLat}}, lon: {{.PreviewLon}}},{{end}}
{{if .Config.MapBounds}}bounds: [[{{index .Config.MapBounds 0}}, {{index .Config.MapBounds 1}}], [{{index .Config.MapBounds 2}}, {{index .Config.MapBounds 3}}]]{{end}}
});
</script>
{{if .Config.PreviewActive}}
<div class="text-secondary small mt-2 text-center">{{template "icon-map-pin" "me-1"}}<span class="font-monospace">{{.PreviewLat}}, {{.PreviewLon}}</span></div>
{{end}}
</div>
</div>
{{end}}
</div>
</div>
{{end}}
{{/* Section 2 — Regions: the map is the view of them, and the list beside it is the
map's legend (swatch + hierarchy by indentation). Everyone sees it; only admins
get the controls. Clicking the map picks a location, which is what drives the
assembled preview above. */}}
{{if or .Config.Regions .CanEdit}}
<div class="card mt-3" data-testid="config-regions">
<div class="card-header">
<div>
<h2 class="card-title">Regions</h2>
<div class="text-secondary small">Named areas drawn on a map. A repeater joins every region whose area contains it.</div>
</div>
{{if .CanEdit}}
<div class="card-actions">
<button type="button" class="btn btn-sm btn-primary" data-testid="config-region-add"
data-bs-toggle="modal" data-bs-target="#config-region-modal"
hx-get="/orgs/{{.Org.Slug}}/config/regions/new" hx-target="#config-region-modal-content" hx-swap="innerHTML">{{template "icon-plus" "me-1"}}Add region</button>
</div>
{{end}}
</div>
<div class="card-body p-0">
<div class="row g-0">
<div class="col-lg-8">
{{if .Config.HasRegionShapes}}
<link rel="stylesheet" href="{{ asset "/static/leaflet.css" }}">
<div id="region-map" style="height:520px" role="region"
aria-label="Map of this organization’s regions — click to preview a location"></div>
<script src="{{ asset "/static/leaflet.js" }}"></script>
<script src="{{ asset "/static/regionmap.js" }}"></script>
{{/* Region names and shapes are user data, interpolated in a JS context so
html/template JSON-encodes and escapes them. */}}
<script nonce="{{.Nonce}}">
regionMapView('region-map', {
regions: {{.Config.MapRegions}},
pickURL: '/orgs/{{.Org.Slug}}/config?{{if .Config.Profiles}}profile={{.Config.Selected | urlquery}}&{{end}}'{{if .Config.PreviewActive}},
preview: {lat: {{.PreviewLat}}, lon: {{.PreviewLon}}}{{end}}
});
</script>
{{else}}
<div class="p-4 text-secondary" data-testid="config-regions-noshapes">
{{if .Config.Regions}}
No region has an area drawn yet, so there is nothing to show on a map.
{{if .CanEdit}}Use the area button on a region to outline where it applies.{{end}}
{{else}}
Regions apply settings by location. Add one, draw where it applies, and a
repeater there picks it up automatically.
{{end}}
</div>
{{end}}
</div>
<div class="col-lg-4 border-start">
<div class="list-group list-group-flush" style="max-height:520px; overflow-y:auto">
{{if .CanEdit}}
{{/* The root (*) isn't a region row — it lives on the org — so it has no
swatch and its only setting is flood policy, saved on its own. */}}
<div class="list-group-item d-flex align-items-center gap-2" data-testid="config-region-root">
<div class="flex-fill" style="min-width:0">
<div class="fw-bold">Root <span class="text-secondary fw-normal">· <code>*</code> · everywhere</span></div>
<div class="text-secondary small">Deny flood to scope flooding to your defined regions.</div>
</div>
<form method="post" action="/orgs/{{.Org.Slug}}/config/root-flood" class="m-0 flex-shrink-0">
{{/* No hidden shadow input: an unchecked checkbox simply isn't submitted,
which the handler reads as deny. */}}
<label class="form-check form-switch m-0 text-nowrap">
<input class="form-check-input" type="checkbox" name="root_allow_flood" value="1"
{{if .Config.RootAllowFlood}}checked{{end}} data-autosubmit
aria-label="Allow flood at the root region">
<span class="form-check-label">Allow flood</span>
</label>
</form>
</div>
{{end}}
{{range .Config.Regions}}
<div class="list-group-item d-flex align-items-center gap-2" data-testid="config-region-row">
{{/* Align to the top, not the middle: the swatch labels the region's name
on the first line, and centering would float it down beside the token. */}}
<div class="flex-fill d-flex align-items-start gap-2" style="min-width:0; padding-left:{{.Depth}}rem">
{{if .HasShape}}
<span class="flex-shrink-0" aria-hidden="true"
style="width:.75rem; height:.75rem; margin-top:.4rem; border-radius:2px; background-color:{{.Color}}"></span>
{{else}}
<span class="flex-shrink-0 text-warning" title="No area drawn yet">{{template "icon-alert" ""}}</span>
{{end}}
{{/* Name, then token, then status — each on its own line. A token is an
identifier, so it never wraps mid-string. */}}
<div style="min-width:0">
<div>{{.DisplayName}}</div>
<div><span class="text-secondary text-nowrap"><code>{{.Token}}</code></span></div>
{{if not .HasShape}}
<div class="text-warning small" data-testid="config-region-draft">Needs an area</div>
{{else if .Matches}}
<div class="text-green small" data-testid="config-region-match">Applies here</div>
{{else if not .AllowFlood}}
<div class="text-secondary small">Flood denied</div>
{{end}}
</div>
</div>
{{if $.CanEdit}}
<a class="btn btn-icon btn-ghost-secondary flex-shrink-0" data-testid="config-region-area" data-tooltip
title="{{if .HasShape}}Edit area{{else}}Draw area{{end}}"
aria-label="{{if .HasShape}}Edit{{else}}Draw{{end}} area for {{.DisplayName}}"
href="/orgs/{{$.Org.Slug}}/config/regions/{{.ID}}/area">{{template "icon-map-pin" ""}}</a>
<button type="button" class="btn btn-icon btn-ghost-secondary flex-shrink-0" data-testid="config-region-edit" data-tooltip
title="Edit region" aria-label="Edit region {{.DisplayName}}"
data-bs-toggle="modal" data-bs-target="#config-region-modal"
hx-get="/orgs/{{$.Org.Slug}}/config/regions/{{.ID}}/edit" hx-target="#config-region-modal-content" hx-swap="innerHTML">{{template "icon-pencil" ""}}</button>
<form method="post" action="/orgs/{{$.Org.Slug}}/config/regions/{{.ID}}/delete" class="m-0 flex-shrink-0" data-confirm="Delete the region “{{.DisplayName}}”?">
<button type="submit" class="btn btn-icon btn-ghost-danger" data-testid="config-region-delete" data-tooltip
title="Delete region" aria-label="Delete region {{.DisplayName}}">{{template "icon-trash" ""}}</button>
</form>
{{end}}
</div>
{{end}}
{{if not .Config.Regions}}
<div class="list-group-item text-secondary">No regions yet.</div>
{{end}}
</div>
</div>
</div>
</div>
</div>
{{end}}
{{if .CanEdit}}
<!-- Admin profile editor; the Add/Edit buttons load their fragment here via htmx. -->
<!-- Admin editors; the Add/Edit buttons load their fragment here via htmx. A region's
area is not edited here — it needs a full-width map, so it has its own page. -->
<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>
<div class="modal modal-blur fade" id="config-region-modal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered modal-dialog-scrollable" role="document">
<div class="modal-content" id="config-region-modal-content"></div>
</div>
</div>
{{end}}
{{end}}
{{end}}
{{/* step-list renders a sequence of ConfigStep as a copy-pasteable block. */}}
{{define "step-list"}}
{{if .}}<pre class="mb-0"><code>{{range .}}{{if .IsComment}}# {{.Comment}}{{else}}{{.CommandLine}}{{end}}
{{/* config-lines renders assembled ConfigLines as one copy-pasteable block. Region
lines are tinted so it reads which came from the location rather than the
profile, matching the highlighted polygons on the map. */}}
{{define "config-lines"}}
{{if .}}<pre class="mb-0" data-testid="config-commands"><code>{{range .}}{{if .IsMarker}}<span class="text-secondary">&#8230; click a spot on the map below to fill in the region commands &#8230;</span>{{else if .IsComment}}# {{.Text}}{{else if .FromRegion}}<span class="text-azure" data-testid="config-region-line">{{.Text}}</span>{{else}}{{.Text}}{{end}}
{{end}}</code></pre>{{else}}<div class="text-secondary">No steps.</div>{{end}}
{{end}}
-1
View File
@@ -19,7 +19,6 @@ host (anonymous). */}}
<div class="dropdown">
<button class="btn dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false" data-testid="org-actions">Actions</button>
<div class="dropdown-menu dropdown-menu-end">
{{if .Nav.IsAdmin}}<a class="dropdown-item" href="/orgs/{{.Nav.Slug}}/config/edit">{{template "icon-settings" "dropdown-item-icon"}}Edit configuration</a>{{end}}
<a class="dropdown-item" href="{{.RootURL}}/orgs/{{.Nav.Slug}}">{{template "icon-world" "dropdown-item-icon"}}View public page</a>
<div class="dropdown-divider"></div>
<form method="post" action="/orgs/{{.Nav.Slug}}/leave" class="m-0" data-confirm="Leave this organization?">