Make org actions consistent

This commit is contained in:
Jonathon Leight
2026-07-07 23:28:09 -04:00
parent ccf0f94dce
commit 2cd07b39ca
13 changed files with 206 additions and 94 deletions
+10 -6
View File
@@ -53,8 +53,12 @@ func (s *Handlers) pageOrgConfig(w http.ResponseWriter, r *http.Request) {
}
data := map[string]any{
"Org": org,
"Nav": s.OrgNavFor(r.Context(), id, org.Slug, "config", isMember, role == "admin"),
"Org": org,
"Nav": s.OrgNavFor(r.Context(), web.OrgNavArgs{
OrgID: id, Name: org.Name, Slug: org.Slug, Active: "config",
IsMember: isMember, IsAdmin: role == "admin", Manage: isMember,
CanGoToOrg: isMember, CanJoin: uid != 0 && !isMember,
}),
"CanEdit": role == "admin",
}
var latP, lonP *float64
@@ -103,7 +107,7 @@ func (s *Handlers) pageConfigHub(w http.ResponseWriter, r *http.Request) {
}
s.Render(w, r, "config_edit.html", map[string]any{
"Org": org,
"Nav": s.OrgNavFor(r.Context(), orgID, org.Slug, "config", true, true),
"Nav": s.OrgNavFor(r.Context(), web.OrgNavArgs{OrgID: orgID, Name: org.Name, Slug: org.Slug, Active: "config", IsMember: true, IsAdmin: true, Manage: true}),
"Profiles": profiles,
"RegionCount": len(regions),
"PrimaryRegion": primary,
@@ -149,7 +153,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) {
s.Render(w, r, "config_profile_edit.html", map[string]any{
"Org": org,
"Nav": s.OrgNavFor(r.Context(), org.ID, org.Slug, "config", true, true),
"Nav": s.OrgNavFor(r.Context(), web.OrgNavArgs{OrgID: org.ID, Name: org.Name, Slug: org.Slug, Active: "config", IsMember: true, IsAdmin: true, Manage: true}),
"ProfileID": pid,
"Name": name,
"StepsText": stepsText,
@@ -266,7 +270,7 @@ func (s *Handlers) pageRegionsEdit(w http.ResponseWriter, r *http.Request) {
}
s.Render(w, r, "config_regions_edit.html", map[string]any{
"Org": org,
"Nav": s.OrgNavFor(r.Context(), orgID, org.Slug, "config", true, true),
"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,
@@ -296,7 +300,7 @@ func (s *Handlers) handleSaveRegions(w http.ResponseWriter, r *http.Request) {
}
s.Render(w, r, "config_regions_edit.html", map[string]any{
"Org": org,
"Nav": s.OrgNavFor(r.Context(), orgID, org.Slug, "config", true, true),
"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,
+21
View File
@@ -129,3 +129,24 @@ func TestOrgJoinLeavePosts(t *testing.T) {
t.Fatal("leave did not remove membership")
}
}
// 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.
func TestOrgTabsHeaderConsistent(t *testing.T) {
t.Parallel()
st, ctx, ts, h := splitServer(t)
owner, sess := appLogin(t, ts, st, ctx, h.app, "tabsadmin") // creator = admin member
org, err := st.CreateOrg(ctx, "Tabs Org", owner.ID)
if err != nil {
t.Fatal(err)
}
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"} {
if !strings.Contains(body, want) {
t.Errorf("tab %q missing %q from the shared Actions header", tab, want)
}
}
}
}
+13 -5
View File
@@ -123,7 +123,7 @@ func (s *Handlers) pageOrg(w http.ResponseWriter, r *http.Request) {
isAdmin := role == "admin"
data := map[string]any{
"Org": org,
"Nav": s.OrgNavFor(r.Context(), org.ID, org.Slug, "home", true, isAdmin),
"Nav": s.OrgNavFor(r.Context(), web.OrgNavArgs{OrgID: org.ID, Name: org.Name, Slug: org.Slug, Active: "home", IsMember: true, IsAdmin: isAdmin, Manage: true}),
"Role": role,
"IsAdmin": isAdmin,
"Members": members,
@@ -174,7 +174,11 @@ func (s *Handlers) renderOrgPublic(w http.ResponseWriter, r *http.Request, org *
// The public view never exposes the Members tab — membership isn't public,
// only the admin list is — so build the nav as a non-member regardless of who
// is viewing (a member previews the public page via ?view=public).
"Nav": s.OrgNavFor(r.Context(), org.ID, org.Slug, "home", false, isAdmin),
"Nav": s.OrgNavFor(r.Context(), web.OrgNavArgs{
OrgID: org.ID, Name: org.Name, Slug: org.Slug, Active: "home",
IsMember: false, IsAdmin: isAdmin, Manage: false,
CanGoToOrg: isMember, CanJoin: uid != 0 && !isMember,
}),
"Admins": admins,
"MemberCount": memberCount,
"RepeaterCount": repeaterCount,
@@ -218,7 +222,7 @@ func (s *Handlers) pageOrgMembers(w http.ResponseWriter, r *http.Request) {
}
s.Render(w, r, "org_members.html", map[string]any{
"Org": org,
"Nav": s.OrgNavFor(r.Context(), org.ID, org.Slug, "members", true, role == "admin"),
"Nav": s.OrgNavFor(r.Context(), web.OrgNavArgs{OrgID: org.ID, Name: org.Name, Slug: org.Slug, Active: "members", IsMember: true, IsAdmin: role == "admin", Manage: true}),
"IsAdmin": role == "admin",
"Members": members,
"Self": uid,
@@ -252,8 +256,12 @@ func (s *Handlers) pageOrgRepeaters(w http.ResponseWriter, r *http.Request) {
return
}
s.Render(w, r, "org_repeaters.html", map[string]any{
"Org": org,
"Nav": s.OrgNavFor(r.Context(), org.ID, org.Slug, "repeaters", isMember, role == "admin"),
"Org": org,
"Nav": s.OrgNavFor(r.Context(), web.OrgNavArgs{
OrgID: org.ID, Name: org.Name, Slug: org.Slug, Active: "repeaters",
IsMember: isMember, IsAdmin: role == "admin", Manage: isMember,
CanGoToOrg: isMember, CanJoin: uid != 0 && !isMember,
}),
"Reps": rv,
})
}
+14
View File
@@ -32,6 +32,13 @@ func TestRootOrgPageIdentityAwareCTA(t *testing.T) {
if strings.Contains(anon, "Go to organization") {
t.Fatalf("anonymous root org page should not show the app jump")
}
// The CTA must be consistent across every public tab, not just Home.
for _, tab := range []string{"/repeaters", "/config"} {
page := readBody(t, do(t, ts, h.root, "/orgs/"+org.Slug+tab))
if !strings.Contains(page, "Sign in to join") || strings.Contains(page, "Go to organization") {
t.Fatalf("anonymous root %s tab CTA inconsistent with Home", tab)
}
}
// Drop a root identity cookie for the member via the beacon (as a fresh
// sign-in on the app host would).
@@ -52,6 +59,13 @@ func TestRootOrgPageIdentityAwareCTA(t *testing.T) {
if strings.Contains(member, "Sign in to join") {
t.Fatalf("member root org page should not show the sign-in CTA")
}
// Same consistency for the member: "Go to organization" on every public tab.
for _, tab := range []string{"/repeaters", "/config"} {
page := readBody(t, do(t, ts, h.root, "/orgs/"+org.Slug+tab, rootSess))
if !strings.Contains(page, "Go to organization") || strings.Contains(page, "Sign in to join") {
t.Fatalf("member root %s tab CTA inconsistent with Home", tab)
}
}
}
func readBody(t *testing.T, resp *http.Response) string {
+1 -20
View File
@@ -1,24 +1,5 @@
{{define "title"}}{{.Org.Name}} · MeshTender{{end}}
{{define "header"}}
<div class="row g-2 align-items-center">
<div class="col">
<div class="page-pretitle">Organization · you're {{if .IsAdmin}}an admin{{else}}a member{{end}}</div>
<h2 class="page-title">{{.Org.Name}}</h2>
</div>
<div class="col-auto ms-auto d-print-none">
<div class="dropdown">
<button class="btn dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">Actions</button>
<div class="dropdown-menu dropdown-menu-end">
<a class="dropdown-item" href="/orgs/{{.Org.Slug}}?view=public">{{template "icon-world" "dropdown-item-icon"}}View public page</a>
<div class="dropdown-divider"></div>
<form method="post" action="/orgs/{{.Org.Slug}}/leave" class="m-0" data-confirm="Leave this organization?">
<button type="submit" class="dropdown-item text-danger">{{template "icon-logout" "dropdown-item-icon"}}Leave organization</button>
</form>
</div>
</div>
</div>
</div>
{{end}}
{{define "header"}}{{template "org-header" .}}{{end}}
{{define "content"}}
{{template "org-tabs" .Nav}}
{{if .Error}}<div class="alert alert-danger">{{.Error}}</div>{{end}}
+1 -8
View File
@@ -1,12 +1,5 @@
{{define "title"}}Members · {{.Org.Name}} · MeshTender{{end}}
{{define "header"}}
<div class="row g-2 align-items-center">
<div class="col">
<div class="page-pretitle">{{.Org.Name}}</div>
<h2 class="page-title">Members</h2>
</div>
</div>
{{end}}
{{define "header"}}{{template "org-header" .}}{{end}}
{{define "content"}}
{{template "org-tabs" .Nav}}
{{if .Error}}<div class="alert alert-danger">{{.Error}}</div>{{end}}
+52
View File
@@ -0,0 +1,52 @@
//go:build browser
package e2e
import (
"strings"
"testing"
cdplog "github.com/chromedp/cdproto/log"
"github.com/chromedp/cdproto/network"
"github.com/chromedp/chromedp"
)
// TestE2EOrgActionsConsistent verifies the shared org header: the Actions menu is
// present on a non-Home org tab (Members) and opens under the strict CSP
// (Bootstrap data-bs-toggle, no inline JS), exposing the management items.
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.
org, err := srv.store.CreateOrg(srv.ctx, "Tabs Org", user.ID)
if err != nil {
t.Fatalf("create org: %v", err)
}
bctx, cancel, watch := startBrowser(t)
defer cancel()
membersURL := srv.appURL + "/orgs/" + org.Slug + "/members"
var items string
if err := chromedp.Run(bctx,
network.Enable(),
cdplog.Enable(),
setSessionCookie(cookie),
chromedp.Navigate(membersURL),
// The Actions button lives in the shared header, so it's here on the Members
// tab (not just Home). Opening it exercises Bootstrap JS under the CSP.
chromedp.WaitVisible(`[data-testid="org-actions"]`, chromedp.ByQuery),
chromedp.Click(`[data-testid="org-actions"]`, chromedp.ByQuery),
chromedp.WaitVisible(`.dropdown-menu.show`, chromedp.ByQuery),
chromedp.Text(`.dropdown-menu.show`, &items, chromedp.ByQuery),
); err != nil {
t.Fatalf("browser run against %s: %v", membersURL, err)
}
for _, want := range []string{"Edit configuration", "View public page", "Leave organization"} {
if !strings.Contains(items, want) {
t.Errorf("Actions menu on the Members tab missing %q; got %q", want, items)
}
}
watch.assertClean(t)
}
+26 -4
View File
@@ -57,6 +57,16 @@ func (s *Handlers) pageOrgs(w http.ResponseWriter, r *http.Request) {
s.Render(w, r, "orgs.html", data)
}
// publicCTA computes the public org page's call-to-action state for the current
// viewer so it's consistent across all public tabs: a member gets "Go to
// organization", a signed-in non-member gets "Join organization", and anyone else
// "Sign in to join". The root host detects membership via the shared identity beacon.
func (s *Handlers) publicCTA(r *http.Request, orgID int64) (canGoToOrg, canJoin bool) {
uid := s.Auth.CurrentUserID(r.Context())
_, isMember, _ := s.Store.OrgRole(r.Context(), orgID, uid)
return isMember, uid != 0 && !isMember
}
// renderOrgPublic renders the public-facing org page (name, description, admins,
// counts, and a map of repeaters opted into public display).
func (s *Handlers) renderOrgPublic(w http.ResponseWriter, r *http.Request, org *store.Org, isMember, isAdmin bool) {
@@ -90,7 +100,11 @@ func (s *Handlers) renderOrgPublic(w http.ResponseWriter, r *http.Request, org *
// The public view never exposes the Members tab — membership isn't public,
// only the admin list is — so build the nav as a non-member regardless of who
// is viewing (the tab also 404s on the root host, which has no members route).
"Nav": s.OrgNavFor(r.Context(), org.ID, org.Slug, "home", false, isAdmin),
"Nav": s.OrgNavFor(r.Context(), web.OrgNavArgs{
OrgID: org.ID, Name: org.Name, Slug: org.Slug, Active: "home",
IsMember: false, IsAdmin: isAdmin, Manage: false,
CanGoToOrg: isMember, CanJoin: uid != 0 && !isMember,
}),
"Admins": admins,
"MemberCount": memberCount,
"RepeaterCount": repeaterCount,
@@ -147,7 +161,11 @@ func (s *Handlers) pageOrgConfig(w http.ResponseWriter, r *http.Request) {
s.NotFound(w, r)
return
}
data := map[string]any{"Org": org, "Nav": s.OrgNavFor(r.Context(), org.ID, org.Slug, "config", false, false), "CanEdit": false}
canGoToOrg, canJoin := s.publicCTA(r, id)
data := map[string]any{"Org": org, "Nav": s.OrgNavFor(r.Context(), web.OrgNavArgs{
OrgID: org.ID, Name: org.Name, Slug: org.Slug, Active: "config", Manage: false,
CanGoToOrg: canGoToOrg, CanJoin: canJoin,
}), "CanEdit": false}
var latP, lonP *float64
if lat, lon, ok := web.PreviewLatLon(r); ok {
latP, lonP = &lat, &lon
@@ -194,9 +212,13 @@ func (s *Handlers) pageOrgRepeaters(w http.ResponseWriter, r *http.Request) {
last := reps[len(reps)-1]
rv.NextCursor = encodeRepCursor(last.Name, last.RepeaterID)
}
canGoToOrg, canJoin := s.publicCTA(r, id)
data := map[string]any{
"Org": org,
"Nav": s.OrgNavFor(r.Context(), org.ID, org.Slug, "repeaters", false, false),
"Org": org,
"Nav": s.OrgNavFor(r.Context(), web.OrgNavArgs{
OrgID: org.ID, Name: org.Name, Slug: org.Slug, Active: "repeaters", Manage: false,
CanGoToOrg: canGoToOrg, CanJoin: canJoin,
}),
"Reps": rv,
"MapURL": orgMapURL(org.Slug),
}
+31 -13
View File
@@ -123,21 +123,39 @@ func BuildConfigView(ctx context.Context, st *store.Store, orgID int64, selected
return cv, nil
}
// OrgNav is the data the shared org-tabs sub-nav partial expects: the org slug,
// which tab is active ("home" | "repeaters" | "members" | "config"), whether the
// viewer is a member (the Members tab, which exposes personal info, only shows for
// members), and whether to show the Configuration tab (hidden when the org has no
// config, unless the viewer can edit it).
func OrgNav(slug, active string, isMember, showConfig bool) map[string]any {
return map[string]any{"Slug": slug, "Active": active, "IsMember": isMember, "ShowConfig": showConfig}
// OrgNavArgs is the input to OrgNavFor and the data the shared org-header +
// org-tabs partials expect.
type OrgNavArgs struct {
OrgID int64
Name string
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.
IsMember bool
IsAdmin bool
// Manage selects the header's right-side controls: true renders the member
// Actions dropdown, false renders the public "Go to organization / Join" CTA.
Manage bool
// CanGoToOrg / CanJoin drive that CTA (used only when !Manage): a member
// previewing the public page gets "Go to organization", a signed-in non-member
// gets "Join organization", and anyone else gets "Sign in to join".
CanGoToOrg bool
CanJoin bool
}
// OrgNavFor builds OrgNav, querying whether the org has any config so the
// Configuration tab hides when empty — for everyone except admins, who always
// see it so they can create the first profile/region.
func (e *Env) OrgNavFor(ctx context.Context, orgID int64, slug, active string, isMember, isAdmin bool) map[string]any {
hasConfig, _ := e.Store.OrgHasConfig(ctx, orgID)
return OrgNav(slug, active, isMember, hasConfig || isAdmin)
// OrgNavFor builds the org nav map, querying whether the org has any config so the
// Configuration tab hides when empty — for everyone except admins, who always see
// it so they can create the first profile/region.
func (e *Env) OrgNavFor(ctx context.Context, a OrgNavArgs) map[string]any {
hasConfig, _ := e.Store.OrgHasConfig(ctx, a.OrgID)
return map[string]any{
"Name": a.Name, "Slug": a.Slug, "Active": a.Active,
"IsMember": a.IsMember, "IsAdmin": a.IsAdmin,
"ShowConfig": hasConfig || a.IsAdmin,
"Manage": a.Manage, "CanGoToOrg": a.CanGoToOrg, "CanJoin": a.CanJoin,
}
}
// RepeatersView is an org's repeaters for the Repeaters page. Full is true for the
+1 -13
View File
@@ -1,17 +1,5 @@
{{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>
<h2 class="page-title">Recommended Configuration</h2>
</div>
{{if .CanEdit}}
<div class="col-auto ms-auto d-print-none">
<a class="btn" href="/orgs/{{.Org.Slug}}/config/edit">{{template "icon-pencil" "me-1"}}Edit</a>
</div>
{{end}}
</div>
{{end}}
{{define "header"}}{{template "org-header" .}}{{end}}
{{define "content"}}
{{template "org-tabs" .Nav}}
+1 -17
View File
@@ -1,21 +1,5 @@
{{define "title"}}{{.Org.Name}} · MeshTender{{end}}
{{define "header"}}
<div class="row g-2 align-items-center">
<div class="col">
<div class="page-pretitle">Organization{{if .IsMember}} · public preview{{end}}</div>
<h2 class="page-title">{{.Org.Name}}</h2>
</div>
<div class="col-auto ms-auto d-print-none">
{{if .IsMember}}
<a class="btn btn-primary" href="{{.AppURL}}/orgs/{{.Org.Slug}}">{{template "icon-arrow-right" "me-1"}}Go to organization</a>
{{else if .CanJoin}}
<a class="btn btn-primary" href="{{.AppURL}}/orgs/{{.Org.Slug}}/join">{{template "icon-users" "me-1"}}Join organization</a>
{{else}}
<a class="btn btn-primary" href="{{.AppURL}}/orgs/{{.Org.Slug}}/join">Sign in to join</a>
{{end}}
</div>
</div>
{{end}}
{{define "header"}}{{template "org-header" .}}{{end}}
{{define "content"}}
{{template "org-tabs" .Nav}}
<!-- Summary stats -->
+1 -8
View File
@@ -1,12 +1,5 @@
{{define "title"}}Repeaters · {{.Org.Name}} · MeshTender{{end}}
{{define "header"}}
<div class="row g-2 align-items-center">
<div class="col">
<div class="page-pretitle">{{.Org.Name}}</div>
<h2 class="page-title">Repeaters</h2>
</div>
</div>
{{end}}
{{define "header"}}{{template "org-header" .}}{{end}}
{{/* rep-rows renders the repeater list items; shared by the full page and the
htmx "show more" fragment so appended pages match the first. */}}
{{define "rep-rows"}}
+34
View File
@@ -4,6 +4,40 @@ Active ("home" | "repeaters" | "members" | "config") and IsMember
missing map renders empty links (harmless for the empty-data compose test). Links
are same-host relative so they work on both the app host (signed-in) and the root
host (anonymous). */}}
{{/* org-header is the shared chrome for every org tab, so the Actions menu (member
views) and the "Go to organization" CTA (public views) stay consistent across
tabs — the parallel to repeater-header. Pass the full page data (it reads
.Nav.* plus the renderer-injected .AppURL). .Nav.Manage picks Actions vs CTA. */}}
{{define "org-header"}}
<div class="row g-2 align-items-center">
<div class="col">
<div class="page-pretitle">Organization{{if and (not .Nav.Manage) .Nav.CanGoToOrg}} · public preview{{end}}</div>
<h2 class="page-title">{{.Nav.Name}}</h2>
</div>
<div class="col-auto ms-auto d-print-none">
{{if .Nav.Manage}}
<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="/orgs/{{.Nav.Slug}}?view=public">{{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?">
<button type="submit" class="dropdown-item text-danger">{{template "icon-logout" "dropdown-item-icon"}}Leave organization</button>
</form>
</div>
</div>
{{else if .Nav.CanGoToOrg}}
<a class="btn btn-primary" href="{{.AppURL}}/orgs/{{.Nav.Slug}}">{{template "icon-arrow-right" "me-1"}}Go to organization</a>
{{else if .Nav.CanJoin}}
<a class="btn btn-primary" href="{{.AppURL}}/orgs/{{.Nav.Slug}}/join">{{template "icon-users" "me-1"}}Join organization</a>
{{else}}
<a class="btn btn-primary" href="{{.AppURL}}/orgs/{{.Nav.Slug}}/join">Sign in to join</a>
{{end}}
</div>
</div>
{{end}}
{{define "org-tabs"}}
<ul class="nav nav-tabs mb-3" role="tablist">
<li class="nav-item"><a class="nav-link{{if eq .Active "home"}} active{{end}}" href="/orgs/{{.Slug}}">{{template "icon-world" "me-1"}}Home</a></li>