mirror of
https://github.com/MeshTender/MeshTender.git
synced 2026-10-23 05:32:05 +00:00
Refactor region editing
This commit is contained in:
1 parent
d394d229ac
commit
abc0dbaee8
26 files changed
+2730
-918
No files matched your search
@@ -3,6 +3,7 @@ package core
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
@@ -44,64 +45,120 @@ func TestOrgConfigProfilesFlow(t *testing.T) {
|
||||
t.Fatalf("create profile %s redirected to %q, want %q", p.name, got, want)
|
||||
}
|
||||
}
|
||||
// A geofenced region, saved on the region page.
|
||||
// A region, created in two steps the way the editor does it: the attribute form
|
||||
// creates it as a draft, then the area page saves its drawn shape.
|
||||
rsave := post(t, ts, h.app, "/orgs/"+org.Slug+"/config/regions", url.Values{
|
||||
"region_display": {"Mountains"},
|
||||
"region_token": {"mtns"},
|
||||
"region_layer": {"2"},
|
||||
"region_allow_flood": {"1"},
|
||||
"root_allow_flood": {"1"},
|
||||
"region_geojson": {`{"type":"Polygon","coordinates":[[[30,10],[40,10],[40,20],[30,20],[30,10]]]}`},
|
||||
}, sess)
|
||||
rsave.Body.Close()
|
||||
if rsave.StatusCode != http.StatusSeeOther {
|
||||
t.Fatalf("save regions status = %d, want 303", rsave.StatusCode)
|
||||
t.Fatalf("create region status = %d, want 303", rsave.StatusCode)
|
||||
}
|
||||
regions, err := st.ListRegions(ctx, org.ID)
|
||||
if err != nil || len(regions) != 1 {
|
||||
t.Fatalf("ListRegions = %v, %v; want one region", regions, err)
|
||||
}
|
||||
rid := strconv.FormatInt(regions[0].ID, 10)
|
||||
|
||||
// Before an area is drawn the region is a draft: flagged on the config page and
|
||||
// contributing nothing to a location's region def.
|
||||
draftView := readBody(t, do(t, ts, h.app, "/orgs/"+org.Slug+"/config?lat=15&lon=35", sess))
|
||||
if !strings.Contains(draftView, `data-testid="config-region-draft"`) {
|
||||
t.Fatal("a region with no area should be flagged as needing one")
|
||||
}
|
||||
if strings.Contains(draftView, "region def") {
|
||||
t.Fatal("a draft region must not produce region def commands")
|
||||
}
|
||||
|
||||
asave := post(t, ts, h.app, "/orgs/"+org.Slug+"/config/regions/"+rid+"/area", url.Values{
|
||||
"region_geojson": {`{"type":"Polygon","coordinates":[[[30,10],[40,10],[40,20],[30,20],[30,10]]]}`},
|
||||
}, sess)
|
||||
asave.Body.Close()
|
||||
if asave.StatusCode != http.StatusSeeOther {
|
||||
t.Fatalf("save area status = %d, want 303", asave.StatusCode)
|
||||
}
|
||||
|
||||
body := readBody(t, do(t, ts, h.app, "/orgs/"+org.Slug+"/config", sess))
|
||||
// The profile list, with per-row admin actions; the geofenced region surfaces as
|
||||
// the click-to-preview location map (regions aren't listed individually).
|
||||
// The profile list and the region list, each with per-row admin actions, plus the
|
||||
// click-to-preview location map the drawn geofence enables.
|
||||
for _, want := range []string{
|
||||
"ESP32", "nRF52", `data-testid="config-profile-list"`,
|
||||
`data-testid="config-profile-add"`, `data-testid="config-profile-edit"`, `data-testid="config-profile-delete"`,
|
||||
`id="config-profile-modal"`, "Preview a location", "region-map",
|
||||
`id="config-profile-modal"`, `data-testid="config-regions"`, "region-map",
|
||||
"Mountains", "mtns", `data-testid="config-region-row"`, `data-testid="config-region-add"`,
|
||||
`data-testid="config-region-area"`, `id="config-region-modal"`,
|
||||
} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Fatalf("config view missing %q", want)
|
||||
}
|
||||
}
|
||||
// Default selection shows the first profile's base settings.
|
||||
if !strings.Contains(body, "ESP32 · base settings") {
|
||||
t.Fatalf("default config view should show the first profile's base settings")
|
||||
// With an area drawn, the region is no longer flagged as a draft.
|
||||
if strings.Contains(body, `data-testid="config-region-draft"`) {
|
||||
t.Fatal("a region with an area should not be flagged as needing one")
|
||||
}
|
||||
// And it now applies at a location inside it.
|
||||
preview := readBody(t, do(t, ts, h.app, "/orgs/"+org.Slug+"/config?lat=15&lon=35", sess))
|
||||
if !strings.Contains(preview, "region def mtns") {
|
||||
t.Fatal("a drawn region should apply at a location inside it")
|
||||
}
|
||||
// Default selection shows the first profile's steps, not the second's.
|
||||
if !strings.Contains(body, "esp base") || strings.Contains(body, "nrf base") {
|
||||
t.Fatal("default config view should show the first profile's base settings")
|
||||
}
|
||||
// The profile selector works: ?profile= switches the shown base settings.
|
||||
sel := readBody(t, do(t, ts, h.app, "/orgs/"+org.Slug+"/config?profile=nRF52", sess))
|
||||
if !strings.Contains(sel, "nRF52 · base settings") {
|
||||
t.Fatalf("?profile=nRF52 should show nRF52's base settings")
|
||||
if !strings.Contains(sel, "nrf base") || strings.Contains(sel, "esp base") {
|
||||
t.Fatal("?profile=nRF52 should show nRF52's base settings")
|
||||
}
|
||||
|
||||
// The admin editor pages render: the hub summarizes the regions (profiles live
|
||||
// on the config page now), the new-profile page shows the base-settings editor,
|
||||
// and the region page has the map + a region row.
|
||||
hub := readBody(t, do(t, ts, h.app, "/orgs/"+org.Slug+"/config/edit", sess))
|
||||
for _, want := range []string{"Edit regions", "1 region"} {
|
||||
if !strings.Contains(hub, want) {
|
||||
t.Fatalf("config hub missing %q", want)
|
||||
// The preview is assembled: with a location picked, the selected profile's steps
|
||||
// and that location's region commands appear in one block, which is what a
|
||||
// repeater owner actually runs.
|
||||
assembled := readBody(t, do(t, ts, h.app, "/orgs/"+org.Slug+"/config?profile=ESP32&lat=15&lon=35", sess))
|
||||
block := assembled[strings.Index(assembled, `data-testid="config-commands"`):]
|
||||
block = block[:strings.Index(block, "</code>")]
|
||||
for _, want := range []string{"esp base", "region def mtns", "region save"} {
|
||||
if !strings.Contains(block, want) {
|
||||
t.Fatalf("assembled preview missing %q; got %q", want, block)
|
||||
}
|
||||
}
|
||||
if strings.Contains(hub, "Add profile") {
|
||||
t.Fatalf("config hub should no longer manage profiles")
|
||||
// Region-contributed lines are marked so the page can show where they came from.
|
||||
if !strings.Contains(block, `data-testid="config-region-line"`) {
|
||||
t.Fatal("region commands in the assembled preview should be marked as region lines")
|
||||
}
|
||||
// And the legend marks the regions that cover the picked location.
|
||||
if !strings.Contains(assembled, `data-testid="config-region-match"`) {
|
||||
t.Fatal("the legend should mark which regions apply at the previewed location")
|
||||
}
|
||||
|
||||
// The standalone editor pages render (the no-JS fallbacks): the new-profile page
|
||||
// shows the base-settings editor, and the region area page has the map.
|
||||
newProf := readBody(t, do(t, ts, h.app, "/orgs/"+org.Slug+"/config/profiles/new", sess))
|
||||
if !strings.Contains(newProf, "Base settings") || !strings.Contains(newProf, "Create profile") {
|
||||
t.Fatalf("new-profile page missing its form")
|
||||
}
|
||||
rgn := readBody(t, do(t, ts, h.app, "/orgs/"+org.Slug+"/config/regions", sess))
|
||||
for _, want := range []string{"region-map", "Mountains", "mtns", "Add region", "everywhere", "Allow flood"} {
|
||||
rgn := readBody(t, do(t, ts, h.app, "/orgs/"+org.Slug+"/config/regions/"+rid+"/area", sess))
|
||||
for _, want := range []string{"region-map", "Mountains", "mtns", "Save area", "Clear area"} {
|
||||
if !strings.Contains(rgn, want) {
|
||||
t.Fatalf("regions editor missing %q", want)
|
||||
t.Fatalf("region area editor missing %q", want)
|
||||
}
|
||||
}
|
||||
// The old config hub is gone entirely (404), and the old bulk region editor's
|
||||
// path is now POST-only — the collection you create a region through — so a GET
|
||||
// there is a 405 rather than a page.
|
||||
hub := do(t, ts, h.app, "/orgs/"+org.Slug+"/config/edit", sess)
|
||||
hub.Body.Close()
|
||||
if hub.StatusCode != http.StatusNotFound {
|
||||
t.Errorf("GET /config/edit = %d, want 404 (route removed)", hub.StatusCode)
|
||||
}
|
||||
bulk := do(t, ts, h.app, "/orgs/"+org.Slug+"/config/regions", sess)
|
||||
bulk.Body.Close()
|
||||
if bulk.StatusCode != http.StatusMethodNotAllowed {
|
||||
t.Errorf("GET /config/regions = %d, want 405 (POST-only collection)", bulk.StatusCode)
|
||||
}
|
||||
|
||||
// A non-admin member of a config-less org does NOT see the Configuration tab.
|
||||
plain, _ := st.CreateUser(ctx, "plainmember", "")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,364 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/jleight/meshtender/internal/geo"
|
||||
"github.com/jleight/meshtender/internal/store"
|
||||
)
|
||||
|
||||
// TestConfigRegionEditorSurfaces: the region attribute editor serves the modal
|
||||
// fragment to htmx and the standalone page to everything else (the no-JS fallback),
|
||||
// a validation error re-renders the fragment in place with the entered values kept,
|
||||
// and a save sends the browser back to the config page.
|
||||
func TestConfigRegionEditorSurfaces(t *testing.T) {
|
||||
t.Parallel()
|
||||
st, ctx, ts, h := splitServer(t)
|
||||
admin, sess := appLogin(t, ts, st, ctx, h.app, "rgnmodaladmin")
|
||||
org, err := st.CreateOrg(ctx, "Region Modal Org", admin.ID) // creator becomes admin
|
||||
if err != nil {
|
||||
t.Fatalf("create org: %v", err)
|
||||
}
|
||||
|
||||
// htmx GET → just the modal fragment (no page chrome), posting to the collection.
|
||||
frag := readBody(t, doHX(t, ts, h.app, "/orgs/"+org.Slug+"/config/regions/new", sess))
|
||||
for _, want := range []string{"modal-header", "Add region", `hx-post="/orgs/` + org.Slug + `/config/regions"`} {
|
||||
if !strings.Contains(frag, want) {
|
||||
t.Fatalf("new-region fragment missing %q", want)
|
||||
}
|
||||
}
|
||||
if strings.Contains(frag, "<html") {
|
||||
t.Fatal("htmx request should get a bare fragment, not the full page")
|
||||
}
|
||||
// The area is drawn on its own page, so the attribute form must not carry it.
|
||||
if strings.Contains(frag, "region_geojson") {
|
||||
t.Fatal("the attribute modal must not carry the geofence field")
|
||||
}
|
||||
|
||||
// Plain GET → the full page fallback, with no htmx attributes to hijack it.
|
||||
page := readBody(t, do(t, ts, h.app, "/orgs/"+org.Slug+"/config/regions/new", sess))
|
||||
if !strings.Contains(page, "<html") || !strings.Contains(page, "Create region") {
|
||||
t.Fatal("non-htmx request should get the standalone editor page")
|
||||
}
|
||||
if strings.Contains(page, "hx-post") {
|
||||
t.Fatal("the fallback page must not carry hx-post (its target doesn't exist there)")
|
||||
}
|
||||
|
||||
// A validation error on an htmx submit keeps the modal: 200 with the fragment,
|
||||
// the error, and the entered values — never a redirect that drops the work.
|
||||
bad := postHX(t, ts, h.app, "/orgs/"+org.Slug+"/config/regions",
|
||||
url.Values{"region_display": {"Buffalo"}, "region_token": {"not a token"}}, sess)
|
||||
badBody := readBody(t, bad)
|
||||
if bad.StatusCode != http.StatusOK || bad.Header.Get("HX-Redirect") != "" {
|
||||
t.Fatalf("invalid submit = %d (HX-Redirect %q), want 200 with no redirect", bad.StatusCode, bad.Header.Get("HX-Redirect"))
|
||||
}
|
||||
for _, want := range []string{"Nothing was saved", "letters, digits", `value="Buffalo"`, `value="not a token"`} {
|
||||
if !strings.Contains(badBody, want) {
|
||||
t.Fatalf("re-rendered fragment missing %q", want)
|
||||
}
|
||||
}
|
||||
|
||||
// A valid htmx submit closes the modal via HX-Redirect back to the config page.
|
||||
ok := postHX(t, ts, h.app, "/orgs/"+org.Slug+"/config/regions",
|
||||
url.Values{"region_display": {"Buffalo"}, "region_token": {"buf"}, "region_layer": {"3"}, "region_allow_flood": {"1"}}, sess)
|
||||
ok.Body.Close()
|
||||
if got, want := ok.Header.Get("HX-Redirect"), "/orgs/"+org.Slug+"/config"; ok.StatusCode != http.StatusOK || got != want {
|
||||
t.Fatalf("valid submit = %d HX-Redirect %q, want 200 → %q", ok.StatusCode, got, want)
|
||||
}
|
||||
|
||||
regions, err := st.ListRegions(ctx, org.ID)
|
||||
if err != nil || len(regions) != 1 {
|
||||
t.Fatalf("ListRegions = %v, %v; want one region", regions, err)
|
||||
}
|
||||
z := regions[0]
|
||||
if z.Token != "buf" || z.DisplayName != "Buffalo" || z.Layer != 3 || !z.AllowFlood {
|
||||
t.Fatalf("saved region = %+v", z)
|
||||
}
|
||||
// Created without an area: a draft, which applies nowhere.
|
||||
if z.Geofence != nil {
|
||||
t.Fatalf("a region created from the modal should have no area yet: %+v", z)
|
||||
}
|
||||
rid := strconv.FormatInt(z.ID, 10)
|
||||
|
||||
// The edit fragment is pre-filled and posts to that region's own URL.
|
||||
editFrag := readBody(t, doHX(t, ts, h.app, "/orgs/"+org.Slug+"/config/regions/"+rid+"/edit", sess))
|
||||
for _, want := range []string{"Edit region", `value="Buffalo"`, `value="buf"`,
|
||||
`hx-post="/orgs/` + org.Slug + `/config/regions/` + rid + `"`} {
|
||||
if !strings.Contains(editFrag, want) {
|
||||
t.Fatalf("edit fragment missing %q", want)
|
||||
}
|
||||
}
|
||||
|
||||
// A duplicate token is reported as a validation error, not a 500.
|
||||
dup := postHX(t, ts, h.app, "/orgs/"+org.Slug+"/config/regions",
|
||||
url.Values{"region_token": {"buf"}}, sess)
|
||||
dupBody := readBody(t, dup)
|
||||
if dup.StatusCode != http.StatusOK || !strings.Contains(dupBody, "already exists") {
|
||||
t.Fatalf("duplicate token = %d, body missing the friendly message: %q", dup.StatusCode, dupBody)
|
||||
}
|
||||
|
||||
// Deleting from the list row (a plain POST) returns to the config page.
|
||||
del := post(t, ts, h.app, "/orgs/"+org.Slug+"/config/regions/"+rid+"/delete", url.Values{}, sess)
|
||||
del.Body.Close()
|
||||
if got, want := del.Header.Get("Location"), "/orgs/"+org.Slug+"/config"; del.StatusCode != http.StatusSeeOther || got != want {
|
||||
t.Fatalf("delete = %d → %q, want 303 → %q", del.StatusCode, got, want)
|
||||
}
|
||||
if left, _ := st.ListRegions(ctx, org.ID); len(left) != 0 {
|
||||
t.Fatalf("regions after delete = %+v, want none", left)
|
||||
}
|
||||
}
|
||||
|
||||
// TestConfigRegionAreaSaves: the area workspace edits only the geofence. Saving a
|
||||
// drawn shape promotes a draft; saving an empty one clears it back to a draft; the
|
||||
// region's attributes are untouched either way, and a malformed shape re-renders the
|
||||
// page with the shape still in hand rather than discarding it.
|
||||
func TestConfigRegionAreaSaves(t *testing.T) {
|
||||
t.Parallel()
|
||||
st, ctx, ts, h := splitServer(t)
|
||||
admin, sess := appLogin(t, ts, st, ctx, h.app, "rgnareaadmin")
|
||||
org, err := st.CreateOrg(ctx, "Region Area Org", admin.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("create org: %v", err)
|
||||
}
|
||||
rid, err := st.CreateRegion(ctx, org.ID, store.RegionInput{
|
||||
Token: "buf", DisplayName: "Buffalo", Layer: 3, Primary: true, AllowFlood: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create region: %v", err)
|
||||
}
|
||||
// A sibling with a shape, which the workspace draws as read-only context.
|
||||
if _, err := st.CreateRegion(ctx, org.ID, store.RegionInput{
|
||||
Token: "us", DisplayName: "United States", Layer: 1, AllowFlood: true,
|
||||
GeofenceJSON: geo.Rectangle(20, -130, 50, -60),
|
||||
}); err != nil {
|
||||
t.Fatalf("create sibling: %v", err)
|
||||
}
|
||||
area := "/orgs/" + org.Slug + "/config/regions/" + strconv.FormatInt(rid, 10) + "/area"
|
||||
|
||||
// The page renders the map, the region's identity, and the sibling for context.
|
||||
page := readBody(t, do(t, ts, h.app, area, sess))
|
||||
for _, want := range []string{"region-map", "region_geojson", "Buffalo", "buf", "United States", "initRegionArea"} {
|
||||
if !strings.Contains(page, want) {
|
||||
t.Fatalf("area page missing %q", want)
|
||||
}
|
||||
}
|
||||
|
||||
// Saving a drawn shape promotes the draft, leaving its attributes alone.
|
||||
drawn := `{"type":"Polygon","coordinates":[[[-80,40],[-75,40],[-75,45],[-80,45],[-80,40]]]}`
|
||||
resp := post(t, ts, h.app, area, url.Values{"region_geojson": {drawn}}, sess)
|
||||
resp.Body.Close()
|
||||
if got, want := resp.Header.Get("Location"), "/orgs/"+org.Slug+"/config"; resp.StatusCode != http.StatusSeeOther || got != want {
|
||||
t.Fatalf("save area = %d → %q, want 303 → %q", resp.StatusCode, got, want)
|
||||
}
|
||||
z, err := st.GetRegion(ctx, org.ID, rid)
|
||||
if err != nil {
|
||||
t.Fatalf("get region: %v", err)
|
||||
}
|
||||
if z.Geofence == nil || !z.Geofence.Contains(42, -78) {
|
||||
t.Fatalf("area not saved: %+v", z)
|
||||
}
|
||||
if z.Token != "buf" || z.DisplayName != "Buffalo" || z.Layer != 3 || !z.Primary || !z.AllowFlood {
|
||||
t.Fatalf("saving an area disturbed the region's attributes: %+v", z)
|
||||
}
|
||||
|
||||
// A malformed shape is rejected, keeps the stored area, and re-renders with the
|
||||
// submitted GeoJSON so it isn't silently lost.
|
||||
badResp := post(t, ts, h.app, area, url.Values{"region_geojson": {"{not geojson"}}, sess)
|
||||
badBody := readBody(t, badResp)
|
||||
if badResp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("invalid area = %d, want 200 with the editor re-rendered", badResp.StatusCode)
|
||||
}
|
||||
if !strings.Contains(badBody, "isn't valid") && !strings.Contains(badBody, "isn't valid") {
|
||||
t.Fatalf("invalid area should explain itself: %q", badBody)
|
||||
}
|
||||
if !strings.Contains(badBody, "not geojson") {
|
||||
t.Fatal("the rejected shape should be preserved in the re-rendered form")
|
||||
}
|
||||
if z, _ = st.GetRegion(ctx, org.ID, rid); z.Geofence == nil {
|
||||
t.Fatal("a rejected save must not clear the stored area")
|
||||
}
|
||||
|
||||
// An empty shape is a deliberate "Clear area" — back to a draft.
|
||||
clear := post(t, ts, h.app, area, url.Values{"region_geojson": {""}}, sess)
|
||||
clear.Body.Close()
|
||||
if clear.StatusCode != http.StatusSeeOther {
|
||||
t.Fatalf("clear area = %d, want 303", clear.StatusCode)
|
||||
}
|
||||
if z, _ = st.GetRegion(ctx, org.ID, rid); z.Geofence != nil {
|
||||
t.Fatalf("clearing the area should return the region to a draft: %+v", z)
|
||||
}
|
||||
}
|
||||
|
||||
// TestConfigRegionEditingIsAdminOnly: the region controls render only for an org
|
||||
// admin — never for a plain member, and never on the anonymous public surface, which
|
||||
// shares this template. The endpoints themselves 404 for non-admins.
|
||||
func TestConfigRegionEditingIsAdminOnly(t *testing.T) {
|
||||
t.Parallel()
|
||||
st, ctx, ts, h := splitServer(t)
|
||||
admin, adminSess := appLogin(t, ts, st, ctx, h.app, "rgnroleadmin")
|
||||
org, err := st.CreateOrg(ctx, "Region Role Org", admin.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("create org: %v", err)
|
||||
}
|
||||
rid, err := st.CreateRegion(ctx, org.ID, store.RegionInput{
|
||||
Token: "buf", DisplayName: "Buffalo", Layer: 3, AllowFlood: true,
|
||||
GeofenceJSON: geo.Rectangle(40, -80, 45, -75),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create region: %v", err)
|
||||
}
|
||||
member, memberSess := appLogin(t, ts, st, ctx, h.app, "rgnrolemember")
|
||||
if err := st.AddOrgMember(ctx, org.ID, member.ID, "member"); err != nil {
|
||||
t.Fatalf("add member: %v", err)
|
||||
}
|
||||
|
||||
adminView := readBody(t, do(t, ts, h.app, "/orgs/"+org.Slug+"/config", adminSess))
|
||||
for _, want := range []string{`data-testid="config-region-add"`, `data-testid="config-region-edit"`,
|
||||
`data-testid="config-region-delete"`, `data-testid="config-region-area"`,
|
||||
`data-testid="config-region-root"`, `id="config-region-modal"`, "Buffalo"} {
|
||||
if !strings.Contains(adminView, want) {
|
||||
t.Fatalf("admin config view missing %q", want)
|
||||
}
|
||||
}
|
||||
|
||||
for _, v := range []struct {
|
||||
name string
|
||||
body string
|
||||
}{
|
||||
{"member", readBody(t, do(t, ts, h.app, "/orgs/"+org.Slug+"/config", memberSess))},
|
||||
{"anonymous", readBody(t, do(t, ts, h.root, "/orgs/"+org.Slug+"/config"))},
|
||||
} {
|
||||
// Regions are public information — a member or visitor needs to see which
|
||||
// areas an org defines and where they apply. Only the controls are admin-only.
|
||||
for _, want := range []string{`data-testid="config-regions"`, `data-testid="config-region-row"`,
|
||||
"Buffalo", "buf", "region-map"} {
|
||||
if !strings.Contains(v.body, want) {
|
||||
t.Errorf("%s config view should still show the regions; missing %q", v.name, want)
|
||||
}
|
||||
}
|
||||
for _, forbidden := range []string{"config-region-add", "config-region-edit", "config-region-delete",
|
||||
"config-region-modal", "config-region-root", "/config/regions"} {
|
||||
if strings.Contains(v.body, forbidden) {
|
||||
t.Fatalf("%s config view leaked the admin control %q", v.name, forbidden)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The endpoints are admin-gated, not just hidden: a member gets 404 everywhere.
|
||||
ridStr := strconv.FormatInt(rid, 10)
|
||||
for _, path := range []string{
|
||||
"/orgs/" + org.Slug + "/config/regions/new",
|
||||
"/orgs/" + org.Slug + "/config/regions/" + ridStr + "/edit",
|
||||
"/orgs/" + org.Slug + "/config/regions/" + ridStr + "/area",
|
||||
} {
|
||||
resp := do(t, ts, h.app, path, memberSess)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusNotFound {
|
||||
t.Errorf("member GET %s = %d, want 404", path, resp.StatusCode)
|
||||
}
|
||||
}
|
||||
for _, path := range []string{
|
||||
"/orgs/" + org.Slug + "/config/regions",
|
||||
"/orgs/" + org.Slug + "/config/regions/" + ridStr,
|
||||
"/orgs/" + org.Slug + "/config/regions/" + ridStr + "/delete",
|
||||
"/orgs/" + org.Slug + "/config/regions/" + ridStr + "/area",
|
||||
"/orgs/" + org.Slug + "/config/root-flood",
|
||||
} {
|
||||
resp := post(t, ts, h.app, path, url.Values{"region_token": {"x"}}, memberSess)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusNotFound {
|
||||
t.Errorf("member POST %s = %d, want 404", path, resp.StatusCode)
|
||||
}
|
||||
}
|
||||
// And nothing a member submitted took effect.
|
||||
if z, err := st.GetRegion(ctx, org.ID, rid); err != nil || z.Token != "buf" {
|
||||
t.Fatalf("region changed under a non-admin: %+v (err %v)", z, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestConfigMapMarksPrimaryRegion: the config map's payload flags the primary
|
||||
// region, which is what it opens framed on. Without that flag the map falls back to
|
||||
// containing every region, so a nationwide parent would force a continental view of
|
||||
// an org that operates in one county.
|
||||
func TestConfigMapMarksPrimaryRegion(t *testing.T) {
|
||||
t.Parallel()
|
||||
st, ctx, ts, h := splitServer(t)
|
||||
admin, sess := appLogin(t, ts, st, ctx, h.app, "cfgmapprimary")
|
||||
org, err := st.CreateOrg(ctx, "Map Primary Org", admin.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("create org: %v", err)
|
||||
}
|
||||
for _, z := range []store.RegionInput{
|
||||
{Token: "us", DisplayName: "US", Layer: 1, AllowFlood: true, GeofenceJSON: geo.Rectangle(25, -125, 49, -67)},
|
||||
{Token: "wny", DisplayName: "Western New York", Layer: 3, Primary: true, AllowFlood: true,
|
||||
GeofenceJSON: geo.Rectangle(42, -79.8, 43.5, -77.5)},
|
||||
} {
|
||||
if _, err := st.CreateRegion(ctx, org.ID, z); err != nil {
|
||||
t.Fatalf("create region %s: %v", z.Token, err)
|
||||
}
|
||||
}
|
||||
|
||||
body := readBody(t, do(t, ts, h.app, "/orgs/"+org.Slug+"/config", sess))
|
||||
// Exactly one region in the map payload is flagged primary, and it's the one the
|
||||
// org marked — not simply the first drawn.
|
||||
if got := strings.Count(body, `"primary":true`); got != 1 {
|
||||
t.Fatalf("map payload has %d primary regions, want exactly 1", got)
|
||||
}
|
||||
// Regions ship ordered by (layer, token), so Western New York (layer 3) is last:
|
||||
// the flag must fall after its name and nowhere before it. Slicing on braces
|
||||
// would cut inside the escaped GeoJSON, which is full of them.
|
||||
at := strings.Index(body, `"name":"Western New York"`)
|
||||
if at < 0 {
|
||||
t.Fatal("the primary region is missing from the map payload")
|
||||
}
|
||||
if strings.Contains(body[:at], `"primary":true`) {
|
||||
t.Fatal("a region before the primary one carries the primary flag")
|
||||
}
|
||||
if !strings.Contains(body[at:], `"primary":true`) {
|
||||
t.Fatal("the primary flag is not on Western New York")
|
||||
}
|
||||
// Every drawn region still ships with a color, so the legend swatches match.
|
||||
if got := strings.Count(body, `"color":"#`); got != 2 {
|
||||
t.Fatalf("map payload carries %d colors, want one per drawn region (2)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestConfigRootFloodToggle: the root (*) flood policy saves on its own from the
|
||||
// config page — it isn't a region row, so it must not require any region to exist.
|
||||
func TestConfigRootFloodToggle(t *testing.T) {
|
||||
t.Parallel()
|
||||
st, ctx, ts, h := splitServer(t)
|
||||
admin, sess := appLogin(t, ts, st, ctx, h.app, "rootfloodadmin")
|
||||
org, err := st.CreateOrg(ctx, "Root Flood Org", admin.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("create org: %v", err)
|
||||
}
|
||||
path := "/orgs/" + org.Slug + "/config/root-flood"
|
||||
|
||||
// An unchecked switch submits nothing → deny.
|
||||
resp := post(t, ts, h.app, path, url.Values{}, sess)
|
||||
resp.Body.Close()
|
||||
if got, want := resp.Header.Get("Location"), "/orgs/"+org.Slug+"/config"; resp.StatusCode != http.StatusSeeOther || got != want {
|
||||
t.Fatalf("deny flood = %d → %q, want 303 → %q", resp.StatusCode, got, want)
|
||||
}
|
||||
if allow, err := st.RootAllowFlood(ctx, org.ID); err != nil || allow {
|
||||
t.Fatalf("root flood after deny = %v (err %v), want false", allow, err)
|
||||
}
|
||||
|
||||
// A checked switch submits "1" → allow.
|
||||
resp = post(t, ts, h.app, path, url.Values{"root_allow_flood": {"1"}}, sess)
|
||||
resp.Body.Close()
|
||||
if allow, err := st.RootAllowFlood(ctx, org.ID); err != nil || !allow {
|
||||
t.Fatalf("root flood after allow = %v (err %v), want true", allow, err)
|
||||
}
|
||||
|
||||
// The switch renders checked, reflecting the stored value.
|
||||
page := readBody(t, do(t, ts, h.app, "/orgs/"+org.Slug+"/config", sess))
|
||||
if !strings.Contains(page, `name="root_allow_flood" value="1"`) {
|
||||
t.Fatal("config page should render the root flood switch")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/jleight/meshtender/internal/geo"
|
||||
)
|
||||
|
||||
func TestRegionGeofence(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// No shape → a draft (nil geofence, accepted). The region simply applies
|
||||
// nowhere until an area is drawn.
|
||||
if gf, ok := regionGeofence("", new([]string)); !ok || gf != nil {
|
||||
t.Fatalf("empty shape: got (%v,%v), want (nil,true)", gf, ok)
|
||||
}
|
||||
// Whitespace-only is the same thing (a cleared hidden input).
|
||||
if gf, ok := regionGeofence(" ", new([]string)); !ok || gf != nil {
|
||||
t.Fatalf("blank shape: got (%v,%v), want (nil,true)", gf, ok)
|
||||
}
|
||||
|
||||
// A drawn polygon → carried through verbatim, parseable, contains its center.
|
||||
drawn := `{"type":"Polygon","coordinates":[[[30,10],[40,10],[40,20],[30,20],[30,10]]]}`
|
||||
var errs []string
|
||||
gf, ok := regionGeofence(drawn, &errs)
|
||||
if !ok || gf == nil {
|
||||
t.Fatalf("drawn polygon: got (%v,%v), want non-nil geofence ok", gf, ok)
|
||||
}
|
||||
shape, err := geo.Parse(gf)
|
||||
if err != nil {
|
||||
t.Fatalf("parse geofence: %v", err)
|
||||
}
|
||||
if !shape.Contains(15, 35) {
|
||||
t.Fatal("geofence should contain its center")
|
||||
}
|
||||
|
||||
// Malformed GeoJSON → error.
|
||||
errs = nil
|
||||
if _, ok := regionGeofence("{not geojson", &errs); ok || len(errs) == 0 {
|
||||
t.Fatal("invalid shape should be rejected with an error")
|
||||
}
|
||||
|
||||
// A non-area geometry is rejected too — geofences are Polygon/MultiPolygon.
|
||||
errs = nil
|
||||
if _, ok := regionGeofence(`{"type":"Point","coordinates":[30,10]}`, &errs); ok || len(errs) == 0 {
|
||||
t.Fatal("a Point should be rejected as a region area")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidRegionToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, tok := range []string{"buf", "us-ny", "us_ny", "L3", "0"} {
|
||||
if !validRegionToken(tok) {
|
||||
t.Errorf("validRegionToken(%q) = false, want true", tok)
|
||||
}
|
||||
}
|
||||
// Tokens are space-joined into one `region def` line, so separators and spaces
|
||||
// would corrupt the command.
|
||||
for _, tok := range []string{"", "two words", "a|b", "a,b", "a/b", "né"} {
|
||||
if validRegionToken(tok) {
|
||||
t.Errorf("validRegionToken(%q) = true, want false", tok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegionFormAction(t *testing.T) {
|
||||
t.Parallel()
|
||||
if got, want := regionFormAction("acme", 0), "/orgs/acme/config/regions"; got != want {
|
||||
t.Errorf("new region action = %q, want %q", got, want)
|
||||
}
|
||||
if got, want := regionFormAction("acme", 7), "/orgs/acme/config/regions/7"; got != want {
|
||||
t.Errorf("existing region action = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
@@ -118,29 +118,24 @@ func (s *Handlers) consoleConfigJSON(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// Profile base settings (verbatim; comment-only steps are notes, not commands),
|
||||
// with the location-derived region commands spliced at the profile's
|
||||
// {{ region }} marker or appended after all steps when it has none. The marker
|
||||
// itself is dropped by SplitAtRegionMarker.
|
||||
emitStep := func(step store.ConfigStep) {
|
||||
c := consoleConfigCommand{Line: step.CommandLine, Comment: step.Comment}
|
||||
if step.IsComment() {
|
||||
c.Reason = "note"
|
||||
} else {
|
||||
c.Runnable, c.Reason = runnable(step.CommandLine)
|
||||
// {{ region }} marker or appended after all steps when it has none.
|
||||
//
|
||||
// AssembledLines owns that ordering, shared with the org Configuration page, so
|
||||
// what an owner previews there is exactly what the console offers to run.
|
||||
for _, l := range cv.AssembledLines() {
|
||||
if l.IsMarker {
|
||||
continue // a placeholder for the page; never sent to a device
|
||||
}
|
||||
c := consoleConfigCommand{}
|
||||
switch {
|
||||
case l.IsComment:
|
||||
c.Comment, c.Reason = l.Text, "note"
|
||||
default:
|
||||
c.Line = l.Text
|
||||
c.Runnable, c.Reason = runnable(l.Text)
|
||||
}
|
||||
resp.Commands = append(resp.Commands, c)
|
||||
}
|
||||
before, after := store.SplitAtRegionMarker(cv.SelectedSteps)
|
||||
for _, step := range before {
|
||||
emitStep(step)
|
||||
}
|
||||
for _, line := range cv.RegionDef {
|
||||
c := consoleConfigCommand{Line: line}
|
||||
c.Runnable, c.Reason = runnable(line)
|
||||
resp.Commands = append(resp.Commands, c)
|
||||
}
|
||||
for _, step := range after {
|
||||
emitStep(step)
|
||||
}
|
||||
|
||||
resp.Location = consoleConfigLocation{
|
||||
Known: lat != nil && lon != nil,
|
||||
|
||||
@@ -7,6 +7,8 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/jleight/meshtender/internal/store"
|
||||
)
|
||||
|
||||
// Black-box coverage for the org-management POST endpoints (create/edit/links/
|
||||
@@ -68,8 +70,10 @@ func TestOrgManagementPosts(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// #56 update config profile, #57 delete config profile. (Create #54 and regions
|
||||
// #59 are covered by TestOrgConfigProfilesFlow.)
|
||||
// #56 update config profile, #57 delete config profile, and the matching region
|
||||
// writes (#107 update, #108 delete, #110 area, #111 root flood). (Profile create #54
|
||||
// and region create #59 are covered by TestOrgConfigProfilesFlow; the modal/area
|
||||
// surfaces in config_region_modal_test.go.)
|
||||
func TestOrgConfigProfileUpdateDelete(t *testing.T) {
|
||||
t.Parallel()
|
||||
st, ctx, ts, h := splitServer(t)
|
||||
@@ -97,6 +101,29 @@ func TestOrgConfigProfileUpdateDelete(t *testing.T) {
|
||||
if loc, _ := url.Parse(del.Header.Get("Location")); del.StatusCode != http.StatusSeeOther || loc.Path != base {
|
||||
t.Fatalf("delete profile = %d %q, want 303 → config", del.StatusCode, del.Header.Get("Location"))
|
||||
}
|
||||
|
||||
// Regions mirror the same shape: every write lands back on the config page.
|
||||
rid, err := st.CreateRegion(ctx, org.ID, store.RegionInput{Token: "buf", DisplayName: "Buffalo", AllowFlood: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
region := base + "/regions/" + strconv.FormatInt(rid, 10)
|
||||
for _, step := range []struct {
|
||||
name string
|
||||
path string
|
||||
form url.Values
|
||||
}{
|
||||
{"update region", region, url.Values{"region_token": {"buf"}, "region_display": {"Buffalo NY"}}},
|
||||
{"save area", region + "/area", url.Values{"region_geojson": {`{"type":"Polygon","coordinates":[[[30,10],[40,10],[40,20],[30,20],[30,10]]]}`}}},
|
||||
{"root flood", base + "/root-flood", url.Values{"root_allow_flood": {"1"}}},
|
||||
{"delete region", region + "/delete", url.Values{}},
|
||||
} {
|
||||
resp := post(t, ts, h.app, step.path, step.form, sess)
|
||||
resp.Body.Close()
|
||||
if loc, _ := url.Parse(resp.Header.Get("Location")); resp.StatusCode != http.StatusSeeOther || loc.Path != base {
|
||||
t.Errorf("%s = %d %q, want 303 → config", step.name, resp.StatusCode, resp.Header.Get("Location"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// #46 join, #47 leave — against an org owned by someone else.
|
||||
@@ -251,7 +278,7 @@ func TestOrgEditModals(t *testing.T) {
|
||||
|
||||
// TestOrgTabsHeaderConsistent: the shared org-header renders the Actions menu on
|
||||
// EVERY member tab (not just Home) — the fix for the menu vanishing when you switch
|
||||
// tabs. Admins also get "Edit configuration" in it.
|
||||
// tabs.
|
||||
func TestOrgTabsHeaderConsistent(t *testing.T) {
|
||||
t.Parallel()
|
||||
st, ctx, ts, h := splitServer(t)
|
||||
@@ -262,10 +289,18 @@ func TestOrgTabsHeaderConsistent(t *testing.T) {
|
||||
}
|
||||
for _, tab := range []string{"", "/members", "/repeaters", "/config"} {
|
||||
body := readBody(t, do(t, ts, h.app, "/orgs/"+org.Slug+tab, sess))
|
||||
for _, want := range []string{">Actions<", "Leave organization", "View public page", "Edit configuration"} {
|
||||
for _, want := range []string{">Actions<", "Leave organization", "View public page"} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("tab %q missing %q from the shared Actions header", tab, want)
|
||||
}
|
||||
}
|
||||
// Configuration is reached by its own tab, which an admin always sees — even
|
||||
// before the org has any config — so the Actions menu doesn't duplicate it.
|
||||
if strings.Contains(body, "Edit configuration") {
|
||||
t.Errorf("tab %q still offers Edit configuration in the Actions menu", tab)
|
||||
}
|
||||
if !strings.Contains(body, "/orgs/"+org.Slug+"/config") {
|
||||
t.Errorf("tab %q does not link to the Configuration tab", tab)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
{{define "title"}}Configuration · {{.Org.Name}} · MeshTender{{end}}
|
||||
{{define "header"}}
|
||||
<div class="row g-2 align-items-center">
|
||||
<div class="col">
|
||||
<div class="page-pretitle">{{.Org.Name}}</div>
|
||||
<h1 class="page-title fs-1">Edit configuration</h1>
|
||||
</div>
|
||||
<div class="col-auto ms-auto d-print-none">
|
||||
<a class="btn" href="/orgs/{{.Org.Slug}}/config">Back to configuration</a>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
{{define "content"}}
|
||||
{{template "org-tabs" .Nav}}
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<div>
|
||||
<h2 class="card-title">Regions</h2>
|
||||
<div class="text-secondary small">Named areas drawn on a map. A repeater is auto-assigned to every region whose area contains it, forming its <code>region def</code>.</div>
|
||||
</div>
|
||||
<div class="card-actions">
|
||||
<a class="btn btn-primary" href="/orgs/{{.Org.Slug}}/config/regions">{{template "icon-map-pin" "me-1"}}Edit regions</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{{if .RegionCount}}
|
||||
<div class="text-secondary">{{.RegionCount}} region{{if ne .RegionCount 1}}s{{end}} defined{{if .PrimaryRegion}} · primary: <span class="text-body">{{.PrimaryRegion}}</span>{{end}}.</div>
|
||||
{{else}}
|
||||
<p class="text-secondary mb-0">No regions yet. Add regions to apply location-based settings automatically.</p>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -0,0 +1,96 @@
|
||||
{{/* The region area workspace: one region's geofence, drawn on a full-width map with
|
||||
its siblings shown as read-only context. Attributes live in the modal on the
|
||||
Configuration page — this page saves only the shape, through its own endpoint.
|
||||
|
||||
The side panel is deliberately roomy: it's where boundary import (load a real
|
||||
administrative border and pick the areas you want) will land next to the draw
|
||||
tools. */}}
|
||||
|
||||
{{define "title"}}{{.Region.DisplayName}} area · {{.Org.Name}} · MeshTender{{end}}
|
||||
{{define "header"}}
|
||||
<div class="row g-2 align-items-center">
|
||||
<div class="col">
|
||||
<div class="page-pretitle">{{.Org.Name}} · Configuration · {{.Region.DisplayName}}</div>
|
||||
<h1 class="page-title fs-1">Edit area</h1>
|
||||
</div>
|
||||
<div class="col-auto ms-auto d-print-none">
|
||||
<a class="btn" href="/orgs/{{.Org.Slug}}/config">Back to configuration</a>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
{{define "content"}}
|
||||
{{template "org-tabs" .Nav}}
|
||||
|
||||
{{if .Errors}}
|
||||
<div class="alert alert-danger" role="alert">
|
||||
<div>
|
||||
<div class="fw-bold">The area wasn't saved — fix this first:</div>
|
||||
<ul class="mb-0">{{range .Errors}}<li>{{.}}</li>{{end}}</ul>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<link rel="stylesheet" href="{{ asset "/static/leaflet.css" }}">
|
||||
<link rel="stylesheet" href="{{ asset "/static/leaflet-geoman.css" }}">
|
||||
|
||||
<form method="post" action="/orgs/{{.Org.Slug}}/config/regions/{{.Region.ID}}/area" id="region-area-form">
|
||||
<div class="row g-3">
|
||||
<div class="col-lg-8">
|
||||
<div id="region-map" style="height:600px; border-radius:var(--tblr-border-radius)"
|
||||
role="region" aria-label="Map for drawing this region's boundary"></div>
|
||||
</div>
|
||||
<div class="col-lg-4">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<div>
|
||||
<h2 class="card-title">{{.Region.DisplayName}}</h2>
|
||||
<div class="text-secondary small">
|
||||
<code>{{.Region.Token}}</code> · level {{.Region.Layer}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="mb-3" data-testid="region-area-status">
|
||||
<span class="text-secondary">Area:</span>
|
||||
<span class="fw-bold" id="region-area-status">—</span>
|
||||
</div>
|
||||
<p class="text-secondary small">
|
||||
Draw with the tools at the top-left of the map. A region needs an area
|
||||
before it applies to anything — until then it's a draft and no repeater
|
||||
picks it up.
|
||||
</p>
|
||||
{{if .Siblings}}
|
||||
<p class="text-secondary small mb-0">
|
||||
The org's other regions are outlined for reference. They can't be edited
|
||||
here — open each one's own area editor.
|
||||
</p>
|
||||
{{end}}
|
||||
</div>
|
||||
<div class="card-footer">
|
||||
<div class="btn-list">
|
||||
<button type="submit" class="btn btn-primary" data-testid="save-area">Save area</button>
|
||||
<a class="btn" href="/orgs/{{.Org.Slug}}/config">Cancel</a>
|
||||
<button type="button" class="btn btn-ghost-danger ms-auto" id="region-area-clear">Clear area</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<input type="hidden" name="region_geojson" id="region_geojson" value="{{.Region.GeofenceJSON}}">
|
||||
</form>
|
||||
|
||||
<script src="{{ asset "/static/leaflet.js" }}"></script>
|
||||
<script src="{{ asset "/static/leaflet-geoman.js" }}"></script>
|
||||
<script src="{{ asset "/static/regionmap.js" }}"></script>
|
||||
{{/* Values are interpolated in a JS context, so html/template JSON-encodes and
|
||||
escapes them — the region names and shapes are user data. */}}
|
||||
<script nonce="{{.Nonce}}">
|
||||
initRegionArea('region-map', {
|
||||
input: 'region_geojson',
|
||||
status: 'region-area-status',
|
||||
clear: 'region-area-clear',
|
||||
name: {{.Region.DisplayName}},
|
||||
siblings: {{.Siblings}}
|
||||
});
|
||||
</script>
|
||||
{{end}}
|
||||
@@ -0,0 +1,106 @@
|
||||
{{/* The region attribute editor — everything about a region except its drawn area,
|
||||
which is saved separately on the area workspace page so a rejected edit here
|
||||
can never discard a polygon. Admins normally reach it as the config-region-modal
|
||||
fragment swapped into #config-region-modal on the Configuration page; it posts
|
||||
via htmx so a validation error re-renders the fragment in place (modal stays
|
||||
open, entered values preserved) and a save sends HX-Redirect back to the config
|
||||
page. The full page below is the no-JS / direct-link fallback, so it carries the
|
||||
same form without the htmx attributes. */}}
|
||||
|
||||
{{define "title"}}{{if .Region.ID}}Edit region{{else}}Add region{{end}} · {{.Org.Name}} · MeshTender{{end}}
|
||||
{{define "header"}}
|
||||
<div class="row g-2 align-items-center">
|
||||
<div class="col">
|
||||
<div class="page-pretitle">{{.Org.Name}} · Configuration</div>
|
||||
<h1 class="page-title fs-1">{{if .Region.ID}}Edit region{{else}}Add region{{end}}</h1>
|
||||
</div>
|
||||
<div class="col-auto ms-auto d-print-none">
|
||||
<a class="btn" href="/orgs/{{.Org.Slug}}/config">Back to configuration</a>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
{{define "content"}}
|
||||
{{template "org-tabs" .Nav}}
|
||||
|
||||
{{template "region-editor-alerts" .}}
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<form method="post" action="{{.Action}}">
|
||||
{{template "region-editor-fields" .}}
|
||||
<div class="btn-list mt-3">
|
||||
<button type="submit" class="btn btn-primary">{{if .Region.ID}}Save region{{else}}Create region{{end}}</button>
|
||||
<a class="btn" href="/orgs/{{.Org.Slug}}/config">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{/* config-region-modal is the htmx fragment swapped into #config-region-modal. */}}
|
||||
{{define "config-region-modal"}}
|
||||
<div class="modal-header">
|
||||
<h2 class="modal-title">{{if .Region.ID}}Edit region{{else}}Add region{{end}}</h2>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
{{template "region-editor-alerts" .}}
|
||||
<form method="post" action="{{.Action}}" id="config-region-form"
|
||||
hx-post="{{.Action}}" hx-target="#config-region-modal-content" hx-swap="innerHTML">
|
||||
{{template "region-editor-fields" .}}
|
||||
</form>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-link link-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="submit" form="config-region-form" class="btn btn-primary" data-testid="save-region">{{if .Region.ID}}Save region{{else}}Create region{{end}}</button>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{/* region-editor-alerts renders the validation errors, shared by page and modal. */}}
|
||||
{{define "region-editor-alerts"}}
|
||||
{{if .Errors}}
|
||||
<div class="alert alert-danger" role="alert">
|
||||
<div>
|
||||
<div class="fw-bold">Nothing was saved — fix these first:</div>
|
||||
<ul class="mb-0">{{range .Errors}}<li>{{.}}</li>{{end}}</ul>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
|
||||
{{/* region-editor-fields are the form controls themselves — the <form> element
|
||||
around them differs between the page and the modal. The area is deliberately
|
||||
absent: it's drawn on the workspace page. */}}
|
||||
{{define "region-editor-fields"}}
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="region_display">Display name</label>
|
||||
<input class="form-control" id="region_display" name="region_display" value="{{.Region.DisplayName}}"
|
||||
placeholder="Buffalo" maxlength="60" autofocus>
|
||||
<small class="form-hint">The human label. Defaults to the short name if you leave it blank.</small>
|
||||
</div>
|
||||
<div class="row g-3">
|
||||
<div class="col">
|
||||
<label class="form-label" for="region_token">Short name</label>
|
||||
<input class="form-control font-monospace" id="region_token" name="region_token" value="{{.Region.Token}}"
|
||||
placeholder="buf" maxlength="30" spellcheck="false">
|
||||
<small class="form-hint">Used by MeshCore in <code>region def</code>. Letters, digits, <code>-</code>, <code>_</code>.</small>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<label class="form-label" for="region_layer">Level</label>
|
||||
<input class="form-control" type="number" id="region_layer" name="region_layer" value="{{.Region.Layer}}" style="width:6rem">
|
||||
<small class="form-hint">Depth</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<label class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" name="region_allow_flood" value="1" {{if .Region.AllowFlood}}checked{{end}}>
|
||||
<span class="form-check-label">Allow flood</span>
|
||||
<small class="form-hint">Whether flooding is permitted inside this region.</small>
|
||||
</label>
|
||||
<label class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" name="region_primary" value="1" {{if .Region.Primary}}checked{{end}}>
|
||||
<span class="form-check-label">Primary region</span>
|
||||
<small class="form-hint">Frames the map on the Configuration page. Turning this on clears whichever region held it.</small>
|
||||
</label>
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -1,168 +0,0 @@
|
||||
{{define "title"}}Edit regions · {{.Org.Name}} · MeshTender{{end}}
|
||||
{{define "header"}}
|
||||
<div class="row g-2 align-items-center">
|
||||
<div class="col">
|
||||
<div class="page-pretitle">{{.Org.Name}} · Configuration</div>
|
||||
<h1 class="page-title fs-1">Edit regions</h1>
|
||||
</div>
|
||||
<div class="col-auto ms-auto d-print-none">
|
||||
<a class="btn" href="/orgs/{{.Org.Slug}}/config/edit">Back to configuration</a>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
{{define "content"}}
|
||||
{{template "org-tabs" .Nav}}
|
||||
|
||||
{{if .Errors}}
|
||||
<div class="alert alert-danger">
|
||||
<div>
|
||||
<div class="fw-bold">Nothing was saved — fix these first:</div>
|
||||
<ul class="mb-0">{{range .Errors}}<li>{{.}}</li>{{end}}</ul>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<link rel="stylesheet" href="{{ asset "/static/leaflet.css" }}">
|
||||
<link rel="stylesheet" href="{{ asset "/static/leaflet-geoman.css" }}">
|
||||
<style>
|
||||
@media (min-width: 992px) { .region-map-sticky { position: sticky; top: 1rem; } }
|
||||
</style>
|
||||
|
||||
<p class="text-secondary">Each region has a <strong>display name</strong> (e.g. Buffalo), a short <strong>name</strong> used by MeshCore (e.g. <code>buf</code>), and a <strong>level</strong> for its depth (lower = broader, e.g. country 1 → state 2 → city 3). Pick a region in the list, then use the map tools to outline where it applies — every region needs an area (the root region at the top of the list is the one that applies everywhere).</p>
|
||||
|
||||
<form method="post" action="/orgs/{{.Org.Slug}}/config/regions" id="regions-form">
|
||||
<div class="row g-3">
|
||||
<div class="col-lg-7">
|
||||
<div class="region-map-sticky">
|
||||
<div id="region-map" style="height:520px; border-radius:var(--tblr-border-radius)" role="region" aria-label="Map for drawing region boundaries"></div>
|
||||
<p class="text-secondary small mt-2 mb-0">Draw with the tools at the top-left of the map. New shapes fill the selected region; drawing again adds a new region.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-5">
|
||||
{{/* The root region (*) is pinned first in the list. It is not a .region-block,
|
||||
so the map editor ignores it; its only setting is flood policy. */}}
|
||||
<div class="card card-sm mb-2">
|
||||
<div class="card-body d-flex align-items-center gap-3">
|
||||
<div class="flex-fill" style="min-width:0">
|
||||
<div class="fw-bold">Root <span class="text-secondary fw-normal">· <code>*</code> · everywhere</span></div>
|
||||
<div class="text-secondary small">Applies to every repeater. Deny flood to scope flooding to your defined regions.</div>
|
||||
</div>
|
||||
<label class="form-check form-switch m-0 flex-shrink-0 text-nowrap">
|
||||
<input type="hidden" name="root_allow_flood" value="{{if .RootAllowFlood}}1{{end}}">
|
||||
<input class="form-check-input" type="checkbox" {{if .RootAllowFlood}}checked{{end}} data-flood>
|
||||
<span class="form-check-label">Allow flood</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div id="regions">
|
||||
{{range .Regions}}{{template "region-block" .}}{{end}}
|
||||
</div>
|
||||
<button type="button" class="btn btn-outline-primary w-100" id="add-region">{{template "icon-plus" "me-1"}}Add region</button>
|
||||
<div class="btn-list mt-3">
|
||||
<button type="submit" class="btn btn-primary">Save regions</button>
|
||||
<a class="btn" href="/orgs/{{.Org.Slug}}/config">Cancel</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{{/* Hidden prototype cloned by addBlock(); kept out of the form via the template tag. */}}
|
||||
<template id="region-proto">{{template "region-block" .EmptyRegion}}</template>
|
||||
<script nonce="{{.Nonce}}">
|
||||
// addBlock stays global: regionmap.js calls it when a freshly drawn shape needs a
|
||||
// new region row (then it reconciles the map). The rest is delegated off the form
|
||||
// so cloned rows work without inline handlers (CSP-friendly).
|
||||
function addBlock(kind) {
|
||||
const proto = document.getElementById(kind + '-proto');
|
||||
document.getElementById(kind + 's').appendChild(proto.content.cloneNode(true));
|
||||
}
|
||||
(function () {
|
||||
const form = document.getElementById('regions-form');
|
||||
const rows = document.getElementById('regions');
|
||||
if (!form || !rows) return;
|
||||
const addBtn = document.getElementById('add-region');
|
||||
if (addBtn) addBtn.addEventListener('click', function () { addBlock('region'); });
|
||||
|
||||
// The flood and primary switches each shadow a hidden input (submitted with the
|
||||
// form). Flood is independent per region; primary is exclusive — turning one on
|
||||
// clears the rest.
|
||||
form.addEventListener('change', function (e) {
|
||||
if (!e.target.matches) return;
|
||||
if (e.target.matches('[data-flood]')) {
|
||||
e.target.previousElementSibling.value = e.target.checked ? '1' : '';
|
||||
} else if (e.target.matches('[data-primary]')) {
|
||||
setPrimaryRegion(e.target);
|
||||
}
|
||||
});
|
||||
|
||||
form.addEventListener('click', function (e) {
|
||||
if (!e.target.closest) return;
|
||||
const removeBtn = e.target.closest('.region-remove-btn');
|
||||
if (removeBtn) { const b = removeBtn.closest('.region-block'); if (b) b.remove(); }
|
||||
});
|
||||
|
||||
// Only one region may be primary: reflect the toggled switch onto every block's
|
||||
// switch + hidden input, clearing the others.
|
||||
function setPrimaryRegion(cb) {
|
||||
const block = cb.closest('.region-block');
|
||||
const on = cb.checked;
|
||||
rows.querySelectorAll('.region-block').forEach(function (b) {
|
||||
const isThis = b === block && on;
|
||||
const box = b.querySelector('[data-primary]');
|
||||
const hidden = b.querySelector('input[name="region_primary"]');
|
||||
if (box) box.checked = isThis;
|
||||
if (hidden) hidden.value = isThis ? '1' : '';
|
||||
});
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<script src="{{ asset "/static/leaflet.js" }}"></script>
|
||||
<script src="{{ asset "/static/leaflet-geoman.js" }}"></script>
|
||||
<script src="{{ asset "/static/regionmap.js" }}"></script>
|
||||
<script nonce="{{.Nonce}}">initRegionEditor('region-map', 'regions');</script>
|
||||
{{end}}
|
||||
|
||||
{{/* region-block is one node of the region hierarchy, shown as a card in the side
|
||||
list. Its area is drawn on the map; region_geojson carries the GeoJSON the map
|
||||
writes. regionmap.js keys off .region-block / region_geojson /
|
||||
.region-clear / .region-active. */}}
|
||||
{{define "region-block"}}
|
||||
<div class="region-block card card-sm mb-2">
|
||||
<div class="card-body">
|
||||
<div class="row g-2 align-items-end">
|
||||
<div class="col-12"><label class="form-label mb-1">Display name</label>
|
||||
<input class="form-control" name="region_display" value="{{.DisplayName}}" placeholder="Buffalo"
|
||||
aria-label="Region display name"></div>
|
||||
<div class="col"><label class="form-label mb-1">Name</label>
|
||||
<input class="form-control font-monospace" name="region_token" value="{{.Token}}" placeholder="buf"
|
||||
aria-label="Region name (token used in commands)"></div>
|
||||
<div class="col-auto"><label class="form-label mb-1">Level</label>
|
||||
<input class="form-control" type="number" name="region_layer" value="{{.Layer}}" style="width:5rem"
|
||||
aria-label="Region level"></div>
|
||||
</div>
|
||||
{{/* Settings/state: the region's own properties, shown as switches (matching
|
||||
the root's flood switch) so each toggle reads clearly with its label,
|
||||
distinct from the action buttons below. */}}
|
||||
<div class="d-flex align-items-center flex-wrap gap-3 mt-2">
|
||||
<label class="form-check form-switch m-0">
|
||||
<input type="hidden" name="region_allow_flood" value="{{if .AllowFlood}}1{{end}}">
|
||||
<input class="form-check-input" type="checkbox" {{if .AllowFlood}}checked{{end}} data-flood>
|
||||
<span class="form-check-label">Allow flood</span>
|
||||
</label>
|
||||
<label class="form-check form-switch m-0">
|
||||
{{/* Only one region may be primary; toggling one on clears the rest (see JS). */}}
|
||||
<input type="hidden" name="region_primary" value="{{if .Primary}}1{{end}}">
|
||||
<input class="form-check-input" type="checkbox" {{if .Primary}}checked{{end}} data-primary>
|
||||
<span class="form-check-label">Primary</span>
|
||||
</label>
|
||||
</div>
|
||||
{{/* Actions on the region's map area, plus Remove (danger) pushed to the end. */}}
|
||||
<div class="d-flex align-items-center flex-wrap gap-2 mt-2 pt-2 border-top">
|
||||
<button type="button" class="btn btn-sm region-edit-btn">Edit area</button>
|
||||
<button type="button" class="btn btn-sm btn-ghost-secondary region-clear">Clear area</button>
|
||||
<button type="button" class="btn btn-sm btn-ghost-danger ms-auto region-remove-btn">Remove</button>
|
||||
</div>
|
||||
<input type="hidden" name="region_geojson" value="{{.GeofenceJSON}}">
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
+11
-3
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -153,7 +153,9 @@ func TestProfileCRUD(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplaceRegionsKeepsProfiles(t *testing.T) {
|
||||
// TestRegionWritesKeepProfiles: profiles and regions are independent halves of an
|
||||
// org's config, so writing regions (or the root flood policy) never disturbs them.
|
||||
func TestRegionWritesKeepProfiles(t *testing.T) {
|
||||
t.Parallel()
|
||||
st, ctx := orgTestStore(t)
|
||||
owner, err := st.CreateUser(ctx, "rgnowner", "")
|
||||
@@ -168,37 +170,276 @@ func TestReplaceRegionsKeepsProfiles(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := st.ReplaceRegions(ctx, org.ID, []RegionInput{
|
||||
{Token: "country", DisplayName: "Country", Layer: 1, AllowFlood: true},
|
||||
{Token: "metro", DisplayName: "Metro", Layer: 2, Primary: true, AllowFlood: true, GeofenceJSON: geo.Rectangle(10, 30, 20, 40)},
|
||||
}, false); err != nil {
|
||||
t.Fatalf("replace regions: %v", err)
|
||||
country, err := st.CreateRegion(ctx, org.ID, RegionInput{Token: "country", DisplayName: "Country", Layer: 1, AllowFlood: true})
|
||||
if err != nil {
|
||||
t.Fatalf("create country: %v", err)
|
||||
}
|
||||
if _, err := st.CreateRegion(ctx, org.ID, RegionInput{
|
||||
Token: "metro", DisplayName: "Metro", Layer: 2, Primary: true, AllowFlood: true,
|
||||
GeofenceJSON: geo.Rectangle(10, 30, 20, 40),
|
||||
}); err != nil {
|
||||
t.Fatalf("create metro: %v", err)
|
||||
}
|
||||
if err := st.SetRootAllowFlood(ctx, org.ID, false); err != nil {
|
||||
t.Fatalf("set root flood: %v", err)
|
||||
}
|
||||
// The root (*) flood policy round-trips (we passed deny above).
|
||||
if allow, err := st.RootAllowFlood(ctx, org.ID); err != nil || allow {
|
||||
t.Fatalf("root allow flood = %v (err %v), want false", allow, err)
|
||||
}
|
||||
// Regions saved and profiles untouched.
|
||||
|
||||
gotR, _ := st.ListRegions(ctx, org.ID)
|
||||
if len(gotR) != 2 {
|
||||
t.Fatalf("regions = %d, want 2", len(gotR))
|
||||
}
|
||||
gotP, _ := st.ListProfiles(ctx, org.ID)
|
||||
if len(gotP) != 1 || gotP[0].Name != "Keep" {
|
||||
t.Fatalf("profiles after region replace = %+v, want [Keep]", gotP)
|
||||
t.Fatalf("profiles after region writes = %+v, want [Keep]", gotP)
|
||||
}
|
||||
|
||||
// Replacing with an empty set clears regions but still keeps profiles.
|
||||
if err := st.ReplaceRegions(ctx, org.ID, nil, true); err != nil {
|
||||
t.Fatalf("clear regions: %v", err)
|
||||
// Deleting every region likewise leaves the profiles alone.
|
||||
if err := st.DeleteRegion(ctx, org.ID, country); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
gotR, _ = st.ListRegions(ctx, org.ID)
|
||||
if len(gotR) != 0 {
|
||||
t.Fatalf("regions after clear = %d, want 0", len(gotR))
|
||||
for _, z := range gotR {
|
||||
_ = st.DeleteRegion(ctx, org.ID, z.ID)
|
||||
}
|
||||
gotP, _ = st.ListProfiles(ctx, org.ID)
|
||||
if len(gotP) != 1 {
|
||||
t.Fatalf("profiles after clear = %d, want 1", len(gotP))
|
||||
if gotR, _ = st.ListRegions(ctx, org.ID); len(gotR) != 0 {
|
||||
t.Fatalf("regions after delete = %d, want 0", len(gotR))
|
||||
}
|
||||
if gotP, _ = st.ListProfiles(ctx, org.ID); len(gotP) != 1 {
|
||||
t.Fatalf("profiles after region delete = %d, want 1", len(gotP))
|
||||
}
|
||||
}
|
||||
|
||||
// TestRegionCRUD covers the per-region write path the region editor uses: one row
|
||||
// at a time, with the geofence saved separately from the attributes.
|
||||
func TestRegionCRUD(t *testing.T) {
|
||||
t.Parallel()
|
||||
st, ctx := orgTestStore(t)
|
||||
owner, err := st.CreateUser(ctx, "rgncrud", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
org, err := st.CreateOrg(ctx, "RgnCrudOrg", owner.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Created without an area: a draft. Geofence stays NULL rather than becoming an
|
||||
// empty shape, so RegionMatches keeps it out of every region def.
|
||||
id, err := st.CreateRegion(ctx, org.ID, RegionInput{
|
||||
Token: "buf", DisplayName: "Buffalo", Layer: 3, AllowFlood: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
got, err := st.GetRegion(ctx, org.ID, id)
|
||||
if err != nil {
|
||||
t.Fatalf("get: %v", err)
|
||||
}
|
||||
if got.Token != "buf" || got.DisplayName != "Buffalo" || got.Layer != 3 || !got.AllowFlood {
|
||||
t.Fatalf("get region = %+v", got)
|
||||
}
|
||||
if got.Geofence != nil || got.GeofenceJSON != nil {
|
||||
t.Fatalf("draft region has a shape: %+v", got)
|
||||
}
|
||||
|
||||
// Token collides within the org.
|
||||
if _, err := st.CreateRegion(ctx, org.ID, RegionInput{Token: "buf", DisplayName: "Dup"}); err != ErrDuplicate {
|
||||
t.Fatalf("duplicate token err = %v, want ErrDuplicate", err)
|
||||
}
|
||||
|
||||
// Another org can't see or write it.
|
||||
other, err := st.CreateOrg(ctx, "OtherRgnOrg", owner.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := st.GetRegion(ctx, other.ID, id); err != ErrNotFound {
|
||||
t.Fatalf("cross-org get err = %v, want ErrNotFound", err)
|
||||
}
|
||||
if err := st.UpdateRegion(ctx, other.ID, id, RegionInput{Token: "x"}); err != ErrNotFound {
|
||||
t.Fatalf("cross-org update err = %v, want ErrNotFound", err)
|
||||
}
|
||||
if err := st.UpdateRegionGeofence(ctx, other.ID, id, geo.Rectangle(0, 0, 1, 1)); err != ErrNotFound {
|
||||
t.Fatalf("cross-org geofence update err = %v, want ErrNotFound", err)
|
||||
}
|
||||
|
||||
// Draw the area, then edit the attributes: the shape must survive an attribute
|
||||
// edit, since the two are saved by separate requests.
|
||||
box := geo.Rectangle(40, -80, 45, -75)
|
||||
if err := st.UpdateRegionGeofence(ctx, org.ID, id, box); err != nil {
|
||||
t.Fatalf("save geofence: %v", err)
|
||||
}
|
||||
if err := st.UpdateRegion(ctx, org.ID, id, RegionInput{
|
||||
Token: "buf", DisplayName: "Buffalo NY", Layer: 4, AllowFlood: false,
|
||||
}); err != nil {
|
||||
t.Fatalf("update: %v", err)
|
||||
}
|
||||
got, _ = st.GetRegion(ctx, org.ID, id)
|
||||
if got.DisplayName != "Buffalo NY" || got.Layer != 4 || got.AllowFlood {
|
||||
t.Fatalf("after attribute update = %+v", got)
|
||||
}
|
||||
if got.Geofence == nil || !got.Geofence.Contains(42, -78) {
|
||||
t.Fatalf("attribute update dropped the drawn area: %+v", got)
|
||||
}
|
||||
|
||||
// Clearing the area returns it to a draft.
|
||||
if err := st.UpdateRegionGeofence(ctx, org.ID, id, nil); err != nil {
|
||||
t.Fatalf("clear geofence: %v", err)
|
||||
}
|
||||
if got, _ = st.GetRegion(ctx, org.ID, id); got.Geofence != nil {
|
||||
t.Fatalf("cleared area still present: %+v", got)
|
||||
}
|
||||
|
||||
// Delete, then the idempotency guard.
|
||||
if err := st.DeleteRegion(ctx, org.ID, id); err != nil {
|
||||
t.Fatalf("delete: %v", err)
|
||||
}
|
||||
if err := st.DeleteRegion(ctx, org.ID, id); err != ErrNotFound {
|
||||
t.Fatalf("re-delete err = %v, want ErrNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRegionPrimaryIsExclusive: at most one region per org is primary. This used to
|
||||
// be enforced by the bulk editor's JavaScript clearing the other switches; now the
|
||||
// write clears siblings in the same transaction, backed by a partial unique index.
|
||||
func TestRegionPrimaryIsExclusive(t *testing.T) {
|
||||
t.Parallel()
|
||||
st, ctx := orgTestStore(t)
|
||||
owner, err := st.CreateUser(ctx, "rgnprimary", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
org, err := st.CreateOrg(ctx, "RgnPrimaryOrg", owner.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// A second org's primary must be untouched by any of this.
|
||||
otherOrg, err := st.CreateOrg(ctx, "RgnPrimaryOther", owner.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := st.CreateRegion(ctx, otherOrg.ID, RegionInput{Token: "oth", DisplayName: "Other", Primary: true}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
primaryToken := func(orgID int64) string {
|
||||
t.Helper()
|
||||
regions, err := st.ListRegions(ctx, orgID)
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
var found []string
|
||||
for _, z := range regions {
|
||||
if z.Primary {
|
||||
found = append(found, z.Token)
|
||||
}
|
||||
}
|
||||
if len(found) > 1 {
|
||||
t.Fatalf("org %d has %d primary regions: %v", orgID, len(found), found)
|
||||
}
|
||||
if len(found) == 0 {
|
||||
return ""
|
||||
}
|
||||
return found[0]
|
||||
}
|
||||
|
||||
a, err := st.CreateRegion(ctx, org.ID, RegionInput{Token: "a", DisplayName: "A", Primary: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := primaryToken(org.ID); got != "a" {
|
||||
t.Fatalf("primary after first create = %q, want a", got)
|
||||
}
|
||||
|
||||
// Creating a second primary demotes the first.
|
||||
if _, err := st.CreateRegion(ctx, org.ID, RegionInput{Token: "b", DisplayName: "B", Primary: true}); err != nil {
|
||||
t.Fatalf("create second primary: %v", err)
|
||||
}
|
||||
if got := primaryToken(org.ID); got != "b" {
|
||||
t.Fatalf("primary after second create = %q, want b", got)
|
||||
}
|
||||
|
||||
// So does promoting one by update.
|
||||
if err := st.UpdateRegion(ctx, org.ID, a, RegionInput{Token: "a", DisplayName: "A", Primary: true}); err != nil {
|
||||
t.Fatalf("promote a: %v", err)
|
||||
}
|
||||
if got := primaryToken(org.ID); got != "a" {
|
||||
t.Fatalf("primary after promoting a = %q, want a", got)
|
||||
}
|
||||
|
||||
// Turning the flag off leaves the org with none — it must not promote a sibling.
|
||||
if err := st.UpdateRegion(ctx, org.ID, a, RegionInput{Token: "a", DisplayName: "A", Primary: false}); err != nil {
|
||||
t.Fatalf("demote a: %v", err)
|
||||
}
|
||||
if got := primaryToken(org.ID); got != "" {
|
||||
t.Fatalf("primary after demoting a = %q, want none", got)
|
||||
}
|
||||
|
||||
// The other org kept its own primary throughout.
|
||||
if got := primaryToken(otherOrg.ID); got != "oth" {
|
||||
t.Fatalf("other org's primary = %q, want oth", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRegionPrimaryIndexIsEnforced checks that one-primary-per-org survives a
|
||||
// writer that doesn't go through CreateRegion/UpdateRegion, by inserting a second
|
||||
// primary with raw SQL. CreateRegion clears siblings first, so this partial unique
|
||||
// index (migration 0044) is otherwise never exercised — without this test nothing
|
||||
// would notice if the migration were dropped.
|
||||
func TestRegionPrimaryIndexIsEnforced(t *testing.T) {
|
||||
t.Parallel()
|
||||
st, ctx := orgTestStore(t)
|
||||
owner, err := st.CreateUser(ctx, "rgnindex", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
org, err := st.CreateOrg(ctx, "RgnIndexOrg", owner.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw := func(token string) error {
|
||||
_, err := st.pool.Exec(ctx,
|
||||
`INSERT INTO config_regions (org_id, token, display_name, layer, is_primary, allow_flood)
|
||||
VALUES ($1, $2, $2, 1, true, true)`, org.ID, token)
|
||||
return err
|
||||
}
|
||||
if err := raw("first"); err != nil {
|
||||
t.Fatalf("first raw primary insert: %v", err)
|
||||
}
|
||||
if err := raw("second"); !isUniqueViolation(err) {
|
||||
t.Fatalf("second raw primary insert err = %v, want a unique violation", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSetRootAllowFlood: the root (*) flood policy lives on the org, not as a
|
||||
// region row, so it toggles independently of any region.
|
||||
func TestSetRootAllowFlood(t *testing.T) {
|
||||
t.Parallel()
|
||||
st, ctx := orgTestStore(t)
|
||||
owner, err := st.CreateUser(ctx, "rootflood", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
org, err := st.CreateOrg(ctx, "RootFloodOrg", owner.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Default is allow.
|
||||
if allow, err := st.RootAllowFlood(ctx, org.ID); err != nil || !allow {
|
||||
t.Fatalf("default root flood = (%v, %v), want (true, nil)", allow, err)
|
||||
}
|
||||
if err := st.SetRootAllowFlood(ctx, org.ID, false); err != nil {
|
||||
t.Fatalf("deny: %v", err)
|
||||
}
|
||||
if allow, err := st.RootAllowFlood(ctx, org.ID); err != nil || allow {
|
||||
t.Fatalf("after deny = (%v, %v), want (false, nil)", allow, err)
|
||||
}
|
||||
if err := st.SetRootAllowFlood(ctx, org.ID, true); err != nil {
|
||||
t.Fatalf("allow: %v", err)
|
||||
}
|
||||
if allow, _ := st.RootAllowFlood(ctx, org.ID); !allow {
|
||||
t.Fatal("root flood did not toggle back to allow")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -247,6 +488,48 @@ func TestRegionDefCommands(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestRegionDefIgnoresDraftRegions: a draft region — one created but whose area
|
||||
// hasn't been drawn yet, so its geofence is NULL — must apply nowhere. Regression
|
||||
// test: RegionMatches used to report a nil geofence as a match, which made a
|
||||
// shapeless row apply to every repeater everywhere and inject its token into every
|
||||
// region def chain. "Everywhere" is the org root (*), which lives on the
|
||||
// organizations row, never as a config_regions row.
|
||||
func TestRegionDefIgnoresDraftRegions(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
mk := func(token string, layer int, box []byte) Region {
|
||||
shape, err := geo.Parse(box)
|
||||
if err != nil {
|
||||
t.Fatalf("parse %s: %v", token, err)
|
||||
}
|
||||
return Region{Token: token, Layer: layer, AllowFlood: true, Geofence: shape}
|
||||
}
|
||||
// draft sits at layer 2 with no shape — between us (L1) and buf (L3), so if it
|
||||
// matched it would also wedge itself into the middle of the chain.
|
||||
regions := []Region{
|
||||
mk("us", 1, geo.Rectangle(0, 0, 100, 100)),
|
||||
mk("draft", 2, nil),
|
||||
mk("buf", 3, geo.Rectangle(12, 12, 18, 18)),
|
||||
}
|
||||
|
||||
// Inside us + buf: the draft must not appear, and must not re-parent buf.
|
||||
got := RegionDefCommands(regions, true, ptr(15.0), ptr(15.0))
|
||||
if len(got) == 0 || got[0] != "region def us buf" {
|
||||
t.Fatalf("inside = %v, want first line %q", got, "region def us buf")
|
||||
}
|
||||
for _, line := range got {
|
||||
if strings.Contains(line, "draft") {
|
||||
t.Errorf("draft region leaked into %q (full %v)", line, got)
|
||||
}
|
||||
}
|
||||
|
||||
// Outside every drawn area: no commands at all. A draft must not be the reason
|
||||
// a config gets emitted — that's the `denyf *` safety guard.
|
||||
if got := RegionDefCommands(regions, true, ptr(-5.0), ptr(-5.0)); got != nil {
|
||||
t.Fatalf("outside with a draft present = %v, want nil", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegionDefFloodCommands(t *testing.T) {
|
||||
t.Parallel()
|
||||
mk := func(token string, layer int, allow bool, box []byte) Region {
|
||||
@@ -346,8 +629,7 @@ func TestListRepeaterConfigOrgs(t *testing.T) {
|
||||
}
|
||||
// B: owner is a member, region-only config (no profiles) → included, empty profiles.
|
||||
orgB, _ := st.CreateOrg(ctx, "Bravo", owner.ID)
|
||||
if err := st.ReplaceRegions(ctx, orgB.ID,
|
||||
[]RegionInput{{Token: "x", DisplayName: "X", Layer: 1}}, true); err != nil {
|
||||
if _, err := st.CreateRegion(ctx, orgB.ID, RegionInput{Token: "x", DisplayName: "X", Layer: 1}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// C: owner is a member but the org has NO config → excluded.
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
-- +goose Up
|
||||
-- Regions are edited one at a time now (per-region CRUD) rather than replaced as a
|
||||
-- whole set, so "at most one primary region per org" can no longer be enforced by
|
||||
-- the editor's JavaScript clearing the other switches. CreateRegion/UpdateRegion
|
||||
-- clear any other primary in the same transaction; this index is the backstop that
|
||||
-- keeps the invariant true no matter who writes the table.
|
||||
--
|
||||
-- Existing data can hold more than one primary (the old bulk editor only enforced
|
||||
-- exclusivity client-side), so demote all but the lowest-id primary per org before
|
||||
-- the unique index goes on.
|
||||
UPDATE config_regions SET is_primary = false
|
||||
WHERE is_primary
|
||||
AND id NOT IN (SELECT min(id) FROM config_regions WHERE is_primary GROUP BY org_id);
|
||||
|
||||
CREATE UNIQUE INDEX config_regions_one_primary_idx
|
||||
ON config_regions (org_id) WHERE is_primary;
|
||||
|
||||
-- +goose Down
|
||||
DROP INDEX config_regions_one_primary_idx;
|
||||
+164
-47
@@ -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
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/jleight/meshtender/internal/store"
|
||||
)
|
||||
|
||||
// lines flattens an assembled preview into "kind:text" tokens, so a test can assert
|
||||
// ordering and provenance in one comparison.
|
||||
func lines(t *testing.T, cv ConfigView) []string {
|
||||
t.Helper()
|
||||
var out []string
|
||||
for _, l := range cv.AssembledLines() {
|
||||
switch {
|
||||
case l.IsMarker:
|
||||
out = append(out, "marker:")
|
||||
case l.IsComment:
|
||||
out = append(out, "note:"+l.Text)
|
||||
case l.FromRegion:
|
||||
out = append(out, "region:"+l.Text)
|
||||
default:
|
||||
out = append(out, "cmd:"+l.Text)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func eq(t *testing.T, got, want []string, label string) {
|
||||
t.Helper()
|
||||
if strings.Join(got, "|") != strings.Join(want, "|") {
|
||||
t.Errorf("%s:\n got %v\nwant %v", label, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAssembledLinesSplicesAtMarker: region commands land where the profile's
|
||||
// {{ region }} marker sits, and the marker itself never reaches the page as literal
|
||||
// text.
|
||||
func TestAssembledLinesSplicesAtMarker(t *testing.T) {
|
||||
t.Parallel()
|
||||
cv := ConfigView{
|
||||
SelectedSteps: []store.ConfigStep{
|
||||
{CommandLine: "set tx 22"},
|
||||
{Comment: "tune later"},
|
||||
{CommandLine: store.RegionMarker},
|
||||
{CommandLine: "set repeat on"},
|
||||
},
|
||||
RegionDef: []string{"region def us", "region save"},
|
||||
}
|
||||
eq(t, lines(t, cv), []string{
|
||||
"cmd:set tx 22",
|
||||
"note:tune later",
|
||||
"region:region def us",
|
||||
"region:region save",
|
||||
"cmd:set repeat on",
|
||||
}, "spliced at marker")
|
||||
|
||||
for _, l := range cv.AssembledLines() {
|
||||
if strings.Contains(l.Text, "{{") {
|
||||
t.Errorf("the region marker leaked into the preview as %q", l.Text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestAssembledLinesAppendsWithoutMarker: with no marker the region block follows
|
||||
// every profile step — the documented default.
|
||||
func TestAssembledLinesAppendsWithoutMarker(t *testing.T) {
|
||||
t.Parallel()
|
||||
cv := ConfigView{
|
||||
SelectedSteps: []store.ConfigStep{{CommandLine: "set tx 22"}, {CommandLine: "set repeat on"}},
|
||||
RegionDef: []string{"region def us", "region save"},
|
||||
}
|
||||
eq(t, lines(t, cv), []string{
|
||||
"cmd:set tx 22",
|
||||
"cmd:set repeat on",
|
||||
"region:region def us",
|
||||
"region:region save",
|
||||
}, "appended without marker")
|
||||
}
|
||||
|
||||
// TestAssembledLinesWithoutLocation: with no location picked there are no region
|
||||
// commands. A profile that has a marker keeps it as a placeholder the page can
|
||||
// explain; one without a marker simply shows its steps.
|
||||
func TestAssembledLinesWithoutLocation(t *testing.T) {
|
||||
t.Parallel()
|
||||
withMarker := ConfigView{SelectedSteps: []store.ConfigStep{
|
||||
{CommandLine: "set tx 22"},
|
||||
{CommandLine: store.RegionMarker},
|
||||
{CommandLine: "set repeat on"},
|
||||
}}
|
||||
eq(t, lines(t, withMarker), []string{"cmd:set tx 22", "marker:", "cmd:set repeat on"},
|
||||
"no location, marker present")
|
||||
|
||||
noMarker := ConfigView{SelectedSteps: []store.ConfigStep{{CommandLine: "set tx 22"}}}
|
||||
eq(t, lines(t, noMarker), []string{"cmd:set tx 22"}, "no location, no marker")
|
||||
|
||||
// Region-only org: nothing selected, but a picked location still previews.
|
||||
regionOnly := ConfigView{RegionDef: []string{"region def us", "region save"}}
|
||||
eq(t, lines(t, regionOnly), []string{"region:region def us", "region:region save"},
|
||||
"regions with no profile")
|
||||
|
||||
// Empty config assembles to nothing rather than a stray marker.
|
||||
var empty ConfigView
|
||||
if got := empty.AssembledLines(); len(got) != 0 {
|
||||
t.Errorf("empty config assembled to %v, want nothing", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAssembledLinesMarkerOnlyProfile: a profile that is nothing but the marker
|
||||
// yields exactly the region block.
|
||||
func TestAssembledLinesMarkerOnlyProfile(t *testing.T) {
|
||||
t.Parallel()
|
||||
cv := ConfigView{
|
||||
SelectedSteps: []store.ConfigStep{{CommandLine: store.RegionMarker}},
|
||||
RegionDef: []string{"region def us"},
|
||||
}
|
||||
eq(t, lines(t, cv), []string{"region:region def us"}, "marker-only profile")
|
||||
}
|
||||
|
||||
// TestRegionColorWrapsAround: every region gets a color, and the palette repeats
|
||||
// rather than running out, so the legend swatch always matches a drawn polygon.
|
||||
func TestRegionColorWrapsAround(t *testing.T) {
|
||||
t.Parallel()
|
||||
if RegionColor(0) != RegionPalette[0] {
|
||||
t.Fatalf("RegionColor(0) = %q, want %q", RegionColor(0), RegionPalette[0])
|
||||
}
|
||||
n := len(RegionPalette)
|
||||
if RegionColor(n) != RegionPalette[0] || RegionColor(n+2) != RegionPalette[2] {
|
||||
t.Fatalf("palette did not wrap: %q, %q", RegionColor(n), RegionColor(n+2))
|
||||
}
|
||||
for i := 0; i < n*2; i++ {
|
||||
if RegionColor(i) == "" {
|
||||
t.Fatalf("RegionColor(%d) is empty", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
+146
-153
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -19,6 +19,10 @@
|
||||
// [data-risky] (checkbox) — confirm before enabling; unchecks on cancel.
|
||||
// Delegated so it works in htmx-swapped content
|
||||
// (a modal fragment can't run inline script).
|
||||
// [data-tooltip] — show this element's title= as a styled tooltip
|
||||
// on hover/focus, instead of the browser's slow
|
||||
// native one. Use it on icon-only controls,
|
||||
// where the icon alone doesn't say what it does.
|
||||
//
|
||||
// It also localizes timestamps: any <time data-fmt="…"> element (emitted by the
|
||||
// `ts` template func) is rewritten from its machine-readable datetime attribute
|
||||
@@ -26,6 +30,18 @@
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
// --- tooltips ------------------------------------------------------------
|
||||
// Bootstrap tooltips are opt-in, so wire them once here rather than per page.
|
||||
// Delegating through the `selector` option means a control swapped in later by
|
||||
// htmx (a modal fragment, a re-rendered list) gets one with no re-initialization.
|
||||
//
|
||||
// It reads the element's title=, which stays as the no-JS fallback: without this,
|
||||
// hovering still surfaces the native tooltip. Note data-bs-toggle is NOT the hook
|
||||
// — an element can only carry one, and these controls already use it for modals.
|
||||
if (window.tabler && window.tabler.Tooltip) {
|
||||
new window.tabler.Tooltip(document.body, { selector: "[data-tooltip]" });
|
||||
}
|
||||
|
||||
// --- timestamp localization ---------------------------------------------
|
||||
// The server emits <time datetime="<RFC3339 UTC>" data-fmt="date|datetime|
|
||||
// time|time-seconds">UTC fallback</time>. We reformat each into the viewer's
|
||||
|
||||
@@ -8,16 +8,35 @@
|
||||
This organization hasn't defined any configuration yet.
|
||||
</div></div>
|
||||
{{else}}
|
||||
|
||||
{{/* Section 1 — the configuration a chosen profile produces, with the profile list
|
||||
in the right rail above the region legend (the sidebar position the rest of the
|
||||
site uses). The main pane shows the *assembled* list — profile steps with the
|
||||
location's region commands spliced in — which is what a repeater owner actually
|
||||
runs; see ConfigView.AssembledLines. Main comes first in the DOM so a narrow
|
||||
screen reads content before its picker, matching the other org pages. */}}
|
||||
{{if or .Config.Profiles .Config.PreviewActive .CanEdit}}
|
||||
<div class="row row-cards">
|
||||
<!-- Main: selected profile's base settings + regions (only what's defined) -->
|
||||
<div class="col-lg-8">
|
||||
{{if .Config.Profiles}}
|
||||
<div class="card">
|
||||
<div class="card-header"><h2 class="card-title">{{.Config.Selected}} · base settings</h2></div>
|
||||
<div class="card-body">{{template "step-list" .Config.SelectedSteps}}</div>
|
||||
{{if or .Config.Profiles .Config.PreviewActive}}
|
||||
<div class="card" data-testid="config-preview">
|
||||
<div class="card-header">
|
||||
<h2 class="card-title">
|
||||
{{if .Config.Profiles}}{{.Config.Selected}}{{else}}Region commands{{end}}
|
||||
<span class="text-secondary fw-normal">{{if .Config.PreviewActive}} · at {{.PreviewLat}}, {{.PreviewLon}}{{else}} · base settings{{end}}</span>
|
||||
</h2>
|
||||
</div>
|
||||
<div class="card-body">{{template "config-lines" .Config.AssembledLines}}</div>
|
||||
{{if .Config.PreviewActive}}
|
||||
{{if not .Config.RegionDef}}
|
||||
<div class="card-footer text-secondary small">No regions cover this location.</div>
|
||||
{{end}}
|
||||
{{else if .Config.HasRegionShapes}}
|
||||
<div class="card-footer text-secondary small">Click a spot on the map below to see the region commands a repeater there would also run.</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{else if .CanEdit}}
|
||||
<!-- Admin, no profiles yet: explain what a profile is and point at Add profile. -->
|
||||
{{/* Admin, no profiles yet: explain what a profile is and point at Add profile. */}}
|
||||
<div class="card">
|
||||
<div class="card-header"><h2 class="card-title">What's a profile?</h2></div>
|
||||
<div class="card-body">
|
||||
@@ -26,22 +45,7 @@
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if .Config.PreviewActive}}
|
||||
<div class="card{{if .Config.Profiles}} mt-3{{end}}">
|
||||
<div class="card-header"><h2 class="card-title">Regions</h2></div>
|
||||
{{if .Config.RegionDef}}
|
||||
<div class="card-body"><pre class="mb-0"><code>{{range .Config.RegionDef}}{{.}}
|
||||
{{end}}</code></pre></div>
|
||||
{{else}}
|
||||
<div class="card-body text-secondary">No regions cover this location.</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<!-- Sidebar: the profile list (select one to show it on the left; admins edit in
|
||||
place) + a click-to-preview location map -->
|
||||
<div class="col-lg-4">
|
||||
{{if or .Config.Profiles .CanEdit}}
|
||||
<div class="card">
|
||||
@@ -63,12 +67,12 @@
|
||||
href="/orgs/{{$.Org.Slug}}/config?profile={{.Name | urlquery}}{{if $.PreviewLat}}&lat={{$.PreviewLat}}&lon={{$.PreviewLon}}{{end}}"
|
||||
{{if eq .Name $.Config.Selected}}aria-current="true"{{end}}>{{.Name}}</a>
|
||||
{{if $.CanEdit}}
|
||||
<button type="button" class="btn btn-icon btn-ghost-secondary" data-testid="config-profile-edit"
|
||||
<button type="button" class="btn btn-icon btn-ghost-secondary" data-testid="config-profile-edit" data-tooltip
|
||||
title="Edit profile" aria-label="Edit profile {{.Name}}"
|
||||
data-bs-toggle="modal" data-bs-target="#config-profile-modal"
|
||||
hx-get="/orgs/{{$.Org.Slug}}/config/profiles/{{.ID}}/edit" hx-target="#config-profile-modal-content" hx-swap="innerHTML">{{template "icon-pencil" ""}}</button>
|
||||
<form method="post" action="/orgs/{{$.Org.Slug}}/config/profiles/{{.ID}}/delete" class="m-0" data-confirm="Delete the profile “{{.Name}}”?">
|
||||
<button type="submit" class="btn btn-icon btn-ghost-danger" data-testid="config-profile-delete"
|
||||
<button type="submit" class="btn btn-icon btn-ghost-danger" data-testid="config-profile-delete" data-tooltip
|
||||
title="Delete profile" aria-label="Delete profile {{.Name}}">{{template "icon-trash" ""}}</button>
|
||||
</form>
|
||||
{{end}}
|
||||
@@ -78,51 +82,156 @@
|
||||
{{else}}
|
||||
<div class="card-body text-secondary">No profiles yet.</div>
|
||||
{{end}}
|
||||
{{if or .Config.Profiles .Config.HasRegions}}
|
||||
<div class="card-footer text-secondary small">
|
||||
{{if .Config.Profiles}}Select a profile to see its base settings.{{end}}{{if .Config.HasRegions}} A repeater's regions are assigned separately, by location.{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if .Config.HasRegionShapes}}
|
||||
<div class="card{{if or .Config.Profiles .CanEdit}} mt-3{{end}}">
|
||||
<div class="card-header"><h2 class="card-title">Preview a location</h2></div>
|
||||
<div class="card-body p-2">
|
||||
<link rel="stylesheet" href="{{ asset "/static/leaflet.css" }}">
|
||||
<div id="region-map" style="height:240px" role="region" aria-label="Click the map to preview a repeater's region config"></div>
|
||||
<script src="{{ asset "/static/leaflet.js" }}"></script>
|
||||
<script src="{{ asset "/static/regionmap.js" }}"></script>
|
||||
<script nonce="{{.Nonce}}">
|
||||
regionMapView('region-map', {
|
||||
pickURL: '/orgs/{{.Org.Slug}}/config?{{if .Config.Profiles}}profile={{.Config.Selected | urlquery}}&{{end}}',
|
||||
{{if .Config.PreviewActive}}preview: {lat: {{.PreviewLat}}, lon: {{.PreviewLon}}},{{end}}
|
||||
{{if .Config.MapBounds}}bounds: [[{{index .Config.MapBounds 0}}, {{index .Config.MapBounds 1}}], [{{index .Config.MapBounds 2}}, {{index .Config.MapBounds 3}}]]{{end}}
|
||||
});
|
||||
</script>
|
||||
{{if .Config.PreviewActive}}
|
||||
<div class="text-secondary small mt-2 text-center">{{template "icon-map-pin" "me-1"}}<span class="font-monospace">{{.PreviewLat}}, {{.PreviewLon}}</span></div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{/* Section 2 — Regions: the map is the view of them, and the list beside it is the
|
||||
map's legend (swatch + hierarchy by indentation). Everyone sees it; only admins
|
||||
get the controls. Clicking the map picks a location, which is what drives the
|
||||
assembled preview above. */}}
|
||||
{{if or .Config.Regions .CanEdit}}
|
||||
<div class="card mt-3" data-testid="config-regions">
|
||||
<div class="card-header">
|
||||
<div>
|
||||
<h2 class="card-title">Regions</h2>
|
||||
<div class="text-secondary small">Named areas drawn on a map. A repeater joins every region whose area contains it.</div>
|
||||
</div>
|
||||
{{if .CanEdit}}
|
||||
<div class="card-actions">
|
||||
<button type="button" class="btn btn-sm btn-primary" data-testid="config-region-add"
|
||||
data-bs-toggle="modal" data-bs-target="#config-region-modal"
|
||||
hx-get="/orgs/{{.Org.Slug}}/config/regions/new" hx-target="#config-region-modal-content" hx-swap="innerHTML">{{template "icon-plus" "me-1"}}Add region</button>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="row g-0">
|
||||
<div class="col-lg-8">
|
||||
{{if .Config.HasRegionShapes}}
|
||||
<link rel="stylesheet" href="{{ asset "/static/leaflet.css" }}">
|
||||
<div id="region-map" style="height:520px" role="region"
|
||||
aria-label="Map of this organization’s regions — click to preview a location"></div>
|
||||
<script src="{{ asset "/static/leaflet.js" }}"></script>
|
||||
<script src="{{ asset "/static/regionmap.js" }}"></script>
|
||||
{{/* Region names and shapes are user data, interpolated in a JS context so
|
||||
html/template JSON-encodes and escapes them. */}}
|
||||
<script nonce="{{.Nonce}}">
|
||||
regionMapView('region-map', {
|
||||
regions: {{.Config.MapRegions}},
|
||||
pickURL: '/orgs/{{.Org.Slug}}/config?{{if .Config.Profiles}}profile={{.Config.Selected | urlquery}}&{{end}}'{{if .Config.PreviewActive}},
|
||||
preview: {lat: {{.PreviewLat}}, lon: {{.PreviewLon}}}{{end}}
|
||||
});
|
||||
</script>
|
||||
{{else}}
|
||||
<div class="p-4 text-secondary" data-testid="config-regions-noshapes">
|
||||
{{if .Config.Regions}}
|
||||
No region has an area drawn yet, so there is nothing to show on a map.
|
||||
{{if .CanEdit}}Use the area button on a region to outline where it applies.{{end}}
|
||||
{{else}}
|
||||
Regions apply settings by location. Add one, draw where it applies, and a
|
||||
repeater there picks it up automatically.
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
<div class="col-lg-4 border-start">
|
||||
<div class="list-group list-group-flush" style="max-height:520px; overflow-y:auto">
|
||||
{{if .CanEdit}}
|
||||
{{/* The root (*) isn't a region row — it lives on the org — so it has no
|
||||
swatch and its only setting is flood policy, saved on its own. */}}
|
||||
<div class="list-group-item d-flex align-items-center gap-2" data-testid="config-region-root">
|
||||
<div class="flex-fill" style="min-width:0">
|
||||
<div class="fw-bold">Root <span class="text-secondary fw-normal">· <code>*</code> · everywhere</span></div>
|
||||
<div class="text-secondary small">Deny flood to scope flooding to your defined regions.</div>
|
||||
</div>
|
||||
<form method="post" action="/orgs/{{.Org.Slug}}/config/root-flood" class="m-0 flex-shrink-0">
|
||||
{{/* No hidden shadow input: an unchecked checkbox simply isn't submitted,
|
||||
which the handler reads as deny. */}}
|
||||
<label class="form-check form-switch m-0 text-nowrap">
|
||||
<input class="form-check-input" type="checkbox" name="root_allow_flood" value="1"
|
||||
{{if .Config.RootAllowFlood}}checked{{end}} data-autosubmit
|
||||
aria-label="Allow flood at the root region">
|
||||
<span class="form-check-label">Allow flood</span>
|
||||
</label>
|
||||
</form>
|
||||
</div>
|
||||
{{end}}
|
||||
{{range .Config.Regions}}
|
||||
<div class="list-group-item d-flex align-items-center gap-2" data-testid="config-region-row">
|
||||
{{/* Align to the top, not the middle: the swatch labels the region's name
|
||||
on the first line, and centering would float it down beside the token. */}}
|
||||
<div class="flex-fill d-flex align-items-start gap-2" style="min-width:0; padding-left:{{.Depth}}rem">
|
||||
{{if .HasShape}}
|
||||
<span class="flex-shrink-0" aria-hidden="true"
|
||||
style="width:.75rem; height:.75rem; margin-top:.4rem; border-radius:2px; background-color:{{.Color}}"></span>
|
||||
{{else}}
|
||||
<span class="flex-shrink-0 text-warning" title="No area drawn yet">{{template "icon-alert" ""}}</span>
|
||||
{{end}}
|
||||
{{/* Name, then token, then status — each on its own line. A token is an
|
||||
identifier, so it never wraps mid-string. */}}
|
||||
<div style="min-width:0">
|
||||
<div>{{.DisplayName}}</div>
|
||||
<div><span class="text-secondary text-nowrap"><code>{{.Token}}</code></span></div>
|
||||
{{if not .HasShape}}
|
||||
<div class="text-warning small" data-testid="config-region-draft">Needs an area</div>
|
||||
{{else if .Matches}}
|
||||
<div class="text-green small" data-testid="config-region-match">Applies here</div>
|
||||
{{else if not .AllowFlood}}
|
||||
<div class="text-secondary small">Flood denied</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{if $.CanEdit}}
|
||||
<a class="btn btn-icon btn-ghost-secondary flex-shrink-0" data-testid="config-region-area" data-tooltip
|
||||
title="{{if .HasShape}}Edit area{{else}}Draw area{{end}}"
|
||||
aria-label="{{if .HasShape}}Edit{{else}}Draw{{end}} area for {{.DisplayName}}"
|
||||
href="/orgs/{{$.Org.Slug}}/config/regions/{{.ID}}/area">{{template "icon-map-pin" ""}}</a>
|
||||
<button type="button" class="btn btn-icon btn-ghost-secondary flex-shrink-0" data-testid="config-region-edit" data-tooltip
|
||||
title="Edit region" aria-label="Edit region {{.DisplayName}}"
|
||||
data-bs-toggle="modal" data-bs-target="#config-region-modal"
|
||||
hx-get="/orgs/{{$.Org.Slug}}/config/regions/{{.ID}}/edit" hx-target="#config-region-modal-content" hx-swap="innerHTML">{{template "icon-pencil" ""}}</button>
|
||||
<form method="post" action="/orgs/{{$.Org.Slug}}/config/regions/{{.ID}}/delete" class="m-0 flex-shrink-0" data-confirm="Delete the region “{{.DisplayName}}”?">
|
||||
<button type="submit" class="btn btn-icon btn-ghost-danger" data-testid="config-region-delete" data-tooltip
|
||||
title="Delete region" aria-label="Delete region {{.DisplayName}}">{{template "icon-trash" ""}}</button>
|
||||
</form>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
{{if not .Config.Regions}}
|
||||
<div class="list-group-item text-secondary">No regions yet.</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if .CanEdit}}
|
||||
<!-- Admin profile editor; the Add/Edit buttons load their fragment here via htmx. -->
|
||||
<!-- Admin editors; the Add/Edit buttons load their fragment here via htmx. A region's
|
||||
area is not edited here — it needs a full-width map, so it has its own page. -->
|
||||
<div class="modal modal-blur fade" id="config-profile-modal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered modal-dialog-scrollable modal-lg" role="document">
|
||||
<div class="modal-content" id="config-profile-modal-content"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal modal-blur fade" id="config-region-modal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered modal-dialog-scrollable" role="document">
|
||||
<div class="modal-content" id="config-region-modal-content"></div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
{{end}}
|
||||
|
||||
{{/* step-list renders a sequence of ConfigStep as a copy-pasteable block. */}}
|
||||
{{define "step-list"}}
|
||||
{{if .}}<pre class="mb-0"><code>{{range .}}{{if .IsComment}}# {{.Comment}}{{else}}{{.CommandLine}}{{end}}
|
||||
{{/* config-lines renders assembled ConfigLines as one copy-pasteable block. Region
|
||||
lines are tinted so it reads which came from the location rather than the
|
||||
profile, matching the highlighted polygons on the map. */}}
|
||||
{{define "config-lines"}}
|
||||
{{if .}}<pre class="mb-0" data-testid="config-commands"><code>{{range .}}{{if .IsMarker}}<span class="text-secondary">… click a spot on the map below to fill in the region commands …</span>{{else if .IsComment}}# {{.Text}}{{else if .FromRegion}}<span class="text-azure" data-testid="config-region-line">{{.Text}}</span>{{else}}{{.Text}}{{end}}
|
||||
{{end}}</code></pre>{{else}}<div class="text-secondary">No steps.</div>{{end}}
|
||||
{{end}}
|
||||
@@ -19,7 +19,6 @@ host (anonymous). */}}
|
||||
<div class="dropdown">
|
||||
<button class="btn dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false" data-testid="org-actions">Actions</button>
|
||||
<div class="dropdown-menu dropdown-menu-end">
|
||||
{{if .Nav.IsAdmin}}<a class="dropdown-item" href="/orgs/{{.Nav.Slug}}/config/edit">{{template "icon-settings" "dropdown-item-icon"}}Edit configuration</a>{{end}}
|
||||
<a class="dropdown-item" href="{{.RootURL}}/orgs/{{.Nav.Slug}}">{{template "icon-world" "dropdown-item-icon"}}View public page</a>
|
||||
<div class="dropdown-divider"></div>
|
||||
<form method="post" action="/orgs/{{.Nav.Slug}}/leave" class="m-0" data-confirm="Leave this organization?">
|
||||
|
||||
Reference in new issue
Block a user