diff --git a/internal/core/config_flow_test.go b/internal/core/config_flow_test.go
index b5c165f..548fc0b 100644
--- a/internal/core/config_flow_test.go
+++ b/internal/core/config_flow_test.go
@@ -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, "")]
+ 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", "")
diff --git a/internal/core/config_profile.go b/internal/core/config_profile.go
index 4c95bad..08e78bf 100644
--- a/internal/core/config_profile.go
+++ b/internal/core/config_profile.go
@@ -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
-}
diff --git a/internal/core/config_profile_test.go b/internal/core/config_profile_test.go
index 4662cb9..b5c0b18 100644
--- a/internal/core/config_profile_test.go
+++ b/internal/core/config_profile_test.go
@@ -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")
- }
-}
diff --git a/internal/core/config_region.go b/internal/core/config_region.go
new file mode 100644
index 0000000..5ca8d64
--- /dev/null
+++ b/internal/core/config_region.go
@@ -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
+}
diff --git a/internal/core/config_region_modal_test.go b/internal/core/config_region_modal_test.go
new file mode 100644
index 0000000..ca81d14
--- /dev/null
+++ b/internal/core/config_region_modal_test.go
@@ -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, "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)
+ }
}
}
diff --git a/internal/core/repeater_setup_test.go b/internal/core/repeater_setup_test.go
index e53698c..d0de301 100644
--- a/internal/core/repeater_setup_test.go
+++ b/internal/core/repeater_setup_test.go
@@ -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.
diff --git a/internal/core/templates/config_edit.html b/internal/core/templates/config_edit.html
deleted file mode 100644
index dcffd73..0000000
--- a/internal/core/templates/config_edit.html
+++ /dev/null
@@ -1,34 +0,0 @@
-{{define "title"}}Configuration · {{.Org.Name}} · MeshTender{{end}}
-{{define "header"}}
-
{{.RegionCount}} region{{if ne .RegionCount 1}}s{{end}} defined{{if .PrimaryRegion}} · primary: {{.PrimaryRegion}}{{end}}.
- {{else}}
-
No regions yet. Add regions to apply location-based settings automatically.
- {{end}}
-
-
-{{end}}
diff --git a/internal/core/templates/config_region_area.html b/internal/core/templates/config_region_area.html
new file mode 100644
index 0000000..8fed620
--- /dev/null
+++ b/internal/core/templates/config_region_area.html
@@ -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"}}
+
+{{end}}
+
+
+
+
+
+
+
+
+
+{{/* Values are interpolated in a JS context, so html/template JSON-encodes and
+ escapes them — the region names and shapes are user data. */}}
+
+{{end}}
diff --git a/internal/core/templates/config_region_edit.html b/internal/core/templates/config_region_edit.html
new file mode 100644
index 0000000..da1d3b8
--- /dev/null
+++ b/internal/core/templates/config_region_edit.html
@@ -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"}}
+
+
+{{end}}
+
+{{/* region-editor-alerts renders the validation errors, shared by page and modal. */}}
+{{define "region-editor-alerts"}}
+{{if .Errors}}
+
+
+
Nothing was saved — fix these first:
+
{{range .Errors}}
{{.}}
{{end}}
+
+
+{{end}}
+{{end}}
+
+{{/* region-editor-fields are the form controls themselves — the
-
-{{/* Hidden prototype cloned by addBlock(); kept out of the form via the template tag. */}}
-{{template "region-block" .EmptyRegion}}
-
-
-
-
-
-{{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"}}
-
-
-
-
-
-
-
-
-
-
- {{/* 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. */}}
-
-
-
-
- {{/* Actions on the region's map area, plus Remove (danger) pushed to the end. */}}
-
-
-
-
-
-
-
-
-{{end}}
diff --git a/internal/core/web.go b/internal/core/web.go
index 171d49d..e1b03b3 100644
--- a/internal/core/web.go
+++ b/internal/core/web.go
@@ -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)
diff --git a/internal/e2e/config_profiles_test.go b/internal/e2e/config_profiles_test.go
index 7fe6d1f..b469ee7 100644
--- a/internal/e2e/config_profiles_test.go
+++ b/internal/e2e/config_profiles_test.go
@@ -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(¤tURL),
); err != nil {
t.Fatalf("browser run against %s: %v", cfgURL, err)
diff --git a/internal/e2e/org_tabs_test.go b/internal/e2e/org_tabs_test.go
index bb1d4e1..970503b 100644
--- a/internal/e2e/org_tabs_test.go
+++ b/internal/e2e/org_tabs_test.go
@@ -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)
}
diff --git a/internal/e2e/region_editor_test.go b/internal/e2e/region_editor_test.go
index 251ea06..46d4eb5 100644
--- a/internal/e2e/region_editor_test.go
+++ b/internal/e2e/region_editor_test.go
@@ -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)
}
diff --git a/internal/store/config_profiles.go b/internal/store/config_profiles.go
index 225052b..42edd5d 100644
--- a/internal/store/config_profiles.go
+++ b/internal/store/config_profiles.go
@@ -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)
}
diff --git a/internal/store/config_profiles_test.go b/internal/store/config_profiles_test.go
index 722154a..3b85467 100644
--- a/internal/store/config_profiles_test.go
+++ b/internal/store/config_profiles_test.go
@@ -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.
diff --git a/internal/store/migrations/0044_region_one_primary.sql b/internal/store/migrations/0044_region_one_primary.sql
new file mode 100644
index 0000000..bc70dd9
--- /dev/null
+++ b/internal/store/migrations/0044_region_one_primary.sql
@@ -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;
diff --git a/internal/web/orgview.go b/internal/web/orgview.go
index 071f5ad..c915856 100644
--- a/internal/web/orgview.go
+++ b/internal/web/orgview.go
@@ -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
diff --git a/internal/web/orgview_test.go b/internal/web/orgview_test.go
new file mode 100644
index 0000000..73e7cdd
--- /dev/null
+++ b/internal/web/orgview_test.go
@@ -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)
+ }
+ }
+}
diff --git a/internal/web/static/regionmap.js b/internal/web/static/regionmap.js
index 606904e..bf61b9e 100644
--- a/internal/web/static/regionmap.js
+++ b/internal/web/static/regionmap.js
@@ -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 });
}
diff --git a/internal/web/static/ui.js b/internal/web/static/ui.js
index e723613..a53a138 100644
--- a/internal/web/static/ui.js
+++ b/internal/web/static/ui.js
@@ -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